diff --git a/.dockerignore b/.dockerignore index 31b16d2aab..ec905b5eb8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,2 @@ -.github/ -.gitpod.yml bin/ tmp/ diff --git a/.editorconfig b/.editorconfig index 27917441d8..e8fdc28f50 100644 --- a/.editorconfig +++ b/.editorconfig @@ -19,3 +19,12 @@ indent_style = tab [{config.yaml.dist,config.dev.yaml}] indent_size = 2 + +[.golangci.yaml] +indent_size = 2 + +[*.nix] +indent_size = 2 + +[devenv.yaml] +indent_size = 2 diff --git a/.envrc b/.envrc deleted file mode 100644 index 5817bffc67..0000000000 --- a/.envrc +++ /dev/null @@ -1,6 +0,0 @@ -if ! has nix_direnv_version || ! nix_direnv_version 1.5.0; then - source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/1.5.0/direnvrc" "sha256-carKk9aUFHMuHt+IWh74hFj58nY4K3uywpZbwXX0BTI=" -fi -use flake - -dotenv_if_exists diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 64156bfa7a..7b8f9b7e2e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,9 @@ blank_issues_enabled: false contact_links: + - name: ๐Ÿ“– Documentation enhancement + url: https://github.com/dexidp/website/issues + about: Suggest an improvement to the documentation + - name: โ“ Ask a question url: https://github.com/dexidp/dex/discussions/new?category=q-a about: Ask and discuss questions with other Dex community members @@ -13,5 +17,5 @@ contact_links: about: Please ask and answer questions here - name: ๐Ÿ’ก Dex Enhancement Proposal - url: https://github.com/dexidp/dex/tree/master/enhancements/README.md + url: https://github.com/dexidp/dex/tree/master/docs/enhancements/README.md about: Open a proposal for significant architectural change diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bcaee00ae3..a706b551a1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,15 +21,3 @@ Thank you for sending a pull request! Here are some tips for contributors: --> #### Special notes for your reviewer - -#### Does this PR introduce a user-facing change? - - - -```release-note - -``` diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 9decd34e3e..eab38858be 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -11,10 +11,10 @@ to confirm receipt of the issue. ## Review Process Once a maintainer has confirmed the relevance of the report, a draft security -advisory will be created on Github. The draft advisory will be used to discuss +advisory will be created on GitHub. The draft advisory will be used to discuss the issue with maintainers, the reporter(s). If the reporter(s) wishes to participate in this discussion, then provide -reporter Github username(s) to be invited to the discussion. If the reporter(s) +reporter GitHub username(s) to be invited to the discussion. If the reporter(s) does not wish to participate directly in the discussion, then the reporter(s) can request to be updated regularly via email. diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index b3129d93cf..f66cc18740 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -7,6 +7,10 @@ updates: - "area/dependencies" schedule: interval: "daily" + groups: + etcd: + patterns: + - "go.etcd.io/*" - package-ecosystem: "gomod" directory: "/api/v2" @@ -15,6 +19,13 @@ updates: schedule: interval: "daily" + - package-ecosystem: "gomod" + directory: "/examples" + labels: + - "area/dependencies" + schedule: + interval: "daily" + - package-ecosystem: "docker" directory: "/" labels: diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml new file mode 100644 index 0000000000..49cdf2da5e --- /dev/null +++ b/.github/workflows/analysis-scorecard.yaml @@ -0,0 +1,47 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + push: + branches: [ main ] + schedule: + - cron: '30 0 * * 5' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + permissions: + actions: read + contents: read + id-token: write + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload results as artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: OpenSSF Scorecard results + path: results.sarif + retention-days: 5 + + - name: Upload results to GitHub Security tab + uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v3.29.5 + with: + sarif_file: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 0237b3ac66..aa7c5ffd15 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -1,12 +1,31 @@ name: Artifacts on: - push: - branches: - - master - tags: - - v[0-9]+.[0-9]+.[0-9]+ - pull_request: + workflow_call: + inputs: + publish: + description: Publish artifacts to the artifact store + default: false + required: false + type: boolean + secrets: + DOCKER_USERNAME: + required: true + DOCKER_PASSWORD: + required: true + outputs: + container-image-name: + description: Container image name + value: ${{ jobs.container-images.outputs.name }} + container-image-digest: + description: Container image digest + value: ${{ jobs.container-images.outputs.digest }} + container-image-ref: + description: Container image ref + value: ${{ jobs.container-images.outputs.ref }} + +permissions: + contents: read jobs: container-images: @@ -18,80 +37,233 @@ jobs: - alpine - distroless + permissions: + attestations: write + contents: read + packages: write + id-token: write + security-events: write + + outputs: + name: ${{ steps.image-name.outputs.value }} + digest: ${{ steps.build.outputs.digest }} + ref: ${{ steps.image-ref.outputs.value }} + steps: - - name: Checkout - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-tags: true + + - name: Set up QEMU + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Set up Syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Set image name + id: image-name + run: echo "value=ghcr.io/${{ github.repository }}" >> "$GITHUB_OUTPUT" - - name: Gather metadata + - name: Gather build metadata id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | - ghcr.io/dexidp/dex - dexidp/dex + ${{ steps.image-name.outputs.value }} + ${{ github.repository == 'dexidp/dex' && 'dexidp/dex' || '' }} flavor: | latest = false tags: | type=ref,event=branch,enable=${{ matrix.variant == 'alpine' }} - type=ref,event=pr,enable=${{ matrix.variant == 'alpine' }} + type=ref,event=pr,prefix=pr-,enable=${{ matrix.variant == 'alpine' }} type=semver,pattern={{raw}},enable=${{ matrix.variant == 'alpine' }} - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && matrix.variant == 'alpine' }} + type=raw,value=latest,enable=${{ github.ref_name == github.event.repository.default_branch && matrix.variant == 'alpine' }} type=ref,event=branch,suffix=-${{ matrix.variant }} - type=ref,event=pr,suffix=-${{ matrix.variant }} + type=ref,event=pr,prefix=pr-,suffix=-${{ matrix.variant }} type=semver,pattern={{raw}},suffix=-${{ matrix.variant }} - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }},suffix=-${{ matrix.variant }} + type=raw,value=latest,enable={{is_default_branch}},suffix=-${{ matrix.variant }} labels: | org.opencontainers.image.documentation=https://dexidp.io/docs/ - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - with: - platforms: all + # Multiple exporters are not supported yet + # See https://github.com/moby/buildkit/pull/2760 + - name: Get version from git-version script + id: version + run: echo "value=$(bash ./scripts/git-version)" >> "$GITHUB_OUTPUT" - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + # Multiple exporters are not supported yet + # See https://github.com/moby/buildkit/pull/2760 + - name: Determine build output + uses: haya14busa/action-cond@94f77f7a80cd666cb3155084e428254fea4281fd # v1.2.1 + id: build-output + with: + cond: ${{ inputs.publish }} + if_true: type=image,push=true + if_false: type=oci,dest=image.tar - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io - username: ${{ github.repository_owner }} + username: ${{ github.actor }} password: ${{ github.token }} - if: github.event_name == 'push' + if: inputs.publish - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - if: github.event_name == 'push' + if: inputs.publish && github.repository == 'dexidp/dex' - - name: Build and push - uses: docker/build-push-action@v3 + - name: Build and push image + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le - # cache-from: type=gha - # cache-to: type=gha,mode=max - push: ${{ github.event_name == 'push' }} + platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x tags: ${{ steps.meta.outputs.tags }} build-args: | BASE_IMAGE=${{ matrix.variant }} - VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} + VERSION=${{ steps.version.outputs.value }} COMMIT_HASH=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} - labels: ${{ steps.meta.outputs.labels }} + labels: | + ${{ steps.meta.outputs.labels }} + # cache-from: type=gha + # cache-to: type=gha,mode=max + outputs: ${{ steps.build-output.outputs.value }} + # push: ${{ inputs.publish }} + + - name: Sign the images with GitHub OIDC Token + run: | + cosign sign --yes ${{ steps.image-name.outputs.value }}@${{ steps.build.outputs.digest }} + if: inputs.publish + + - name: Set image ref + id: image-ref + run: echo "value=${{ steps.image-name.outputs.value }}@${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT" + + - name: Fetch image + run: skopeo --insecure-policy copy docker://${{ steps.image-ref.outputs.value }} oci-archive:image.tar + if: inputs.publish + + # Uncomment the following lines for debugging: + # - name: Upload image as artifact + # uses: actions/upload-artifact@v3 + # with: + # name: "[${{ github.job }}] OCI tarball" + # path: image.tar + + - name: Extract OCI tarball + id: extract-oci + run: | + mkdir -p image + tar -xf image.tar -C image + + image_name=$(jq -r '.manifests[0].annotations["io.containerd.image.name"]' image/index.json) + image_tag=$(jq -r '.manifests[0].annotations["org.opencontainers.image.ref.name"]' image/index.json) + + echo "Copying $image_tag -> $image_name" + skopeo copy "oci:image:$image_tag" "docker-daemon:$image_name" + + echo "value=$image_name" >> "$GITHUB_OUTPUT" + if: ${{ !inputs.publish }} + + + # - name: List tags + # run: skopeo --insecure-policy list-tags oci:image + # + # # See https://github.com/anchore/syft/issues/1545 + # - name: Extract image from multi-arch image + # run: skopeo --override-os linux --override-arch amd64 --insecure-policy copy oci:image:${{ steps.image-name.outputs.value }}:${{ steps.meta.outputs.version }} docker-archive:docker.tar + # + # - name: Generate SBOM + # run: syft -o spdx-json=sbom-spdx.json docker-archive:docker.tar + # + # - name: Upload SBOM as artifact + # uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + # with: + # name: "[${{ github.job }}] SBOM" + # path: sbom-spdx.json + # retention-days: 5 + + # TODO: uncomment when the action is working for non ghcr.io pushes. GH Issue: https://github.com/actions/attest-build-provenance/issues/80 + # - name: Generate build provenance attestation + # uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + # with: + # subject-name: dexidp/dex + # subject-digest: ${{ steps.build.outputs.digest }} + # push-to-registry: true + + - name: Generate build provenance attestation + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ghcr.io/${{ github.repository }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + if: inputs.publish + + - name: Prepare image fs for scanning + run: | + image_ref=${{ steps.extract-oci.outputs.value != '' && steps.extract-oci.outputs.value || steps.image-ref.outputs.value }} + docker export $(docker create --rm $image_ref) -o docker-image.tar + + mkdir -p docker-image + tar -xf docker-image.tar -C docker-image + + ## Use cache for the trivy-db to avoid the TOOMANYREQUESTS error https://github.com/aquasecurity/trivy-action/pull/397 + ## To avoid the trivy-db becoming outdated, we save the cache for one day + - name: Get data + id: date + run: echo "date=$(date +%Y-%m-%d)" >> $GITHUB_OUTPUT + + - name: Restore trivy cache + id: trivy-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: cache/db + key: trivy-cache-${{ steps.date.outputs.date }} + restore-keys: trivy-cache- - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.7.1 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # 0.36.0 + with: + input: docker-image + format: sarif + output: trivy-results.sarif + scan-type: "rootfs" + scan-ref: "." + cache-dir: "./cache" + env: + TRIVY_SKIP_DB_UPDATE: ${{ steps.trivy-cache.outputs.cache-hit == 'true' }} + TRIVY_SKIP_JAVA_DB_UPDATE: ${{ steps.trivy-cache.outputs.cache-hit == 'true' }} + + ## Trivy-db uses `0600` permissions. + ## But `action/cache` use `runner` user by default + ## So we need to change the permissions before caching the database. + - name: change permissions for trivy.db + run: sudo chmod 0644 ./cache/db/trivy.db + + - name: Check Trivy sarif + run: cat trivy-results.sarif + + - name: Upload Trivy scan results as artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - image-ref: "ghcr.io/dexidp/dex:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}" - format: "sarif" - output: "trivy-results.sarif" - if: github.event_name == 'push' + name: "[${{ github.job }}] Trivy scan results" + path: trivy-results.sarif + retention-days: 5 + overwrite: true - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v3.29.5 with: - sarif_file: "trivy-results.sarif" - if: github.event_name == 'push' + sarif_file: trivy-results.sarif diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index c7eb4ea73c..fbccdf8a18 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -4,14 +4,19 @@ on: pull_request: types: [opened, labeled, unlabeled, synchronize] +permissions: + contents: read + jobs: release-label: name: Release note label runs-on: ubuntu-latest + if: github.repository == 'dexidp/dex' + steps: - name: Check minimum labels - uses: mheap/github-action-required-labels@v2 + uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5.6.0 with: mode: minimum count: 1 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 192a44046e..f5c1a597a8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,26 +2,30 @@ name: CI on: push: - branches: - - master + branches: [master] pull_request: +permissions: + contents: read + jobs: - build: - name: Build + test: + name: Test runs-on: ubuntu-latest - env: - GOFLAGS: -mod=readonly services: postgres: image: postgres:10.8 + env: + TZ: UTC ports: - 5432 options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 postgres-ent: image: postgres:10.8 + env: + TZ: UTC ports: - 5432 options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 @@ -44,6 +48,24 @@ jobs: - 3306 options: --health-cmd "mysql -proot -e \"show databases;\"" --health-interval 10s --health-timeout 5s --health-retries 5 + mysql8: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: dex + ports: + - 3306 + options: --health-cmd "mysql -proot -e \"show databases;\"" --health-interval 10s --health-timeout 5s --health-retries 5 + + mysql8-ent: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: dex + ports: + - 3306 + options: --health-cmd "mysql -proot -e \"show databases;\"" --health-interval 10s --health-timeout 5s --health-retries 5 + etcd: image: gcr.io/etcd-development/etcd:v3.5.0 ports: @@ -60,26 +82,50 @@ jobs: - 35357 options: --health-cmd "curl --fail http://localhost:5000/v3" --health-interval 10s --health-timeout 5s --health-retries 5 + vault: + image: hashicorp/vault:1.21 + ports: + - 8200 + env: + VAULT_DEV_ROOT_TOKEN_ID: root-token + VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200" + options: --health-cmd "vault status -address=http://localhost:8200 || exit 1" --health-interval 10s --health-timeout 5s --health-retries 5 + + openbao: + image: quay.io/openbao/openbao:2.5 + ports: + - 8210 + env: + BAO_DEV_ROOT_TOKEN_ID: root-token + BAO_DEV_LISTEN_ADDRESS: "0.0.0.0:8210" + options: --health-cmd "bao status -address=http://localhost:8210 || exit 1" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.18 + go-version: "1.25" + + - name: Download tool dependencies + run: make deps - - name: Checkout code - uses: actions/checkout@v3 + # Ensure that generated files were committed. + # It can help us determine, that the code is in the intermediate state, which should not be tested. + # Thus, heavy jobs like creating a kind cluster and testing / linting will be skipped. + - name: Verify + run: make verify - name: Start services - run: docker-compose -f docker-compose.test.yaml up -d + run: docker compose -f docker-compose.test.yaml up -d - name: Create kind cluster - uses: helm/kind-action@v1.3.0 + uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 with: - version: v0.11.1 - node_image: kindest/node:v1.19.11@sha256:07db187ae84b4b7de440a73886f008cf903fcf5764ba8106a9fd5243d6f32729 - - - name: Download tool dependencies - run: make deps + version: "v0.17.0" + node_image: "kindest/node:v1.25.3@sha256:cd248d1438192f7814fbca8fede13cfe5b9918746dfa12583976158a834fd5c5" - name: Test run: make testall @@ -96,6 +142,18 @@ jobs: DEX_MYSQL_ENT_HOST: 127.0.0.1 DEX_MYSQL_ENT_PORT: ${{ job.services.mysql-ent.ports[3306] }} + DEX_MYSQL8_DATABASE: dex + DEX_MYSQL8_USER: root + DEX_MYSQL8_PASSWORD: root + DEX_MYSQL8_HOST: 127.0.0.1 + DEX_MYSQL8_PORT: ${{ job.services.mysql8.ports[3306] }} + + DEX_MYSQL8_ENT_DATABASE: dex + DEX_MYSQL8_ENT_USER: root + DEX_MYSQL8_ENT_PASSWORD: root + DEX_MYSQL8_ENT_HOST: 127.0.0.1 + DEX_MYSQL8_ENT_PORT: ${{ job.services.mysql8-ent.ports[3306] }} + DEX_POSTGRES_DATABASE: postgres DEX_POSTGRES_USER: postgres DEX_POSTGRES_PASSWORD: postgres @@ -111,19 +169,63 @@ jobs: DEX_ETCD_ENDPOINTS: http://localhost:${{ job.services.etcd.ports[2379] }} DEX_LDAP_HOST: localhost - DEX_LDAP_PORT: 389 - DEX_LDAP_TLS_PORT: 636 + DEX_LDAP_PORT: 3890 + DEX_LDAP_TLS_PORT: 6360 DEX_KEYSTONE_URL: http://localhost:${{ job.services.keystone.ports[5000] }} DEX_KEYSTONE_ADMIN_URL: http://localhost:${{ job.services.keystone.ports[35357] }} DEX_KEYSTONE_ADMIN_USER: demo DEX_KEYSTONE_ADMIN_PASS: DEMO_PASS + DEX_VAULT_ADDR: http://localhost:${{ job.services.vault.ports[8200] }} + DEX_VAULT_TOKEN: root-token + DEX_OPENBAO_ADDR: http://localhost:${{ job.services.openbao.ports[8210] }} + DEX_OPENBAO_TOKEN: root-token + DEX_KUBERNETES_CONFIG_PATH: ~/.kube/config + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.25" + + - name: Download golangci-lint + run: make bin/golangci-lint + - name: Lint run: make lint - # Ensure proto generation doesn't depend on external packages. - - name: Verify proto - run: make verify-proto + artifacts: + name: Artifacts + uses: ./.github/workflows/artifacts.yaml + with: + publish: ${{ github.event_name == 'push' }} + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + permissions: + attestations: write + contents: read + packages: write + id-token: write + security-events: write + + dependency-review: + name: Dependency review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Dependency Review + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/.github/workflows/codeql-analysis.yaml b/.github/workflows/codeql-analysis.yaml deleted file mode 100644 index 926f8be539..0000000000 --- a/.github/workflows/codeql-analysis.yaml +++ /dev/null @@ -1,67 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ master, v1 ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '28 10 * * 6' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - language: [ 'go' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # โ„น๏ธ Command-line programs to run using the OS shell. - # ๐Ÿ“š https://git.io/JvXDl - - # โœ๏ธ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml deleted file mode 100644 index f841d55640..0000000000 --- a/.github/workflows/docker.yaml +++ /dev/null @@ -1,111 +0,0 @@ -name: Docker - -on: - # push: - # branches: - # - master - # tags: - # - v[0-9]+.[0-9]+.[0-9]+ - pull_request: - -jobs: - docker: - name: Docker - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Calculate Docker image tags - id: tags - env: - DOCKER_IMAGES: "ghcr.io/dexidp/dex dexidp/dex" - run: | - case $GITHUB_REF in - refs/tags/*) VERSION=${GITHUB_REF#refs/tags/};; - refs/heads/*) VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's#/+#-#g');; - refs/pull/*) VERSION=pr-${{ github.event.number }};; - *) VERSION=sha-${GITHUB_SHA::8};; - esac - - TAGS=() - for image in $DOCKER_IMAGES; do - TAGS+=("${image}:${VERSION}") - - if [[ "${{ github.event.repository.default_branch }}" == "$VERSION" ]]; then - TAGS+=("${image}:latest") - fi - done - - echo ::set-output name=version::${VERSION} - echo ::set-output name=tags::$(IFS=,; echo "${TAGS[*]}") - echo ::set-output name=commit_hash::${GITHUB_SHA::8} - echo ::set-output name=build_date::$(git show -s --format=%cI) - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - with: - platforms: all - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - with: - install: true - version: latest - # TODO: Remove driver-opts once fix is released docker/buildx#386 - driver-opts: image=moby/buildkit:master - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ github.token }} - if: github.event_name == 'push' - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - if: github.event_name == 'push' - - - name: Build and push - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le - # cache-from: type=gha - # cache-to: type=gha,mode=max - push: ${{ github.event_name == 'push' }} - tags: ${{ steps.tags.outputs.tags }} - build-args: | - VERSION=${{ steps.tags.outputs.version }} - COMMIT_HASH=${{ steps.tags.outputs.commit_hash }} - BUILD_DATE=${{ steps.tags.outputs.build_date }} - labels: | - org.opencontainers.image.title=${{ github.event.repository.name }} - org.opencontainers.image.description=${{ github.event.repository.description }} - org.opencontainers.image.url=${{ github.event.repository.html_url }} - org.opencontainers.image.source=${{ github.event.repository.clone_url }} - org.opencontainers.image.version=${{ steps.tags.outputs.version }} - org.opencontainers.image.created=${{ steps.tags.outputs.build_date }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} - org.opencontainers.image.documentation=https://dexidp.io/docs/ - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.7.1 - with: - image-ref: "ghcr.io/dexidp/dex:${{ steps.tags.outputs.version }}" - format: "template" - template: "@/contrib/sarif.tpl" - output: "trivy-results.sarif" - if: github.event_name == 'push' - - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v2 - with: - sarif_file: "trivy-results.sarif" - if: github.event_name == 'push' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000000..dbf397cbbe --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,24 @@ +name: Release + +on: + push: + tags: [ "v[0-9]+.[0-9]+.[0-9]+" ] + +permissions: + contents: read + +jobs: + artifacts: + name: Artifacts + uses: ./.github/workflows/artifacts.yaml + with: + publish: true + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + permissions: + attestations: write + contents: read + packages: write + id-token: write + security-events: write diff --git a/.github/workflows/trivydb-cache.yaml b/.github/workflows/trivydb-cache.yaml new file mode 100644 index 0000000000..feaf6da318 --- /dev/null +++ b/.github/workflows/trivydb-cache.yaml @@ -0,0 +1,42 @@ +# Note: This workflow only updates the cache. You should create a separate workflow for your actual Trivy scans. +# In your scan workflow, set TRIVY_SKIP_DB_UPDATE=true and TRIVY_SKIP_JAVA_DB_UPDATE=true. +name: Update Trivy Cache + +on: + schedule: + - cron: '0 0 * * *' # Run daily at midnight UTC + workflow_dispatch: # Allow manual triggering + +permissions: + contents: read + +jobs: + update-trivy-db: + runs-on: ubuntu-latest + steps: + - name: Setup oras + uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 + + - name: Get current date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + + - name: Download and extract the vulnerability DB + run: | + mkdir -p $GITHUB_WORKSPACE/.cache/trivy/db + oras pull ghcr.io/aquasecurity/trivy-db:2 + tar -xzf db.tar.gz -C $GITHUB_WORKSPACE/.cache/trivy/db + rm db.tar.gz + + - name: Download and extract the Java DB + run: | + mkdir -p $GITHUB_WORKSPACE/.cache/trivy/java-db + oras pull ghcr.io/aquasecurity/trivy-java-db:1 + tar -xzf javadb.tar.gz -C $GITHUB_WORKSPACE/.cache/trivy/java-db + rm javadb.tar.gz + + - name: Cache DBs + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ github.workspace }}/.cache/trivy + key: cache-trivy-${{ steps.date.outputs.date }} diff --git a/.gitignore b/.gitignore index 66dc41ccfe..3873e81af0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.devenv/ /.direnv/ /.idea/ /bin/ @@ -5,3 +6,15 @@ /docker-compose.override.yaml /var/ /vendor/ +*.db + +# Devenv +.devenv* +devenv.local.nix +devenv.local.yaml + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 443b711f60..0000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,3 +0,0 @@ -tasks: - - init: go get && go build ./... && go test ./... && make - command: go run diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000000..9fa3141874 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,124 @@ +version: "2" + +run: + timeout: 5m + +linters: + disable: + - staticcheck + - errcheck + enable: + - depguard + - dogsled + - exhaustive + - gochecknoinits + # - gocritic + - goprintffuncname + - govet + - ineffassign + - misspell + - nakedret + - nolintlint + - prealloc + # - revive + # - sqlclosecheck + # - staticcheck + - unconvert + - unused + - whitespace + + # Disable temporarily until everything works with Go 1.20 + # - bodyclose + # - rowserrcheck + # - tparallel + # - unparam + + # Disable temporarily until the following issue is resolved: https://github.com/golangci/golangci-lint/issues/3086 + # - sqlclosecheck + + # TODO: fix linter errors before enabling + # - exhaustivestruct + # - gochecknoglobals + # - errorlint + # - gocognit + # - godot + # - nlreturn + # - noctx + # - revive + # - wrapcheck + + # TODO: fix linter errors before enabling (from original config) + # - dupl + # - errcheck + # - goconst + # - gocyclo + # - gosec + # - lll + # - scopelint + + # unused + # - goheader + # - gomodguard + + # don't enable: + # - asciicheck + # - funlen + # - godox + # - goerr113 + # - gomnd + # - interfacer + # - maligned + # - nestif + # - testpackage + # - wsl + + exclusions: + rules: + - linters: + - errcheck + - noctx + path: _test.go + presets: + - comments + - std-error-handling + + settings: + misspell: + locale: US + nolintlint: + allow-unused: false # report any unused nolint directives + require-specific: false # don't require nolint directives to be specific about which linter is being skipped + gocritic: + # Enable multiple checks by tags. See "Tags" section in https://github.com/go-critic/go-critic#usage. + enabled-tags: + - diagnostic + - experimental + - opinionated + - style + disabled-checks: + - importShadow + - unnamedResult + depguard: + rules: + deprecated: + deny: + - pkg: "io/ioutil" + desc: "The 'io/ioutil' package is deprecated. Use corresponding 'os' or 'io' functions instead." + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + # - golines + + settings: + gci: + sections: + - standard + - default + - localmodule +# issues: +# exclude-dirs: +# - storage/ent/db # generated ent code diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index cfb64a75bf..0000000000 --- a/.golangci.yml +++ /dev/null @@ -1,90 +0,0 @@ -run: - timeout: 4m - -linters-settings: - depguard: - list-type: blacklist - include-go-root: true - packages: - - io/ioutil - packages-with-error-message: - - io/ioutil: "The 'io/ioutil' package is deprecated. Use corresponding 'os' or 'io' functions instead." - gci: - local-prefixes: github.com/dexidp/dex - goimports: - local-prefixes: github.com/dexidp/dex - - -linters: - disable-all: true - enable: - - bodyclose - - deadcode - - depguard - - dogsled - - exhaustive - - exportloopref - - gci - - gochecknoinits - - gocritic - - gofmt - - gofumpt - - goimports - - goprintffuncname - - gosimple - - govet - - ineffassign - - misspell - - nakedret - - nolintlint - - prealloc - - revive - - rowserrcheck - - sqlclosecheck - - staticcheck - - structcheck - - stylecheck - - tparallel - - unconvert - - unparam - - unused - - varcheck - - whitespace - - # Disable temporarily until everything works with Go 1.18 - # - typecheck - - # TODO: fix linter errors before enabling - # - exhaustivestruct - # - gochecknoglobals - # - errorlint - # - gocognit - # - godot - # - nlreturn - # - noctx - # - wrapcheck - - # TODO: fix linter errors before enabling (from original config) - # - dupl - # - errcheck - # - goconst - # - gocyclo - # - gosec - # - lll - # - scopelint - - # unused - # - goheader - # - gomodguard - - # don't enable: - # - asciicheck - # - funlen - # - godox - # - goerr113 - # - gomnd - # - interfacer - # - maligned - # - nestif - # - testpackage - # - wsl diff --git a/ADOPTERS.md b/ADOPTERS.md index 50f9ba988d..88a835cbdd 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,15 +1,26 @@ # Adopters -This is a list of production adopters of Dex (in alphabetical order): +This is a list of production adopters of Dex (in alphabetical order). + +# Companies - [Aspect](https://www.aspect.com/) uses Dex for authenticating users across their Kubernetes infrastructure (using Kubernetes OIDC support). - [Banzai Cloud](https://banzaicloud.com) is using Dex for authenticating to its Pipeline control plane and also to authenticate users against provisioned Kubernetes clusters (via Kubernetes OIDC support). -- [Chef](https://chef.io) uses Dex for authenticating users in [Chef Automate](https://automate.chef.io/). The code is Open Source, available at [`github.com/chef/automate`](https://github.com/chef/automate). -- [Elastisys](https://elastisys.com) uses Dex for authentication in their [Compliant Kubernetes](https://compliantkubernetes.io) distribution, including SSO to the custom dashboard, Grafana, Kibana, and Harbor. +- [Ericsson](https://www.ericsson.com) is using Dex to authenticate access to Kubernetes API server in [Cloud Container Distribution](https://www.ericsson.com/en/portfolio/cloud-software-and-services/cloud-core/cloud-infrastructure/nfvi/cloud-container-distribution). - [Flant](https://flant.com) uses Dex for providing access to core components of [Managed Kubernetes as a Service](https://flant.com/services/managed-kubernetes-as-a-service), integration with various authentication providers, plugging custom applications. - [JuliaBox](https://juliabox.com/) is leveraging federated OIDC provided by Dex for authenticating users to their compute infrastructure based on Kubernetes. +- [Pusher](https://pusher.com) uses Dex for authenticating users across their Kubernetes infrastructure (using Kubernetes OIDC support) in conjunction with the [OAuth2 Proxy](https://github.com/pusher/oauth2_proxy) for protecting web UIs. + +# Projects + +- [Argo CD](https://argoproj.github.io/cd) integrates Dex to provide convenient Single Sign On capabilities to its web UI and CLI +- [Chef](https://chef.io) uses Dex for authenticating users in [Chef Automate](https://automate.chef.io/). The code is Open Source, available at [`github.com/chef/automate`](https://github.com/chef/automate). +- [Elastisys](https://elastisys.com) uses Dex for authentication in [Welkin, The Application Platform for Software Critical to Society](https://elastisys.io/welkin/), including SSO to Grafana, OpenSearch, and Harbor. - [Kasten](https://www.kasten.io) is using Dex for authenticating access to the dashboard of [K10](https://www.kasten.io/product/), a Kubernetes-native platform for backup, disaster recovery and mobility of Kubernetes applications. K10 is widely used by a variety of customers including large enterprises, financial services, design firms, and IT companies. +- [Kubeflow](https://www.kubeflow.org/) [uses](https://github.com/kubeflow/manifests#dex) Dex as one of its components in the Kubeflow Platform for external OIDC authentication. - [Kyma](https://kyma-project.io) is using Dex to authenticate access to Kubernetes API server (even for managed Kubernetes like Google Kubernetes Engine or Azure Kubernetes Service) and for protecting web UI of [Kyma Console](https://github.com/kyma-project/console) and other UIs integrated in Kyma ([Grafana](https://github.com/grafana/grafana), [Loki](https://github.com/grafana/loki), and [Jaeger](https://github.com/jaegertracing/jaeger)). Kyma is an open-source project ([`github.com/kyma-project`](https://github.com/kyma-project/kyma)) designed natively on Kubernetes, that allows you to extend and customize your applications in a quick and modern way, using serverless computing or microservice architecture. -- [Pusher](https://pusher.com) uses Dex for authenticating users across their Kubernetes infrastructure (using Kubernetes OIDC support) in conjunction with the [OAuth2 Proxy](https://github.com/pusher/oauth2_proxy) for protecting web UIs. +- [LitmusChaos](https://litmuschaos.io/) uses Dex to [implement](https://docs.litmuschaos.io/docs/user-guides/chaoscenter-oauth-dex-installation#deploy-dex-oidc-provider) OAuth2 login support in ChaosCenter, its centralized chaos management tool. +- [LLMariner](https://llmariner.ai/) uses Dex for [user management](https://llmariner.ai/docs/features/user_management/). - [Pydio](https://pydio.com/) Pydio Cells is an open source sync & share platform written in Go. Cells is using Dex as an OIDC service for authentication and authorizations. Check out [Pydio Cells repository](https://github.com/pydio/cells) for more information and/or to contribute. - [sigstore](https://sigstore.dev) uses Dex for authentication in their public Fulcio instance, which is a certificate authority for code signing certificates bound to OIDC-based identities. +- [Terrakube](https://docs.terrakube.io/) relies on Dex for [user authentication](https://docs.terrakube.io/getting-started/deployment/user-authentication-dex). Its Helm chart uses Dex as a dependency. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..0bc91cd893 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,161 @@ +# Contributing to Dex + +Dex is [Apache 2.0 licensed](LICENSE) and accepts contributions via GitHub pull requests. +This document outlines how to contribute to the project. + +- [Code of Conduct](#code-of-conduct) +- [Finding something to work on](#finding-something-to-work-on) +- [Setting up a development environment](#setting-up-a-development-environment) +- [Making changes](#making-changes) +- [Running the example app](#running-the-example-app) +- [Committing your changes](#committing-your-changes) +- [Submitting a pull request](#submitting-a-pull-request) +- [Enhancement proposals](#enhancement-proposals) +- [Getting help](#getting-help) + +## Code of Conduct + +This project follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). + +## Finding something to work on + +If you have a bug fix or a small improvement, go ahead and open a pull request. + +For larger changes, please open a [discussion](https://github.com/dexidp/dex/discussions/new?category=Ideas) first +to align with the community and avoid unnecessary work. Major features or significant architectural +changes should go through the [Enhancement Proposal](#enhancement-proposals) process. + +If you're looking for something to work on, check: + +- Issues labeled with [good first issue](https://github.com/dexidp/dex/labels/good%20first%20issue) +- Issues labeled with [help wanted](https://github.com/dexidp/dex/labels/help%20wanted) + +Please comment on the issue to claim it before starting work to avoid duplicated efforts. + +## Setting up a development environment + +For the best developer experience, install [Nix](https://builtwithnix.org/) and [direnv](https://direnv.net/). +This will automatically set up all required tools (Go, golangci-lint, protobuf compiler, kind, etc.). + +Alternatively, you can set up the environment manually: + +1. Install [Go](https://go.dev/doc/install) (see the version in [go.mod](go.mod)). +2. Install [Docker](https://docs.docker.com/get-started/). +3. Install development dependencies: + +```shell +make deps +``` + +This installs `golangci-lint`, `gotestsum`, `protoc`, `protoc-gen-go`, `protoc-gen-go-grpc`, and `kind`. + +You can also use [GitHub Codespaces](https://github.com/codespaces/new?repo=dexidp/dex) for a ready-to-code cloud environment. + +## Making changes + +Run `make help` to see all available commands. The key ones for contributors: + +```shell +make deps # Install development dependencies +make build # Build Dex binaries +make testall # Run all tests (includes race detection) +make lint # Run linter +make generate # Regenerate protobuf, ent, and go mod tidy +``` + +## Running the example app + +To test the login flow locally, run Dex with the dev config and the example OIDC client app. + +**Terminal 1** โ€” start Dex: + +```shell +make build +./bin/dex serve config.dev.yaml +``` + +**Terminal 2** โ€” start the example app: + +```shell +make examples +./bin/example-app +``` + +Open http://127.0.0.1:5555 in your browser, click "Login", and authenticate with: + +- **Email:** `admin@example.com` +- **Password:** `password` + +After successful login, the example app displays the ID token claims returned by Dex. + +## Committing your changes + +The project follows [Conventional Commits](https://www.conventionalcommits.org/) style: + +``` +[optional scope]: + +[optional body] + +Signed-off-by: First Last +``` + +Common types: `feat`, `fix`, `build`, `chore`, `docs`, `refactor`, `test`. + +Examples from the project: + +``` +feat: use protobuf for session cookie (#4675) +fix: non-constant format string in call to newRedirectedErr (#4671) +build(deps): bump github/codeql-action from 4.33.0 to 4.34.1 (#4679) +``` + +### Developer Certificate of Origin + +As a CNCF project, Dex requires all contributors to sign the [Developer Certificate of Origin (DCO)](https://developercertificate.org/). +This certifies that you have the right to submit your contribution under the project's open source license. + +You must add a `Signed-off-by` line to every commit. Use git's `-s` flag to do this automatically: + +```shell +git commit -s -m "feat: add new feature" +``` + +The DCO check will fail on pull requests with unsigned commits. + +## Submitting a pull request + +1. Fork the repository and create your branch from `master`. +2. Make your changes, following the guidelines above. +3. Ensure all tests pass (`make testall`) and the linter is clean (`make lint`). +4. Ensure generated code is up to date (`make generate`). +5. Push your branch and open a pull request. + +When opening a pull request: + +- Fill in the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) with an overview and explanation of the change. +- After opening a PR, a maintainer will add least one [release note label](https://github.com/dexidp/dex/labels?q=release-note) to the PR. + Valid labels include: `kind/feature`, `kind/enhancement`, `kind/bug`, `release-note/new-feature`, + `release-note/enhancement`, `release-note/bug-fix`, `release-note/breaking-change`, + `release-note/deprecation`, `release-note/ignore`, `area/dependencies`, `release-note/dependency-update`. +- If the PR is still in progress, use GitHub's [Draft PR](https://github.blog/2019-02-14-introducing-draft-pull-requests/) feature. + +All CI checks (tests, linting, DCO, release label) must pass before the PR can be merged. + +## Enhancement proposals + +Significant features or architectural changes require a [Dex Enhancement Proposal (DEP)](docs/enhancements/README.md). + +The process: + +1. Search existing [issues](https://github.com/dexidp/dex/issues), [discussions](https://github.com/dexidp/dex/discussions), and [DEPs](https://github.com/dexidp/dex/tree/master/docs/enhancements). +2. Open a [discussion](https://github.com/dexidp/dex/discussions/new?category=Ideas) to get initial feedback. +3. Fork the repo and copy the [DEP template](docs/enhancements/_title-YYYY-MM-DD-#issue.md) with an appropriate name. +4. Fill in all sections and submit a PR for review. + +## Getting help + +- For bugs and feature requests, file an [issue](https://github.com/dexidp/dex/issues). +- For general discussion, open a [discussion](https://github.com/dexidp/dex/discussions) or join [#dexidp](https://cloud-native.slack.com/messages/dexidp) on the CNCF Slack. +- Mailing list (as a backup): [dex-dev](https://groups.google.com/forum/#!forum/dex-dev). +- For security vulnerabilities, see the [security policy](.github/SECURITY.md). diff --git a/Dockerfile b/Dockerfile index 3462ae52ac..43ec35238d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,48 +1,65 @@ ARG BASE_IMAGE=alpine -FROM golang:1.19.1-alpine3.16 AS builder +FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx -WORKDIR /usr/local/src/dex +FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine3.22@sha256:727cfc3c40be55cd1bc9a4a059406b28a059857e3be752aa9d09531e12c20c56 AS builder -RUN apk add --no-cache --update alpine-sdk ca-certificates openssl +COPY --from=xx / / -ARG TARGETOS -ARG TARGETARCH -ARG TARGETVARIANT="" +RUN apk add --update alpine-sdk ca-certificates openssl clang lld + +ARG TARGETPLATFORM -ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT} +RUN xx-apk --update add musl-dev gcc + +# lld has issues building static binaries for ppc so prefer ld for it +RUN [ "$(xx-info arch)" != "ppc64le" ] || XX_CC_PREFER_LINKER=ld xx-clang --setup-target-triple + +RUN xx-go --wrap + +WORKDIR /usr/local/src/dex ARG GOPROXY +ENV CGO_ENABLED=1 + COPY go.mod go.sum ./ COPY api/v2/go.mod api/v2/go.sum ./api/v2/ RUN go mod download COPY . . +# Propagate Dex version from build args to the build environment +ARG VERSION RUN make release-binary -FROM alpine:3.16.2 AS stager +RUN xx-verify /go/bin/dex && xx-verify /go/bin/docker-entrypoint + +FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS stager RUN mkdir -p /var/dex RUN mkdir -p /etc/dex COPY config.docker.yaml /etc/dex/ -FROM alpine:3.16.2 AS gomplate +FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS gomplate ARG TARGETOS ARG TARGETARCH ARG TARGETVARIANT -ENV GOMPLATE_VERSION=v3.11.2 +ENV GOMPLATE_VERSION=v5.2.0 RUN wget -O /usr/local/bin/gomplate \ "https://github.com/hairyhenderson/gomplate/releases/download/${GOMPLATE_VERSION}/gomplate_${TARGETOS:-linux}-${TARGETARCH:-amd64}${TARGETVARIANT}" \ && chmod +x /usr/local/bin/gomplate # For Dependabot to detect base image versions -FROM alpine:3.16.2 AS alpine -FROM gcr.io/distroless/static:latest AS distroless +FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS alpine + +FROM alpine AS user-setup +RUN addgroup -g 1001 -S dex && adduser -u 1001 -S -G dex -D -H -s /sbin/nologin dex + +FROM gcr.io/distroless/static-debian13:nonroot@sha256:f7f8f729987ad0fdf6b05eeeae94b26e6a0f613bdf46feea7fc40f7bd72953e6 AS distroless FROM $BASE_IMAGE @@ -53,6 +70,10 @@ FROM $BASE_IMAGE # See https://go.dev/src/crypto/x509/root_linux.go for Go root CA bundle locations. COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +# Ensure the dex user/group exist before setting ownership or switching to them. +COPY --from=user-setup /etc/passwd /etc/passwd +COPY --from=user-setup /etc/group /etc/group + COPY --from=stager --chown=1001:1001 /var/dex /var/dex COPY --from=stager --chown=1001:1001 /etc/dex /etc/dex @@ -66,7 +87,7 @@ COPY --from=builder /usr/local/src/dex/web /srv/dex/web COPY --from=gomplate /usr/local/bin/gomplate /usr/local/bin/gomplate -USER 1001:1001 +USER dex:dex ENTRYPOINT ["/usr/local/bin/docker-entrypoint"] CMD ["dex", "serve", "/etc/dex/config.docker.yaml"] diff --git a/MAINTAINERS b/MAINTAINERS deleted file mode 100644 index b95c2499de..0000000000 --- a/MAINTAINERS +++ /dev/null @@ -1,6 +0,0 @@ -Joel Speed (@JoelSpeed) -Maksim Nabokikh (@nabokihms) -Mark Sagi-Kazar (@sagikazarmark) -Nandor Kracser (@bonifaido) -Rithu John (@rithujohn191) -Stephen Augustus (@justaugustus) diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000000..889cbc05d8 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,25 @@ +# Dex Maintainers + +The current Maintainers Group for the Dex Project consists of: + +| Name | GitHub | Employer | Responsibilities | +| ---- | ------ | -------- | ---------------- | +| Maksim Nabokikh | [@nabokihms](https://github.com/nabokihms) | [Palark GmbH](https://palark.de/) | ALL | +| Mรกrk Sรกgi-Kazรกr | [@sagikazarmark](https://github.com/sagikazarmark) | | ALL | + +This list must be kept in sync with the [CNCF Project Maintainers list](https://github.com/cncf/foundation/blob/master/project-maintainers.csv). + + + +## Emeritus Maintainers + +We are grateful to our former maintainers for their contributions to the Dex project. + +- [@ericchiang](https://github.com/ericchiang) - Eric Chiang +- [@JoelSpeed](https://github.com/JoelSpeed) - Joel Speed +- [@bonifaido](https://github.com/bonifaido) - Nandor Kracser +- [@rithujohn191](https://github.com/rithujohn191) - Rithu John +- [@justaugustus](https://github.com/justaugustus) - Stephen Augustus +- [@srenatus](https://github.com/srenatus) - Stephan Renatus diff --git a/Makefile b/Makefile index 9c55ad2b11..2722be7d8d 100644 --- a/Makefile +++ b/Makefile @@ -1,139 +1,85 @@ -OS = $(shell uname | tr A-Z a-z) - export PATH := $(abspath bin/protoc/bin/):$(abspath bin/):${PATH} -PROJ=dex -ORG_PATH=github.com/dexidp -REPO_PATH=$(ORG_PATH)/$(PROJ) - -VERSION ?= $(shell ./scripts/git-version) +OS = $(shell uname | tr A-Z a-z) -DOCKER_REPO=quay.io/dexidp/dex -DOCKER_IMAGE=$(DOCKER_REPO):$(VERSION) +user=$(shell id -u -n) +group=$(shell id -g -n) $( shell mkdir -p bin ) -user=$(shell id -u -n) -group=$(shell id -g -n) +PROJ = dex +ORG_PATH = github.com/dexidp +REPO_PATH = $(ORG_PATH)/$(PROJ) +VERSION ?= $(shell ./scripts/git-version) -export GOBIN=$(PWD)/bin +export GOBIN=$(PWD)/bin LD_FLAGS="-w -X main.version=$(VERSION)" # Dependency versions +GOLANGCI_VERSION = 2.4.0 +GOTESTSUM_VERSION ?= 1.12.0 -KIND_NODE_IMAGE = "kindest/node:v1.19.11@sha256:07db187ae84b4b7de440a73886f008cf903fcf5764ba8106a9fd5243d6f32729" -KIND_TMP_DIR = "$(PWD)/bin/test/dex-kind-kubeconfig" +PROTOC_VERSION = 29.3 +PROTOC_GEN_GO_VERSION = 1.36.5 +PROTOC_GEN_GO_GRPC_VERSION = 1.5.1 -.PHONY: generate -generate: - @go generate $(REPO_PATH)/storage/ent/ +KIND_VERSION = 0.22.0 +KIND_NODE_IMAGE = "kindest/node:v1.25.3@sha256:cd248d1438192f7814fbca8fede13cfe5b9918746dfa12583976158a834fd5c5" +KIND_TMP_DIR = "$(PWD)/bin/test/dex-kind-kubeconfig" -build: generate bin/dex -bin/dex: - @mkdir -p bin/ - @go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex +##@ Build -examples: bin/grpc-client bin/example-app +build: bin/dex ## Build Dex binaries. -bin/grpc-client: - @mkdir -p bin/ - @cd examples/ && go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/grpc-client +examples: bin/grpc-client bin/example-app ## Build example app. -bin/example-app: - @mkdir -p bin/ - @cd examples/ && go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/example-app +.PHONY: update-gomplate +update-gomplate: ## Check and update gomplate version in Dockerfile. + @./scripts/update-gomplate .PHONY: release-binary release-binary: LD_FLAGS = "-w -X main.version=$(VERSION) -extldflags \"-static\"" -release-binary: generate +release-binary: ## Build release binaries (used to build a final container image). @go build -o /go/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex @go build -o /go/bin/docker-entrypoint -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/docker-entrypoint -docker-compose.override.yaml: - cp docker-compose.override.yaml.dist docker-compose.override.yaml - -.PHONY: up -up: docker-compose.override.yaml ## Launch the development environment - @ if [ docker-compose.override.yaml -ot docker-compose.override.yaml.dist ]; then diff -u docker-compose.override.yaml docker-compose.override.yaml.dist || (echo "!!! The distributed docker-compose.override.yaml example changed. Please update your file accordingly (or at least touch it). !!!" && false); fi - docker-compose up -d - -.PHONY: down -down: clear ## Destroy the development environment - docker-compose down --volumes --remove-orphans --rmi local - -test: - @go test -v ./... - -testrace: - @go test -v --race ./... - -.PHONY: kind-up kind-down kind-tests -kind-up: - @mkdir -p bin/test - @kind create cluster --image ${KIND_NODE_IMAGE} --kubeconfig ${KIND_TMP_DIR} - -kind-down: - @kind delete cluster - rm ${KIND_TMP_DIR} - -kind-tests: export DEX_KUBERNETES_CONFIG_PATH=${KIND_TMP_DIR} -kind-tests: testall - -.PHONY: lint lint-fix -lint: ## Run linter - golangci-lint run - -.PHONY: fix -fix: ## Fix lint violations - golangci-lint run --fix +bin/dex: + @mkdir -p bin/ + @go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex -.PHONY: docker-image -docker-image: - @sudo docker build -t $(DOCKER_IMAGE) . +bin/grpc-client: + @mkdir -p bin/ + @cd examples/ && go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/grpc-client -.PHONY: verify-proto -verify-proto: proto - @./scripts/git-diff +bin/example-app: + @mkdir -p bin/ + @cd examples/ && go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/example-app -clean: - @rm -rf bin/ -testall: testrace +##@ Generate -FORCE: +.PHONY: generate +generate: generate-proto generate-proto-internal generate-ent go-mod-tidy ## Run all generators. -.PHONY: test testrace testall +.PHONY: generate-ent +generate-ent: ## Generate code for database ORM. + @go generate $(REPO_PATH)/storage/ent/ -.PHONY: proto -proto: +.PHONY: generate-proto +generate-proto: ## Generate the Dex client's protobuf code. @protoc --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. api/v2/*.proto @protoc --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. api/*.proto - #@cp api/v2/*.proto api/ -.PHONY: proto-internal -proto-internal: +.PHONY: generate-proto-internal +generate-proto-internal: ## Generate protobuf code for token encoding. @protoc --go_out=paths=source_relative:. server/internal/*.proto -# Dependency versions -GOLANGCI_VERSION = 1.46.0 -GOTESTSUM_VERSION ?= 1.7.0 -PROTOC_VERSION = 3.15.6 -PROTOC_GEN_GO_VERSION = 1.26.0 -PROTOC_GEN_GO_GRPC_VERSION = 1.1.0 -KIND_VERSION = 0.11.1 - -deps: bin/gotestsum bin/golangci-lint bin/protoc bin/protoc-gen-go bin/protoc-gen-go-grpc bin/kind - -bin/gotestsum: - @mkdir -p bin - curl -L https://github.com/gotestyourself/gotestsum/releases/download/v${GOTESTSUM_VERSION}/gotestsum_${GOTESTSUM_VERSION}_$(shell uname | tr A-Z a-z)_amd64.tar.gz | tar -zOxf - gotestsum > ./bin/gotestsum - @chmod +x ./bin/gotestsum - -bin/golangci-lint: - @mkdir -p bin - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | BINARY=golangci-lint bash -s -- v${GOLANGCI_VERSION} +go-mod-tidy: ## Run go mod tidy for all targets. + @go mod tidy + @cd examples/ && go mod tidy + @cd api/v2/ && go mod tidy bin/protoc: @mkdir -p bin/protoc @@ -156,7 +102,128 @@ bin/protoc-gen-go-grpc: curl -L https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v${PROTOC_GEN_GO_GRPC_VERSION}/protoc-gen-go-grpc.v${PROTOC_GEN_GO_GRPC_VERSION}.$(shell uname | tr A-Z a-z).amd64.tar.gz | tar -zOxf - ./protoc-gen-go-grpc > ./bin/protoc-gen-go-grpc @chmod +x ./bin/protoc-gen-go-grpc +##@ Verify + +verify: generate ## Verify that all the code was generated and committed to repository. + @git diff --exit-code + +.PHONY: verify-proto +verify-proto: generate-proto ## Verify that the Dex client's protobuf code was generated. + @git diff --exit-code + +.PHONY: verify-proto +verify-proto-internal: generate-proto-internal ## Verify internal protobuf code for token encoding was generated. + @git diff --exit-code + +.PHONY: verify-ent +verify-ent: generate-ent ## Verify code for database ORM was generated. + @git diff --exit-code + +.PHONY: verify-go-mod +verify-go-mod: go-mod-tidy ## Check that go.mod and go.sum formatted according to the changes. + @git diff --exit-code + +##@ Test and Lint + +deps: bin/gotestsum bin/golangci-lint bin/protoc bin/protoc-gen-go bin/protoc-gen-go-grpc bin/kind ## Install dev dependencies. + +# Detect if we're running in GitHub Actions +ifdef GITHUB_ACTIONS +GOTESTSUM_FORMAT = github-actions +else +GOTESTSUM_FORMAT = testname +GOTESTSUM_FORMAT_ICONS = hivis +endif + +.PHONY: test testrace testall +test: bin/gotestsum ## Test go code. +ifdef GOTESTSUM_FORMAT_ICONS + @gotestsum --format $(GOTESTSUM_FORMAT) --format-icons $(GOTESTSUM_FORMAT_ICONS) -- -v ./... +else + @gotestsum --format $(GOTESTSUM_FORMAT) -- -v ./... +endif + +testrace: bin/gotestsum ## Test go code and check for possible race conditions. +ifdef GOTESTSUM_FORMAT_ICONS + @gotestsum --format $(GOTESTSUM_FORMAT) --format-icons $(GOTESTSUM_FORMAT_ICONS) -- -v --race ./... +else + @gotestsum --format $(GOTESTSUM_FORMAT) -- -v --race ./... +endif + +testall: testrace ## Run all tests for go code. + +.PHONY: lint +lint: ## Run linter. + @golangci-lint version + @golangci-lint run + +.PHONY: fix +fix: ## Fix lint violations. + @golangci-lint version + @golangci-lint fmt + +docker-compose.override.yaml: + cp docker-compose.override.yaml.dist docker-compose.override.yaml + +.PHONY: up +up: docker-compose.override.yaml ## Launch the development environment. + @ if [ docker-compose.override.yaml -ot docker-compose.override.yaml.dist ]; then diff -u docker-compose.override.yaml docker-compose.override.yaml.dist || (echo "!!! The distributed docker-compose.override.yaml example changed. Please update your file accordingly (or at least touch it). !!!" && false); fi + docker-compose up -d + +.PHONY: down +down: clear ## Destroy the development environment. + docker-compose down --volumes --remove-orphans --rmi local + +.PHONY: kind-up kind-down kind-tests +kind-up: ## Create a kind cluster. + @mkdir -p bin/test + @kind create cluster --image ${KIND_NODE_IMAGE} --kubeconfig ${KIND_TMP_DIR} --name dex-tests + +kind-tests: export DEX_KUBERNETES_CONFIG_PATH=${KIND_TMP_DIR} +kind-tests: testall ## Run test on kind cluster (kind cluster must be created). + +kind-down: ## Delete the kind cluster. + @kind delete cluster --name dex-tests + rm ${KIND_TMP_DIR} + +bin/golangci-lint: + @mkdir -p bin + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | BINARY=golangci-lint bash -s -- v${GOLANGCI_VERSION} + +bin/gotestsum: + @mkdir -p bin + curl -L https://github.com/gotestyourself/gotestsum/releases/download/v${GOTESTSUM_VERSION}/gotestsum_${GOTESTSUM_VERSION}_$(shell uname | tr A-Z a-z)_amd64.tar.gz | tar -zOxf - gotestsum > ./bin/gotestsum + @chmod +x ./bin/gotestsum + bin/kind: @mkdir -p bin curl -L https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION}/kind-$(shell uname | tr A-Z a-z)-amd64 > ./bin/kind @chmod +x ./bin/kind + +##@ Clean +clean: ## Delete all builds and downloaded dependencies. + @rm -rf bin/ + + +FORMATTING_BEGIN_YELLOW = \033[0;33m +FORMATTING_BEGIN_BLUE = \033[36m +FORMATTING_END = \033[0m + +.PHONY: help +help: + @printf -- "${FORMATTING_BEGIN_BLUE}%s${FORMATTING_END}\n" \ + "" \ + " ___ " \ + " / _ \_____ __ " \ + " / // / -_) \ / " \ + " /____/\__/_\_\ " \ + "" \ + "-----------------------" \ + "" + @awk 'BEGIN {\ + FS = ":.*##"; \ + printf "Usage: ${FORMATTING_BEGIN_BLUE}OPTION${FORMATTING_END}= make ${FORMATTING_BEGIN_YELLOW}${FORMATTING_END}\n"\ + } \ + /^[a-zA-Z0-9_-]+:.*?##/ { printf " ${FORMATTING_BEGIN_BLUE}%-46s${FORMATTING_END} %s\n", $$1, $$2 } \ + /^.?.?##~/ { printf " %-46s${FORMATTING_BEGIN_YELLOW}%-46s${FORMATTING_END}\n", "", substr($$1, 6) } \ + /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) diff --git a/README.md b/README.md index 271376d65e..41fc3f2281 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # dex - A federated OpenID Connect provider -![GitHub Workflow Status](https://img.shields.io/github/workflow/status/dexidp/dex/CI?style=flat-square) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dexidp/dex/ci.yaml?style=flat-square&branch=master) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/dexidp/dex/badge?style=flat-square)](https://api.securityscorecards.dev/projects/github.com/dexidp/dex) +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/12566/badge)](https://www.bestpractices.dev/projects/12566) [![Go Report Card](https://goreportcard.com/badge/github.com/dexidp/dex?style=flat-square)](https://goreportcard.com/report/github.com/dexidp/dex) -[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod&style=flat-square)](https://gitpod.io/#https://github.com/dexidp/dex) +[![LFX Health Score](https://insights.linuxfoundation.org/api/badge/health-score?project=dex)](https://insights.linuxfoundation.org/project/dex/contributors) ![logo](docs/logos/dex-horizontal-color.png) @@ -12,7 +14,7 @@ Dex acts as a portal to other identity providers through ["connectors."](#connec ## ID Tokens -ID Tokens are an OAuth2 extension introduced by OpenID Connect and dex's primary feature. ID Tokens are [JSON Web Tokens][jwt-io] (JWTs) signed by dex and returned as part of the OAuth2 response that attest to the end user's identity. An example JWT might look like: +ID Tokens are an OAuth2 extension introduced by OpenID Connect and dex's primary feature. ID Tokens are [JSON Web Tokens][jwt-io] (JWTs) signed by dex and returned as part of the OAuth2 response that attests to the end user's identity. An example JWT might look like: ``` eyJhbGciOiJSUzI1NiIsImtpZCI6IjlkNDQ3NDFmNzczYjkzOGNmNjVkZDMyNjY4NWI4NjE4MGMzMjRkOTkifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU1NTYvZGV4Iiwic3ViIjoiQ2djeU16UXlOelE1RWdabmFYUm9kV0kiLCJhdWQiOiJleGFtcGxlLWFwcCIsImV4cCI6MTQ5Mjg4MjA0MiwiaWF0IjoxNDkyNzk1NjQyLCJhdF9oYXNoIjoiYmk5NmdPWFpTaHZsV1l0YWw5RXFpdyIsImVtYWlsIjoiZXJpYy5jaGlhbmdAY29yZW9zLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJncm91cHMiOlsiYWRtaW5zIiwiZGV2ZWxvcGVycyJdLCJuYW1lIjoiRXJpYyBDaGlhbmcifQ.OhROPq_0eP-zsQRjg87KZ4wGkjiQGnTi5QuG877AdJDb3R2ZCOk2Vkf5SdP8cPyb3VMqL32G4hLDayniiv8f1_ZXAde0sKrayfQ10XAXFgZl_P1yilkLdknxn6nbhDRVllpWcB12ki9vmAxklAr0B1C4kr5nI3-BZLrFcUR5sQbxwJj4oW1OuG6jJCNGHXGNTBTNEaM28eD-9nhfBeuBTzzO7BKwPsojjj4C9ogU4JQhGvm_l4yfVi0boSx8c0FX3JsiB0yLa1ZdJVWVl9m90XmbWRSD85pNDQHcWZP9hR6CMgbvGkZsgjG32qeRwUL_eNkNowSBNWLrGNPoON1gMg @@ -43,14 +45,14 @@ Because these tokens are signed by dex and [contain standard-based claims][stand * [Kubernetes][kubernetes] * [AWS STS][aws-sts] -For details on how to request or validate an ID Token, see [_"Writing apps that use dex"_][using-dex]. +For details on how to request or validate an ID Token, see [_"Writing apps that use dex"_](https://dexidp.io/docs/using-dex/). ## Kubernetes and Dex -Dex runs natively on top of any Kubernetes cluster using Custom Resource Definitions and can drive API server authentication through the OpenID Connect plugin. Clients, such as the [`kubernetes-dashboard`](https://github.com/kubernetes/dashboard) and `kubectl`, can act on behalf of users who can login to the cluster through any identity provider dex supports. +Dex runs natively on top of any Kubernetes cluster using Custom Resource Definitions and can drive API server authentication through the OpenID Connect plugin. Clients, such as [`kubelogin`](https://github.com/int128/kubelogin) and `kubectl`, can act on behalf of users who can login to the cluster through any identity provider dex supports. -* More docs for running dex as a Kubernetes authenticator can be found [here](https://dexidp.io/docs/kubernetes/). -* You can find more about companies and projects, which uses dex, [here](./ADOPTERS.md). +* More docs for running dex as a Kubernetes authenticator can be found [here](https://dexidp.io/docs/guides/kubernetes/). +* You can find more about companies and projects which use dex, [here](./ADOPTERS.md). ## Connectors @@ -77,8 +79,8 @@ Dex implements the following connectors: | [Microsoft](https://dexidp.io/docs/connectors/microsoft/) | yes | yes | no | beta | | | [AuthProxy](https://dexidp.io/docs/connectors/authproxy/) | no | yes | no | alpha | Authentication proxies such as Apache2 mod_auth, etc. | | [Bitbucket Cloud](https://dexidp.io/docs/connectors/bitbucketcloud/) | yes | yes | no | alpha | | -| [OpenShift](https://dexidp.io/docs/connectors/openshift/) | no | yes | no | alpha | | -| [Atlassian Crowd](https://dexidp.io/docs/connectors/atlassiancrowd/) | yes | yes | yes * | beta | preferred_username claim must be configured through config | +| [OpenShift](https://dexidp.io/docs/connectors/openshift/) | yes | yes | no | alpha | | +| [Atlassian Crowd](https://dexidp.io/docs/connectors/atlassian-crowd/) | yes | yes | yes * | beta | preferred_username claim must be configured through config | | [Gitea](https://dexidp.io/docs/connectors/gitea/) | yes | no | yes | beta | | | [OpenStack Keystone](https://dexidp.io/docs/connectors/keystone/) | yes | yes | no | alpha | | @@ -92,16 +94,7 @@ All changes or deprecations of connector features will be announced in the [rele ## Documentation -* [Getting started](https://dexidp.io/docs/getting-started/) -* [Intro to OpenID Connect](https://dexidp.io/docs/openid-connect/) -* [Writing apps that use dex][using-dex] -* [What's new in v2](https://dexidp.io/docs/v2/) -* [Custom scopes, claims, and client features](https://dexidp.io/docs/custom-scopes-claims-clients/) -* [Storage options](https://dexidp.io/docs/storage/) -* [gRPC API](https://dexidp.io/docs/api/) -* [Using Kubernetes with dex](https://dexidp.io/docs/kubernetes/) -* Client libraries - * [Go][go-oidc] +See the [official documentation](https://dexidp.io/docs/) for getting started, configuration, and usage guides. ## Reporting a vulnerability @@ -113,30 +106,18 @@ Please see our [security policy](.github/SECURITY.md) for details about reportin - For general discussion about both using and developing Dex: - join the [#dexidp](https://cloud-native.slack.com/messages/dexidp) on the CNCF Slack - open a new [discussion](https://github.com/dexidp/dex/discussions) - - join the [dex-dev](https://groups.google.com/forum/#!forum/dex-dev) mailing list [openid-connect]: https://openid.net/connect/ [standard-claims]: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims [scopes]: https://dexidp.io/docs/custom-scopes-claims-clients/#scopes -[using-dex]: https://dexidp.io/docs/using-dex/ [jwt-io]: https://jwt.io/ -[kubernetes]: http://kubernetes.io/docs/admin/authentication/#openid-connect-tokens +[kubernetes]: https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens [aws-sts]: https://docs.aws.amazon.com/STS/latest/APIReference/Welcome.html -[go-oidc]: https://github.com/coreos/go-oidc -[issue-1065]: https://github.com/dexidp/dex/issues/1065 [release-notes]: https://github.com/dexidp/dex/releases -## Development +## Contributing -When all coding and testing is done, please run the test suite: - -```shell -make testall -``` - -For the best developer experience, install [Nix](https://builtwithnix.org/) and [direnv](https://direnv.net/). - -Alternatively, install Go and Docker manually or using a package manager. Install the rest of the dependencies by running `make deps`. +Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, guidelines, and how to submit pull requests. ## License diff --git a/api/api.pb.go b/api/api.pb.go index 6d1c2ca82e..23c6141071 100644 --- a/api/api.pb.go +++ b/api/api.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.26.0 -// protoc v3.15.6 +// protoc-gen-go v1.36.5 +// protoc v5.29.3 // source: api/api.proto package api @@ -11,6 +11,7 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" + unsafe "unsafe" ) const ( @@ -22,26 +23,24 @@ const ( // Client represents an OAuth2 client. type Client struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Secret string `protobuf:"bytes,2,opt,name=secret,proto3" json:"secret,omitempty"` - RedirectUris []string `protobuf:"bytes,3,rep,name=redirect_uris,json=redirectUris,proto3" json:"redirect_uris,omitempty"` - TrustedPeers []string `protobuf:"bytes,4,rep,name=trusted_peers,json=trustedPeers,proto3" json:"trusted_peers,omitempty"` - Public bool `protobuf:"varint,5,opt,name=public,proto3" json:"public,omitempty"` - Name string `protobuf:"bytes,6,opt,name=name,proto3" json:"name,omitempty"` - LogoUrl string `protobuf:"bytes,7,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Secret string `protobuf:"bytes,2,opt,name=secret,proto3" json:"secret,omitempty"` + RedirectUris []string `protobuf:"bytes,3,rep,name=redirect_uris,json=redirectUris,proto3" json:"redirect_uris,omitempty"` + TrustedPeers []string `protobuf:"bytes,4,rep,name=trusted_peers,json=trustedPeers,proto3" json:"trusted_peers,omitempty"` + Public bool `protobuf:"varint,5,opt,name=public,proto3" json:"public,omitempty"` + Name string `protobuf:"bytes,6,opt,name=name,proto3" json:"name,omitempty"` + LogoUrl string `protobuf:"bytes,7,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"` + AllowedConnectors []string `protobuf:"bytes,8,rep,name=allowed_connectors,json=allowedConnectors,proto3" json:"allowed_connectors,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Client) Reset() { *x = Client{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Client) String() string { @@ -52,7 +51,7 @@ func (*Client) ProtoMessage() {} func (x *Client) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -116,22 +115,26 @@ func (x *Client) GetLogoUrl() string { return "" } +func (x *Client) GetAllowedConnectors() []string { + if x != nil { + return x.AllowedConnectors + } + return nil +} + // CreateClientReq is a request to make a client. type CreateClientReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Client *Client `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"` unknownFields protoimpl.UnknownFields - - Client *Client `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CreateClientReq) Reset() { *x = CreateClientReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreateClientReq) String() string { @@ -142,7 +145,7 @@ func (*CreateClientReq) ProtoMessage() {} func (x *CreateClientReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -166,21 +169,18 @@ func (x *CreateClientReq) GetClient() *Client { // CreateClientResp returns the response from creating a client. type CreateClientResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` + Client *Client `protobuf:"bytes,2,opt,name=client,proto3" json:"client,omitempty"` unknownFields protoimpl.UnknownFields - - AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` - Client *Client `protobuf:"bytes,2,opt,name=client,proto3" json:"client,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CreateClientResp) Reset() { *x = CreateClientResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreateClientResp) String() string { @@ -191,7 +191,7 @@ func (*CreateClientResp) ProtoMessage() {} func (x *CreateClientResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -222,21 +222,18 @@ func (x *CreateClientResp) GetClient() *Client { // DeleteClientReq is a request to delete a client. type DeleteClientReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // The ID of the client. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteClientReq) Reset() { *x = DeleteClientReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeleteClientReq) String() string { @@ -247,7 +244,7 @@ func (*DeleteClientReq) ProtoMessage() {} func (x *DeleteClientReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -271,20 +268,17 @@ func (x *DeleteClientReq) GetId() string { // DeleteClientResp determines if the client is deleted successfully. type DeleteClientResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` unknownFields protoimpl.UnknownFields - - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + sizeCache protoimpl.SizeCache } func (x *DeleteClientResp) Reset() { *x = DeleteClientResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeleteClientResp) String() string { @@ -295,7 +289,7 @@ func (*DeleteClientResp) ProtoMessage() {} func (x *DeleteClientResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -319,24 +313,22 @@ func (x *DeleteClientResp) GetNotFound() bool { // UpdateClientReq is a request to update an existing client. type UpdateClientReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - RedirectUris []string `protobuf:"bytes,2,rep,name=redirect_uris,json=redirectUris,proto3" json:"redirect_uris,omitempty"` - TrustedPeers []string `protobuf:"bytes,3,rep,name=trusted_peers,json=trustedPeers,proto3" json:"trusted_peers,omitempty"` - Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` - LogoUrl string `protobuf:"bytes,5,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + RedirectUris []string `protobuf:"bytes,2,rep,name=redirect_uris,json=redirectUris,proto3" json:"redirect_uris,omitempty"` + TrustedPeers []string `protobuf:"bytes,3,rep,name=trusted_peers,json=trustedPeers,proto3" json:"trusted_peers,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + LogoUrl string `protobuf:"bytes,5,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"` + AllowedConnectors []string `protobuf:"bytes,6,rep,name=allowed_connectors,json=allowedConnectors,proto3" json:"allowed_connectors,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateClientReq) Reset() { *x = UpdateClientReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *UpdateClientReq) String() string { @@ -347,7 +339,7 @@ func (*UpdateClientReq) ProtoMessage() {} func (x *UpdateClientReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -397,22 +389,26 @@ func (x *UpdateClientReq) GetLogoUrl() string { return "" } +func (x *UpdateClientReq) GetAllowedConnectors() []string { + if x != nil { + return x.AllowedConnectors + } + return nil +} + // UpdateClientResp returns the response from updating a client. type UpdateClientResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` unknownFields protoimpl.UnknownFields - - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + sizeCache protoimpl.SizeCache } func (x *UpdateClientResp) Reset() { *x = UpdateClientResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *UpdateClientResp) String() string { @@ -423,7 +419,7 @@ func (*UpdateClientResp) ProtoMessage() {} func (x *UpdateClientResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -447,24 +443,21 @@ func (x *UpdateClientResp) GetNotFound() bool { // Password is an email for password mapping managed by the storage. type Password struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` // Currently we do not accept plain text passwords. Could be an option in the future. - Hash []byte `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"` - Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` - UserId string `protobuf:"bytes,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Hash []byte `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + UserId string `protobuf:"bytes,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Password) Reset() { *x = Password{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Password) String() string { @@ -475,7 +468,7 @@ func (*Password) ProtoMessage() {} func (x *Password) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -520,20 +513,17 @@ func (x *Password) GetUserId() string { // CreatePasswordReq is a request to make a password. type CreatePasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Password *Password `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` unknownFields protoimpl.UnknownFields - - Password *Password `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CreatePasswordReq) Reset() { *x = CreatePasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreatePasswordReq) String() string { @@ -544,7 +534,7 @@ func (*CreatePasswordReq) ProtoMessage() {} func (x *CreatePasswordReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -568,20 +558,17 @@ func (x *CreatePasswordReq) GetPassword() *Password { // CreatePasswordResp returns the response from creating a password. type CreatePasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` unknownFields protoimpl.UnknownFields - - AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CreatePasswordResp) Reset() { *x = CreatePasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreatePasswordResp) String() string { @@ -592,7 +579,7 @@ func (*CreatePasswordResp) ProtoMessage() {} func (x *CreatePasswordResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -616,23 +603,20 @@ func (x *CreatePasswordResp) GetAlreadyExists() bool { // UpdatePasswordReq is a request to modify an existing password. type UpdatePasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // The email used to lookup the password. This field cannot be modified - Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` - NewHash []byte `protobuf:"bytes,2,opt,name=new_hash,json=newHash,proto3" json:"new_hash,omitempty"` - NewUsername string `protobuf:"bytes,3,opt,name=new_username,json=newUsername,proto3" json:"new_username,omitempty"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + NewHash []byte `protobuf:"bytes,2,opt,name=new_hash,json=newHash,proto3" json:"new_hash,omitempty"` + NewUsername string `protobuf:"bytes,3,opt,name=new_username,json=newUsername,proto3" json:"new_username,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdatePasswordReq) Reset() { *x = UpdatePasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *UpdatePasswordReq) String() string { @@ -643,7 +627,7 @@ func (*UpdatePasswordReq) ProtoMessage() {} func (x *UpdatePasswordReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -681,20 +665,17 @@ func (x *UpdatePasswordReq) GetNewUsername() string { // UpdatePasswordResp returns the response from modifying an existing password. type UpdatePasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` unknownFields protoimpl.UnknownFields - - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + sizeCache protoimpl.SizeCache } func (x *UpdatePasswordResp) Reset() { *x = UpdatePasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *UpdatePasswordResp) String() string { @@ -705,7 +686,7 @@ func (*UpdatePasswordResp) ProtoMessage() {} func (x *UpdatePasswordResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[11] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -729,20 +710,17 @@ func (x *UpdatePasswordResp) GetNotFound() bool { // DeletePasswordReq is a request to delete a password. type DeletePasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` unknownFields protoimpl.UnknownFields - - Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + sizeCache protoimpl.SizeCache } func (x *DeletePasswordReq) Reset() { *x = DeletePasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeletePasswordReq) String() string { @@ -753,7 +731,7 @@ func (*DeletePasswordReq) ProtoMessage() {} func (x *DeletePasswordReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[12] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -777,20 +755,17 @@ func (x *DeletePasswordReq) GetEmail() string { // DeletePasswordResp returns the response from deleting a password. type DeletePasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` unknownFields protoimpl.UnknownFields - - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + sizeCache protoimpl.SizeCache } func (x *DeletePasswordResp) Reset() { *x = DeletePasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeletePasswordResp) String() string { @@ -801,7 +776,7 @@ func (*DeletePasswordResp) ProtoMessage() {} func (x *DeletePasswordResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -825,18 +800,16 @@ func (x *DeletePasswordResp) GetNotFound() bool { // ListPasswordReq is a request to enumerate passwords. type ListPasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListPasswordReq) Reset() { *x = ListPasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListPasswordReq) String() string { @@ -847,7 +820,7 @@ func (*ListPasswordReq) ProtoMessage() {} func (x *ListPasswordReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[14] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -864,20 +837,17 @@ func (*ListPasswordReq) Descriptor() ([]byte, []int) { // ListPasswordResp returns a list of passwords. type ListPasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Passwords []*Password `protobuf:"bytes,1,rep,name=passwords,proto3" json:"passwords,omitempty"` unknownFields protoimpl.UnknownFields - - Passwords []*Password `protobuf:"bytes,1,rep,name=passwords,proto3" json:"passwords,omitempty"` + sizeCache protoimpl.SizeCache } func (x *ListPasswordResp) Reset() { *x = ListPasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListPasswordResp) String() string { @@ -888,7 +858,7 @@ func (*ListPasswordResp) ProtoMessage() {} func (x *ListPasswordResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[15] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -912,18 +882,16 @@ func (x *ListPasswordResp) GetPasswords() []*Password { // VersionReq is a request to fetch version info. type VersionReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *VersionReq) Reset() { *x = VersionReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *VersionReq) String() string { @@ -934,7 +902,7 @@ func (*VersionReq) ProtoMessage() {} func (x *VersionReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[16] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -951,24 +919,21 @@ func (*VersionReq) Descriptor() ([]byte, []int) { // VersionResp holds the version info of components. type VersionResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // Semantic version of the server. Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` - // Numeric version of the API. It increases everytime a new call is added to the API. + // Numeric version of the API. It increases every time a new call is added to the API. // Clients should use this info to determine if the server supports specific features. - Api int32 `protobuf:"varint,2,opt,name=api,proto3" json:"api,omitempty"` + Api int32 `protobuf:"varint,2,opt,name=api,proto3" json:"api,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *VersionResp) Reset() { *x = VersionResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *VersionResp) String() string { @@ -979,7 +944,7 @@ func (*VersionResp) ProtoMessage() {} func (x *VersionResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[17] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1010,24 +975,21 @@ func (x *VersionResp) GetApi() int32 { // RefreshTokenRef contains the metadata for a refresh token that is managed by the storage. type RefreshTokenRef struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // ID of the refresh token. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` - CreatedAt int64 `protobuf:"varint,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - LastUsed int64 `protobuf:"varint,6,opt,name=last_used,json=lastUsed,proto3" json:"last_used,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + CreatedAt int64 `protobuf:"varint,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + LastUsed int64 `protobuf:"varint,6,opt,name=last_used,json=lastUsed,proto3" json:"last_used,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RefreshTokenRef) Reset() { *x = RefreshTokenRef{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RefreshTokenRef) String() string { @@ -1038,7 +1000,7 @@ func (*RefreshTokenRef) ProtoMessage() {} func (x *RefreshTokenRef) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[18] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1083,21 +1045,18 @@ func (x *RefreshTokenRef) GetLastUsed() int64 { // ListRefreshReq is a request to enumerate the refresh tokens of a user. type ListRefreshReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // The "sub" claim returned in the ID Token. - UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListRefreshReq) Reset() { *x = ListRefreshReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListRefreshReq) String() string { @@ -1108,7 +1067,7 @@ func (*ListRefreshReq) ProtoMessage() {} func (x *ListRefreshReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[19] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1132,20 +1091,17 @@ func (x *ListRefreshReq) GetUserId() string { // ListRefreshResp returns a list of refresh tokens for a user. type ListRefreshResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + RefreshTokens []*RefreshTokenRef `protobuf:"bytes,1,rep,name=refresh_tokens,json=refreshTokens,proto3" json:"refresh_tokens,omitempty"` unknownFields protoimpl.UnknownFields - - RefreshTokens []*RefreshTokenRef `protobuf:"bytes,1,rep,name=refresh_tokens,json=refreshTokens,proto3" json:"refresh_tokens,omitempty"` + sizeCache protoimpl.SizeCache } func (x *ListRefreshResp) Reset() { *x = ListRefreshResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListRefreshResp) String() string { @@ -1156,7 +1112,7 @@ func (*ListRefreshResp) ProtoMessage() {} func (x *ListRefreshResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[20] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1180,22 +1136,19 @@ func (x *ListRefreshResp) GetRefreshTokens() []*RefreshTokenRef { // RevokeRefreshReq is a request to revoke the refresh token of the user-client pair. type RevokeRefreshReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // The "sub" claim returned in the ID Token. - UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RevokeRefreshReq) Reset() { *x = RevokeRefreshReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RevokeRefreshReq) String() string { @@ -1206,7 +1159,7 @@ func (*RevokeRefreshReq) ProtoMessage() {} func (x *RevokeRefreshReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[21] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1237,21 +1190,18 @@ func (x *RevokeRefreshReq) GetClientId() string { // RevokeRefreshResp determines if the refresh token is revoked successfully. type RevokeRefreshResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // Set to true is refresh token was not found and token could not be revoked. - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RevokeRefreshResp) Reset() { *x = RevokeRefreshResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *RevokeRefreshResp) String() string { @@ -1262,7 +1212,7 @@ func (*RevokeRefreshResp) ProtoMessage() {} func (x *RevokeRefreshResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[22] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1285,21 +1235,18 @@ func (x *RevokeRefreshResp) GetNotFound() bool { } type VerifyPasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` unknownFields protoimpl.UnknownFields - - Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` - Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` + sizeCache protoimpl.SizeCache } func (x *VerifyPasswordReq) Reset() { *x = VerifyPasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *VerifyPasswordReq) String() string { @@ -1310,7 +1257,7 @@ func (*VerifyPasswordReq) ProtoMessage() {} func (x *VerifyPasswordReq) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[23] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1340,21 +1287,18 @@ func (x *VerifyPasswordReq) GetPassword() string { } type VerifyPasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Verified bool `protobuf:"varint,1,opt,name=verified,proto3" json:"verified,omitempty"` + NotFound bool `protobuf:"varint,2,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` unknownFields protoimpl.UnknownFields - - Verified bool `protobuf:"varint,1,opt,name=verified,proto3" json:"verified,omitempty"` - NotFound bool `protobuf:"varint,2,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + sizeCache protoimpl.SizeCache } func (x *VerifyPasswordResp) Reset() { *x = VerifyPasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_api_proto_msgTypes[24] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_api_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *VerifyPasswordResp) String() string { @@ -1365,7 +1309,7 @@ func (*VerifyPasswordResp) ProtoMessage() {} func (x *VerifyPasswordResp) ProtoReflect() protoreflect.Message { mi := &file_api_api_proto_msgTypes[24] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1396,9 +1340,9 @@ func (x *VerifyPasswordResp) GetNotFound() bool { var File_api_api_proto protoreflect.FileDescriptor -var file_api_api_proto_rawDesc = []byte{ +var file_api_api_proto_rawDesc = string([]byte{ 0x0a, 0x0d, 0x61, 0x70, 0x69, 0x2f, 0x61, 0x70, 0x69, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, - 0x03, 0x61, 0x70, 0x69, 0x22, 0xc1, 0x01, 0x0a, 0x06, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, + 0x03, 0x61, 0x70, 0x69, 0x22, 0xf0, 0x01, 0x0a, 0x06, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x64, 0x69, 0x72, @@ -1410,171 +1354,177 @@ var file_api_api_proto_rawDesc = []byte{ 0x08, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x6f, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x6c, 0x6f, 0x67, 0x6f, 0x55, 0x72, 0x6c, 0x22, 0x36, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x23, 0x0a, 0x06, 0x63, - 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x22, 0x5e, 0x0a, 0x10, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x52, 0x65, 0x73, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, - 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61, 0x6c, - 0x72, 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x12, 0x23, 0x0a, 0x06, 0x63, - 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x22, 0x21, 0x0a, 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x02, 0x69, 0x64, 0x22, 0x2f, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, - 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, - 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x9a, 0x01, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, - 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x69, 0x73, 0x12, 0x23, 0x0a, - 0x0d, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x50, 0x65, 0x65, - 0x72, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x6f, 0x5f, 0x75, - 0x72, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x6f, 0x55, 0x72, - 0x6c, 0x22, 0x2f, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, - 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, - 0x6e, 0x64, 0x22, 0x69, 0x0a, 0x08, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x14, - 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, - 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, - 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x3e, 0x0a, - 0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, - 0x65, 0x71, 0x12, 0x29, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x3b, 0x0a, - 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, + 0x07, 0x6c, 0x6f, 0x67, 0x6f, 0x55, 0x72, 0x6c, 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x6c, 0x6c, 0x6f, + 0x77, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x18, 0x08, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x22, 0x36, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x23, 0x0a, 0x06, 0x63, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x22, + 0x5e, 0x0a, 0x10, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61, 0x6c, 0x72, - 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x67, 0x0a, 0x11, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, - 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, 0x68, 0x61, 0x73, - 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x48, 0x61, 0x73, 0x68, - 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x77, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x77, 0x55, 0x73, 0x65, 0x72, 0x6e, - 0x61, 0x6d, 0x65, 0x22, 0x31, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, - 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, - 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, - 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x29, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, 0x0a, 0x05, 0x65, - 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, - 0x6c, 0x22, 0x31, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, + 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x12, 0x23, 0x0a, 0x06, 0x63, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x22, + 0x21, 0x0a, 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, + 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x64, 0x22, 0x2f, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, + 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, + 0x75, 0x6e, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x64, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, + 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x69, 0x73, 0x12, 0x23, 0x0a, 0x0d, + 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, + 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x6f, 0x5f, 0x75, 0x72, + 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x6f, 0x55, 0x72, 0x6c, + 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x6e, 0x6e, + 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x11, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x22, + 0x2f, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, + 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, + 0x22, 0x69, 0x0a, 0x08, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x14, 0x0a, 0x05, + 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, + 0x69, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x3e, 0x0a, 0x11, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, + 0x12, 0x29, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, + 0x64, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x3b, 0x0a, 0x12, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, + 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, 0x65, 0x78, 0x69, + 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61, 0x6c, 0x72, 0x65, 0x61, + 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x67, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, 0x0a, + 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, + 0x61, 0x69, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x48, 0x61, 0x73, 0x68, 0x12, 0x21, + 0x0a, 0x0c, 0x6e, 0x65, 0x77, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x77, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, + 0x65, 0x22, 0x31, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, - 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x11, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x22, 0x3f, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x50, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2b, 0x0a, 0x09, 0x70, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x09, 0x70, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x22, 0x0c, 0x0a, 0x0a, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x22, 0x37, 0x0a, 0x0b, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x10, 0x0a, - 0x03, 0x61, 0x70, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x61, 0x70, 0x69, 0x22, - 0x7a, 0x0a, 0x0f, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, - 0x65, 0x66, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, - 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, - 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1b, - 0x0a, 0x09, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x08, 0x6c, 0x61, 0x73, 0x74, 0x55, 0x73, 0x65, 0x64, 0x22, 0x29, 0x0a, 0x0e, 0x4c, - 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, - 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4e, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, - 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x12, 0x3b, 0x0a, 0x0e, 0x72, 0x65, 0x66, - 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x66, 0x52, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0x48, 0x0a, 0x10, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, - 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, - 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, - 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, - 0x22, 0x30, 0x0a, 0x11, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, - 0x68, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, + 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x29, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, + 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x22, + 0x31, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, + 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, - 0x6e, 0x64, 0x22, 0x45, 0x0a, 0x11, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1a, 0x0a, - 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x4d, 0x0a, 0x12, 0x56, 0x65, 0x72, - 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, - 0x1a, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x6e, - 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, - 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x32, 0xc7, 0x05, 0x0a, 0x03, 0x44, 0x65, 0x78, - 0x12, 0x3d, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, - 0x3d, 0x0a, 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, - 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3d, - 0x0a, 0x0c, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x14, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, - 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, - 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, - 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, - 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, - 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3e, 0x0a, 0x0d, - 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x14, 0x2e, - 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, - 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, - 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x31, 0x0a, 0x0a, - 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0f, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x1a, 0x10, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, - 0x3a, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, 0x13, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, - 0x52, 0x65, 0x71, 0x1a, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, - 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x40, 0x0a, 0x0d, 0x52, - 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, 0x15, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, - 0x52, 0x65, 0x71, 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, - 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, - 0x0e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, - 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, - 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, - 0x22, 0x00, 0x42, 0x2f, 0x0a, 0x12, 0x63, 0x6f, 0x6d, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x6f, 0x73, - 0x2e, 0x64, 0x65, 0x78, 0x2e, 0x61, 0x70, 0x69, 0x5a, 0x19, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, 0x64, 0x65, 0x78, 0x2f, - 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} + 0x6e, 0x64, 0x22, 0x11, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x52, 0x65, 0x71, 0x22, 0x3f, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2b, 0x0a, 0x09, 0x70, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x09, 0x70, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x22, 0x0c, 0x0a, 0x0a, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x71, 0x22, 0x37, 0x0a, 0x0b, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x73, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x61, + 0x70, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x61, 0x70, 0x69, 0x22, 0x7a, 0x0a, + 0x0f, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x66, + 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, + 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, + 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1b, 0x0a, 0x09, + 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x08, 0x6c, 0x61, 0x73, 0x74, 0x55, 0x73, 0x65, 0x64, 0x22, 0x29, 0x0a, 0x0e, 0x4c, 0x69, 0x73, + 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, + 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, + 0x65, 0x72, 0x49, 0x64, 0x22, 0x4e, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, + 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x12, 0x3b, 0x0a, 0x0e, 0x72, 0x65, 0x66, 0x72, 0x65, + 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x52, 0x65, 0x66, 0x52, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x73, 0x22, 0x48, 0x0a, 0x10, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, + 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0x30, + 0x0a, 0x11, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, + 0x22, 0x45, 0x0a, 0x11, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x4d, 0x0a, 0x12, 0x56, 0x65, 0x72, 0x69, 0x66, + 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1a, 0x0a, + 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, + 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, + 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x32, 0xc7, 0x05, 0x0a, 0x03, 0x44, 0x65, 0x78, 0x12, 0x3d, + 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x14, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, + 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3d, 0x0a, + 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x0c, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, + 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, + 0x12, 0x43, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, + 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, + 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, + 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3e, 0x0a, 0x0d, 0x4c, 0x69, + 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x14, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, + 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, + 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x31, 0x0a, 0x0a, 0x47, 0x65, + 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3a, 0x0a, + 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, 0x13, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, + 0x71, 0x1a, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, + 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x40, 0x0a, 0x0d, 0x52, 0x65, 0x76, + 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, 0x15, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, + 0x71, 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, + 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x56, + 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x69, + 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, + 0x42, 0x2f, 0x0a, 0x12, 0x63, 0x6f, 0x6d, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x6f, 0x73, 0x2e, 0x64, + 0x65, 0x78, 0x2e, 0x61, 0x70, 0x69, 0x5a, 0x19, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, + 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, 0x64, 0x65, 0x78, 0x2f, 0x61, 0x70, + 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) var ( file_api_api_proto_rawDescOnce sync.Once - file_api_api_proto_rawDescData = file_api_api_proto_rawDesc + file_api_api_proto_rawDescData []byte ) func file_api_api_proto_rawDescGZIP() []byte { file_api_api_proto_rawDescOnce.Do(func() { - file_api_api_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_api_proto_rawDescData) + file_api_api_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_api_proto_rawDesc), len(file_api_api_proto_rawDesc))) }) return file_api_api_proto_rawDescData } var file_api_api_proto_msgTypes = make([]protoimpl.MessageInfo, 25) -var file_api_api_proto_goTypes = []interface{}{ +var file_api_api_proto_goTypes = []any{ (*Client)(nil), // 0: api.Client (*CreateClientReq)(nil), // 1: api.CreateClientReq (*CreateClientResp)(nil), // 2: api.CreateClientResp @@ -1641,313 +1591,11 @@ func file_api_api_proto_init() { if File_api_api_proto != nil { return } - if !protoimpl.UnsafeEnabled { - file_api_api_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Client); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateClientReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateClientResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteClientReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteClientResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UpdateClientReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UpdateClientResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Password); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreatePasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreatePasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UpdatePasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UpdatePasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeletePasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeletePasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListPasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListPasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*VersionReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*VersionResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RefreshTokenRef); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListRefreshReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListRefreshResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RevokeRefreshReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RevokeRefreshResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*VerifyPasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_api_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*VerifyPasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_api_api_proto_rawDesc, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_api_proto_rawDesc), len(file_api_api_proto_rawDesc)), NumEnums: 0, NumMessages: 25, NumExtensions: 0, @@ -1958,7 +1606,6 @@ func file_api_api_proto_init() { MessageInfos: file_api_api_proto_msgTypes, }.Build() File_api_api_proto = out.File - file_api_api_proto_rawDesc = nil file_api_api_proto_goTypes = nil file_api_api_proto_depIdxs = nil } diff --git a/api/api.proto b/api/api.proto index 7d25771a6e..01e3db1718 100644 --- a/api/api.proto +++ b/api/api.proto @@ -14,6 +14,7 @@ message Client { bool public = 5; string name = 6; string logo_url = 7; + repeated string allowed_connectors = 8; } // CreateClientReq is a request to make a client. @@ -45,6 +46,7 @@ message UpdateClientReq { repeated string trusted_peers = 3; string name = 4; string logo_url = 5; + repeated string allowed_connectors = 6; } // UpdateClientResp returns the response from updating a client. @@ -112,7 +114,7 @@ message VersionReq {} message VersionResp { // Semantic version of the server. string server = 1; - // Numeric version of the API. It increases everytime a new call is added to the API. + // Numeric version of the API. It increases every time a new call is added to the API. // Clients should use this info to determine if the server supports specific features. int32 api = 2; } diff --git a/api/api_grpc.pb.go b/api/api_grpc.pb.go index e8c9873cb5..aeeaa508c0 100644 --- a/api/api_grpc.pb.go +++ b/api/api_grpc.pb.go @@ -1,4 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.3 +// source: api/api.proto package api @@ -11,12 +15,28 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Dex_CreateClient_FullMethodName = "/api.Dex/CreateClient" + Dex_UpdateClient_FullMethodName = "/api.Dex/UpdateClient" + Dex_DeleteClient_FullMethodName = "/api.Dex/DeleteClient" + Dex_CreatePassword_FullMethodName = "/api.Dex/CreatePassword" + Dex_UpdatePassword_FullMethodName = "/api.Dex/UpdatePassword" + Dex_DeletePassword_FullMethodName = "/api.Dex/DeletePassword" + Dex_ListPasswords_FullMethodName = "/api.Dex/ListPasswords" + Dex_GetVersion_FullMethodName = "/api.Dex/GetVersion" + Dex_ListRefresh_FullMethodName = "/api.Dex/ListRefresh" + Dex_RevokeRefresh_FullMethodName = "/api.Dex/RevokeRefresh" + Dex_VerifyPassword_FullMethodName = "/api.Dex/VerifyPassword" +) // DexClient is the client API for Dex service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Dex represents the dex gRPC service. type DexClient interface { // CreateClient creates a client. CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error) @@ -53,8 +73,9 @@ func NewDexClient(cc grpc.ClientConnInterface) DexClient { } func (c *dexClient) CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(CreateClientResp) - err := c.cc.Invoke(ctx, "/api.Dex/CreateClient", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_CreateClient_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -62,8 +83,9 @@ func (c *dexClient) CreateClient(ctx context.Context, in *CreateClientReq, opts } func (c *dexClient) UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(UpdateClientResp) - err := c.cc.Invoke(ctx, "/api.Dex/UpdateClient", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_UpdateClient_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -71,8 +93,9 @@ func (c *dexClient) UpdateClient(ctx context.Context, in *UpdateClientReq, opts } func (c *dexClient) DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DeleteClientResp) - err := c.cc.Invoke(ctx, "/api.Dex/DeleteClient", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_DeleteClient_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -80,8 +103,9 @@ func (c *dexClient) DeleteClient(ctx context.Context, in *DeleteClientReq, opts } func (c *dexClient) CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(CreatePasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/CreatePassword", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_CreatePassword_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -89,8 +113,9 @@ func (c *dexClient) CreatePassword(ctx context.Context, in *CreatePasswordReq, o } func (c *dexClient) UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(UpdatePasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/UpdatePassword", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_UpdatePassword_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -98,8 +123,9 @@ func (c *dexClient) UpdatePassword(ctx context.Context, in *UpdatePasswordReq, o } func (c *dexClient) DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DeletePasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/DeletePassword", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_DeletePassword_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -107,8 +133,9 @@ func (c *dexClient) DeletePassword(ctx context.Context, in *DeletePasswordReq, o } func (c *dexClient) ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListPasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/ListPasswords", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_ListPasswords_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -116,8 +143,9 @@ func (c *dexClient) ListPasswords(ctx context.Context, in *ListPasswordReq, opts } func (c *dexClient) GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(VersionResp) - err := c.cc.Invoke(ctx, "/api.Dex/GetVersion", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_GetVersion_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -125,8 +153,9 @@ func (c *dexClient) GetVersion(ctx context.Context, in *VersionReq, opts ...grpc } func (c *dexClient) ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListRefreshResp) - err := c.cc.Invoke(ctx, "/api.Dex/ListRefresh", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_ListRefresh_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -134,8 +163,9 @@ func (c *dexClient) ListRefresh(ctx context.Context, in *ListRefreshReq, opts .. } func (c *dexClient) RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RevokeRefreshResp) - err := c.cc.Invoke(ctx, "/api.Dex/RevokeRefresh", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_RevokeRefresh_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -143,8 +173,9 @@ func (c *dexClient) RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opt } func (c *dexClient) VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(VerifyPasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/VerifyPassword", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_VerifyPassword_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -153,7 +184,9 @@ func (c *dexClient) VerifyPassword(ctx context.Context, in *VerifyPasswordReq, o // DexServer is the server API for Dex service. // All implementations must embed UnimplementedDexServer -// for forward compatibility +// for forward compatibility. +// +// Dex represents the dex gRPC service. type DexServer interface { // CreateClient creates a client. CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error) @@ -182,9 +215,12 @@ type DexServer interface { mustEmbedUnimplementedDexServer() } -// UnimplementedDexServer must be embedded to have forward compatible implementations. -type UnimplementedDexServer struct { -} +// UnimplementedDexServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDexServer struct{} func (UnimplementedDexServer) CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error) { return nil, status.Errorf(codes.Unimplemented, "method CreateClient not implemented") @@ -220,6 +256,7 @@ func (UnimplementedDexServer) VerifyPassword(context.Context, *VerifyPasswordReq return nil, status.Errorf(codes.Unimplemented, "method VerifyPassword not implemented") } func (UnimplementedDexServer) mustEmbedUnimplementedDexServer() {} +func (UnimplementedDexServer) testEmbeddedByValue() {} // UnsafeDexServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to DexServer will @@ -229,6 +266,13 @@ type UnsafeDexServer interface { } func RegisterDexServer(s grpc.ServiceRegistrar, srv DexServer) { + // If the following call pancis, it indicates UnimplementedDexServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&Dex_ServiceDesc, srv) } @@ -242,7 +286,7 @@ func _Dex_CreateClient_Handler(srv interface{}, ctx context.Context, dec func(in } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/CreateClient", + FullMethod: Dex_CreateClient_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).CreateClient(ctx, req.(*CreateClientReq)) @@ -260,7 +304,7 @@ func _Dex_UpdateClient_Handler(srv interface{}, ctx context.Context, dec func(in } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/UpdateClient", + FullMethod: Dex_UpdateClient_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).UpdateClient(ctx, req.(*UpdateClientReq)) @@ -278,7 +322,7 @@ func _Dex_DeleteClient_Handler(srv interface{}, ctx context.Context, dec func(in } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/DeleteClient", + FullMethod: Dex_DeleteClient_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).DeleteClient(ctx, req.(*DeleteClientReq)) @@ -296,7 +340,7 @@ func _Dex_CreatePassword_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/CreatePassword", + FullMethod: Dex_CreatePassword_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).CreatePassword(ctx, req.(*CreatePasswordReq)) @@ -314,7 +358,7 @@ func _Dex_UpdatePassword_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/UpdatePassword", + FullMethod: Dex_UpdatePassword_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).UpdatePassword(ctx, req.(*UpdatePasswordReq)) @@ -332,7 +376,7 @@ func _Dex_DeletePassword_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/DeletePassword", + FullMethod: Dex_DeletePassword_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).DeletePassword(ctx, req.(*DeletePasswordReq)) @@ -350,7 +394,7 @@ func _Dex_ListPasswords_Handler(srv interface{}, ctx context.Context, dec func(i } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/ListPasswords", + FullMethod: Dex_ListPasswords_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).ListPasswords(ctx, req.(*ListPasswordReq)) @@ -368,7 +412,7 @@ func _Dex_GetVersion_Handler(srv interface{}, ctx context.Context, dec func(inte } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/GetVersion", + FullMethod: Dex_GetVersion_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).GetVersion(ctx, req.(*VersionReq)) @@ -386,7 +430,7 @@ func _Dex_ListRefresh_Handler(srv interface{}, ctx context.Context, dec func(int } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/ListRefresh", + FullMethod: Dex_ListRefresh_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).ListRefresh(ctx, req.(*ListRefreshReq)) @@ -404,7 +448,7 @@ func _Dex_RevokeRefresh_Handler(srv interface{}, ctx context.Context, dec func(i } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/RevokeRefresh", + FullMethod: Dex_RevokeRefresh_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).RevokeRefresh(ctx, req.(*RevokeRefreshReq)) @@ -422,7 +466,7 @@ func _Dex_VerifyPassword_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/VerifyPassword", + FullMethod: Dex_VerifyPassword_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).VerifyPassword(ctx, req.(*VerifyPasswordReq)) diff --git a/api/v2/api.pb.go b/api/v2/api.pb.go index f49310f311..62dec99041 100644 --- a/api/v2/api.pb.go +++ b/api/v2/api.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.26.0 -// protoc v3.15.6 +// protoc-gen-go v1.36.5 +// protoc v5.29.3 // source: api/v2/api.proto package api @@ -11,6 +11,7 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" + unsafe "unsafe" ) const ( @@ -22,26 +23,35 @@ const ( // Client represents an OAuth2 client. type Client struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Secret string `protobuf:"bytes,2,opt,name=secret,proto3" json:"secret,omitempty"` - RedirectUris []string `protobuf:"bytes,3,rep,name=redirect_uris,json=redirectUris,proto3" json:"redirect_uris,omitempty"` - TrustedPeers []string `protobuf:"bytes,4,rep,name=trusted_peers,json=trustedPeers,proto3" json:"trusted_peers,omitempty"` - Public bool `protobuf:"varint,5,opt,name=public,proto3" json:"public,omitempty"` - Name string `protobuf:"bytes,6,opt,name=name,proto3" json:"name,omitempty"` - LogoUrl string `protobuf:"bytes,7,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Secret string `protobuf:"bytes,2,opt,name=secret,proto3" json:"secret,omitempty"` + RedirectUris []string `protobuf:"bytes,3,rep,name=redirect_uris,json=redirectUris,proto3" json:"redirect_uris,omitempty"` + TrustedPeers []string `protobuf:"bytes,4,rep,name=trusted_peers,json=trustedPeers,proto3" json:"trusted_peers,omitempty"` + Public bool `protobuf:"varint,5,opt,name=public,proto3" json:"public,omitempty"` + Name string `protobuf:"bytes,6,opt,name=name,proto3" json:"name,omitempty"` + LogoUrl string `protobuf:"bytes,7,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"` + AllowedConnectors []string `protobuf:"bytes,8,rep,name=allowed_connectors,json=allowedConnectors,proto3" json:"allowed_connectors,omitempty"` + SsoSharedWith []string `protobuf:"bytes,9,rep,name=sso_shared_with,json=ssoSharedWith,proto3" json:"sso_shared_with,omitempty"` + // Where dex POSTs a logout token when a session this client took part in + // ends, per OIDC Back-Channel Logout 1.0. Empty means the client is not + // notified. + BackchannelLogoutUri string `protobuf:"bytes,10,opt,name=backchannel_logout_uri,json=backchannelLogoutUri,proto3" json:"backchannel_logout_uri,omitempty"` + // Where the browser may be sent after an RP-initiated logout. A + // post_logout_redirect_uri that is not listed here is refused. + PostLogoutRedirectUris []string `protobuf:"bytes,11,rep,name=post_logout_redirect_uris,json=postLogoutRedirectUris,proto3" json:"post_logout_redirect_uris,omitempty"` + // Whether this client's refresh tokens outlive the browser session that + // issued them: "standalone" (the default) or "session". + RefreshTokenLifetime string `protobuf:"bytes,12,opt,name=refresh_token_lifetime,json=refreshTokenLifetime,proto3" json:"refresh_token_lifetime,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Client) Reset() { *x = Client{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Client) String() string { @@ -52,7 +62,7 @@ func (*Client) ProtoMessage() {} func (x *Client) ProtoReflect() protoreflect.Message { mi := &file_api_v2_api_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -116,22 +126,270 @@ func (x *Client) GetLogoUrl() string { return "" } -// CreateClientReq is a request to make a client. -type CreateClientReq struct { - state protoimpl.MessageState +func (x *Client) GetAllowedConnectors() []string { + if x != nil { + return x.AllowedConnectors + } + return nil +} + +func (x *Client) GetSsoSharedWith() []string { + if x != nil { + return x.SsoSharedWith + } + return nil +} + +func (x *Client) GetBackchannelLogoutUri() string { + if x != nil { + return x.BackchannelLogoutUri + } + return "" +} + +func (x *Client) GetPostLogoutRedirectUris() []string { + if x != nil { + return x.PostLogoutRedirectUris + } + return nil +} + +func (x *Client) GetRefreshTokenLifetime() string { + if x != nil { + return x.RefreshTokenLifetime + } + return "" +} + +// ClientInfo represents an OAuth2 client without sensitive information. +type ClientInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + RedirectUris []string `protobuf:"bytes,2,rep,name=redirect_uris,json=redirectUris,proto3" json:"redirect_uris,omitempty"` + TrustedPeers []string `protobuf:"bytes,3,rep,name=trusted_peers,json=trustedPeers,proto3" json:"trusted_peers,omitempty"` + Public bool `protobuf:"varint,4,opt,name=public,proto3" json:"public,omitempty"` + Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` + LogoUrl string `protobuf:"bytes,6,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"` + AllowedConnectors []string `protobuf:"bytes,7,rep,name=allowed_connectors,json=allowedConnectors,proto3" json:"allowed_connectors,omitempty"` + SsoSharedWith []string `protobuf:"bytes,8,rep,name=sso_shared_with,json=ssoSharedWith,proto3" json:"sso_shared_with,omitempty"` + BackchannelLogoutUri string `protobuf:"bytes,9,opt,name=backchannel_logout_uri,json=backchannelLogoutUri,proto3" json:"backchannel_logout_uri,omitempty"` + PostLogoutRedirectUris []string `protobuf:"bytes,10,rep,name=post_logout_redirect_uris,json=postLogoutRedirectUris,proto3" json:"post_logout_redirect_uris,omitempty"` + RefreshTokenLifetime string `protobuf:"bytes,11,opt,name=refresh_token_lifetime,json=refreshTokenLifetime,proto3" json:"refresh_token_lifetime,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientInfo) Reset() { + *x = ClientInfo{} + mi := &file_api_v2_api_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientInfo) ProtoMessage() {} + +func (x *ClientInfo) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientInfo.ProtoReflect.Descriptor instead. +func (*ClientInfo) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{1} +} + +func (x *ClientInfo) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ClientInfo) GetRedirectUris() []string { + if x != nil { + return x.RedirectUris + } + return nil +} + +func (x *ClientInfo) GetTrustedPeers() []string { + if x != nil { + return x.TrustedPeers + } + return nil +} + +func (x *ClientInfo) GetPublic() bool { + if x != nil { + return x.Public + } + return false +} + +func (x *ClientInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ClientInfo) GetLogoUrl() string { + if x != nil { + return x.LogoUrl + } + return "" +} + +func (x *ClientInfo) GetAllowedConnectors() []string { + if x != nil { + return x.AllowedConnectors + } + return nil +} + +func (x *ClientInfo) GetSsoSharedWith() []string { + if x != nil { + return x.SsoSharedWith + } + return nil +} + +func (x *ClientInfo) GetBackchannelLogoutUri() string { + if x != nil { + return x.BackchannelLogoutUri + } + return "" +} + +func (x *ClientInfo) GetPostLogoutRedirectUris() []string { + if x != nil { + return x.PostLogoutRedirectUris + } + return nil +} + +func (x *ClientInfo) GetRefreshTokenLifetime() string { + if x != nil { + return x.RefreshTokenLifetime + } + return "" +} + +// GetClientReq is a request to retrieve client details. +type GetClientReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The ID of the client. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache +} + +func (x *GetClientReq) Reset() { + *x = GetClientReq{} + mi := &file_api_v2_api_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetClientReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetClientReq) ProtoMessage() {} + +func (x *GetClientReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetClientReq.ProtoReflect.Descriptor instead. +func (*GetClientReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{2} +} + +func (x *GetClientReq) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// GetClientResp returns the client details. +type GetClientResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Client *Client `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} - Client *Client `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"` +func (x *GetClientResp) Reset() { + *x = GetClientResp{} + mi := &file_api_v2_api_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *CreateClientReq) Reset() { - *x = CreateClientReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[1] +func (x *GetClientResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetClientResp) ProtoMessage() {} + +func (x *GetClientResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[3] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetClientResp.ProtoReflect.Descriptor instead. +func (*GetClientResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{3} +} + +func (x *GetClientResp) GetClient() *Client { + if x != nil { + return x.Client } + return nil +} + +// CreateClientReq is a request to make a client. +type CreateClientReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Client *Client `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateClientReq) Reset() { + *x = CreateClientReq{} + mi := &file_api_v2_api_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreateClientReq) String() string { @@ -141,8 +399,8 @@ func (x *CreateClientReq) String() string { func (*CreateClientReq) ProtoMessage() {} func (x *CreateClientReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[4] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -154,7 +412,7 @@ func (x *CreateClientReq) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateClientReq.ProtoReflect.Descriptor instead. func (*CreateClientReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{1} + return file_api_v2_api_proto_rawDescGZIP(), []int{4} } func (x *CreateClientReq) GetClient() *Client { @@ -166,21 +424,18 @@ func (x *CreateClientReq) GetClient() *Client { // CreateClientResp returns the response from creating a client. type CreateClientResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` + Client *Client `protobuf:"bytes,2,opt,name=client,proto3" json:"client,omitempty"` unknownFields protoimpl.UnknownFields - - AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` - Client *Client `protobuf:"bytes,2,opt,name=client,proto3" json:"client,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CreateClientResp) Reset() { *x = CreateClientResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreateClientResp) String() string { @@ -190,8 +445,8 @@ func (x *CreateClientResp) String() string { func (*CreateClientResp) ProtoMessage() {} func (x *CreateClientResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[5] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -203,7 +458,7 @@ func (x *CreateClientResp) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateClientResp.ProtoReflect.Descriptor instead. func (*CreateClientResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{2} + return file_api_v2_api_proto_rawDescGZIP(), []int{5} } func (x *CreateClientResp) GetAlreadyExists() bool { @@ -222,21 +477,18 @@ func (x *CreateClientResp) GetClient() *Client { // DeleteClientReq is a request to delete a client. type DeleteClientReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // The ID of the client. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteClientReq) Reset() { *x = DeleteClientReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeleteClientReq) String() string { @@ -246,8 +498,8 @@ func (x *DeleteClientReq) String() string { func (*DeleteClientReq) ProtoMessage() {} func (x *DeleteClientReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[6] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -259,7 +511,7 @@ func (x *DeleteClientReq) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteClientReq.ProtoReflect.Descriptor instead. func (*DeleteClientReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{3} + return file_api_v2_api_proto_rawDescGZIP(), []int{6} } func (x *DeleteClientReq) GetId() string { @@ -271,20 +523,17 @@ func (x *DeleteClientReq) GetId() string { // DeleteClientResp determines if the client is deleted successfully. type DeleteClientResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` unknownFields protoimpl.UnknownFields - - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + sizeCache protoimpl.SizeCache } func (x *DeleteClientResp) Reset() { *x = DeleteClientResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeleteClientResp) String() string { @@ -294,8 +543,8 @@ func (x *DeleteClientResp) String() string { func (*DeleteClientResp) ProtoMessage() {} func (x *DeleteClientResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[7] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -307,7 +556,7 @@ func (x *DeleteClientResp) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteClientResp.ProtoReflect.Descriptor instead. func (*DeleteClientResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{4} + return file_api_v2_api_proto_rawDescGZIP(), []int{7} } func (x *DeleteClientResp) GetNotFound() bool { @@ -319,24 +568,32 @@ func (x *DeleteClientResp) GetNotFound() bool { // UpdateClientReq is a request to update an existing client. type UpdateClientReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - RedirectUris []string `protobuf:"bytes,2,rep,name=redirect_uris,json=redirectUris,proto3" json:"redirect_uris,omitempty"` - TrustedPeers []string `protobuf:"bytes,3,rep,name=trusted_peers,json=trustedPeers,proto3" json:"trusted_peers,omitempty"` - Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` - LogoUrl string `protobuf:"bytes,5,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + RedirectUris []string `protobuf:"bytes,2,rep,name=redirect_uris,json=redirectUris,proto3" json:"redirect_uris,omitempty"` + TrustedPeers []string `protobuf:"bytes,3,rep,name=trusted_peers,json=trustedPeers,proto3" json:"trusted_peers,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + LogoUrl string `protobuf:"bytes,5,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"` + AllowedConnectors []string `protobuf:"bytes,6,rep,name=allowed_connectors,json=allowedConnectors,proto3" json:"allowed_connectors,omitempty"` + SsoSharedWith []string `protobuf:"bytes,7,rep,name=sso_shared_with,json=ssoSharedWith,proto3" json:"sso_shared_with,omitempty"` + // Optional so that an empty value clears the URI. Without explicit presence + // a client could be given a back-channel endpoint but never relieved of one, + // leaving dex posting logout tokens at something that no longer exists. + BackchannelLogoutUri *string `protobuf:"bytes,8,opt,name=backchannel_logout_uri,json=backchannelLogoutUri,proto3,oneof" json:"backchannel_logout_uri,omitempty"` + PostLogoutRedirectUris []string `protobuf:"bytes,9,rep,name=post_logout_redirect_uris,json=postLogoutRedirectUris,proto3" json:"post_logout_redirect_uris,omitempty"` + // Optional for the same reason as backchannel_logout_uri: an empty value has + // to be tellable apart from "leave it alone" to put a client back on the + // default lifetime. + RefreshTokenLifetime *string `protobuf:"bytes,10,opt,name=refresh_token_lifetime,json=refreshTokenLifetime,proto3,oneof" json:"refresh_token_lifetime,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateClientReq) Reset() { *x = UpdateClientReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *UpdateClientReq) String() string { @@ -346,8 +603,8 @@ func (x *UpdateClientReq) String() string { func (*UpdateClientReq) ProtoMessage() {} func (x *UpdateClientReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[8] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -359,7 +616,7 @@ func (x *UpdateClientReq) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateClientReq.ProtoReflect.Descriptor instead. func (*UpdateClientReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{5} + return file_api_v2_api_proto_rawDescGZIP(), []int{8} } func (x *UpdateClientReq) GetId() string { @@ -397,33 +654,65 @@ func (x *UpdateClientReq) GetLogoUrl() string { return "" } -// UpdateClientResp returns the response from updating a client. -type UpdateClientResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` +func (x *UpdateClientReq) GetAllowedConnectors() []string { + if x != nil { + return x.AllowedConnectors + } + return nil } -func (x *UpdateClientResp) Reset() { - *x = UpdateClientResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) +func (x *UpdateClientReq) GetSsoSharedWith() []string { + if x != nil { + return x.SsoSharedWith } + return nil } -func (x *UpdateClientResp) String() string { - return protoimpl.X.MessageStringOf(x) +func (x *UpdateClientReq) GetBackchannelLogoutUri() string { + if x != nil && x.BackchannelLogoutUri != nil { + return *x.BackchannelLogoutUri + } + return "" } -func (*UpdateClientResp) ProtoMessage() {} +func (x *UpdateClientReq) GetPostLogoutRedirectUris() []string { + if x != nil { + return x.PostLogoutRedirectUris + } + return nil +} + +func (x *UpdateClientReq) GetRefreshTokenLifetime() string { + if x != nil && x.RefreshTokenLifetime != nil { + return *x.RefreshTokenLifetime + } + return "" +} + +// UpdateClientResp returns the response from updating a client. +type UpdateClientResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateClientResp) Reset() { + *x = UpdateClientResp{} + mi := &file_api_v2_api_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateClientResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateClientResp) ProtoMessage() {} func (x *UpdateClientResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[9] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -435,7 +724,7 @@ func (x *UpdateClientResp) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateClientResp.ProtoReflect.Descriptor instead. func (*UpdateClientResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{6} + return file_api_v2_api_proto_rawDescGZIP(), []int{9} } func (x *UpdateClientResp) GetNotFound() bool { @@ -445,26 +734,105 @@ func (x *UpdateClientResp) GetNotFound() bool { return false } -// Password is an email for password mapping managed by the storage. -type Password struct { - state protoimpl.MessageState +// ListClientReq is a request to enumerate clients. +type ListClientReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache +} + +func (x *ListClientReq) Reset() { + *x = ListClientReq{} + mi := &file_api_v2_api_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListClientReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListClientReq) ProtoMessage() {} + +func (x *ListClientReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListClientReq.ProtoReflect.Descriptor instead. +func (*ListClientReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{10} +} + +// ListClientResp returns a list of clients. +type ListClientResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Clients []*ClientInfo `protobuf:"bytes,1,rep,name=clients,proto3" json:"clients,omitempty"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListClientResp) Reset() { + *x = ListClientResp{} + mi := &file_api_v2_api_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListClientResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListClientResp) ProtoMessage() {} + +func (x *ListClientResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListClientResp.ProtoReflect.Descriptor instead. +func (*ListClientResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{11} +} + +func (x *ListClientResp) GetClients() []*ClientInfo { + if x != nil { + return x.Clients + } + return nil +} - Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` +// Password is an email for password mapping managed by the storage. +type Password struct { + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` // Currently we do not accept plain text passwords. Could be an option in the future. - Hash []byte `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"` - Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` - UserId string `protobuf:"bytes,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Hash []byte `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + UserId string `protobuf:"bytes,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Password) Reset() { *x = Password{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *Password) String() string { @@ -474,8 +842,8 @@ func (x *Password) String() string { func (*Password) ProtoMessage() {} func (x *Password) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[12] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -487,7 +855,7 @@ func (x *Password) ProtoReflect() protoreflect.Message { // Deprecated: Use Password.ProtoReflect.Descriptor instead. func (*Password) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{7} + return file_api_v2_api_proto_rawDescGZIP(), []int{12} } func (x *Password) GetEmail() string { @@ -520,20 +888,17 @@ func (x *Password) GetUserId() string { // CreatePasswordReq is a request to make a password. type CreatePasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Password *Password `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` unknownFields protoimpl.UnknownFields - - Password *Password `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CreatePasswordReq) Reset() { *x = CreatePasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreatePasswordReq) String() string { @@ -543,8 +908,8 @@ func (x *CreatePasswordReq) String() string { func (*CreatePasswordReq) ProtoMessage() {} func (x *CreatePasswordReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[13] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -556,7 +921,7 @@ func (x *CreatePasswordReq) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatePasswordReq.ProtoReflect.Descriptor instead. func (*CreatePasswordReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{8} + return file_api_v2_api_proto_rawDescGZIP(), []int{13} } func (x *CreatePasswordReq) GetPassword() *Password { @@ -568,20 +933,17 @@ func (x *CreatePasswordReq) GetPassword() *Password { // CreatePasswordResp returns the response from creating a password. type CreatePasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` unknownFields protoimpl.UnknownFields - - AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` + sizeCache protoimpl.SizeCache } func (x *CreatePasswordResp) Reset() { *x = CreatePasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreatePasswordResp) String() string { @@ -591,8 +953,8 @@ func (x *CreatePasswordResp) String() string { func (*CreatePasswordResp) ProtoMessage() {} func (x *CreatePasswordResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[14] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -604,7 +966,7 @@ func (x *CreatePasswordResp) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatePasswordResp.ProtoReflect.Descriptor instead. func (*CreatePasswordResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{9} + return file_api_v2_api_proto_rawDescGZIP(), []int{14} } func (x *CreatePasswordResp) GetAlreadyExists() bool { @@ -616,23 +978,20 @@ func (x *CreatePasswordResp) GetAlreadyExists() bool { // UpdatePasswordReq is a request to modify an existing password. type UpdatePasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` // The email used to lookup the password. This field cannot be modified - Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` - NewHash []byte `protobuf:"bytes,2,opt,name=new_hash,json=newHash,proto3" json:"new_hash,omitempty"` - NewUsername string `protobuf:"bytes,3,opt,name=new_username,json=newUsername,proto3" json:"new_username,omitempty"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + NewHash []byte `protobuf:"bytes,2,opt,name=new_hash,json=newHash,proto3" json:"new_hash,omitempty"` + NewUsername string `protobuf:"bytes,3,opt,name=new_username,json=newUsername,proto3" json:"new_username,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdatePasswordReq) Reset() { *x = UpdatePasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *UpdatePasswordReq) String() string { @@ -642,8 +1001,8 @@ func (x *UpdatePasswordReq) String() string { func (*UpdatePasswordReq) ProtoMessage() {} func (x *UpdatePasswordReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[15] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -655,7 +1014,7 @@ func (x *UpdatePasswordReq) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdatePasswordReq.ProtoReflect.Descriptor instead. func (*UpdatePasswordReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{10} + return file_api_v2_api_proto_rawDescGZIP(), []int{15} } func (x *UpdatePasswordReq) GetEmail() string { @@ -681,20 +1040,17 @@ func (x *UpdatePasswordReq) GetNewUsername() string { // UpdatePasswordResp returns the response from modifying an existing password. type UpdatePasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` unknownFields protoimpl.UnknownFields - - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + sizeCache protoimpl.SizeCache } func (x *UpdatePasswordResp) Reset() { *x = UpdatePasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *UpdatePasswordResp) String() string { @@ -704,8 +1060,8 @@ func (x *UpdatePasswordResp) String() string { func (*UpdatePasswordResp) ProtoMessage() {} func (x *UpdatePasswordResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[11] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[16] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -717,7 +1073,7 @@ func (x *UpdatePasswordResp) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdatePasswordResp.ProtoReflect.Descriptor instead. func (*UpdatePasswordResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{11} + return file_api_v2_api_proto_rawDescGZIP(), []int{16} } func (x *UpdatePasswordResp) GetNotFound() bool { @@ -729,20 +1085,17 @@ func (x *UpdatePasswordResp) GetNotFound() bool { // DeletePasswordReq is a request to delete a password. type DeletePasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` unknownFields protoimpl.UnknownFields - - Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + sizeCache protoimpl.SizeCache } func (x *DeletePasswordReq) Reset() { *x = DeletePasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeletePasswordReq) String() string { @@ -752,8 +1105,8 @@ func (x *DeletePasswordReq) String() string { func (*DeletePasswordReq) ProtoMessage() {} func (x *DeletePasswordReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[12] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[17] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -765,7 +1118,7 @@ func (x *DeletePasswordReq) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePasswordReq.ProtoReflect.Descriptor instead. func (*DeletePasswordReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{12} + return file_api_v2_api_proto_rawDescGZIP(), []int{17} } func (x *DeletePasswordReq) GetEmail() string { @@ -777,20 +1130,17 @@ func (x *DeletePasswordReq) GetEmail() string { // DeletePasswordResp returns the response from deleting a password. type DeletePasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` unknownFields protoimpl.UnknownFields - - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + sizeCache protoimpl.SizeCache } func (x *DeletePasswordResp) Reset() { *x = DeletePasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeletePasswordResp) String() string { @@ -800,8 +1150,8 @@ func (x *DeletePasswordResp) String() string { func (*DeletePasswordResp) ProtoMessage() {} func (x *DeletePasswordResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[18] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -813,7 +1163,7 @@ func (x *DeletePasswordResp) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePasswordResp.ProtoReflect.Descriptor instead. func (*DeletePasswordResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{13} + return file_api_v2_api_proto_rawDescGZIP(), []int{18} } func (x *DeletePasswordResp) GetNotFound() bool { @@ -825,18 +1175,16 @@ func (x *DeletePasswordResp) GetNotFound() bool { // ListPasswordReq is a request to enumerate passwords. type ListPasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListPasswordReq) Reset() { *x = ListPasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListPasswordReq) String() string { @@ -846,8 +1194,8 @@ func (x *ListPasswordReq) String() string { func (*ListPasswordReq) ProtoMessage() {} func (x *ListPasswordReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[14] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[19] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -859,25 +1207,22 @@ func (x *ListPasswordReq) ProtoReflect() protoreflect.Message { // Deprecated: Use ListPasswordReq.ProtoReflect.Descriptor instead. func (*ListPasswordReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{14} + return file_api_v2_api_proto_rawDescGZIP(), []int{19} } // ListPasswordResp returns a list of passwords. type ListPasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Passwords []*Password `protobuf:"bytes,1,rep,name=passwords,proto3" json:"passwords,omitempty"` unknownFields protoimpl.UnknownFields - - Passwords []*Password `protobuf:"bytes,1,rep,name=passwords,proto3" json:"passwords,omitempty"` + sizeCache protoimpl.SizeCache } func (x *ListPasswordResp) Reset() { *x = ListPasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_api_v2_api_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListPasswordResp) String() string { @@ -887,8 +1232,8 @@ func (x *ListPasswordResp) String() string { func (*ListPasswordResp) ProtoMessage() {} func (x *ListPasswordResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[15] - if protoimpl.UnsafeEnabled && x != nil { + mi := &file_api_v2_api_proto_msgTypes[20] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -900,7 +1245,7 @@ func (x *ListPasswordResp) ProtoReflect() protoreflect.Message { // Deprecated: Use ListPasswordResp.ProtoReflect.Descriptor instead. func (*ListPasswordResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{15} + return file_api_v2_api_proto_rawDescGZIP(), []int{20} } func (x *ListPasswordResp) GetPasswords() []*Password { @@ -910,31 +1255,34 @@ func (x *ListPasswordResp) GetPasswords() []*Password { return nil } -// VersionReq is a request to fetch version info. -type VersionReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache +// Connector is a strategy used by Dex for authenticating a user against another identity provider +type Connector struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Config []byte `protobuf:"bytes,4,opt,name=config,proto3" json:"config,omitempty"` + GrantTypes []string `protobuf:"bytes,5,rep,name=grant_types,json=grantTypes,proto3" json:"grant_types,omitempty"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *VersionReq) Reset() { - *x = VersionReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } +func (x *Connector) Reset() { + *x = Connector{} + mi := &file_api_v2_api_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *VersionReq) String() string { +func (x *Connector) String() string { return protoimpl.X.MessageStringOf(x) } -func (*VersionReq) ProtoMessage() {} +func (*Connector) ProtoMessage() {} -func (x *VersionReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[16] - if protoimpl.UnsafeEnabled && x != nil { +func (x *Connector) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[21] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -944,42 +1292,70 @@ func (x *VersionReq) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use VersionReq.ProtoReflect.Descriptor instead. -func (*VersionReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{16} +// Deprecated: Use Connector.ProtoReflect.Descriptor instead. +func (*Connector) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{21} } -// VersionResp holds the version info of components. -type VersionResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields +func (x *Connector) GetId() string { + if x != nil { + return x.Id + } + return "" +} - // Semantic version of the server. - Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` - // Numeric version of the API. It increases everytime a new call is added to the API. - // Clients should use this info to determine if the server supports specific features. - Api int32 `protobuf:"varint,2,opt,name=api,proto3" json:"api,omitempty"` +func (x *Connector) GetType() string { + if x != nil { + return x.Type + } + return "" } -func (x *VersionResp) Reset() { - *x = VersionResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) +func (x *Connector) GetName() string { + if x != nil { + return x.Name } + return "" } -func (x *VersionResp) String() string { +func (x *Connector) GetConfig() []byte { + if x != nil { + return x.Config + } + return nil +} + +func (x *Connector) GetGrantTypes() []string { + if x != nil { + return x.GrantTypes + } + return nil +} + +// CreateConnectorReq is a request to make a connector. +type CreateConnectorReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Connector *Connector `protobuf:"bytes,1,opt,name=connector,proto3" json:"connector,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateConnectorReq) Reset() { + *x = CreateConnectorReq{} + mi := &file_api_v2_api_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateConnectorReq) String() string { return protoimpl.X.MessageStringOf(x) } -func (*VersionResp) ProtoMessage() {} +func (*CreateConnectorReq) ProtoMessage() {} -func (x *VersionResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[17] - if protoimpl.UnsafeEnabled && x != nil { +func (x *CreateConnectorReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[22] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -989,56 +1365,2455 @@ func (x *VersionResp) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use VersionResp.ProtoReflect.Descriptor instead. -func (*VersionResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{17} +// Deprecated: Use CreateConnectorReq.ProtoReflect.Descriptor instead. +func (*CreateConnectorReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{22} } -func (x *VersionResp) GetServer() string { +func (x *CreateConnectorReq) GetConnector() *Connector { if x != nil { - return x.Server + return x.Connector } - return "" + return nil } -func (x *VersionResp) GetApi() int32 { - if x != nil { - return x.Api - } +// CreateConnectorResp returns the response from creating a connector. +type CreateConnectorResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + AlreadyExists bool `protobuf:"varint,1,opt,name=already_exists,json=alreadyExists,proto3" json:"already_exists,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateConnectorResp) Reset() { + *x = CreateConnectorResp{} + mi := &file_api_v2_api_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateConnectorResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateConnectorResp) ProtoMessage() {} + +func (x *CreateConnectorResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateConnectorResp.ProtoReflect.Descriptor instead. +func (*CreateConnectorResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{23} +} + +func (x *CreateConnectorResp) GetAlreadyExists() bool { + if x != nil { + return x.AlreadyExists + } + return false +} + +// GrantTypes wraps a list of grant types to distinguish between +// "not specified" (no update) and "empty list" (unrestricted). +type GrantTypes struct { + state protoimpl.MessageState `protogen:"open.v1"` + GrantTypes []string `protobuf:"bytes,1,rep,name=grant_types,json=grantTypes,proto3" json:"grant_types,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GrantTypes) Reset() { + *x = GrantTypes{} + mi := &file_api_v2_api_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GrantTypes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GrantTypes) ProtoMessage() {} + +func (x *GrantTypes) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GrantTypes.ProtoReflect.Descriptor instead. +func (*GrantTypes) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{24} +} + +func (x *GrantTypes) GetGrantTypes() []string { + if x != nil { + return x.GrantTypes + } + return nil +} + +// UpdateConnectorReq is a request to modify an existing connector. +type UpdateConnectorReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The id used to lookup the connector. This field cannot be modified + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + NewType string `protobuf:"bytes,2,opt,name=new_type,json=newType,proto3" json:"new_type,omitempty"` + NewName string `protobuf:"bytes,3,opt,name=new_name,json=newName,proto3" json:"new_name,omitempty"` + NewConfig []byte `protobuf:"bytes,4,opt,name=new_config,json=newConfig,proto3" json:"new_config,omitempty"` + // If set, updates the connector's allowed grant types. + // An empty grant_types list means unrestricted (all grant types allowed). + // If not set (null), grant types are not modified. + NewGrantTypes *GrantTypes `protobuf:"bytes,5,opt,name=new_grant_types,json=newGrantTypes,proto3" json:"new_grant_types,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateConnectorReq) Reset() { + *x = UpdateConnectorReq{} + mi := &file_api_v2_api_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateConnectorReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateConnectorReq) ProtoMessage() {} + +func (x *UpdateConnectorReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateConnectorReq.ProtoReflect.Descriptor instead. +func (*UpdateConnectorReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{25} +} + +func (x *UpdateConnectorReq) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *UpdateConnectorReq) GetNewType() string { + if x != nil { + return x.NewType + } + return "" +} + +func (x *UpdateConnectorReq) GetNewName() string { + if x != nil { + return x.NewName + } + return "" +} + +func (x *UpdateConnectorReq) GetNewConfig() []byte { + if x != nil { + return x.NewConfig + } + return nil +} + +func (x *UpdateConnectorReq) GetNewGrantTypes() *GrantTypes { + if x != nil { + return x.NewGrantTypes + } + return nil +} + +// UpdateConnectorResp returns the response from modifying an existing connector. +type UpdateConnectorResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateConnectorResp) Reset() { + *x = UpdateConnectorResp{} + mi := &file_api_v2_api_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateConnectorResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateConnectorResp) ProtoMessage() {} + +func (x *UpdateConnectorResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateConnectorResp.ProtoReflect.Descriptor instead. +func (*UpdateConnectorResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{26} +} + +func (x *UpdateConnectorResp) GetNotFound() bool { + if x != nil { + return x.NotFound + } + return false +} + +// DeleteConnectorReq is a request to delete a connector. +type DeleteConnectorReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteConnectorReq) Reset() { + *x = DeleteConnectorReq{} + mi := &file_api_v2_api_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteConnectorReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteConnectorReq) ProtoMessage() {} + +func (x *DeleteConnectorReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteConnectorReq.ProtoReflect.Descriptor instead. +func (*DeleteConnectorReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{27} +} + +func (x *DeleteConnectorReq) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// DeleteConnectorResp returns the response from deleting a connector. +type DeleteConnectorResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteConnectorResp) Reset() { + *x = DeleteConnectorResp{} + mi := &file_api_v2_api_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteConnectorResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteConnectorResp) ProtoMessage() {} + +func (x *DeleteConnectorResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteConnectorResp.ProtoReflect.Descriptor instead. +func (*DeleteConnectorResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{28} +} + +func (x *DeleteConnectorResp) GetNotFound() bool { + if x != nil { + return x.NotFound + } + return false +} + +// ListConnectorReq is a request to enumerate connectors. +type ListConnectorReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListConnectorReq) Reset() { + *x = ListConnectorReq{} + mi := &file_api_v2_api_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListConnectorReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListConnectorReq) ProtoMessage() {} + +func (x *ListConnectorReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListConnectorReq.ProtoReflect.Descriptor instead. +func (*ListConnectorReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{29} +} + +// ListConnectorResp returns a list of connectors. +type ListConnectorResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Connectors []*Connector `protobuf:"bytes,1,rep,name=connectors,proto3" json:"connectors,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListConnectorResp) Reset() { + *x = ListConnectorResp{} + mi := &file_api_v2_api_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListConnectorResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListConnectorResp) ProtoMessage() {} + +func (x *ListConnectorResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListConnectorResp.ProtoReflect.Descriptor instead. +func (*ListConnectorResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{30} +} + +func (x *ListConnectorResp) GetConnectors() []*Connector { + if x != nil { + return x.Connectors + } + return nil +} + +// VersionReq is a request to fetch version info. +type VersionReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VersionReq) Reset() { + *x = VersionReq{} + mi := &file_api_v2_api_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VersionReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VersionReq) ProtoMessage() {} + +func (x *VersionReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VersionReq.ProtoReflect.Descriptor instead. +func (*VersionReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{31} +} + +// VersionResp holds the version info of components. +type VersionResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Semantic version of the server. + Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` + // Numeric version of the API. It increases every time a new call is added to the API. + // Clients should use this info to determine if the server supports specific features. + Api int32 `protobuf:"varint,2,opt,name=api,proto3" json:"api,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VersionResp) Reset() { + *x = VersionResp{} + mi := &file_api_v2_api_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VersionResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VersionResp) ProtoMessage() {} + +func (x *VersionResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VersionResp.ProtoReflect.Descriptor instead. +func (*VersionResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{32} +} + +func (x *VersionResp) GetServer() string { + if x != nil { + return x.Server + } + return "" +} + +func (x *VersionResp) GetApi() int32 { + if x != nil { + return x.Api + } + return 0 +} + +// DiscoveryReq is a request to fetch discover information. +type DiscoveryReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DiscoveryReq) Reset() { + *x = DiscoveryReq{} + mi := &file_api_v2_api_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DiscoveryReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DiscoveryReq) ProtoMessage() {} + +func (x *DiscoveryReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DiscoveryReq.ProtoReflect.Descriptor instead. +func (*DiscoveryReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{33} +} + +// DiscoverResp holds the version oidc disovery info. +type DiscoveryResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"` + AuthorizationEndpoint string `protobuf:"bytes,2,opt,name=authorization_endpoint,json=authorizationEndpoint,proto3" json:"authorization_endpoint,omitempty"` + TokenEndpoint string `protobuf:"bytes,3,opt,name=token_endpoint,json=tokenEndpoint,proto3" json:"token_endpoint,omitempty"` + JwksUri string `protobuf:"bytes,4,opt,name=jwks_uri,json=jwksUri,proto3" json:"jwks_uri,omitempty"` + UserinfoEndpoint string `protobuf:"bytes,5,opt,name=userinfo_endpoint,json=userinfoEndpoint,proto3" json:"userinfo_endpoint,omitempty"` + DeviceAuthorizationEndpoint string `protobuf:"bytes,6,opt,name=device_authorization_endpoint,json=deviceAuthorizationEndpoint,proto3" json:"device_authorization_endpoint,omitempty"` + IntrospectionEndpoint string `protobuf:"bytes,7,opt,name=introspection_endpoint,json=introspectionEndpoint,proto3" json:"introspection_endpoint,omitempty"` + GrantTypesSupported []string `protobuf:"bytes,8,rep,name=grant_types_supported,json=grantTypesSupported,proto3" json:"grant_types_supported,omitempty"` + ResponseTypesSupported []string `protobuf:"bytes,9,rep,name=response_types_supported,json=responseTypesSupported,proto3" json:"response_types_supported,omitempty"` + SubjectTypesSupported []string `protobuf:"bytes,10,rep,name=subject_types_supported,json=subjectTypesSupported,proto3" json:"subject_types_supported,omitempty"` + IdTokenSigningAlgValuesSupported []string `protobuf:"bytes,11,rep,name=id_token_signing_alg_values_supported,json=idTokenSigningAlgValuesSupported,proto3" json:"id_token_signing_alg_values_supported,omitempty"` + CodeChallengeMethodsSupported []string `protobuf:"bytes,12,rep,name=code_challenge_methods_supported,json=codeChallengeMethodsSupported,proto3" json:"code_challenge_methods_supported,omitempty"` + ScopesSupported []string `protobuf:"bytes,13,rep,name=scopes_supported,json=scopesSupported,proto3" json:"scopes_supported,omitempty"` + TokenEndpointAuthMethodsSupported []string `protobuf:"bytes,14,rep,name=token_endpoint_auth_methods_supported,json=tokenEndpointAuthMethodsSupported,proto3" json:"token_endpoint_auth_methods_supported,omitempty"` + ClaimsSupported []string `protobuf:"bytes,15,rep,name=claims_supported,json=claimsSupported,proto3" json:"claims_supported,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DiscoveryResp) Reset() { + *x = DiscoveryResp{} + mi := &file_api_v2_api_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DiscoveryResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DiscoveryResp) ProtoMessage() {} + +func (x *DiscoveryResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DiscoveryResp.ProtoReflect.Descriptor instead. +func (*DiscoveryResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{34} +} + +func (x *DiscoveryResp) GetIssuer() string { + if x != nil { + return x.Issuer + } + return "" +} + +func (x *DiscoveryResp) GetAuthorizationEndpoint() string { + if x != nil { + return x.AuthorizationEndpoint + } + return "" +} + +func (x *DiscoveryResp) GetTokenEndpoint() string { + if x != nil { + return x.TokenEndpoint + } + return "" +} + +func (x *DiscoveryResp) GetJwksUri() string { + if x != nil { + return x.JwksUri + } + return "" +} + +func (x *DiscoveryResp) GetUserinfoEndpoint() string { + if x != nil { + return x.UserinfoEndpoint + } + return "" +} + +func (x *DiscoveryResp) GetDeviceAuthorizationEndpoint() string { + if x != nil { + return x.DeviceAuthorizationEndpoint + } + return "" +} + +func (x *DiscoveryResp) GetIntrospectionEndpoint() string { + if x != nil { + return x.IntrospectionEndpoint + } + return "" +} + +func (x *DiscoveryResp) GetGrantTypesSupported() []string { + if x != nil { + return x.GrantTypesSupported + } + return nil +} + +func (x *DiscoveryResp) GetResponseTypesSupported() []string { + if x != nil { + return x.ResponseTypesSupported + } + return nil +} + +func (x *DiscoveryResp) GetSubjectTypesSupported() []string { + if x != nil { + return x.SubjectTypesSupported + } + return nil +} + +func (x *DiscoveryResp) GetIdTokenSigningAlgValuesSupported() []string { + if x != nil { + return x.IdTokenSigningAlgValuesSupported + } + return nil +} + +func (x *DiscoveryResp) GetCodeChallengeMethodsSupported() []string { + if x != nil { + return x.CodeChallengeMethodsSupported + } + return nil +} + +func (x *DiscoveryResp) GetScopesSupported() []string { + if x != nil { + return x.ScopesSupported + } + return nil +} + +func (x *DiscoveryResp) GetTokenEndpointAuthMethodsSupported() []string { + if x != nil { + return x.TokenEndpointAuthMethodsSupported + } + return nil +} + +func (x *DiscoveryResp) GetClaimsSupported() []string { + if x != nil { + return x.ClaimsSupported + } + return nil +} + +// RefreshTokenRef contains the metadata for a refresh token that is managed by the storage. +type RefreshTokenRef struct { + state protoimpl.MessageState `protogen:"open.v1"` + // ID of the refresh token. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + CreatedAt int64 `protobuf:"varint,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + LastUsed int64 `protobuf:"varint,6,opt,name=last_used,json=lastUsed,proto3" json:"last_used,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RefreshTokenRef) Reset() { + *x = RefreshTokenRef{} + mi := &file_api_v2_api_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RefreshTokenRef) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RefreshTokenRef) ProtoMessage() {} + +func (x *RefreshTokenRef) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RefreshTokenRef.ProtoReflect.Descriptor instead. +func (*RefreshTokenRef) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{35} +} + +func (x *RefreshTokenRef) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *RefreshTokenRef) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *RefreshTokenRef) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *RefreshTokenRef) GetLastUsed() int64 { + if x != nil { + return x.LastUsed + } + return 0 +} + +// ListRefreshReq is a request to enumerate the refresh tokens of a user. +type ListRefreshReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The "sub" claim returned in the ID Token. + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRefreshReq) Reset() { + *x = ListRefreshReq{} + mi := &file_api_v2_api_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRefreshReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRefreshReq) ProtoMessage() {} + +func (x *ListRefreshReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRefreshReq.ProtoReflect.Descriptor instead. +func (*ListRefreshReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{36} +} + +func (x *ListRefreshReq) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +// ListRefreshResp returns a list of refresh tokens for a user. +type ListRefreshResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + RefreshTokens []*RefreshTokenRef `protobuf:"bytes,1,rep,name=refresh_tokens,json=refreshTokens,proto3" json:"refresh_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRefreshResp) Reset() { + *x = ListRefreshResp{} + mi := &file_api_v2_api_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRefreshResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRefreshResp) ProtoMessage() {} + +func (x *ListRefreshResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRefreshResp.ProtoReflect.Descriptor instead. +func (*ListRefreshResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{37} +} + +func (x *ListRefreshResp) GetRefreshTokens() []*RefreshTokenRef { + if x != nil { + return x.RefreshTokens + } + return nil +} + +// RevokeRefreshReq is a request to revoke the refresh token of the user-client pair. +type RevokeRefreshReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The "sub" claim returned in the ID Token. + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeRefreshReq) Reset() { + *x = RevokeRefreshReq{} + mi := &file_api_v2_api_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeRefreshReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeRefreshReq) ProtoMessage() {} + +func (x *RevokeRefreshReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeRefreshReq.ProtoReflect.Descriptor instead. +func (*RevokeRefreshReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{38} +} + +func (x *RevokeRefreshReq) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *RevokeRefreshReq) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +// RevokeRefreshResp determines if the refresh token is revoked successfully. +type RevokeRefreshResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Set to true is refresh token was not found and token could not be revoked. + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeRefreshResp) Reset() { + *x = RevokeRefreshResp{} + mi := &file_api_v2_api_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeRefreshResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeRefreshResp) ProtoMessage() {} + +func (x *RevokeRefreshResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeRefreshResp.ProtoReflect.Descriptor instead. +func (*RevokeRefreshResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{39} +} + +func (x *RevokeRefreshResp) GetNotFound() bool { + if x != nil { + return x.NotFound + } + return false +} + +type VerifyPasswordReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyPasswordReq) Reset() { + *x = VerifyPasswordReq{} + mi := &file_api_v2_api_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyPasswordReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyPasswordReq) ProtoMessage() {} + +func (x *VerifyPasswordReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyPasswordReq.ProtoReflect.Descriptor instead. +func (*VerifyPasswordReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{40} +} + +func (x *VerifyPasswordReq) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *VerifyPasswordReq) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type VerifyPasswordResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Verified bool `protobuf:"varint,1,opt,name=verified,proto3" json:"verified,omitempty"` + NotFound bool `protobuf:"varint,2,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyPasswordResp) Reset() { + *x = VerifyPasswordResp{} + mi := &file_api_v2_api_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyPasswordResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyPasswordResp) ProtoMessage() {} + +func (x *VerifyPasswordResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[41] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyPasswordResp.ProtoReflect.Descriptor instead. +func (*VerifyPasswordResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{41} +} + +func (x *VerifyPasswordResp) GetVerified() bool { + if x != nil { + return x.Verified + } + return false +} + +func (x *VerifyPasswordResp) GetNotFound() bool { + if x != nil { + return x.NotFound + } + return false +} + +// ClientAuthState represents authentication state for a specific client within a session. +// The user_id and connector_id are on the parent AuthSession message. +type ClientAuthState struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + AuthenticatedAt int64 `protobuf:"varint,2,opt,name=authenticated_at,json=authenticatedAt,proto3" json:"authenticated_at,omitempty"` + LastActivity int64 `protobuf:"varint,3,opt,name=last_activity,json=lastActivity,proto3" json:"last_activity,omitempty"` + LastTokenIssuedAt int64 `protobuf:"varint,4,opt,name=last_token_issued_at,json=lastTokenIssuedAt,proto3" json:"last_token_issued_at,omitempty"` + // Whether this client was reached through another client's SSO sharing rather + // than by authenticating directly. + ViaSso bool `protobuf:"varint,5,opt,name=via_sso,json=viaSso,proto3" json:"via_sso,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientAuthState) Reset() { + *x = ClientAuthState{} + mi := &file_api_v2_api_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientAuthState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientAuthState) ProtoMessage() {} + +func (x *ClientAuthState) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientAuthState.ProtoReflect.Descriptor instead. +func (*ClientAuthState) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{42} +} + +func (x *ClientAuthState) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *ClientAuthState) GetAuthenticatedAt() int64 { + if x != nil { + return x.AuthenticatedAt + } + return 0 +} + +func (x *ClientAuthState) GetLastActivity() int64 { + if x != nil { + return x.LastActivity + } + return 0 +} + +func (x *ClientAuthState) GetLastTokenIssuedAt() int64 { + if x != nil { + return x.LastTokenIssuedAt + } + return 0 +} + +func (x *ClientAuthState) GetViaSso() bool { + if x != nil { + return x.ViaSso + } + return false +} + +// AuthSession represents a user's authentication session. +type AuthSession struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Random identifier of the session, published to clients as the "sid" claim. One + // signed-in browser is one session, so a user has as many as they have devices. + Id string `protobuf:"bytes,10,opt,name=id,proto3" json:"id,omitempty"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + ClientStates []*ClientAuthState `protobuf:"bytes,3,rep,name=client_states,json=clientStates,proto3" json:"client_states,omitempty"` + CreatedAt int64 `protobuf:"varint,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + LastActivity int64 `protobuf:"varint,5,opt,name=last_activity,json=lastActivity,proto3" json:"last_activity,omitempty"` + IpAddress string `protobuf:"bytes,6,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` + UserAgent string `protobuf:"bytes,7,opt,name=user_agent,json=userAgent,proto3" json:"user_agent,omitempty"` + AbsoluteExpiry int64 `protobuf:"varint,8,opt,name=absolute_expiry,json=absoluteExpiry,proto3" json:"absolute_expiry,omitempty"` + IdleExpiry int64 `protobuf:"varint,9,opt,name=idle_expiry,json=idleExpiry,proto3" json:"idle_expiry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AuthSession) Reset() { + *x = AuthSession{} + mi := &file_api_v2_api_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AuthSession) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthSession) ProtoMessage() {} + +func (x *AuthSession) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[43] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthSession.ProtoReflect.Descriptor instead. +func (*AuthSession) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{43} +} + +func (x *AuthSession) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *AuthSession) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *AuthSession) GetConnectorId() string { + if x != nil { + return x.ConnectorId + } + return "" +} + +func (x *AuthSession) GetClientStates() []*ClientAuthState { + if x != nil { + return x.ClientStates + } + return nil +} + +func (x *AuthSession) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *AuthSession) GetLastActivity() int64 { + if x != nil { + return x.LastActivity + } + return 0 +} + +func (x *AuthSession) GetIpAddress() string { + if x != nil { + return x.IpAddress + } + return "" +} + +func (x *AuthSession) GetUserAgent() string { + if x != nil { + return x.UserAgent + } + return "" +} + +func (x *AuthSession) GetAbsoluteExpiry() int64 { + if x != nil { + return x.AbsoluteExpiry + } + return 0 +} + +func (x *AuthSession) GetIdleExpiry() int64 { + if x != nil { + return x.IdleExpiry + } + return 0 +} + +// GetAuthSessionReq is a request to retrieve an auth session. +type GetAuthSessionReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAuthSessionReq) Reset() { + *x = GetAuthSessionReq{} + mi := &file_api_v2_api_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAuthSessionReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAuthSessionReq) ProtoMessage() {} + +func (x *GetAuthSessionReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAuthSessionReq.ProtoReflect.Descriptor instead. +func (*GetAuthSessionReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{44} +} + +func (x *GetAuthSessionReq) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// GetAuthSessionResp returns the auth session details. +type GetAuthSessionResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Session *AuthSession `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAuthSessionResp) Reset() { + *x = GetAuthSessionResp{} + mi := &file_api_v2_api_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAuthSessionResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAuthSessionResp) ProtoMessage() {} + +func (x *GetAuthSessionResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAuthSessionResp.ProtoReflect.Descriptor instead. +func (*GetAuthSessionResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{45} +} + +func (x *GetAuthSessionResp) GetSession() *AuthSession { + if x != nil { + return x.Session + } + return nil +} + +// ListAuthSessionsReq is a request to list auth sessions. +type ListAuthSessionsReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional filter: if set, only sessions for this user are returned. + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + // Optional filter: if set, only sessions from this connector are returned. + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAuthSessionsReq) Reset() { + *x = ListAuthSessionsReq{} + mi := &file_api_v2_api_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAuthSessionsReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAuthSessionsReq) ProtoMessage() {} + +func (x *ListAuthSessionsReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[46] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAuthSessionsReq.ProtoReflect.Descriptor instead. +func (*ListAuthSessionsReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{46} +} + +func (x *ListAuthSessionsReq) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ListAuthSessionsReq) GetConnectorId() string { + if x != nil { + return x.ConnectorId + } + return "" +} + +// ListAuthSessionsResp returns a list of auth sessions. +type ListAuthSessionsResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sessions []*AuthSession `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAuthSessionsResp) Reset() { + *x = ListAuthSessionsResp{} + mi := &file_api_v2_api_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAuthSessionsResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAuthSessionsResp) ProtoMessage() {} + +func (x *ListAuthSessionsResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[47] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAuthSessionsResp.ProtoReflect.Descriptor instead. +func (*ListAuthSessionsResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{47} +} + +func (x *ListAuthSessionsResp) GetSessions() []*AuthSession { + if x != nil { + return x.Sessions + } + return nil +} + +// DeleteAuthSessionReq is a request to delete an auth session. +// Deleting a session also revokes all associated refresh tokens (consistent with logout behavior). +type DeleteAuthSessionReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteAuthSessionReq) Reset() { + *x = DeleteAuthSessionReq{} + mi := &file_api_v2_api_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteAuthSessionReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteAuthSessionReq) ProtoMessage() {} + +func (x *DeleteAuthSessionReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[48] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteAuthSessionReq.ProtoReflect.Descriptor instead. +func (*DeleteAuthSessionReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{48} +} + +func (x *DeleteAuthSessionReq) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// DeleteAuthSessionResp returns the result of deleting an auth session. +type DeleteAuthSessionResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteAuthSessionResp) Reset() { + *x = DeleteAuthSessionResp{} + mi := &file_api_v2_api_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteAuthSessionResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteAuthSessionResp) ProtoMessage() {} + +func (x *DeleteAuthSessionResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteAuthSessionResp.ProtoReflect.Descriptor instead. +func (*DeleteAuthSessionResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{49} +} + +func (x *DeleteAuthSessionResp) GetNotFound() bool { + if x != nil { + return x.NotFound + } + return false +} + +// TerminateSessionsByConnectorReq is a request to terminate all sessions for a connector. +// Use when connector configuration changes or is removed. Also revokes associated refresh tokens. +type TerminateSessionsByConnectorReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConnectorId string `protobuf:"bytes,1,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TerminateSessionsByConnectorReq) Reset() { + *x = TerminateSessionsByConnectorReq{} + mi := &file_api_v2_api_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TerminateSessionsByConnectorReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminateSessionsByConnectorReq) ProtoMessage() {} + +func (x *TerminateSessionsByConnectorReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TerminateSessionsByConnectorReq.ProtoReflect.Descriptor instead. +func (*TerminateSessionsByConnectorReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{50} +} + +func (x *TerminateSessionsByConnectorReq) GetConnectorId() string { + if x != nil { + return x.ConnectorId + } + return "" +} + +// TerminateSessionsByConnectorResp returns the count of terminated sessions. +type TerminateSessionsByConnectorResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionsTerminated int64 `protobuf:"varint,1,opt,name=sessions_terminated,json=sessionsTerminated,proto3" json:"sessions_terminated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TerminateSessionsByConnectorResp) Reset() { + *x = TerminateSessionsByConnectorResp{} + mi := &file_api_v2_api_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TerminateSessionsByConnectorResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminateSessionsByConnectorResp) ProtoMessage() {} + +func (x *TerminateSessionsByConnectorResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TerminateSessionsByConnectorResp.ProtoReflect.Descriptor instead. +func (*TerminateSessionsByConnectorResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{51} +} + +func (x *TerminateSessionsByConnectorResp) GetSessionsTerminated() int64 { + if x != nil { + return x.SessionsTerminated + } + return 0 +} + +// TerminateSessionsByUserReq is a request to terminate all sessions for a user. +// Use for account compromise scenarios. Also revokes associated refresh tokens. +type TerminateSessionsByUserReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TerminateSessionsByUserReq) Reset() { + *x = TerminateSessionsByUserReq{} + mi := &file_api_v2_api_proto_msgTypes[52] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TerminateSessionsByUserReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminateSessionsByUserReq) ProtoMessage() {} + +func (x *TerminateSessionsByUserReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[52] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TerminateSessionsByUserReq.ProtoReflect.Descriptor instead. +func (*TerminateSessionsByUserReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{52} +} + +func (x *TerminateSessionsByUserReq) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +// TerminateSessionsByUserResp returns the count of terminated sessions. +type TerminateSessionsByUserResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionsTerminated int64 `protobuf:"varint,1,opt,name=sessions_terminated,json=sessionsTerminated,proto3" json:"sessions_terminated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TerminateSessionsByUserResp) Reset() { + *x = TerminateSessionsByUserResp{} + mi := &file_api_v2_api_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TerminateSessionsByUserResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminateSessionsByUserResp) ProtoMessage() {} + +func (x *TerminateSessionsByUserResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[53] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TerminateSessionsByUserResp.ProtoReflect.Descriptor instead. +func (*TerminateSessionsByUserResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{53} +} + +func (x *TerminateSessionsByUserResp) GetSessionsTerminated() int64 { + if x != nil { + return x.SessionsTerminated + } + return 0 +} + +// ConsentEntry represents approved scopes for a single client. +type ConsentEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + Scopes []string `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConsentEntry) Reset() { + *x = ConsentEntry{} + mi := &file_api_v2_api_proto_msgTypes[54] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConsentEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConsentEntry) ProtoMessage() {} + +func (x *ConsentEntry) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[54] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConsentEntry.ProtoReflect.Descriptor instead. +func (*ConsentEntry) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{54} +} + +func (x *ConsentEntry) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *ConsentEntry) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +// MFASecret represents metadata of an enrolled MFA authenticator. +// The actual secret value is never exposed through the admin API. +type MFASecret struct { + state protoimpl.MessageState `protogen:"open.v1"` + AuthenticatorId string `protobuf:"bytes,1,opt,name=authenticator_id,json=authenticatorId,proto3" json:"authenticator_id,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Confirmed bool `protobuf:"varint,3,opt,name=confirmed,proto3" json:"confirmed,omitempty"` + CreatedAt int64 `protobuf:"varint,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MFASecret) Reset() { + *x = MFASecret{} + mi := &file_api_v2_api_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MFASecret) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MFASecret) ProtoMessage() {} + +func (x *MFASecret) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[55] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MFASecret.ProtoReflect.Descriptor instead. +func (*MFASecret) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{55} +} + +func (x *MFASecret) GetAuthenticatorId() string { + if x != nil { + return x.AuthenticatorId + } + return "" +} + +func (x *MFASecret) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *MFASecret) GetConfirmed() bool { + if x != nil { + return x.Confirmed + } + return false +} + +func (x *MFASecret) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +// WebAuthnCredential represents metadata of a registered WebAuthn credential. +// The public key is never exposed through the admin API. +type WebAuthnCredential struct { + state protoimpl.MessageState `protogen:"open.v1"` + CredentialId []byte `protobuf:"bytes,1,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"` + AttestationType string `protobuf:"bytes,2,opt,name=attestation_type,json=attestationType,proto3" json:"attestation_type,omitempty"` + Aaguid []byte `protobuf:"bytes,3,opt,name=aaguid,proto3" json:"aaguid,omitempty"` + SignCount uint32 `protobuf:"varint,4,opt,name=sign_count,json=signCount,proto3" json:"sign_count,omitempty"` + CloneWarning bool `protobuf:"varint,5,opt,name=clone_warning,json=cloneWarning,proto3" json:"clone_warning,omitempty"` + Transport []string `protobuf:"bytes,6,rep,name=transport,proto3" json:"transport,omitempty"` + BackupEligible bool `protobuf:"varint,7,opt,name=backup_eligible,json=backupEligible,proto3" json:"backup_eligible,omitempty"` + BackupState bool `protobuf:"varint,8,opt,name=backup_state,json=backupState,proto3" json:"backup_state,omitempty"` + DisplayName string `protobuf:"bytes,9,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + CreatedAt int64 `protobuf:"varint,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WebAuthnCredential) Reset() { + *x = WebAuthnCredential{} + mi := &file_api_v2_api_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WebAuthnCredential) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WebAuthnCredential) ProtoMessage() {} + +func (x *WebAuthnCredential) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[56] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WebAuthnCredential.ProtoReflect.Descriptor instead. +func (*WebAuthnCredential) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{56} +} + +func (x *WebAuthnCredential) GetCredentialId() []byte { + if x != nil { + return x.CredentialId + } + return nil +} + +func (x *WebAuthnCredential) GetAttestationType() string { + if x != nil { + return x.AttestationType + } + return "" +} + +func (x *WebAuthnCredential) GetAaguid() []byte { + if x != nil { + return x.Aaguid + } + return nil +} + +func (x *WebAuthnCredential) GetSignCount() uint32 { + if x != nil { + return x.SignCount + } + return 0 +} + +func (x *WebAuthnCredential) GetCloneWarning() bool { + if x != nil { + return x.CloneWarning + } + return false +} + +func (x *WebAuthnCredential) GetTransport() []string { + if x != nil { + return x.Transport + } + return nil +} + +func (x *WebAuthnCredential) GetBackupEligible() bool { + if x != nil { + return x.BackupEligible + } + return false +} + +func (x *WebAuthnCredential) GetBackupState() bool { + if x != nil { + return x.BackupState + } + return false +} + +func (x *WebAuthnCredential) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *WebAuthnCredential) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +// MFADeviceInfo groups MFA secret and WebAuthn credentials for one authenticator. +type MFADeviceInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + AuthenticatorId string `protobuf:"bytes,1,opt,name=authenticator_id,json=authenticatorId,proto3" json:"authenticator_id,omitempty"` + MfaSecret *MFASecret `protobuf:"bytes,2,opt,name=mfa_secret,json=mfaSecret,proto3" json:"mfa_secret,omitempty"` + WebauthnCredentials []*WebAuthnCredential `protobuf:"bytes,3,rep,name=webauthn_credentials,json=webauthnCredentials,proto3" json:"webauthn_credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MFADeviceInfo) Reset() { + *x = MFADeviceInfo{} + mi := &file_api_v2_api_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MFADeviceInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MFADeviceInfo) ProtoMessage() {} + +func (x *MFADeviceInfo) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[57] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MFADeviceInfo.ProtoReflect.Descriptor instead. +func (*MFADeviceInfo) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{57} +} + +func (x *MFADeviceInfo) GetAuthenticatorId() string { + if x != nil { + return x.AuthenticatorId + } + return "" +} + +func (x *MFADeviceInfo) GetMfaSecret() *MFASecret { + if x != nil { + return x.MfaSecret + } + return nil +} + +func (x *MFADeviceInfo) GetWebauthnCredentials() []*WebAuthnCredential { + if x != nil { + return x.WebauthnCredentials + } + return nil +} + +// UserIdentity represents persistent per-user identity data. +type UserIdentity struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + EmailVerified bool `protobuf:"varint,4,opt,name=email_verified,json=emailVerified,proto3" json:"email_verified,omitempty"` + Username string `protobuf:"bytes,5,opt,name=username,proto3" json:"username,omitempty"` + Groups []string `protobuf:"bytes,6,rep,name=groups,proto3" json:"groups,omitempty"` + Consents []*ConsentEntry `protobuf:"bytes,7,rep,name=consents,proto3" json:"consents,omitempty"` + MfaDevices []*MFADeviceInfo `protobuf:"bytes,8,rep,name=mfa_devices,json=mfaDevices,proto3" json:"mfa_devices,omitempty"` + CreatedAt int64 `protobuf:"varint,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + LastLogin int64 `protobuf:"varint,10,opt,name=last_login,json=lastLogin,proto3" json:"last_login,omitempty"` + BlockedUntil int64 `protobuf:"varint,11,opt,name=blocked_until,json=blockedUntil,proto3" json:"blocked_until,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserIdentity) Reset() { + *x = UserIdentity{} + mi := &file_api_v2_api_proto_msgTypes[58] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserIdentity) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserIdentity) ProtoMessage() {} + +func (x *UserIdentity) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[58] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserIdentity.ProtoReflect.Descriptor instead. +func (*UserIdentity) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{58} +} + +func (x *UserIdentity) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserIdentity) GetConnectorId() string { + if x != nil { + return x.ConnectorId + } + return "" +} + +func (x *UserIdentity) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *UserIdentity) GetEmailVerified() bool { + if x != nil { + return x.EmailVerified + } + return false +} + +func (x *UserIdentity) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *UserIdentity) GetGroups() []string { + if x != nil { + return x.Groups + } + return nil +} + +func (x *UserIdentity) GetConsents() []*ConsentEntry { + if x != nil { + return x.Consents + } + return nil +} + +func (x *UserIdentity) GetMfaDevices() []*MFADeviceInfo { + if x != nil { + return x.MfaDevices + } + return nil +} + +func (x *UserIdentity) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *UserIdentity) GetLastLogin() int64 { + if x != nil { + return x.LastLogin + } return 0 } -// RefreshTokenRef contains the metadata for a refresh token that is managed by the storage. -type RefreshTokenRef struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields +func (x *UserIdentity) GetBlockedUntil() int64 { + if x != nil { + return x.BlockedUntil + } + return 0 +} + +// GetUserIdentityReq is a request to retrieve a user identity. +type GetUserIdentityReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetUserIdentityReq) Reset() { + *x = GetUserIdentityReq{} + mi := &file_api_v2_api_proto_msgTypes[59] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetUserIdentityReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetUserIdentityReq) ProtoMessage() {} + +func (x *GetUserIdentityReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[59] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetUserIdentityReq.ProtoReflect.Descriptor instead. +func (*GetUserIdentityReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{59} +} + +func (x *GetUserIdentityReq) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *GetUserIdentityReq) GetConnectorId() string { + if x != nil { + return x.ConnectorId + } + return "" +} + +// GetUserIdentityResp returns the user identity details. +type GetUserIdentityResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identity *UserIdentity `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetUserIdentityResp) Reset() { + *x = GetUserIdentityResp{} + mi := &file_api_v2_api_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetUserIdentityResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetUserIdentityResp) ProtoMessage() {} + +func (x *GetUserIdentityResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[60] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetUserIdentityResp.ProtoReflect.Descriptor instead. +func (*GetUserIdentityResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{60} +} + +func (x *GetUserIdentityResp) GetIdentity() *UserIdentity { + if x != nil { + return x.Identity + } + return nil +} + +// ListUserIdentitiesReq is a request to list user identities. +type ListUserIdentitiesReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListUserIdentitiesReq) Reset() { + *x = ListUserIdentitiesReq{} + mi := &file_api_v2_api_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListUserIdentitiesReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListUserIdentitiesReq) ProtoMessage() {} + +func (x *ListUserIdentitiesReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[61] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListUserIdentitiesReq.ProtoReflect.Descriptor instead. +func (*ListUserIdentitiesReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{61} +} + +// ListUserIdentitiesResp returns a list of user identities. +type ListUserIdentitiesResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identities []*UserIdentity `protobuf:"bytes,1,rep,name=identities,proto3" json:"identities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListUserIdentitiesResp) Reset() { + *x = ListUserIdentitiesResp{} + mi := &file_api_v2_api_proto_msgTypes[62] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListUserIdentitiesResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListUserIdentitiesResp) ProtoMessage() {} + +func (x *ListUserIdentitiesResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[62] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListUserIdentitiesResp.ProtoReflect.Descriptor instead. +func (*ListUserIdentitiesResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{62} +} + +func (x *ListUserIdentitiesResp) GetIdentities() []*UserIdentity { + if x != nil { + return x.Identities + } + return nil +} + +// DeleteUserIdentityReq is a request to delete a user identity. +// This is a full data purge for GDPR compliance and account deletion. +// It cascades to: auth session, all refresh tokens, offline sessions, the +// password record (matched by the identity's email), and the identity itself. +type DeleteUserIdentityReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteUserIdentityReq) Reset() { + *x = DeleteUserIdentityReq{} + mi := &file_api_v2_api_proto_msgTypes[63] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteUserIdentityReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteUserIdentityReq) ProtoMessage() {} + +func (x *DeleteUserIdentityReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[63] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteUserIdentityReq.ProtoReflect.Descriptor instead. +func (*DeleteUserIdentityReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{63} +} + +func (x *DeleteUserIdentityReq) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *DeleteUserIdentityReq) GetConnectorId() string { + if x != nil { + return x.ConnectorId + } + return "" +} + +// DeleteUserIdentityResp returns the result of deleting a user identity. +type DeleteUserIdentityResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteUserIdentityResp) Reset() { + *x = DeleteUserIdentityResp{} + mi := &file_api_v2_api_proto_msgTypes[64] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteUserIdentityResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteUserIdentityResp) ProtoMessage() {} + +func (x *DeleteUserIdentityResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[64] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteUserIdentityResp.ProtoReflect.Descriptor instead. +func (*DeleteUserIdentityResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{64} +} + +func (x *DeleteUserIdentityResp) GetNotFound() bool { + if x != nil { + return x.NotFound + } + return false +} - // ID of the refresh token. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` - CreatedAt int64 `protobuf:"varint,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - LastUsed int64 `protobuf:"varint,6,opt,name=last_used,json=lastUsed,proto3" json:"last_used,omitempty"` +// ResetMFAReq is a request to clear all MFA secrets and WebAuthn credentials for a user. +// Use when a user has lost access to all their MFA devices. +type ResetMFAReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *RefreshTokenRef) Reset() { - *x = RefreshTokenRef{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } +func (x *ResetMFAReq) Reset() { + *x = ResetMFAReq{} + mi := &file_api_v2_api_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *RefreshTokenRef) String() string { +func (x *ResetMFAReq) String() string { return protoimpl.X.MessageStringOf(x) } -func (*RefreshTokenRef) ProtoMessage() {} +func (*ResetMFAReq) ProtoMessage() {} -func (x *RefreshTokenRef) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[18] - if protoimpl.UnsafeEnabled && x != nil { +func (x *ResetMFAReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[65] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1048,67 +3823,95 @@ func (x *RefreshTokenRef) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use RefreshTokenRef.ProtoReflect.Descriptor instead. -func (*RefreshTokenRef) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{18} +// Deprecated: Use ResetMFAReq.ProtoReflect.Descriptor instead. +func (*ResetMFAReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{65} } -func (x *RefreshTokenRef) GetId() string { +func (x *ResetMFAReq) GetUserId() string { if x != nil { - return x.Id + return x.UserId } return "" } -func (x *RefreshTokenRef) GetClientId() string { +func (x *ResetMFAReq) GetConnectorId() string { if x != nil { - return x.ClientId + return x.ConnectorId } return "" } -func (x *RefreshTokenRef) GetCreatedAt() int64 { +// ResetMFAResp returns the result of resetting MFA. +type ResetMFAResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResetMFAResp) Reset() { + *x = ResetMFAResp{} + mi := &file_api_v2_api_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResetMFAResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResetMFAResp) ProtoMessage() {} + +func (x *ResetMFAResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[66] if x != nil { - return x.CreatedAt + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return 0 + return mi.MessageOf(x) } -func (x *RefreshTokenRef) GetLastUsed() int64 { +// Deprecated: Use ResetMFAResp.ProtoReflect.Descriptor instead. +func (*ResetMFAResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{66} +} + +func (x *ResetMFAResp) GetNotFound() bool { if x != nil { - return x.LastUsed + return x.NotFound } - return 0 + return false } -// ListRefreshReq is a request to enumerate the refresh tokens of a user. -type ListRefreshReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache +// ListMFADevicesReq is a request to list registered MFA authenticators for a user. +type ListMFADevicesReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` unknownFields protoimpl.UnknownFields - - // The "sub" claim returned in the ID Token. - UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + sizeCache protoimpl.SizeCache } -func (x *ListRefreshReq) Reset() { - *x = ListRefreshReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } +func (x *ListMFADevicesReq) Reset() { + *x = ListMFADevicesReq{} + mi := &file_api_v2_api_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *ListRefreshReq) String() string { +func (x *ListMFADevicesReq) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListRefreshReq) ProtoMessage() {} +func (*ListMFADevicesReq) ProtoMessage() {} -func (x *ListRefreshReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[19] - if protoimpl.UnsafeEnabled && x != nil { +func (x *ListMFADevicesReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[67] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1118,45 +3921,50 @@ func (x *ListRefreshReq) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListRefreshReq.ProtoReflect.Descriptor instead. -func (*ListRefreshReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{19} +// Deprecated: Use ListMFADevicesReq.ProtoReflect.Descriptor instead. +func (*ListMFADevicesReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{67} } -func (x *ListRefreshReq) GetUserId() string { +func (x *ListMFADevicesReq) GetUserId() string { if x != nil { return x.UserId } return "" } -// ListRefreshResp returns a list of refresh tokens for a user. -type ListRefreshResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields +func (x *ListMFADevicesReq) GetConnectorId() string { + if x != nil { + return x.ConnectorId + } + return "" +} - RefreshTokens []*RefreshTokenRef `protobuf:"bytes,1,rep,name=refresh_tokens,json=refreshTokens,proto3" json:"refresh_tokens,omitempty"` +// ListMFADevicesResp returns MFA device information. +// Secret values and public keys are never included in the response. +type ListMFADevicesResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Devices []*MFADeviceInfo `protobuf:"bytes,1,rep,name=devices,proto3" json:"devices,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *ListRefreshResp) Reset() { - *x = ListRefreshResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } +func (x *ListMFADevicesResp) Reset() { + *x = ListMFADevicesResp{} + mi := &file_api_v2_api_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *ListRefreshResp) String() string { +func (x *ListMFADevicesResp) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListRefreshResp) ProtoMessage() {} +func (*ListMFADevicesResp) ProtoMessage() {} -func (x *ListRefreshResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[20] - if protoimpl.UnsafeEnabled && x != nil { +func (x *ListMFADevicesResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[68] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1166,47 +3974,45 @@ func (x *ListRefreshResp) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListRefreshResp.ProtoReflect.Descriptor instead. -func (*ListRefreshResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{20} +// Deprecated: Use ListMFADevicesResp.ProtoReflect.Descriptor instead. +func (*ListMFADevicesResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{68} } -func (x *ListRefreshResp) GetRefreshTokens() []*RefreshTokenRef { +func (x *ListMFADevicesResp) GetDevices() []*MFADeviceInfo { if x != nil { - return x.RefreshTokens + return x.Devices } return nil } -// RevokeRefreshReq is a request to revoke the refresh token of the user-client pair. -type RevokeRefreshReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache +// DeleteWebAuthnCredentialReq is a request to delete a specific WebAuthn credential. +// Use when a user has lost or wants to deregister a specific security key. +type DeleteWebAuthnCredentialReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + CredentialId []byte `protobuf:"bytes,3,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"` unknownFields protoimpl.UnknownFields - - // The "sub" claim returned in the ID Token. - UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + sizeCache protoimpl.SizeCache } -func (x *RevokeRefreshReq) Reset() { - *x = RevokeRefreshReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } +func (x *DeleteWebAuthnCredentialReq) Reset() { + *x = DeleteWebAuthnCredentialReq{} + mi := &file_api_v2_api_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *RevokeRefreshReq) String() string { +func (x *DeleteWebAuthnCredentialReq) String() string { return protoimpl.X.MessageStringOf(x) } -func (*RevokeRefreshReq) ProtoMessage() {} +func (*DeleteWebAuthnCredentialReq) ProtoMessage() {} -func (x *RevokeRefreshReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[21] - if protoimpl.UnsafeEnabled && x != nil { +func (x *DeleteWebAuthnCredentialReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[69] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1216,53 +4022,56 @@ func (x *RevokeRefreshReq) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use RevokeRefreshReq.ProtoReflect.Descriptor instead. -func (*RevokeRefreshReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{21} +// Deprecated: Use DeleteWebAuthnCredentialReq.ProtoReflect.Descriptor instead. +func (*DeleteWebAuthnCredentialReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{69} } -func (x *RevokeRefreshReq) GetUserId() string { +func (x *DeleteWebAuthnCredentialReq) GetUserId() string { if x != nil { return x.UserId } return "" } -func (x *RevokeRefreshReq) GetClientId() string { +func (x *DeleteWebAuthnCredentialReq) GetConnectorId() string { if x != nil { - return x.ClientId + return x.ConnectorId } return "" } -// RevokeRefreshResp determines if the refresh token is revoked successfully. -type RevokeRefreshResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields +func (x *DeleteWebAuthnCredentialReq) GetCredentialId() []byte { + if x != nil { + return x.CredentialId + } + return nil +} - // Set to true is refresh token was not found and token could not be revoked. - NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` +// DeleteWebAuthnCredentialResp returns the result of deleting a WebAuthn credential. +type DeleteWebAuthnCredentialResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *RevokeRefreshResp) Reset() { - *x = RevokeRefreshResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } +func (x *DeleteWebAuthnCredentialResp) Reset() { + *x = DeleteWebAuthnCredentialResp{} + mi := &file_api_v2_api_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *RevokeRefreshResp) String() string { +func (x *DeleteWebAuthnCredentialResp) String() string { return protoimpl.X.MessageStringOf(x) } -func (*RevokeRefreshResp) ProtoMessage() {} +func (*DeleteWebAuthnCredentialResp) ProtoMessage() {} -func (x *RevokeRefreshResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[22] - if protoimpl.UnsafeEnabled && x != nil { +func (x *DeleteWebAuthnCredentialResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[70] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1272,45 +4081,45 @@ func (x *RevokeRefreshResp) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use RevokeRefreshResp.ProtoReflect.Descriptor instead. -func (*RevokeRefreshResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{22} +// Deprecated: Use DeleteWebAuthnCredentialResp.ProtoReflect.Descriptor instead. +func (*DeleteWebAuthnCredentialResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{70} } -func (x *RevokeRefreshResp) GetNotFound() bool { +func (x *DeleteWebAuthnCredentialResp) GetNotFound() bool { if x != nil { return x.NotFound } return false } -type VerifyPasswordReq struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` - Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` +// DeleteMFASecretReq is a request to delete a specific MFA authenticator secret. +// Also removes any associated WebAuthn credentials for the same authenticator. +type DeleteMFASecretReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + AuthenticatorId string `protobuf:"bytes,3,opt,name=authenticator_id,json=authenticatorId,proto3" json:"authenticator_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *VerifyPasswordReq) Reset() { - *x = VerifyPasswordReq{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } +func (x *DeleteMFASecretReq) Reset() { + *x = DeleteMFASecretReq{} + mi := &file_api_v2_api_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *VerifyPasswordReq) String() string { +func (x *DeleteMFASecretReq) String() string { return protoimpl.X.MessageStringOf(x) } -func (*VerifyPasswordReq) ProtoMessage() {} +func (*DeleteMFASecretReq) ProtoMessage() {} -func (x *VerifyPasswordReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[23] - if protoimpl.UnsafeEnabled && x != nil { +func (x *DeleteMFASecretReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[71] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1320,52 +4129,104 @@ func (x *VerifyPasswordReq) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use VerifyPasswordReq.ProtoReflect.Descriptor instead. -func (*VerifyPasswordReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{23} +// Deprecated: Use DeleteMFASecretReq.ProtoReflect.Descriptor instead. +func (*DeleteMFASecretReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{71} } -func (x *VerifyPasswordReq) GetEmail() string { +func (x *DeleteMFASecretReq) GetUserId() string { if x != nil { - return x.Email + return x.UserId } return "" } -func (x *VerifyPasswordReq) GetPassword() string { +func (x *DeleteMFASecretReq) GetConnectorId() string { if x != nil { - return x.Password + return x.ConnectorId } return "" } -type VerifyPasswordResp struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache +func (x *DeleteMFASecretReq) GetAuthenticatorId() string { + if x != nil { + return x.AuthenticatorId + } + return "" +} + +// DeleteMFASecretResp returns the result of deleting an MFA secret. +type DeleteMFASecretResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} - Verified bool `protobuf:"varint,1,opt,name=verified,proto3" json:"verified,omitempty"` - NotFound bool `protobuf:"varint,2,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` +func (x *DeleteMFASecretResp) Reset() { + *x = DeleteMFASecretResp{} + mi := &file_api_v2_api_proto_msgTypes[72] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *VerifyPasswordResp) Reset() { - *x = VerifyPasswordResp{} - if protoimpl.UnsafeEnabled { - mi := &file_api_v2_api_proto_msgTypes[24] +func (x *DeleteMFASecretResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteMFASecretResp) ProtoMessage() {} + +func (x *DeleteMFASecretResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[72] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } + return mi.MessageOf(x) } -func (x *VerifyPasswordResp) String() string { +// Deprecated: Use DeleteMFASecretResp.ProtoReflect.Descriptor instead. +func (*DeleteMFASecretResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{72} +} + +func (x *DeleteMFASecretResp) GetNotFound() bool { + if x != nil { + return x.NotFound + } + return false +} + +// RevokeConsentReq is a request to revoke consent for a specific client. +// The user will see the consent screen again on next authorization. +type RevokeConsentReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ConnectorId string `protobuf:"bytes,2,opt,name=connector_id,json=connectorId,proto3" json:"connector_id,omitempty"` + ClientId string `protobuf:"bytes,3,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeConsentReq) Reset() { + *x = RevokeConsentReq{} + mi := &file_api_v2_api_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeConsentReq) String() string { return protoimpl.X.MessageStringOf(x) } -func (*VerifyPasswordResp) ProtoMessage() {} +func (*RevokeConsentReq) ProtoMessage() {} -func (x *VerifyPasswordResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[24] - if protoimpl.UnsafeEnabled && x != nil { +func (x *RevokeConsentReq) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[73] + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -1375,19 +4236,71 @@ func (x *VerifyPasswordResp) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use VerifyPasswordResp.ProtoReflect.Descriptor instead. -func (*VerifyPasswordResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{24} +// Deprecated: Use RevokeConsentReq.ProtoReflect.Descriptor instead. +func (*RevokeConsentReq) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{73} } -func (x *VerifyPasswordResp) GetVerified() bool { +func (x *RevokeConsentReq) GetUserId() string { if x != nil { - return x.Verified + return x.UserId } - return false + return "" } -func (x *VerifyPasswordResp) GetNotFound() bool { +func (x *RevokeConsentReq) GetConnectorId() string { + if x != nil { + return x.ConnectorId + } + return "" +} + +func (x *RevokeConsentReq) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +// RevokeConsentResp returns the result of revoking consent. +type RevokeConsentResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotFound bool `protobuf:"varint,1,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeConsentResp) Reset() { + *x = RevokeConsentResp{} + mi := &file_api_v2_api_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeConsentResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeConsentResp) ProtoMessage() {} + +func (x *RevokeConsentResp) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[74] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeConsentResp.ProtoReflect.Descriptor instead. +func (*RevokeConsentResp) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{74} +} + +func (x *RevokeConsentResp) GetNotFound() bool { if x != nil { return x.NotFound } @@ -1396,9 +4309,9 @@ func (x *VerifyPasswordResp) GetNotFound() bool { var File_api_v2_api_proto protoreflect.FileDescriptor -var file_api_v2_api_proto_rawDesc = []byte{ +var file_api_v2_api_proto_rawDesc = string([]byte{ 0x0a, 0x10, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x70, 0x69, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x22, 0xc1, 0x01, 0x0a, 0x06, 0x43, 0x6c, 0x69, 0x65, + 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x22, 0xbf, 0x03, 0x0a, 0x06, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, @@ -1410,231 +4323,818 @@ var file_api_v2_api_proto_rawDesc = []byte{ 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x6f, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x6f, 0x55, 0x72, 0x6c, 0x22, 0x36, 0x0a, 0x0f, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x23, - 0x0a, 0x06, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x63, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x22, 0x5e, 0x0a, 0x10, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x6c, 0x72, 0x65, 0x61, - 0x64, 0x79, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0d, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x12, 0x23, - 0x0a, 0x06, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x63, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x22, 0x21, 0x0a, 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x2f, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, - 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, - 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x9a, 0x01, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, - 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x69, 0x73, - 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, - 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, - 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x6f, 0x67, - 0x6f, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x67, - 0x6f, 0x55, 0x72, 0x6c, 0x22, 0x2f, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, + 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x6f, 0x55, 0x72, 0x6c, 0x12, 0x2d, 0x0a, 0x12, 0x61, + 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, + 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x73, + 0x6f, 0x5f, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x5f, 0x77, 0x69, 0x74, 0x68, 0x18, 0x09, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x73, 0x6f, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x57, 0x69, + 0x74, 0x68, 0x12, 0x34, 0x0a, 0x16, 0x62, 0x61, 0x63, 0x6b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, + 0x6c, 0x5f, 0x6c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x0a, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x14, 0x62, 0x61, 0x63, 0x6b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4c, + 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x55, 0x72, 0x69, 0x12, 0x39, 0x0a, 0x19, 0x70, 0x6f, 0x73, 0x74, + 0x5f, 0x6c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x5f, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x5f, 0x75, 0x72, 0x69, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, 0x16, 0x70, 0x6f, 0x73, + 0x74, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, + 0x72, 0x69, 0x73, 0x12, 0x34, 0x0a, 0x16, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x6c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x14, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x4c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x22, 0xab, 0x03, 0x0a, 0x0a, 0x43, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x64, 0x69, + 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x69, 0x73, 0x12, 0x23, 0x0a, + 0x0d, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x50, 0x65, 0x65, + 0x72, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x19, + 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x6f, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x6f, 0x55, 0x72, 0x6c, 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x6c, 0x6c, + 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x18, + 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x73, 0x6f, 0x5f, + 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x5f, 0x77, 0x69, 0x74, 0x68, 0x18, 0x08, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0d, 0x73, 0x73, 0x6f, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x57, 0x69, 0x74, 0x68, + 0x12, 0x34, 0x0a, 0x16, 0x62, 0x61, 0x63, 0x6b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, + 0x6c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x14, 0x62, 0x61, 0x63, 0x6b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4c, 0x6f, 0x67, + 0x6f, 0x75, 0x74, 0x55, 0x72, 0x69, 0x12, 0x39, 0x0a, 0x19, 0x70, 0x6f, 0x73, 0x74, 0x5f, 0x6c, + 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x5f, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, + 0x72, 0x69, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x16, 0x70, 0x6f, 0x73, 0x74, 0x4c, + 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x69, + 0x73, 0x12, 0x34, 0x0a, 0x16, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x5f, 0x6c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x14, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x4c, + 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x1e, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x43, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x34, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x43, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x23, 0x0a, 0x06, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x22, 0x36, 0x0a, + 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, + 0x12, 0x23, 0x0a, 0x06, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x22, 0x5e, 0x0a, 0x10, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x6c, 0x72, + 0x65, 0x61, 0x64, 0x79, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0d, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, + 0x12, 0x23, 0x0a, 0x06, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x22, 0x21, 0x0a, 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x2f, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, + 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0xd8, 0x03, 0x0a, 0x0f, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, + 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x23, 0x0a, + 0x0d, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, + 0x69, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x65, + 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x72, 0x75, 0x73, 0x74, + 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6c, + 0x6f, 0x67, 0x6f, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, + 0x6f, 0x67, 0x6f, 0x55, 0x72, 0x6c, 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, + 0x64, 0x5f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x73, 0x6f, 0x5f, 0x73, 0x68, 0x61, + 0x72, 0x65, 0x64, 0x5f, 0x77, 0x69, 0x74, 0x68, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, + 0x73, 0x73, 0x6f, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x57, 0x69, 0x74, 0x68, 0x12, 0x39, 0x0a, + 0x16, 0x62, 0x61, 0x63, 0x6b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x6c, 0x6f, 0x67, + 0x6f, 0x75, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, + 0x14, 0x62, 0x61, 0x63, 0x6b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4c, 0x6f, 0x67, 0x6f, + 0x75, 0x74, 0x55, 0x72, 0x69, 0x88, 0x01, 0x01, 0x12, 0x39, 0x0a, 0x19, 0x70, 0x6f, 0x73, 0x74, + 0x5f, 0x6c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x5f, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x5f, 0x75, 0x72, 0x69, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x16, 0x70, 0x6f, 0x73, + 0x74, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, + 0x72, 0x69, 0x73, 0x12, 0x39, 0x0a, 0x16, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x6c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x14, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x4c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x19, + 0x0a, 0x17, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x6c, + 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x72, 0x65, + 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x6c, 0x69, 0x66, 0x65, + 0x74, 0x69, 0x6d, 0x65, 0x22, 0x2f, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, - 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x69, 0x0a, 0x08, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x75, - 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, - 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x22, 0x3e, 0x0a, 0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x29, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x61, - 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, - 0x22, 0x3b, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, - 0x79, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, - 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x67, 0x0a, - 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, - 0x65, 0x71, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, - 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x48, - 0x61, 0x73, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x77, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x77, 0x55, 0x73, - 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x31, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, - 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x29, 0x0a, 0x11, 0x44, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, - 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, - 0x6d, 0x61, 0x69, 0x6c, 0x22, 0x31, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, + 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x0f, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x22, 0x3b, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x29, 0x0a, 0x07, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x73, 0x22, 0x69, 0x0a, 0x08, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, + 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, + 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, + 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x3e, + 0x0a, 0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, + 0x52, 0x65, 0x71, 0x12, 0x29, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x61, 0x73, 0x73, + 0x77, 0x6f, 0x72, 0x64, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x3b, + 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, + 0x52, 0x65, 0x73, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, + 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61, 0x6c, + 0x72, 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x67, 0x0a, 0x11, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, + 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, 0x68, 0x61, + 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x48, 0x61, 0x73, + 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x77, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x77, 0x55, 0x73, 0x65, 0x72, + 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x31, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, - 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x11, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x50, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x22, 0x3f, 0x0a, 0x10, 0x4c, 0x69, - 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2b, - 0x0a, 0x09, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, - 0x52, 0x09, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x22, 0x0c, 0x0a, 0x0a, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x22, 0x37, 0x0a, 0x0b, 0x56, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x12, 0x10, 0x0a, 0x03, 0x61, 0x70, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x61, - 0x70, 0x69, 0x22, 0x7a, 0x0a, 0x0f, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x52, 0x65, 0x66, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, - 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x73, 0x74, 0x55, 0x73, 0x65, 0x64, 0x22, 0x29, - 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, - 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4e, 0x0a, 0x0f, 0x4c, 0x69, 0x73, - 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x12, 0x3b, 0x0a, 0x0e, - 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, - 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x66, 0x52, 0x0d, 0x72, 0x65, 0x66, 0x72, - 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0x48, 0x0a, 0x10, 0x52, 0x65, 0x76, - 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, - 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x49, 0x64, 0x22, 0x30, 0x0a, 0x11, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, - 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, + 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x29, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, 0x0a, 0x05, + 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, + 0x69, 0x6c, 0x22, 0x31, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, + 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, - 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x45, 0x0a, 0x11, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, - 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, - 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x4d, 0x0a, 0x12, - 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, - 0x73, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x12, 0x1b, - 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x32, 0xc7, 0x05, 0x0a, 0x03, - 0x44, 0x65, 0x78, 0x12, 0x3d, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, - 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, - 0x00, 0x12, 0x3d, 0x0a, 0x0c, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, - 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, - 0x12, 0x43, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, - 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, - 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, - 0x3e, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, - 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, - 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, - 0x31, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0f, 0x2e, - 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x1a, 0x10, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, - 0x22, 0x00, 0x12, 0x3a, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, - 0x68, 0x12, 0x13, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, - 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x1a, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, - 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x40, - 0x0a, 0x0d, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, - 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, - 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, - 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, - 0x12, 0x43, 0x0a, 0x0e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, - 0x65, 0x73, 0x70, 0x22, 0x00, 0x42, 0x36, 0x0a, 0x12, 0x63, 0x6f, 0x6d, 0x2e, 0x63, 0x6f, 0x72, - 0x65, 0x6f, 0x73, 0x2e, 0x64, 0x65, 0x78, 0x2e, 0x61, 0x70, 0x69, 0x5a, 0x20, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, 0x64, - 0x65, 0x78, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x3b, 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, -} + 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x11, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x22, 0x3f, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, + 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2b, 0x0a, 0x09, + 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x09, + 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x22, 0x7c, 0x0a, 0x09, 0x43, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x16, + 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, + 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1f, 0x0a, 0x0b, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x67, 0x72, 0x61, + 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x22, 0x42, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x12, 0x2c, 0x0a, + 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x3c, 0x0a, 0x13, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, + 0x73, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, 0x65, 0x78, + 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61, 0x6c, 0x72, 0x65, + 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x2d, 0x0a, 0x0a, 0x47, 0x72, 0x61, + 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x67, 0x72, 0x61, 0x6e, 0x74, + 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x67, 0x72, + 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x22, 0xb2, 0x01, 0x0a, 0x12, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x12, + 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, + 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x54, 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, + 0x77, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, + 0x77, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6e, 0x65, 0x77, 0x5f, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x6e, 0x65, 0x77, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x12, 0x37, 0x0a, 0x0f, 0x6e, 0x65, 0x77, 0x5f, 0x67, 0x72, 0x61, 0x6e, + 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x52, 0x0d, + 0x6e, 0x65, 0x77, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x22, 0x32, 0x0a, + 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, + 0x64, 0x22, 0x24, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x32, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, + 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x12, 0x0a, 0x10, 0x4c, + 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x22, + 0x43, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x52, 0x65, 0x73, 0x70, 0x12, 0x2e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, + 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x73, 0x22, 0x0c, 0x0a, 0x0a, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x71, 0x22, 0x37, 0x0a, 0x0b, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x70, 0x69, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x61, 0x70, 0x69, 0x22, 0x0e, 0x0a, 0x0c, 0x44, + 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x22, 0xb0, 0x06, 0x0a, 0x0d, + 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x12, 0x16, 0x0a, + 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, + 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x35, 0x0a, 0x16, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6a, 0x77, 0x6b, 0x73, 0x5f, 0x75, 0x72, 0x69, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6a, 0x77, 0x6b, 0x73, 0x55, 0x72, 0x69, 0x12, 0x2b, + 0x0a, 0x11, 0x75, 0x73, 0x65, 0x72, 0x69, 0x6e, 0x66, 0x6f, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x75, 0x73, 0x65, 0x72, 0x69, + 0x6e, 0x66, 0x6f, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x42, 0x0a, 0x1d, 0x64, + 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x1b, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, + 0x35, 0x0a, 0x16, 0x69, 0x6e, 0x74, 0x72, 0x6f, 0x73, 0x70, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x15, 0x69, 0x6e, 0x74, 0x72, 0x6f, 0x73, 0x70, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, + 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x32, 0x0a, 0x15, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, + 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x13, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, + 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x38, 0x0a, 0x18, 0x72, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, + 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x16, 0x72, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x54, 0x79, 0x70, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, + 0x72, 0x74, 0x65, 0x64, 0x12, 0x36, 0x0a, 0x17, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, + 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x54, 0x79, + 0x70, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x4f, 0x0a, 0x25, + 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, + 0x5f, 0x61, 0x6c, 0x67, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, + 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, 0x20, 0x69, 0x64, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x41, 0x6c, 0x67, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x47, 0x0a, + 0x20, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x5f, + 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, + 0x64, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1d, 0x63, 0x6f, 0x64, 0x65, 0x43, 0x68, 0x61, + 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x73, 0x53, 0x75, 0x70, + 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, + 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x0f, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, + 0x64, 0x12, 0x50, 0x0a, 0x25, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x73, + 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x21, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x41, + 0x75, 0x74, 0x68, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, + 0x74, 0x65, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x5f, 0x73, 0x75, + 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x63, + 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x22, 0x7a, + 0x0a, 0x0f, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, + 0x66, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, + 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1b, 0x0a, + 0x09, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x08, 0x6c, 0x61, 0x73, 0x74, 0x55, 0x73, 0x65, 0x64, 0x22, 0x29, 0x0a, 0x0e, 0x4c, 0x69, + 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, + 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4e, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, + 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x12, 0x3b, 0x0a, 0x0e, 0x72, 0x65, 0x66, 0x72, + 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x66, 0x52, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0x48, 0x0a, 0x10, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, + 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, + 0x30, 0x0a, 0x11, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, + 0x64, 0x22, 0x45, 0x0a, 0x11, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, + 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1a, 0x0a, 0x08, + 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x4d, 0x0a, 0x12, 0x56, 0x65, 0x72, 0x69, + 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1a, + 0x0a, 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, + 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, + 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0xc8, 0x01, 0x0a, 0x0f, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x41, 0x75, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x61, 0x75, 0x74, 0x68, + 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0f, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, + 0x64, 0x41, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x61, 0x63, 0x74, 0x69, + 0x76, 0x69, 0x74, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x61, 0x73, 0x74, + 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x12, 0x2f, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x41, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x76, 0x69, 0x61, + 0x5f, 0x73, 0x73, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x76, 0x69, 0x61, 0x53, + 0x73, 0x6f, 0x22, 0xe0, 0x02, 0x0a, 0x0b, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, + 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x39, + 0x0a, 0x0d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x73, 0x18, + 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x41, 0x75, 0x74, 0x68, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0c, 0x63, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x6c, 0x61, 0x73, 0x74, + 0x5f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x12, 0x1d, 0x0a, + 0x0a, 0x69, 0x70, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x69, 0x70, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x1d, 0x0a, 0x0a, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x61, + 0x62, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x65, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x61, 0x62, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x65, 0x45, 0x78, + 0x70, 0x69, 0x72, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x64, 0x6c, 0x65, 0x5f, 0x65, 0x78, 0x70, + 0x69, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x69, 0x64, 0x6c, 0x65, 0x45, + 0x78, 0x70, 0x69, 0x72, 0x79, 0x22, 0x23, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, 0x68, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x40, 0x0a, 0x12, 0x47, 0x65, + 0x74, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, + 0x12, 0x2a, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x51, 0x0a, 0x13, + 0x4c, 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, + 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x22, + 0x44, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2c, 0x0a, 0x08, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x26, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, + 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, + 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x34, 0x0a, + 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, + 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, + 0x75, 0x6e, 0x64, 0x22, 0x44, 0x0a, 0x1f, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x22, 0x53, 0x0a, 0x20, 0x54, 0x65, 0x72, + 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, + 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2f, 0x0a, + 0x13, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x5f, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, + 0x61, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x64, 0x22, 0x35, + 0x0a, 0x1a, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, + 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4e, 0x0a, 0x1b, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, + 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x55, 0x73, 0x65, 0x72, + 0x52, 0x65, 0x73, 0x70, 0x12, 0x2f, 0x0a, 0x13, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x5f, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x12, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x54, 0x65, 0x72, 0x6d, 0x69, + 0x6e, 0x61, 0x74, 0x65, 0x64, 0x22, 0x43, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x73, 0x65, 0x6e, 0x74, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x22, 0x87, 0x01, 0x0a, 0x09, 0x4d, + 0x46, 0x41, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x61, 0x75, 0x74, 0x68, + 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0f, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, + 0x72, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x72, 0x6d, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x72, 0x6d, 0x65, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x64, 0x41, 0x74, 0x22, 0xec, 0x02, 0x0a, 0x12, 0x57, 0x65, 0x62, 0x41, 0x75, 0x74, 0x68, + 0x6e, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x63, + 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x0c, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x49, 0x64, + 0x12, 0x29, 0x0a, 0x10, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x61, 0x74, 0x74, 0x65, + 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x61, + 0x61, 0x67, 0x75, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x61, 0x61, 0x67, + 0x75, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x6c, 0x6f, 0x6e, 0x65, 0x5f, 0x77, 0x61, 0x72, 0x6e, + 0x69, 0x6e, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x63, 0x6c, 0x6f, 0x6e, 0x65, + 0x57, 0x61, 0x72, 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x70, 0x6f, 0x72, 0x74, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x74, 0x72, 0x61, 0x6e, + 0x73, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x61, 0x63, 0x6b, 0x75, 0x70, 0x5f, + 0x65, 0x6c, 0x69, 0x67, 0x69, 0x62, 0x6c, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, + 0x62, 0x61, 0x63, 0x6b, 0x75, 0x70, 0x45, 0x6c, 0x69, 0x67, 0x69, 0x62, 0x6c, 0x65, 0x12, 0x21, + 0x0a, 0x0c, 0x62, 0x61, 0x63, 0x6b, 0x75, 0x70, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x62, 0x61, 0x63, 0x6b, 0x75, 0x70, 0x53, 0x74, 0x61, 0x74, + 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, + 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, + 0x61, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x64, 0x41, 0x74, 0x22, 0xb5, 0x01, 0x0a, 0x0d, 0x4d, 0x46, 0x41, 0x44, 0x65, 0x76, 0x69, 0x63, + 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x29, 0x0a, 0x10, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, + 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0f, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x49, 0x64, + 0x12, 0x2d, 0x0a, 0x0a, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x46, 0x41, 0x53, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x52, 0x09, 0x6d, 0x66, 0x61, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, + 0x4a, 0x0a, 0x14, 0x77, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x5f, 0x63, 0x72, 0x65, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x57, 0x65, 0x62, 0x41, 0x75, 0x74, 0x68, 0x6e, 0x43, 0x72, 0x65, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x52, 0x13, 0x77, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, + 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x22, 0x82, 0x03, 0x0a, 0x0c, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x17, 0x0a, 0x07, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, + 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, + 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, + 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x25, + 0x0a, 0x0e, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x56, 0x65, 0x72, + 0x69, 0x66, 0x69, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, + 0x65, 0x12, 0x16, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2d, 0x0a, 0x08, 0x63, 0x6f, 0x6e, + 0x73, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x73, 0x65, 0x6e, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, + 0x63, 0x6f, 0x6e, 0x73, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x33, 0x0a, 0x0b, 0x6d, 0x66, 0x61, 0x5f, + 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x46, 0x41, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x6e, 0x66, + 0x6f, 0x52, 0x0a, 0x6d, 0x66, 0x61, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, 0x1d, 0x0a, + 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, + 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x09, 0x6c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x62, + 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x75, 0x6e, 0x74, 0x69, 0x6c, 0x18, 0x0b, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x55, 0x6e, 0x74, 0x69, 0x6c, + 0x22, 0x50, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x49, 0x64, 0x22, 0x44, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2d, 0x0a, 0x08, 0x69, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, + 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x17, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x65, + 0x71, 0x22, 0x4b, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x12, 0x31, 0x0a, 0x0a, 0x69, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x53, + 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x49, 0x64, 0x22, 0x35, 0x0a, 0x16, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, + 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x49, 0x0a, 0x0b, 0x52, 0x65, + 0x73, 0x65, 0x74, 0x4d, 0x46, 0x41, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x49, 0x64, 0x22, 0x2b, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, 0x74, 0x4d, 0x46, + 0x41, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, + 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, + 0x6e, 0x64, 0x22, 0x4f, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x46, 0x41, 0x44, 0x65, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x49, 0x64, 0x22, 0x42, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x46, 0x41, 0x44, 0x65, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2c, 0x0a, 0x07, 0x64, 0x65, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x4d, 0x46, 0x41, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, + 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x22, 0x7e, 0x0a, 0x1b, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x57, 0x65, 0x62, 0x41, 0x75, 0x74, 0x68, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x61, 0x6c, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, + 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x63, 0x72, 0x65, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x49, 0x64, 0x22, 0x3b, 0x0a, 0x1c, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x57, 0x65, 0x62, 0x41, 0x75, 0x74, 0x68, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x61, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, + 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, + 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x7b, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x46, + 0x41, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, + 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0f, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x49, + 0x64, 0x22, 0x32, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x46, 0x41, 0x53, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, + 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, + 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x6b, 0x0a, 0x10, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, + 0x6f, 0x6e, 0x73, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, + 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x49, 0x64, 0x22, 0x30, 0x0a, 0x11, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x6f, 0x6e, 0x73, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, + 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, + 0x6f, 0x75, 0x6e, 0x64, 0x32, 0x87, 0x11, 0x0a, 0x03, 0x44, 0x65, 0x78, 0x12, 0x34, 0x0a, 0x09, + 0x47, 0x65, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x47, 0x65, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x12, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, + 0x00, 0x12, 0x3d, 0x0a, 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, + 0x74, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, + 0x12, 0x3d, 0x0a, 0x0c, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, + 0x38, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x12, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, + 0x65, 0x71, 0x1a, 0x13, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, + 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, + 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, + 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, + 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3e, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, + 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, + 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x52, 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, + 0x12, 0x46, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, + 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x0f, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x52, 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, + 0x12, 0x41, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x73, 0x12, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, + 0x70, 0x22, 0x00, 0x12, 0x31, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x0f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x71, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x44, 0x69, 0x73, + 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x69, 0x73, + 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, + 0x3a, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, 0x13, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, + 0x52, 0x65, 0x71, 0x1a, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, + 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x40, 0x0a, 0x0d, 0x52, + 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, 0x15, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, + 0x52, 0x65, 0x71, 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, + 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, + 0x0e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, + 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, + 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, + 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, + 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x49, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x41, + 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x73, 0x52, 0x65, 0x71, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x71, 0x1a, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, + 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, + 0x12, 0x6d, 0x0a, 0x1c, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x12, 0x24, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x1a, 0x25, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x54, 0x65, 0x72, + 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, + 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, + 0x5e, 0x0a, 0x17, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x55, 0x73, 0x65, 0x72, 0x12, 0x1f, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x73, 0x42, 0x79, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x1a, 0x20, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, + 0x46, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x4f, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x55, + 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x1a, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x69, + 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x4f, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x1a, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x31, 0x0a, 0x08, 0x52, 0x65, 0x73, + 0x65, 0x74, 0x4d, 0x46, 0x41, 0x12, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73, 0x65, + 0x74, 0x4d, 0x46, 0x41, 0x52, 0x65, 0x71, 0x1a, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, + 0x73, 0x65, 0x74, 0x4d, 0x46, 0x41, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, + 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x46, 0x41, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, 0x16, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x46, 0x41, 0x44, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x4d, 0x46, 0x41, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x22, + 0x00, 0x12, 0x61, 0x0a, 0x18, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x57, 0x65, 0x62, 0x41, 0x75, + 0x74, 0x68, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x12, 0x20, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x57, 0x65, 0x62, 0x41, 0x75, 0x74, + 0x68, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x52, 0x65, 0x71, 0x1a, + 0x21, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x57, 0x65, 0x62, 0x41, + 0x75, 0x74, 0x68, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x52, 0x65, + 0x73, 0x70, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x46, + 0x41, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x46, 0x41, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, + 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x46, 0x41, + 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x40, 0x0a, 0x0d, + 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x6f, 0x6e, 0x73, 0x65, 0x6e, 0x74, 0x12, 0x15, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x6f, 0x6e, 0x73, 0x65, 0x6e, + 0x74, 0x52, 0x65, 0x71, 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, + 0x65, 0x43, 0x6f, 0x6e, 0x73, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x42, 0x36, + 0x0a, 0x12, 0x63, 0x6f, 0x6d, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x6f, 0x73, 0x2e, 0x64, 0x65, 0x78, + 0x2e, 0x61, 0x70, 0x69, 0x5a, 0x20, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, 0x64, 0x65, 0x78, 0x2f, 0x61, 0x70, 0x69, 0x2f, + 0x76, 0x32, 0x3b, 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) var ( file_api_v2_api_proto_rawDescOnce sync.Once - file_api_v2_api_proto_rawDescData = file_api_v2_api_proto_rawDesc + file_api_v2_api_proto_rawDescData []byte ) func file_api_v2_api_proto_rawDescGZIP() []byte { file_api_v2_api_proto_rawDescOnce.Do(func() { - file_api_v2_api_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_v2_api_proto_rawDescData) + file_api_v2_api_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_v2_api_proto_rawDesc), len(file_api_v2_api_proto_rawDesc))) }) return file_api_v2_api_proto_rawDescData } -var file_api_v2_api_proto_msgTypes = make([]protoimpl.MessageInfo, 25) -var file_api_v2_api_proto_goTypes = []interface{}{ - (*Client)(nil), // 0: api.Client - (*CreateClientReq)(nil), // 1: api.CreateClientReq - (*CreateClientResp)(nil), // 2: api.CreateClientResp - (*DeleteClientReq)(nil), // 3: api.DeleteClientReq - (*DeleteClientResp)(nil), // 4: api.DeleteClientResp - (*UpdateClientReq)(nil), // 5: api.UpdateClientReq - (*UpdateClientResp)(nil), // 6: api.UpdateClientResp - (*Password)(nil), // 7: api.Password - (*CreatePasswordReq)(nil), // 8: api.CreatePasswordReq - (*CreatePasswordResp)(nil), // 9: api.CreatePasswordResp - (*UpdatePasswordReq)(nil), // 10: api.UpdatePasswordReq - (*UpdatePasswordResp)(nil), // 11: api.UpdatePasswordResp - (*DeletePasswordReq)(nil), // 12: api.DeletePasswordReq - (*DeletePasswordResp)(nil), // 13: api.DeletePasswordResp - (*ListPasswordReq)(nil), // 14: api.ListPasswordReq - (*ListPasswordResp)(nil), // 15: api.ListPasswordResp - (*VersionReq)(nil), // 16: api.VersionReq - (*VersionResp)(nil), // 17: api.VersionResp - (*RefreshTokenRef)(nil), // 18: api.RefreshTokenRef - (*ListRefreshReq)(nil), // 19: api.ListRefreshReq - (*ListRefreshResp)(nil), // 20: api.ListRefreshResp - (*RevokeRefreshReq)(nil), // 21: api.RevokeRefreshReq - (*RevokeRefreshResp)(nil), // 22: api.RevokeRefreshResp - (*VerifyPasswordReq)(nil), // 23: api.VerifyPasswordReq - (*VerifyPasswordResp)(nil), // 24: api.VerifyPasswordResp +var file_api_v2_api_proto_msgTypes = make([]protoimpl.MessageInfo, 75) +var file_api_v2_api_proto_goTypes = []any{ + (*Client)(nil), // 0: api.Client + (*ClientInfo)(nil), // 1: api.ClientInfo + (*GetClientReq)(nil), // 2: api.GetClientReq + (*GetClientResp)(nil), // 3: api.GetClientResp + (*CreateClientReq)(nil), // 4: api.CreateClientReq + (*CreateClientResp)(nil), // 5: api.CreateClientResp + (*DeleteClientReq)(nil), // 6: api.DeleteClientReq + (*DeleteClientResp)(nil), // 7: api.DeleteClientResp + (*UpdateClientReq)(nil), // 8: api.UpdateClientReq + (*UpdateClientResp)(nil), // 9: api.UpdateClientResp + (*ListClientReq)(nil), // 10: api.ListClientReq + (*ListClientResp)(nil), // 11: api.ListClientResp + (*Password)(nil), // 12: api.Password + (*CreatePasswordReq)(nil), // 13: api.CreatePasswordReq + (*CreatePasswordResp)(nil), // 14: api.CreatePasswordResp + (*UpdatePasswordReq)(nil), // 15: api.UpdatePasswordReq + (*UpdatePasswordResp)(nil), // 16: api.UpdatePasswordResp + (*DeletePasswordReq)(nil), // 17: api.DeletePasswordReq + (*DeletePasswordResp)(nil), // 18: api.DeletePasswordResp + (*ListPasswordReq)(nil), // 19: api.ListPasswordReq + (*ListPasswordResp)(nil), // 20: api.ListPasswordResp + (*Connector)(nil), // 21: api.Connector + (*CreateConnectorReq)(nil), // 22: api.CreateConnectorReq + (*CreateConnectorResp)(nil), // 23: api.CreateConnectorResp + (*GrantTypes)(nil), // 24: api.GrantTypes + (*UpdateConnectorReq)(nil), // 25: api.UpdateConnectorReq + (*UpdateConnectorResp)(nil), // 26: api.UpdateConnectorResp + (*DeleteConnectorReq)(nil), // 27: api.DeleteConnectorReq + (*DeleteConnectorResp)(nil), // 28: api.DeleteConnectorResp + (*ListConnectorReq)(nil), // 29: api.ListConnectorReq + (*ListConnectorResp)(nil), // 30: api.ListConnectorResp + (*VersionReq)(nil), // 31: api.VersionReq + (*VersionResp)(nil), // 32: api.VersionResp + (*DiscoveryReq)(nil), // 33: api.DiscoveryReq + (*DiscoveryResp)(nil), // 34: api.DiscoveryResp + (*RefreshTokenRef)(nil), // 35: api.RefreshTokenRef + (*ListRefreshReq)(nil), // 36: api.ListRefreshReq + (*ListRefreshResp)(nil), // 37: api.ListRefreshResp + (*RevokeRefreshReq)(nil), // 38: api.RevokeRefreshReq + (*RevokeRefreshResp)(nil), // 39: api.RevokeRefreshResp + (*VerifyPasswordReq)(nil), // 40: api.VerifyPasswordReq + (*VerifyPasswordResp)(nil), // 41: api.VerifyPasswordResp + (*ClientAuthState)(nil), // 42: api.ClientAuthState + (*AuthSession)(nil), // 43: api.AuthSession + (*GetAuthSessionReq)(nil), // 44: api.GetAuthSessionReq + (*GetAuthSessionResp)(nil), // 45: api.GetAuthSessionResp + (*ListAuthSessionsReq)(nil), // 46: api.ListAuthSessionsReq + (*ListAuthSessionsResp)(nil), // 47: api.ListAuthSessionsResp + (*DeleteAuthSessionReq)(nil), // 48: api.DeleteAuthSessionReq + (*DeleteAuthSessionResp)(nil), // 49: api.DeleteAuthSessionResp + (*TerminateSessionsByConnectorReq)(nil), // 50: api.TerminateSessionsByConnectorReq + (*TerminateSessionsByConnectorResp)(nil), // 51: api.TerminateSessionsByConnectorResp + (*TerminateSessionsByUserReq)(nil), // 52: api.TerminateSessionsByUserReq + (*TerminateSessionsByUserResp)(nil), // 53: api.TerminateSessionsByUserResp + (*ConsentEntry)(nil), // 54: api.ConsentEntry + (*MFASecret)(nil), // 55: api.MFASecret + (*WebAuthnCredential)(nil), // 56: api.WebAuthnCredential + (*MFADeviceInfo)(nil), // 57: api.MFADeviceInfo + (*UserIdentity)(nil), // 58: api.UserIdentity + (*GetUserIdentityReq)(nil), // 59: api.GetUserIdentityReq + (*GetUserIdentityResp)(nil), // 60: api.GetUserIdentityResp + (*ListUserIdentitiesReq)(nil), // 61: api.ListUserIdentitiesReq + (*ListUserIdentitiesResp)(nil), // 62: api.ListUserIdentitiesResp + (*DeleteUserIdentityReq)(nil), // 63: api.DeleteUserIdentityReq + (*DeleteUserIdentityResp)(nil), // 64: api.DeleteUserIdentityResp + (*ResetMFAReq)(nil), // 65: api.ResetMFAReq + (*ResetMFAResp)(nil), // 66: api.ResetMFAResp + (*ListMFADevicesReq)(nil), // 67: api.ListMFADevicesReq + (*ListMFADevicesResp)(nil), // 68: api.ListMFADevicesResp + (*DeleteWebAuthnCredentialReq)(nil), // 69: api.DeleteWebAuthnCredentialReq + (*DeleteWebAuthnCredentialResp)(nil), // 70: api.DeleteWebAuthnCredentialResp + (*DeleteMFASecretReq)(nil), // 71: api.DeleteMFASecretReq + (*DeleteMFASecretResp)(nil), // 72: api.DeleteMFASecretResp + (*RevokeConsentReq)(nil), // 73: api.RevokeConsentReq + (*RevokeConsentResp)(nil), // 74: api.RevokeConsentResp } var file_api_v2_api_proto_depIdxs = []int32{ - 0, // 0: api.CreateClientReq.client:type_name -> api.Client - 0, // 1: api.CreateClientResp.client:type_name -> api.Client - 7, // 2: api.CreatePasswordReq.password:type_name -> api.Password - 7, // 3: api.ListPasswordResp.passwords:type_name -> api.Password - 18, // 4: api.ListRefreshResp.refresh_tokens:type_name -> api.RefreshTokenRef - 1, // 5: api.Dex.CreateClient:input_type -> api.CreateClientReq - 5, // 6: api.Dex.UpdateClient:input_type -> api.UpdateClientReq - 3, // 7: api.Dex.DeleteClient:input_type -> api.DeleteClientReq - 8, // 8: api.Dex.CreatePassword:input_type -> api.CreatePasswordReq - 10, // 9: api.Dex.UpdatePassword:input_type -> api.UpdatePasswordReq - 12, // 10: api.Dex.DeletePassword:input_type -> api.DeletePasswordReq - 14, // 11: api.Dex.ListPasswords:input_type -> api.ListPasswordReq - 16, // 12: api.Dex.GetVersion:input_type -> api.VersionReq - 19, // 13: api.Dex.ListRefresh:input_type -> api.ListRefreshReq - 21, // 14: api.Dex.RevokeRefresh:input_type -> api.RevokeRefreshReq - 23, // 15: api.Dex.VerifyPassword:input_type -> api.VerifyPasswordReq - 2, // 16: api.Dex.CreateClient:output_type -> api.CreateClientResp - 6, // 17: api.Dex.UpdateClient:output_type -> api.UpdateClientResp - 4, // 18: api.Dex.DeleteClient:output_type -> api.DeleteClientResp - 9, // 19: api.Dex.CreatePassword:output_type -> api.CreatePasswordResp - 11, // 20: api.Dex.UpdatePassword:output_type -> api.UpdatePasswordResp - 13, // 21: api.Dex.DeletePassword:output_type -> api.DeletePasswordResp - 15, // 22: api.Dex.ListPasswords:output_type -> api.ListPasswordResp - 17, // 23: api.Dex.GetVersion:output_type -> api.VersionResp - 20, // 24: api.Dex.ListRefresh:output_type -> api.ListRefreshResp - 22, // 25: api.Dex.RevokeRefresh:output_type -> api.RevokeRefreshResp - 24, // 26: api.Dex.VerifyPassword:output_type -> api.VerifyPasswordResp - 16, // [16:27] is the sub-list for method output_type - 5, // [5:16] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 0, // 0: api.GetClientResp.client:type_name -> api.Client + 0, // 1: api.CreateClientReq.client:type_name -> api.Client + 0, // 2: api.CreateClientResp.client:type_name -> api.Client + 1, // 3: api.ListClientResp.clients:type_name -> api.ClientInfo + 12, // 4: api.CreatePasswordReq.password:type_name -> api.Password + 12, // 5: api.ListPasswordResp.passwords:type_name -> api.Password + 21, // 6: api.CreateConnectorReq.connector:type_name -> api.Connector + 24, // 7: api.UpdateConnectorReq.new_grant_types:type_name -> api.GrantTypes + 21, // 8: api.ListConnectorResp.connectors:type_name -> api.Connector + 35, // 9: api.ListRefreshResp.refresh_tokens:type_name -> api.RefreshTokenRef + 42, // 10: api.AuthSession.client_states:type_name -> api.ClientAuthState + 43, // 11: api.GetAuthSessionResp.session:type_name -> api.AuthSession + 43, // 12: api.ListAuthSessionsResp.sessions:type_name -> api.AuthSession + 55, // 13: api.MFADeviceInfo.mfa_secret:type_name -> api.MFASecret + 56, // 14: api.MFADeviceInfo.webauthn_credentials:type_name -> api.WebAuthnCredential + 54, // 15: api.UserIdentity.consents:type_name -> api.ConsentEntry + 57, // 16: api.UserIdentity.mfa_devices:type_name -> api.MFADeviceInfo + 58, // 17: api.GetUserIdentityResp.identity:type_name -> api.UserIdentity + 58, // 18: api.ListUserIdentitiesResp.identities:type_name -> api.UserIdentity + 57, // 19: api.ListMFADevicesResp.devices:type_name -> api.MFADeviceInfo + 2, // 20: api.Dex.GetClient:input_type -> api.GetClientReq + 4, // 21: api.Dex.CreateClient:input_type -> api.CreateClientReq + 8, // 22: api.Dex.UpdateClient:input_type -> api.UpdateClientReq + 6, // 23: api.Dex.DeleteClient:input_type -> api.DeleteClientReq + 10, // 24: api.Dex.ListClients:input_type -> api.ListClientReq + 13, // 25: api.Dex.CreatePassword:input_type -> api.CreatePasswordReq + 15, // 26: api.Dex.UpdatePassword:input_type -> api.UpdatePasswordReq + 17, // 27: api.Dex.DeletePassword:input_type -> api.DeletePasswordReq + 19, // 28: api.Dex.ListPasswords:input_type -> api.ListPasswordReq + 22, // 29: api.Dex.CreateConnector:input_type -> api.CreateConnectorReq + 25, // 30: api.Dex.UpdateConnector:input_type -> api.UpdateConnectorReq + 27, // 31: api.Dex.DeleteConnector:input_type -> api.DeleteConnectorReq + 29, // 32: api.Dex.ListConnectors:input_type -> api.ListConnectorReq + 31, // 33: api.Dex.GetVersion:input_type -> api.VersionReq + 33, // 34: api.Dex.GetDiscovery:input_type -> api.DiscoveryReq + 36, // 35: api.Dex.ListRefresh:input_type -> api.ListRefreshReq + 38, // 36: api.Dex.RevokeRefresh:input_type -> api.RevokeRefreshReq + 40, // 37: api.Dex.VerifyPassword:input_type -> api.VerifyPasswordReq + 44, // 38: api.Dex.GetAuthSession:input_type -> api.GetAuthSessionReq + 46, // 39: api.Dex.ListAuthSessions:input_type -> api.ListAuthSessionsReq + 48, // 40: api.Dex.DeleteAuthSession:input_type -> api.DeleteAuthSessionReq + 50, // 41: api.Dex.TerminateSessionsByConnector:input_type -> api.TerminateSessionsByConnectorReq + 52, // 42: api.Dex.TerminateSessionsByUser:input_type -> api.TerminateSessionsByUserReq + 59, // 43: api.Dex.GetUserIdentity:input_type -> api.GetUserIdentityReq + 61, // 44: api.Dex.ListUserIdentities:input_type -> api.ListUserIdentitiesReq + 63, // 45: api.Dex.DeleteUserIdentity:input_type -> api.DeleteUserIdentityReq + 65, // 46: api.Dex.ResetMFA:input_type -> api.ResetMFAReq + 67, // 47: api.Dex.ListMFADevices:input_type -> api.ListMFADevicesReq + 69, // 48: api.Dex.DeleteWebAuthnCredential:input_type -> api.DeleteWebAuthnCredentialReq + 71, // 49: api.Dex.DeleteMFASecret:input_type -> api.DeleteMFASecretReq + 73, // 50: api.Dex.RevokeConsent:input_type -> api.RevokeConsentReq + 3, // 51: api.Dex.GetClient:output_type -> api.GetClientResp + 5, // 52: api.Dex.CreateClient:output_type -> api.CreateClientResp + 9, // 53: api.Dex.UpdateClient:output_type -> api.UpdateClientResp + 7, // 54: api.Dex.DeleteClient:output_type -> api.DeleteClientResp + 11, // 55: api.Dex.ListClients:output_type -> api.ListClientResp + 14, // 56: api.Dex.CreatePassword:output_type -> api.CreatePasswordResp + 16, // 57: api.Dex.UpdatePassword:output_type -> api.UpdatePasswordResp + 18, // 58: api.Dex.DeletePassword:output_type -> api.DeletePasswordResp + 20, // 59: api.Dex.ListPasswords:output_type -> api.ListPasswordResp + 23, // 60: api.Dex.CreateConnector:output_type -> api.CreateConnectorResp + 26, // 61: api.Dex.UpdateConnector:output_type -> api.UpdateConnectorResp + 28, // 62: api.Dex.DeleteConnector:output_type -> api.DeleteConnectorResp + 30, // 63: api.Dex.ListConnectors:output_type -> api.ListConnectorResp + 32, // 64: api.Dex.GetVersion:output_type -> api.VersionResp + 34, // 65: api.Dex.GetDiscovery:output_type -> api.DiscoveryResp + 37, // 66: api.Dex.ListRefresh:output_type -> api.ListRefreshResp + 39, // 67: api.Dex.RevokeRefresh:output_type -> api.RevokeRefreshResp + 41, // 68: api.Dex.VerifyPassword:output_type -> api.VerifyPasswordResp + 45, // 69: api.Dex.GetAuthSession:output_type -> api.GetAuthSessionResp + 47, // 70: api.Dex.ListAuthSessions:output_type -> api.ListAuthSessionsResp + 49, // 71: api.Dex.DeleteAuthSession:output_type -> api.DeleteAuthSessionResp + 51, // 72: api.Dex.TerminateSessionsByConnector:output_type -> api.TerminateSessionsByConnectorResp + 53, // 73: api.Dex.TerminateSessionsByUser:output_type -> api.TerminateSessionsByUserResp + 60, // 74: api.Dex.GetUserIdentity:output_type -> api.GetUserIdentityResp + 62, // 75: api.Dex.ListUserIdentities:output_type -> api.ListUserIdentitiesResp + 64, // 76: api.Dex.DeleteUserIdentity:output_type -> api.DeleteUserIdentityResp + 66, // 77: api.Dex.ResetMFA:output_type -> api.ResetMFAResp + 68, // 78: api.Dex.ListMFADevices:output_type -> api.ListMFADevicesResp + 70, // 79: api.Dex.DeleteWebAuthnCredential:output_type -> api.DeleteWebAuthnCredentialResp + 72, // 80: api.Dex.DeleteMFASecret:output_type -> api.DeleteMFASecretResp + 74, // 81: api.Dex.RevokeConsent:output_type -> api.RevokeConsentResp + 51, // [51:82] is the sub-list for method output_type + 20, // [20:51] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_api_v2_api_proto_init() } @@ -1642,315 +5142,14 @@ func file_api_v2_api_proto_init() { if File_api_v2_api_proto != nil { return } - if !protoimpl.UnsafeEnabled { - file_api_v2_api_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Client); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateClientReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateClientResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteClientReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteClientResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UpdateClientReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UpdateClientResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Password); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreatePasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreatePasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UpdatePasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UpdatePasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeletePasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeletePasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListPasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListPasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*VersionReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*VersionResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RefreshTokenRef); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListRefreshReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListRefreshResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RevokeRefreshReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RevokeRefreshResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*VerifyPasswordReq); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_api_v2_api_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*VerifyPasswordResp); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } + file_api_v2_api_proto_msgTypes[8].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_api_v2_api_proto_rawDesc, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v2_api_proto_rawDesc), len(file_api_v2_api_proto_rawDesc)), NumEnums: 0, - NumMessages: 25, + NumMessages: 75, NumExtensions: 0, NumServices: 1, }, @@ -1959,7 +5158,6 @@ func file_api_v2_api_proto_init() { MessageInfos: file_api_v2_api_proto_msgTypes, }.Build() File_api_v2_api_proto = out.File - file_api_v2_api_proto_rawDesc = nil file_api_v2_api_proto_goTypes = nil file_api_v2_api_proto_depIdxs = nil } diff --git a/api/v2/api.proto b/api/v2/api.proto index 82a2e2afa1..93c9c54349 100644 --- a/api/v2/api.proto +++ b/api/v2/api.proto @@ -14,6 +14,44 @@ message Client { bool public = 5; string name = 6; string logo_url = 7; + repeated string allowed_connectors = 8; + repeated string sso_shared_with = 9; + // Where dex POSTs a logout token when a session this client took part in + // ends, per OIDC Back-Channel Logout 1.0. Empty means the client is not + // notified. + string backchannel_logout_uri = 10; + // Where the browser may be sent after an RP-initiated logout. A + // post_logout_redirect_uri that is not listed here is refused. + repeated string post_logout_redirect_uris = 11; + // Whether this client's refresh tokens outlive the browser session that + // issued them: "standalone" (the default) or "session". + string refresh_token_lifetime = 12; +} + +// ClientInfo represents an OAuth2 client without sensitive information. +message ClientInfo { + string id = 1; + repeated string redirect_uris = 2; + repeated string trusted_peers = 3; + bool public = 4; + string name = 5; + string logo_url = 6; + repeated string allowed_connectors = 7; + repeated string sso_shared_with = 8; + string backchannel_logout_uri = 9; + repeated string post_logout_redirect_uris = 10; + string refresh_token_lifetime = 11; +} + +// GetClientReq is a request to retrieve client details. +message GetClientReq { + // The ID of the client. + string id = 1; +} + +// GetClientResp returns the client details. +message GetClientResp { + Client client = 1; } // CreateClientReq is a request to make a client. @@ -45,6 +83,17 @@ message UpdateClientReq { repeated string trusted_peers = 3; string name = 4; string logo_url = 5; + repeated string allowed_connectors = 6; + repeated string sso_shared_with = 7; + // Optional so that an empty value clears the URI. Without explicit presence + // a client could be given a back-channel endpoint but never relieved of one, + // leaving dex posting logout tokens at something that no longer exists. + optional string backchannel_logout_uri = 8; + repeated string post_logout_redirect_uris = 9; + // Optional for the same reason as backchannel_logout_uri: an empty value has + // to be tellable apart from "leave it alone" to put a client back on the + // default lifetime. + optional string refresh_token_lifetime = 10; } // UpdateClientResp returns the response from updating a client. @@ -52,6 +101,14 @@ message UpdateClientResp { bool not_found = 1; } +// ListClientReq is a request to enumerate clients. +message ListClientReq {} + +// ListClientResp returns a list of clients. +message ListClientResp { + repeated ClientInfo clients = 1; +} + // TODO(ericchiang): expand this. // Password is an email for password mapping managed by the storage. @@ -105,6 +162,67 @@ message ListPasswordResp { repeated Password passwords = 1; } +// Connector is a strategy used by Dex for authenticating a user against another identity provider +message Connector { + string id = 1; + string type = 2; + string name = 3; + bytes config = 4; + repeated string grant_types = 5; +} + +// CreateConnectorReq is a request to make a connector. +message CreateConnectorReq { + Connector connector = 1; +} + +// CreateConnectorResp returns the response from creating a connector. +message CreateConnectorResp { + bool already_exists = 1; +} + +// GrantTypes wraps a list of grant types to distinguish between +// "not specified" (no update) and "empty list" (unrestricted). +message GrantTypes { + repeated string grant_types = 1; +} + +// UpdateConnectorReq is a request to modify an existing connector. +message UpdateConnectorReq { + // The id used to lookup the connector. This field cannot be modified + string id = 1; + string new_type = 2; + string new_name = 3; + bytes new_config = 4; + // If set, updates the connector's allowed grant types. + // An empty grant_types list means unrestricted (all grant types allowed). + // If not set (null), grant types are not modified. + GrantTypes new_grant_types = 5; +} + +// UpdateConnectorResp returns the response from modifying an existing connector. +message UpdateConnectorResp { + bool not_found = 1; +} + +// DeleteConnectorReq is a request to delete a connector. +message DeleteConnectorReq { + string id = 1; +} + +// DeleteConnectorResp returns the response from deleting a connector. +message DeleteConnectorResp { + bool not_found = 1; +} + +// ListConnectorReq is a request to enumerate connectors. +message ListConnectorReq {} + +// ListConnectorResp returns a list of connectors. +message ListConnectorResp { + repeated Connector connectors = 1; +} + // VersionReq is a request to fetch version info. message VersionReq {} @@ -112,11 +230,33 @@ message VersionReq {} message VersionResp { // Semantic version of the server. string server = 1; - // Numeric version of the API. It increases everytime a new call is added to the API. + // Numeric version of the API. It increases every time a new call is added to the API. // Clients should use this info to determine if the server supports specific features. int32 api = 2; } +// DiscoveryReq is a request to fetch discover information. +message DiscoveryReq {} + +//DiscoverResp holds the version oidc disovery info. +message DiscoveryResp { + string issuer = 1; + string authorization_endpoint = 2; + string token_endpoint = 3; + string jwks_uri = 4; + string userinfo_endpoint = 5; + string device_authorization_endpoint = 6; + string introspection_endpoint = 7; + repeated string grant_types_supported = 8; + repeated string response_types_supported = 9; + repeated string subject_types_supported = 10; + repeated string id_token_signing_alg_values_supported = 11; + repeated string code_challenge_methods_supported = 12; + repeated string scopes_supported = 13; + repeated string token_endpoint_auth_methods_supported = 14; + repeated string claims_supported = 15; +} + // RefreshTokenRef contains the metadata for a refresh token that is managed by the storage. message RefreshTokenRef { // ID of the refresh token. @@ -160,14 +300,250 @@ message VerifyPasswordResp { bool not_found = 2; } +// ClientAuthState represents authentication state for a specific client within a session. +// The user_id and connector_id are on the parent AuthSession message. +message ClientAuthState { + string client_id = 1; + int64 authenticated_at = 2; + int64 last_activity = 3; + int64 last_token_issued_at = 4; + // Whether this client was reached through another client's SSO sharing rather + // than by authenticating directly. + bool via_sso = 5; +} + +// AuthSession represents a user's authentication session. +message AuthSession { + // Random identifier of the session, published to clients as the "sid" claim. One + // signed-in browser is one session, so a user has as many as they have devices. + string id = 10; + string user_id = 1; + string connector_id = 2; + repeated ClientAuthState client_states = 3; + int64 created_at = 4; + int64 last_activity = 5; + string ip_address = 6; + string user_agent = 7; + int64 absolute_expiry = 8; + int64 idle_expiry = 9; +} + +// GetAuthSessionReq is a request to retrieve an auth session. +message GetAuthSessionReq { + string id = 1; +} + +// GetAuthSessionResp returns the auth session details. +message GetAuthSessionResp { + AuthSession session = 1; +} + +// ListAuthSessionsReq is a request to list auth sessions. +message ListAuthSessionsReq { + // Optional filter: if set, only sessions for this user are returned. + string user_id = 1; + // Optional filter: if set, only sessions from this connector are returned. + string connector_id = 2; +} + +// ListAuthSessionsResp returns a list of auth sessions. +message ListAuthSessionsResp { + repeated AuthSession sessions = 1; +} + +// DeleteAuthSessionReq is a request to delete an auth session. +// Deleting a session also revokes all associated refresh tokens (consistent with logout behavior). +message DeleteAuthSessionReq { + string id = 1; +} + +// DeleteAuthSessionResp returns the result of deleting an auth session. +message DeleteAuthSessionResp { + bool not_found = 1; +} + +// TerminateSessionsByConnectorReq is a request to terminate all sessions for a connector. +// Use when connector configuration changes or is removed. Also revokes associated refresh tokens. +message TerminateSessionsByConnectorReq { + string connector_id = 1; +} + +// TerminateSessionsByConnectorResp returns the count of terminated sessions. +message TerminateSessionsByConnectorResp { + int64 sessions_terminated = 1; +} + +// TerminateSessionsByUserReq is a request to terminate all sessions for a user. +// Use for account compromise scenarios. Also revokes associated refresh tokens. +message TerminateSessionsByUserReq { + string user_id = 1; +} + +// TerminateSessionsByUserResp returns the count of terminated sessions. +message TerminateSessionsByUserResp { + int64 sessions_terminated = 1; +} + +// ConsentEntry represents approved scopes for a single client. +message ConsentEntry { + string client_id = 1; + repeated string scopes = 2; +} + +// MFASecret represents metadata of an enrolled MFA authenticator. +// The actual secret value is never exposed through the admin API. +message MFASecret { + string authenticator_id = 1; + string type = 2; + bool confirmed = 3; + int64 created_at = 4; +} + +// WebAuthnCredential represents metadata of a registered WebAuthn credential. +// The public key is never exposed through the admin API. +message WebAuthnCredential { + bytes credential_id = 1; + string attestation_type = 2; + bytes aaguid = 3; + uint32 sign_count = 4; + bool clone_warning = 5; + repeated string transport = 6; + bool backup_eligible = 7; + bool backup_state = 8; + string display_name = 9; + int64 created_at = 10; +} + +// MFADeviceInfo groups MFA secret and WebAuthn credentials for one authenticator. +message MFADeviceInfo { + string authenticator_id = 1; + MFASecret mfa_secret = 2; + repeated WebAuthnCredential webauthn_credentials = 3; +} + +// UserIdentity represents persistent per-user identity data. +message UserIdentity { + string user_id = 1; + string connector_id = 2; + string email = 3; + bool email_verified = 4; + string username = 5; + repeated string groups = 6; + repeated ConsentEntry consents = 7; + repeated MFADeviceInfo mfa_devices = 8; + int64 created_at = 9; + int64 last_login = 10; + int64 blocked_until = 11; +} + +// GetUserIdentityReq is a request to retrieve a user identity. +message GetUserIdentityReq { + string user_id = 1; + string connector_id = 2; +} + +// GetUserIdentityResp returns the user identity details. +message GetUserIdentityResp { + UserIdentity identity = 1; +} + +// ListUserIdentitiesReq is a request to list user identities. +message ListUserIdentitiesReq {} + +// ListUserIdentitiesResp returns a list of user identities. +message ListUserIdentitiesResp { + repeated UserIdentity identities = 1; +} + +// DeleteUserIdentityReq is a request to delete a user identity. +// This is a full data purge for GDPR compliance and account deletion. +// It cascades to: auth session, all refresh tokens, offline sessions, the +// password record (matched by the identity's email), and the identity itself. +message DeleteUserIdentityReq { + string user_id = 1; + string connector_id = 2; +} + +// DeleteUserIdentityResp returns the result of deleting a user identity. +message DeleteUserIdentityResp { + bool not_found = 1; +} + +// ResetMFAReq is a request to clear all MFA secrets and WebAuthn credentials for a user. +// Use when a user has lost access to all their MFA devices. +message ResetMFAReq { + string user_id = 1; + string connector_id = 2; +} + +// ResetMFAResp returns the result of resetting MFA. +message ResetMFAResp { + bool not_found = 1; +} + +// ListMFADevicesReq is a request to list registered MFA authenticators for a user. +message ListMFADevicesReq { + string user_id = 1; + string connector_id = 2; +} + +// ListMFADevicesResp returns MFA device information. +// Secret values and public keys are never included in the response. +message ListMFADevicesResp { + repeated MFADeviceInfo devices = 1; +} + +// DeleteWebAuthnCredentialReq is a request to delete a specific WebAuthn credential. +// Use when a user has lost or wants to deregister a specific security key. +message DeleteWebAuthnCredentialReq { + string user_id = 1; + string connector_id = 2; + bytes credential_id = 3; +} + +// DeleteWebAuthnCredentialResp returns the result of deleting a WebAuthn credential. +message DeleteWebAuthnCredentialResp { + bool not_found = 1; +} + +// DeleteMFASecretReq is a request to delete a specific MFA authenticator secret. +// Also removes any associated WebAuthn credentials for the same authenticator. +message DeleteMFASecretReq { + string user_id = 1; + string connector_id = 2; + string authenticator_id = 3; +} + +// DeleteMFASecretResp returns the result of deleting an MFA secret. +message DeleteMFASecretResp { + bool not_found = 1; +} + +// RevokeConsentReq is a request to revoke consent for a specific client. +// The user will see the consent screen again on next authorization. +message RevokeConsentReq { + string user_id = 1; + string connector_id = 2; + string client_id = 3; +} + +// RevokeConsentResp returns the result of revoking consent. +message RevokeConsentResp { + bool not_found = 1; +} + // Dex represents the dex gRPC service. service Dex { + // GetClient gets a client. + rpc GetClient(GetClientReq) returns (GetClientResp) {}; // CreateClient creates a client. rpc CreateClient(CreateClientReq) returns (CreateClientResp) {}; // UpdateClient updates an existing client rpc UpdateClient(UpdateClientReq) returns (UpdateClientResp) {}; // DeleteClient deletes the provided client. rpc DeleteClient(DeleteClientReq) returns (DeleteClientResp) {}; + // ListClients lists all client entries. + rpc ListClients(ListClientReq) returns (ListClientResp) {}; // CreatePassword creates a password. rpc CreatePassword(CreatePasswordReq) returns (CreatePasswordResp) {}; // UpdatePassword modifies existing password. @@ -176,8 +552,18 @@ service Dex { rpc DeletePassword(DeletePasswordReq) returns (DeletePasswordResp) {}; // ListPassword lists all password entries. rpc ListPasswords(ListPasswordReq) returns (ListPasswordResp) {}; + // CreateConnector creates a connector. + rpc CreateConnector(CreateConnectorReq) returns (CreateConnectorResp) {}; + // UpdateConnector modifies existing connector. + rpc UpdateConnector(UpdateConnectorReq) returns (UpdateConnectorResp) {}; + // DeleteConnector deletes the connector. + rpc DeleteConnector(DeleteConnectorReq) returns (DeleteConnectorResp) {}; + // ListConnectors lists all connector entries. + rpc ListConnectors(ListConnectorReq) returns (ListConnectorResp) {}; // GetVersion returns version information of the server. rpc GetVersion(VersionReq) returns (VersionResp) {}; + // GetDiscovery returns discovery information of the server. + rpc GetDiscovery(DiscoveryReq) returns (DiscoveryResp) {}; // ListRefresh lists all the refresh token entries for a particular user. rpc ListRefresh(ListRefreshReq) returns (ListRefreshResp) {}; // RevokeRefresh revokes the refresh token for the provided user-client pair. @@ -186,4 +572,31 @@ service Dex { rpc RevokeRefresh(RevokeRefreshReq) returns (RevokeRefreshResp) {}; // VerifyPassword returns whether a password matches a hash for a specific email or not. rpc VerifyPassword(VerifyPasswordReq) returns (VerifyPasswordResp) {}; + // GetAuthSession returns an auth session by its ID. + rpc GetAuthSession(GetAuthSessionReq) returns (GetAuthSessionResp) {}; + // ListAuthSessions lists auth sessions, optionally filtered by user and connector. + rpc ListAuthSessions(ListAuthSessionsReq) returns (ListAuthSessionsResp) {}; + // DeleteAuthSession deletes an auth session and revokes associated refresh tokens. + rpc DeleteAuthSession(DeleteAuthSessionReq) returns (DeleteAuthSessionResp) {}; + // TerminateSessionsByConnector terminates all sessions for a connector and revokes associated refresh tokens. + rpc TerminateSessionsByConnector(TerminateSessionsByConnectorReq) returns (TerminateSessionsByConnectorResp) {}; + // TerminateSessionsByUser terminates all sessions for a user and revokes associated refresh tokens. + rpc TerminateSessionsByUser(TerminateSessionsByUserReq) returns (TerminateSessionsByUserResp) {}; + // GetUserIdentity returns a user identity by user and connector ID. + rpc GetUserIdentity(GetUserIdentityReq) returns (GetUserIdentityResp) {}; + // ListUserIdentities lists all user identities. + rpc ListUserIdentities(ListUserIdentitiesReq) returns (ListUserIdentitiesResp) {}; + // DeleteUserIdentity performs a full data purge for GDPR compliance: deletes the identity, + // auth session, refresh tokens, and offline sessions. + rpc DeleteUserIdentity(DeleteUserIdentityReq) returns (DeleteUserIdentityResp) {}; + // ResetMFA clears all MFA secrets and WebAuthn credentials for a user. + rpc ResetMFA(ResetMFAReq) returns (ResetMFAResp) {}; + // ListMFADevices lists registered MFA authenticators for a user. + rpc ListMFADevices(ListMFADevicesReq) returns (ListMFADevicesResp) {}; + // DeleteWebAuthnCredential deletes a specific WebAuthn credential. + rpc DeleteWebAuthnCredential(DeleteWebAuthnCredentialReq) returns (DeleteWebAuthnCredentialResp) {}; + // DeleteMFASecret deletes a specific MFA authenticator and its associated WebAuthn credentials. + rpc DeleteMFASecret(DeleteMFASecretReq) returns (DeleteMFASecretResp) {}; + // RevokeConsent revokes consent for a specific client. + rpc RevokeConsent(RevokeConsentReq) returns (RevokeConsentResp) {}; } diff --git a/api/v2/api_grpc.pb.go b/api/v2/api_grpc.pb.go index 8b3b10bc52..8b7f7f725c 100644 --- a/api/v2/api_grpc.pb.go +++ b/api/v2/api_grpc.pb.go @@ -1,4 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.3 +// source: api/v2/api.proto package api @@ -11,19 +15,59 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Dex_GetClient_FullMethodName = "/api.Dex/GetClient" + Dex_CreateClient_FullMethodName = "/api.Dex/CreateClient" + Dex_UpdateClient_FullMethodName = "/api.Dex/UpdateClient" + Dex_DeleteClient_FullMethodName = "/api.Dex/DeleteClient" + Dex_ListClients_FullMethodName = "/api.Dex/ListClients" + Dex_CreatePassword_FullMethodName = "/api.Dex/CreatePassword" + Dex_UpdatePassword_FullMethodName = "/api.Dex/UpdatePassword" + Dex_DeletePassword_FullMethodName = "/api.Dex/DeletePassword" + Dex_ListPasswords_FullMethodName = "/api.Dex/ListPasswords" + Dex_CreateConnector_FullMethodName = "/api.Dex/CreateConnector" + Dex_UpdateConnector_FullMethodName = "/api.Dex/UpdateConnector" + Dex_DeleteConnector_FullMethodName = "/api.Dex/DeleteConnector" + Dex_ListConnectors_FullMethodName = "/api.Dex/ListConnectors" + Dex_GetVersion_FullMethodName = "/api.Dex/GetVersion" + Dex_GetDiscovery_FullMethodName = "/api.Dex/GetDiscovery" + Dex_ListRefresh_FullMethodName = "/api.Dex/ListRefresh" + Dex_RevokeRefresh_FullMethodName = "/api.Dex/RevokeRefresh" + Dex_VerifyPassword_FullMethodName = "/api.Dex/VerifyPassword" + Dex_GetAuthSession_FullMethodName = "/api.Dex/GetAuthSession" + Dex_ListAuthSessions_FullMethodName = "/api.Dex/ListAuthSessions" + Dex_DeleteAuthSession_FullMethodName = "/api.Dex/DeleteAuthSession" + Dex_TerminateSessionsByConnector_FullMethodName = "/api.Dex/TerminateSessionsByConnector" + Dex_TerminateSessionsByUser_FullMethodName = "/api.Dex/TerminateSessionsByUser" + Dex_GetUserIdentity_FullMethodName = "/api.Dex/GetUserIdentity" + Dex_ListUserIdentities_FullMethodName = "/api.Dex/ListUserIdentities" + Dex_DeleteUserIdentity_FullMethodName = "/api.Dex/DeleteUserIdentity" + Dex_ResetMFA_FullMethodName = "/api.Dex/ResetMFA" + Dex_ListMFADevices_FullMethodName = "/api.Dex/ListMFADevices" + Dex_DeleteWebAuthnCredential_FullMethodName = "/api.Dex/DeleteWebAuthnCredential" + Dex_DeleteMFASecret_FullMethodName = "/api.Dex/DeleteMFASecret" + Dex_RevokeConsent_FullMethodName = "/api.Dex/RevokeConsent" +) // DexClient is the client API for Dex service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Dex represents the dex gRPC service. type DexClient interface { + // GetClient gets a client. + GetClient(ctx context.Context, in *GetClientReq, opts ...grpc.CallOption) (*GetClientResp, error) // CreateClient creates a client. CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error) // UpdateClient updates an existing client UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error) // DeleteClient deletes the provided client. DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error) + // ListClients lists all client entries. + ListClients(ctx context.Context, in *ListClientReq, opts ...grpc.CallOption) (*ListClientResp, error) // CreatePassword creates a password. CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error) // UpdatePassword modifies existing password. @@ -32,8 +76,18 @@ type DexClient interface { DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error) // ListPassword lists all password entries. ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error) + // CreateConnector creates a connector. + CreateConnector(ctx context.Context, in *CreateConnectorReq, opts ...grpc.CallOption) (*CreateConnectorResp, error) + // UpdateConnector modifies existing connector. + UpdateConnector(ctx context.Context, in *UpdateConnectorReq, opts ...grpc.CallOption) (*UpdateConnectorResp, error) + // DeleteConnector deletes the connector. + DeleteConnector(ctx context.Context, in *DeleteConnectorReq, opts ...grpc.CallOption) (*DeleteConnectorResp, error) + // ListConnectors lists all connector entries. + ListConnectors(ctx context.Context, in *ListConnectorReq, opts ...grpc.CallOption) (*ListConnectorResp, error) // GetVersion returns version information of the server. GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error) + // GetDiscovery returns discovery information of the server. + GetDiscovery(ctx context.Context, in *DiscoveryReq, opts ...grpc.CallOption) (*DiscoveryResp, error) // ListRefresh lists all the refresh token entries for a particular user. ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error) // RevokeRefresh revokes the refresh token for the provided user-client pair. @@ -42,6 +96,33 @@ type DexClient interface { RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error) // VerifyPassword returns whether a password matches a hash for a specific email or not. VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error) + // GetAuthSession returns an auth session by its ID. + GetAuthSession(ctx context.Context, in *GetAuthSessionReq, opts ...grpc.CallOption) (*GetAuthSessionResp, error) + // ListAuthSessions lists auth sessions, optionally filtered by user and connector. + ListAuthSessions(ctx context.Context, in *ListAuthSessionsReq, opts ...grpc.CallOption) (*ListAuthSessionsResp, error) + // DeleteAuthSession deletes an auth session and revokes associated refresh tokens. + DeleteAuthSession(ctx context.Context, in *DeleteAuthSessionReq, opts ...grpc.CallOption) (*DeleteAuthSessionResp, error) + // TerminateSessionsByConnector terminates all sessions for a connector and revokes associated refresh tokens. + TerminateSessionsByConnector(ctx context.Context, in *TerminateSessionsByConnectorReq, opts ...grpc.CallOption) (*TerminateSessionsByConnectorResp, error) + // TerminateSessionsByUser terminates all sessions for a user and revokes associated refresh tokens. + TerminateSessionsByUser(ctx context.Context, in *TerminateSessionsByUserReq, opts ...grpc.CallOption) (*TerminateSessionsByUserResp, error) + // GetUserIdentity returns a user identity by user and connector ID. + GetUserIdentity(ctx context.Context, in *GetUserIdentityReq, opts ...grpc.CallOption) (*GetUserIdentityResp, error) + // ListUserIdentities lists all user identities. + ListUserIdentities(ctx context.Context, in *ListUserIdentitiesReq, opts ...grpc.CallOption) (*ListUserIdentitiesResp, error) + // DeleteUserIdentity performs a full data purge for GDPR compliance: deletes the identity, + // auth session, refresh tokens, and offline sessions. + DeleteUserIdentity(ctx context.Context, in *DeleteUserIdentityReq, opts ...grpc.CallOption) (*DeleteUserIdentityResp, error) + // ResetMFA clears all MFA secrets and WebAuthn credentials for a user. + ResetMFA(ctx context.Context, in *ResetMFAReq, opts ...grpc.CallOption) (*ResetMFAResp, error) + // ListMFADevices lists registered MFA authenticators for a user. + ListMFADevices(ctx context.Context, in *ListMFADevicesReq, opts ...grpc.CallOption) (*ListMFADevicesResp, error) + // DeleteWebAuthnCredential deletes a specific WebAuthn credential. + DeleteWebAuthnCredential(ctx context.Context, in *DeleteWebAuthnCredentialReq, opts ...grpc.CallOption) (*DeleteWebAuthnCredentialResp, error) + // DeleteMFASecret deletes a specific MFA authenticator and its associated WebAuthn credentials. + DeleteMFASecret(ctx context.Context, in *DeleteMFASecretReq, opts ...grpc.CallOption) (*DeleteMFASecretResp, error) + // RevokeConsent revokes consent for a specific client. + RevokeConsent(ctx context.Context, in *RevokeConsentReq, opts ...grpc.CallOption) (*RevokeConsentResp, error) } type dexClient struct { @@ -52,9 +133,20 @@ func NewDexClient(cc grpc.ClientConnInterface) DexClient { return &dexClient{cc} } +func (c *dexClient) GetClient(ctx context.Context, in *GetClientReq, opts ...grpc.CallOption) (*GetClientResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetClientResp) + err := c.cc.Invoke(ctx, Dex_GetClient_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *dexClient) CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(CreateClientResp) - err := c.cc.Invoke(ctx, "/api.Dex/CreateClient", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_CreateClient_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -62,8 +154,9 @@ func (c *dexClient) CreateClient(ctx context.Context, in *CreateClientReq, opts } func (c *dexClient) UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(UpdateClientResp) - err := c.cc.Invoke(ctx, "/api.Dex/UpdateClient", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_UpdateClient_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -71,8 +164,19 @@ func (c *dexClient) UpdateClient(ctx context.Context, in *UpdateClientReq, opts } func (c *dexClient) DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DeleteClientResp) - err := c.cc.Invoke(ctx, "/api.Dex/DeleteClient", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_DeleteClient_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) ListClients(ctx context.Context, in *ListClientReq, opts ...grpc.CallOption) (*ListClientResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListClientResp) + err := c.cc.Invoke(ctx, Dex_ListClients_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -80,8 +184,9 @@ func (c *dexClient) DeleteClient(ctx context.Context, in *DeleteClientReq, opts } func (c *dexClient) CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(CreatePasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/CreatePassword", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_CreatePassword_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -89,8 +194,9 @@ func (c *dexClient) CreatePassword(ctx context.Context, in *CreatePasswordReq, o } func (c *dexClient) UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(UpdatePasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/UpdatePassword", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_UpdatePassword_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -98,8 +204,9 @@ func (c *dexClient) UpdatePassword(ctx context.Context, in *UpdatePasswordReq, o } func (c *dexClient) DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DeletePasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/DeletePassword", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_DeletePassword_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -107,8 +214,49 @@ func (c *dexClient) DeletePassword(ctx context.Context, in *DeletePasswordReq, o } func (c *dexClient) ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListPasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/ListPasswords", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_ListPasswords_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) CreateConnector(ctx context.Context, in *CreateConnectorReq, opts ...grpc.CallOption) (*CreateConnectorResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateConnectorResp) + err := c.cc.Invoke(ctx, Dex_CreateConnector_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) UpdateConnector(ctx context.Context, in *UpdateConnectorReq, opts ...grpc.CallOption) (*UpdateConnectorResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateConnectorResp) + err := c.cc.Invoke(ctx, Dex_UpdateConnector_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) DeleteConnector(ctx context.Context, in *DeleteConnectorReq, opts ...grpc.CallOption) (*DeleteConnectorResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteConnectorResp) + err := c.cc.Invoke(ctx, Dex_DeleteConnector_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) ListConnectors(ctx context.Context, in *ListConnectorReq, opts ...grpc.CallOption) (*ListConnectorResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListConnectorResp) + err := c.cc.Invoke(ctx, Dex_ListConnectors_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -116,8 +264,19 @@ func (c *dexClient) ListPasswords(ctx context.Context, in *ListPasswordReq, opts } func (c *dexClient) GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(VersionResp) - err := c.cc.Invoke(ctx, "/api.Dex/GetVersion", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_GetVersion_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) GetDiscovery(ctx context.Context, in *DiscoveryReq, opts ...grpc.CallOption) (*DiscoveryResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DiscoveryResp) + err := c.cc.Invoke(ctx, Dex_GetDiscovery_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -125,8 +284,9 @@ func (c *dexClient) GetVersion(ctx context.Context, in *VersionReq, opts ...grpc } func (c *dexClient) ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListRefreshResp) - err := c.cc.Invoke(ctx, "/api.Dex/ListRefresh", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_ListRefresh_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -134,8 +294,9 @@ func (c *dexClient) ListRefresh(ctx context.Context, in *ListRefreshReq, opts .. } func (c *dexClient) RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RevokeRefreshResp) - err := c.cc.Invoke(ctx, "/api.Dex/RevokeRefresh", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_RevokeRefresh_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -143,8 +304,139 @@ func (c *dexClient) RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opt } func (c *dexClient) VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(VerifyPasswordResp) - err := c.cc.Invoke(ctx, "/api.Dex/VerifyPassword", in, out, opts...) + err := c.cc.Invoke(ctx, Dex_VerifyPassword_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) GetAuthSession(ctx context.Context, in *GetAuthSessionReq, opts ...grpc.CallOption) (*GetAuthSessionResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetAuthSessionResp) + err := c.cc.Invoke(ctx, Dex_GetAuthSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) ListAuthSessions(ctx context.Context, in *ListAuthSessionsReq, opts ...grpc.CallOption) (*ListAuthSessionsResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListAuthSessionsResp) + err := c.cc.Invoke(ctx, Dex_ListAuthSessions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) DeleteAuthSession(ctx context.Context, in *DeleteAuthSessionReq, opts ...grpc.CallOption) (*DeleteAuthSessionResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteAuthSessionResp) + err := c.cc.Invoke(ctx, Dex_DeleteAuthSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) TerminateSessionsByConnector(ctx context.Context, in *TerminateSessionsByConnectorReq, opts ...grpc.CallOption) (*TerminateSessionsByConnectorResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(TerminateSessionsByConnectorResp) + err := c.cc.Invoke(ctx, Dex_TerminateSessionsByConnector_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) TerminateSessionsByUser(ctx context.Context, in *TerminateSessionsByUserReq, opts ...grpc.CallOption) (*TerminateSessionsByUserResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(TerminateSessionsByUserResp) + err := c.cc.Invoke(ctx, Dex_TerminateSessionsByUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) GetUserIdentity(ctx context.Context, in *GetUserIdentityReq, opts ...grpc.CallOption) (*GetUserIdentityResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetUserIdentityResp) + err := c.cc.Invoke(ctx, Dex_GetUserIdentity_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) ListUserIdentities(ctx context.Context, in *ListUserIdentitiesReq, opts ...grpc.CallOption) (*ListUserIdentitiesResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListUserIdentitiesResp) + err := c.cc.Invoke(ctx, Dex_ListUserIdentities_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) DeleteUserIdentity(ctx context.Context, in *DeleteUserIdentityReq, opts ...grpc.CallOption) (*DeleteUserIdentityResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteUserIdentityResp) + err := c.cc.Invoke(ctx, Dex_DeleteUserIdentity_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) ResetMFA(ctx context.Context, in *ResetMFAReq, opts ...grpc.CallOption) (*ResetMFAResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResetMFAResp) + err := c.cc.Invoke(ctx, Dex_ResetMFA_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) ListMFADevices(ctx context.Context, in *ListMFADevicesReq, opts ...grpc.CallOption) (*ListMFADevicesResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListMFADevicesResp) + err := c.cc.Invoke(ctx, Dex_ListMFADevices_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) DeleteWebAuthnCredential(ctx context.Context, in *DeleteWebAuthnCredentialReq, opts ...grpc.CallOption) (*DeleteWebAuthnCredentialResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteWebAuthnCredentialResp) + err := c.cc.Invoke(ctx, Dex_DeleteWebAuthnCredential_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) DeleteMFASecret(ctx context.Context, in *DeleteMFASecretReq, opts ...grpc.CallOption) (*DeleteMFASecretResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteMFASecretResp) + err := c.cc.Invoke(ctx, Dex_DeleteMFASecret_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dexClient) RevokeConsent(ctx context.Context, in *RevokeConsentReq, opts ...grpc.CallOption) (*RevokeConsentResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RevokeConsentResp) + err := c.cc.Invoke(ctx, Dex_RevokeConsent_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -153,14 +445,20 @@ func (c *dexClient) VerifyPassword(ctx context.Context, in *VerifyPasswordReq, o // DexServer is the server API for Dex service. // All implementations must embed UnimplementedDexServer -// for forward compatibility +// for forward compatibility. +// +// Dex represents the dex gRPC service. type DexServer interface { + // GetClient gets a client. + GetClient(context.Context, *GetClientReq) (*GetClientResp, error) // CreateClient creates a client. CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error) // UpdateClient updates an existing client UpdateClient(context.Context, *UpdateClientReq) (*UpdateClientResp, error) // DeleteClient deletes the provided client. DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error) + // ListClients lists all client entries. + ListClients(context.Context, *ListClientReq) (*ListClientResp, error) // CreatePassword creates a password. CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error) // UpdatePassword modifies existing password. @@ -169,8 +467,18 @@ type DexServer interface { DeletePassword(context.Context, *DeletePasswordReq) (*DeletePasswordResp, error) // ListPassword lists all password entries. ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error) + // CreateConnector creates a connector. + CreateConnector(context.Context, *CreateConnectorReq) (*CreateConnectorResp, error) + // UpdateConnector modifies existing connector. + UpdateConnector(context.Context, *UpdateConnectorReq) (*UpdateConnectorResp, error) + // DeleteConnector deletes the connector. + DeleteConnector(context.Context, *DeleteConnectorReq) (*DeleteConnectorResp, error) + // ListConnectors lists all connector entries. + ListConnectors(context.Context, *ListConnectorReq) (*ListConnectorResp, error) // GetVersion returns version information of the server. GetVersion(context.Context, *VersionReq) (*VersionResp, error) + // GetDiscovery returns discovery information of the server. + GetDiscovery(context.Context, *DiscoveryReq) (*DiscoveryResp, error) // ListRefresh lists all the refresh token entries for a particular user. ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error) // RevokeRefresh revokes the refresh token for the provided user-client pair. @@ -179,13 +487,46 @@ type DexServer interface { RevokeRefresh(context.Context, *RevokeRefreshReq) (*RevokeRefreshResp, error) // VerifyPassword returns whether a password matches a hash for a specific email or not. VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error) + // GetAuthSession returns an auth session by its ID. + GetAuthSession(context.Context, *GetAuthSessionReq) (*GetAuthSessionResp, error) + // ListAuthSessions lists auth sessions, optionally filtered by user and connector. + ListAuthSessions(context.Context, *ListAuthSessionsReq) (*ListAuthSessionsResp, error) + // DeleteAuthSession deletes an auth session and revokes associated refresh tokens. + DeleteAuthSession(context.Context, *DeleteAuthSessionReq) (*DeleteAuthSessionResp, error) + // TerminateSessionsByConnector terminates all sessions for a connector and revokes associated refresh tokens. + TerminateSessionsByConnector(context.Context, *TerminateSessionsByConnectorReq) (*TerminateSessionsByConnectorResp, error) + // TerminateSessionsByUser terminates all sessions for a user and revokes associated refresh tokens. + TerminateSessionsByUser(context.Context, *TerminateSessionsByUserReq) (*TerminateSessionsByUserResp, error) + // GetUserIdentity returns a user identity by user and connector ID. + GetUserIdentity(context.Context, *GetUserIdentityReq) (*GetUserIdentityResp, error) + // ListUserIdentities lists all user identities. + ListUserIdentities(context.Context, *ListUserIdentitiesReq) (*ListUserIdentitiesResp, error) + // DeleteUserIdentity performs a full data purge for GDPR compliance: deletes the identity, + // auth session, refresh tokens, and offline sessions. + DeleteUserIdentity(context.Context, *DeleteUserIdentityReq) (*DeleteUserIdentityResp, error) + // ResetMFA clears all MFA secrets and WebAuthn credentials for a user. + ResetMFA(context.Context, *ResetMFAReq) (*ResetMFAResp, error) + // ListMFADevices lists registered MFA authenticators for a user. + ListMFADevices(context.Context, *ListMFADevicesReq) (*ListMFADevicesResp, error) + // DeleteWebAuthnCredential deletes a specific WebAuthn credential. + DeleteWebAuthnCredential(context.Context, *DeleteWebAuthnCredentialReq) (*DeleteWebAuthnCredentialResp, error) + // DeleteMFASecret deletes a specific MFA authenticator and its associated WebAuthn credentials. + DeleteMFASecret(context.Context, *DeleteMFASecretReq) (*DeleteMFASecretResp, error) + // RevokeConsent revokes consent for a specific client. + RevokeConsent(context.Context, *RevokeConsentReq) (*RevokeConsentResp, error) mustEmbedUnimplementedDexServer() } -// UnimplementedDexServer must be embedded to have forward compatible implementations. -type UnimplementedDexServer struct { -} +// UnimplementedDexServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDexServer struct{} +func (UnimplementedDexServer) GetClient(context.Context, *GetClientReq) (*GetClientResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetClient not implemented") +} func (UnimplementedDexServer) CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error) { return nil, status.Errorf(codes.Unimplemented, "method CreateClient not implemented") } @@ -195,6 +536,9 @@ func (UnimplementedDexServer) UpdateClient(context.Context, *UpdateClientReq) (* func (UnimplementedDexServer) DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error) { return nil, status.Errorf(codes.Unimplemented, "method DeleteClient not implemented") } +func (UnimplementedDexServer) ListClients(context.Context, *ListClientReq) (*ListClientResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListClients not implemented") +} func (UnimplementedDexServer) CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error) { return nil, status.Errorf(codes.Unimplemented, "method CreatePassword not implemented") } @@ -207,9 +551,24 @@ func (UnimplementedDexServer) DeletePassword(context.Context, *DeletePasswordReq func (UnimplementedDexServer) ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error) { return nil, status.Errorf(codes.Unimplemented, "method ListPasswords not implemented") } +func (UnimplementedDexServer) CreateConnector(context.Context, *CreateConnectorReq) (*CreateConnectorResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateConnector not implemented") +} +func (UnimplementedDexServer) UpdateConnector(context.Context, *UpdateConnectorReq) (*UpdateConnectorResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateConnector not implemented") +} +func (UnimplementedDexServer) DeleteConnector(context.Context, *DeleteConnectorReq) (*DeleteConnectorResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteConnector not implemented") +} +func (UnimplementedDexServer) ListConnectors(context.Context, *ListConnectorReq) (*ListConnectorResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListConnectors not implemented") +} func (UnimplementedDexServer) GetVersion(context.Context, *VersionReq) (*VersionResp, error) { return nil, status.Errorf(codes.Unimplemented, "method GetVersion not implemented") } +func (UnimplementedDexServer) GetDiscovery(context.Context, *DiscoveryReq) (*DiscoveryResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetDiscovery not implemented") +} func (UnimplementedDexServer) ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error) { return nil, status.Errorf(codes.Unimplemented, "method ListRefresh not implemented") } @@ -219,7 +578,47 @@ func (UnimplementedDexServer) RevokeRefresh(context.Context, *RevokeRefreshReq) func (UnimplementedDexServer) VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error) { return nil, status.Errorf(codes.Unimplemented, "method VerifyPassword not implemented") } +func (UnimplementedDexServer) GetAuthSession(context.Context, *GetAuthSessionReq) (*GetAuthSessionResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetAuthSession not implemented") +} +func (UnimplementedDexServer) ListAuthSessions(context.Context, *ListAuthSessionsReq) (*ListAuthSessionsResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListAuthSessions not implemented") +} +func (UnimplementedDexServer) DeleteAuthSession(context.Context, *DeleteAuthSessionReq) (*DeleteAuthSessionResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteAuthSession not implemented") +} +func (UnimplementedDexServer) TerminateSessionsByConnector(context.Context, *TerminateSessionsByConnectorReq) (*TerminateSessionsByConnectorResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method TerminateSessionsByConnector not implemented") +} +func (UnimplementedDexServer) TerminateSessionsByUser(context.Context, *TerminateSessionsByUserReq) (*TerminateSessionsByUserResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method TerminateSessionsByUser not implemented") +} +func (UnimplementedDexServer) GetUserIdentity(context.Context, *GetUserIdentityReq) (*GetUserIdentityResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetUserIdentity not implemented") +} +func (UnimplementedDexServer) ListUserIdentities(context.Context, *ListUserIdentitiesReq) (*ListUserIdentitiesResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListUserIdentities not implemented") +} +func (UnimplementedDexServer) DeleteUserIdentity(context.Context, *DeleteUserIdentityReq) (*DeleteUserIdentityResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteUserIdentity not implemented") +} +func (UnimplementedDexServer) ResetMFA(context.Context, *ResetMFAReq) (*ResetMFAResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method ResetMFA not implemented") +} +func (UnimplementedDexServer) ListMFADevices(context.Context, *ListMFADevicesReq) (*ListMFADevicesResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListMFADevices not implemented") +} +func (UnimplementedDexServer) DeleteWebAuthnCredential(context.Context, *DeleteWebAuthnCredentialReq) (*DeleteWebAuthnCredentialResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteWebAuthnCredential not implemented") +} +func (UnimplementedDexServer) DeleteMFASecret(context.Context, *DeleteMFASecretReq) (*DeleteMFASecretResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteMFASecret not implemented") +} +func (UnimplementedDexServer) RevokeConsent(context.Context, *RevokeConsentReq) (*RevokeConsentResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method RevokeConsent not implemented") +} func (UnimplementedDexServer) mustEmbedUnimplementedDexServer() {} +func (UnimplementedDexServer) testEmbeddedByValue() {} // UnsafeDexServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to DexServer will @@ -229,9 +628,34 @@ type UnsafeDexServer interface { } func RegisterDexServer(s grpc.ServiceRegistrar, srv DexServer) { + // If the following call pancis, it indicates UnimplementedDexServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&Dex_ServiceDesc, srv) } +func _Dex_GetClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetClientReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).GetClient(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_GetClient_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).GetClient(ctx, req.(*GetClientReq)) + } + return interceptor(ctx, in, info, handler) +} + func _Dex_CreateClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(CreateClientReq) if err := dec(in); err != nil { @@ -242,7 +666,7 @@ func _Dex_CreateClient_Handler(srv interface{}, ctx context.Context, dec func(in } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/CreateClient", + FullMethod: Dex_CreateClient_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).CreateClient(ctx, req.(*CreateClientReq)) @@ -260,7 +684,7 @@ func _Dex_UpdateClient_Handler(srv interface{}, ctx context.Context, dec func(in } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/UpdateClient", + FullMethod: Dex_UpdateClient_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).UpdateClient(ctx, req.(*UpdateClientReq)) @@ -278,7 +702,7 @@ func _Dex_DeleteClient_Handler(srv interface{}, ctx context.Context, dec func(in } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/DeleteClient", + FullMethod: Dex_DeleteClient_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).DeleteClient(ctx, req.(*DeleteClientReq)) @@ -286,6 +710,24 @@ func _Dex_DeleteClient_Handler(srv interface{}, ctx context.Context, dec func(in return interceptor(ctx, in, info, handler) } +func _Dex_ListClients_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListClientReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).ListClients(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_ListClients_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).ListClients(ctx, req.(*ListClientReq)) + } + return interceptor(ctx, in, info, handler) +} + func _Dex_CreatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(CreatePasswordReq) if err := dec(in); err != nil { @@ -296,7 +738,7 @@ func _Dex_CreatePassword_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/CreatePassword", + FullMethod: Dex_CreatePassword_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).CreatePassword(ctx, req.(*CreatePasswordReq)) @@ -314,7 +756,7 @@ func _Dex_UpdatePassword_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/UpdatePassword", + FullMethod: Dex_UpdatePassword_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).UpdatePassword(ctx, req.(*UpdatePasswordReq)) @@ -332,7 +774,7 @@ func _Dex_DeletePassword_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/DeletePassword", + FullMethod: Dex_DeletePassword_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).DeletePassword(ctx, req.(*DeletePasswordReq)) @@ -350,7 +792,7 @@ func _Dex_ListPasswords_Handler(srv interface{}, ctx context.Context, dec func(i } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/ListPasswords", + FullMethod: Dex_ListPasswords_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).ListPasswords(ctx, req.(*ListPasswordReq)) @@ -358,6 +800,78 @@ func _Dex_ListPasswords_Handler(srv interface{}, ctx context.Context, dec func(i return interceptor(ctx, in, info, handler) } +func _Dex_CreateConnector_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateConnectorReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).CreateConnector(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_CreateConnector_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).CreateConnector(ctx, req.(*CreateConnectorReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_UpdateConnector_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateConnectorReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).UpdateConnector(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_UpdateConnector_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).UpdateConnector(ctx, req.(*UpdateConnectorReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_DeleteConnector_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteConnectorReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).DeleteConnector(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_DeleteConnector_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).DeleteConnector(ctx, req.(*DeleteConnectorReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_ListConnectors_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListConnectorReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).ListConnectors(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_ListConnectors_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).ListConnectors(ctx, req.(*ListConnectorReq)) + } + return interceptor(ctx, in, info, handler) +} + func _Dex_GetVersion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(VersionReq) if err := dec(in); err != nil { @@ -368,7 +882,7 @@ func _Dex_GetVersion_Handler(srv interface{}, ctx context.Context, dec func(inte } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/GetVersion", + FullMethod: Dex_GetVersion_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).GetVersion(ctx, req.(*VersionReq)) @@ -376,6 +890,24 @@ func _Dex_GetVersion_Handler(srv interface{}, ctx context.Context, dec func(inte return interceptor(ctx, in, info, handler) } +func _Dex_GetDiscovery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DiscoveryReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).GetDiscovery(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_GetDiscovery_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).GetDiscovery(ctx, req.(*DiscoveryReq)) + } + return interceptor(ctx, in, info, handler) +} + func _Dex_ListRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListRefreshReq) if err := dec(in); err != nil { @@ -386,7 +918,7 @@ func _Dex_ListRefresh_Handler(srv interface{}, ctx context.Context, dec func(int } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/ListRefresh", + FullMethod: Dex_ListRefresh_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).ListRefresh(ctx, req.(*ListRefreshReq)) @@ -404,7 +936,7 @@ func _Dex_RevokeRefresh_Handler(srv interface{}, ctx context.Context, dec func(i } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/RevokeRefresh", + FullMethod: Dex_RevokeRefresh_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).RevokeRefresh(ctx, req.(*RevokeRefreshReq)) @@ -422,7 +954,7 @@ func _Dex_VerifyPassword_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/api.Dex/VerifyPassword", + FullMethod: Dex_VerifyPassword_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DexServer).VerifyPassword(ctx, req.(*VerifyPasswordReq)) @@ -430,6 +962,240 @@ func _Dex_VerifyPassword_Handler(srv interface{}, ctx context.Context, dec func( return interceptor(ctx, in, info, handler) } +func _Dex_GetAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetAuthSessionReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).GetAuthSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_GetAuthSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).GetAuthSession(ctx, req.(*GetAuthSessionReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_ListAuthSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListAuthSessionsReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).ListAuthSessions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_ListAuthSessions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).ListAuthSessions(ctx, req.(*ListAuthSessionsReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_DeleteAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteAuthSessionReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).DeleteAuthSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_DeleteAuthSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).DeleteAuthSession(ctx, req.(*DeleteAuthSessionReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_TerminateSessionsByConnector_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TerminateSessionsByConnectorReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).TerminateSessionsByConnector(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_TerminateSessionsByConnector_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).TerminateSessionsByConnector(ctx, req.(*TerminateSessionsByConnectorReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_TerminateSessionsByUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TerminateSessionsByUserReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).TerminateSessionsByUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_TerminateSessionsByUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).TerminateSessionsByUser(ctx, req.(*TerminateSessionsByUserReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_GetUserIdentity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetUserIdentityReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).GetUserIdentity(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_GetUserIdentity_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).GetUserIdentity(ctx, req.(*GetUserIdentityReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_ListUserIdentities_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListUserIdentitiesReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).ListUserIdentities(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_ListUserIdentities_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).ListUserIdentities(ctx, req.(*ListUserIdentitiesReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_DeleteUserIdentity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteUserIdentityReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).DeleteUserIdentity(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_DeleteUserIdentity_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).DeleteUserIdentity(ctx, req.(*DeleteUserIdentityReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_ResetMFA_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResetMFAReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).ResetMFA(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_ResetMFA_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).ResetMFA(ctx, req.(*ResetMFAReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_ListMFADevices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListMFADevicesReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).ListMFADevices(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_ListMFADevices_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).ListMFADevices(ctx, req.(*ListMFADevicesReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_DeleteWebAuthnCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteWebAuthnCredentialReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).DeleteWebAuthnCredential(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_DeleteWebAuthnCredential_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).DeleteWebAuthnCredential(ctx, req.(*DeleteWebAuthnCredentialReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_DeleteMFASecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteMFASecretReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).DeleteMFASecret(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_DeleteMFASecret_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).DeleteMFASecret(ctx, req.(*DeleteMFASecretReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Dex_RevokeConsent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeConsentReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DexServer).RevokeConsent(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Dex_RevokeConsent_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DexServer).RevokeConsent(ctx, req.(*RevokeConsentReq)) + } + return interceptor(ctx, in, info, handler) +} + // Dex_ServiceDesc is the grpc.ServiceDesc for Dex service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -437,6 +1203,10 @@ var Dex_ServiceDesc = grpc.ServiceDesc{ ServiceName: "api.Dex", HandlerType: (*DexServer)(nil), Methods: []grpc.MethodDesc{ + { + MethodName: "GetClient", + Handler: _Dex_GetClient_Handler, + }, { MethodName: "CreateClient", Handler: _Dex_CreateClient_Handler, @@ -449,6 +1219,10 @@ var Dex_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeleteClient", Handler: _Dex_DeleteClient_Handler, }, + { + MethodName: "ListClients", + Handler: _Dex_ListClients_Handler, + }, { MethodName: "CreatePassword", Handler: _Dex_CreatePassword_Handler, @@ -465,10 +1239,30 @@ var Dex_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListPasswords", Handler: _Dex_ListPasswords_Handler, }, + { + MethodName: "CreateConnector", + Handler: _Dex_CreateConnector_Handler, + }, + { + MethodName: "UpdateConnector", + Handler: _Dex_UpdateConnector_Handler, + }, + { + MethodName: "DeleteConnector", + Handler: _Dex_DeleteConnector_Handler, + }, + { + MethodName: "ListConnectors", + Handler: _Dex_ListConnectors_Handler, + }, { MethodName: "GetVersion", Handler: _Dex_GetVersion_Handler, }, + { + MethodName: "GetDiscovery", + Handler: _Dex_GetDiscovery_Handler, + }, { MethodName: "ListRefresh", Handler: _Dex_ListRefresh_Handler, @@ -481,6 +1275,58 @@ var Dex_ServiceDesc = grpc.ServiceDesc{ MethodName: "VerifyPassword", Handler: _Dex_VerifyPassword_Handler, }, + { + MethodName: "GetAuthSession", + Handler: _Dex_GetAuthSession_Handler, + }, + { + MethodName: "ListAuthSessions", + Handler: _Dex_ListAuthSessions_Handler, + }, + { + MethodName: "DeleteAuthSession", + Handler: _Dex_DeleteAuthSession_Handler, + }, + { + MethodName: "TerminateSessionsByConnector", + Handler: _Dex_TerminateSessionsByConnector_Handler, + }, + { + MethodName: "TerminateSessionsByUser", + Handler: _Dex_TerminateSessionsByUser_Handler, + }, + { + MethodName: "GetUserIdentity", + Handler: _Dex_GetUserIdentity_Handler, + }, + { + MethodName: "ListUserIdentities", + Handler: _Dex_ListUserIdentities_Handler, + }, + { + MethodName: "DeleteUserIdentity", + Handler: _Dex_DeleteUserIdentity_Handler, + }, + { + MethodName: "ResetMFA", + Handler: _Dex_ResetMFA_Handler, + }, + { + MethodName: "ListMFADevices", + Handler: _Dex_ListMFADevices_Handler, + }, + { + MethodName: "DeleteWebAuthnCredential", + Handler: _Dex_DeleteWebAuthnCredential_Handler, + }, + { + MethodName: "DeleteMFASecret", + Handler: _Dex_DeleteMFASecret_Handler, + }, + { + MethodName: "RevokeConsent", + Handler: _Dex_RevokeConsent_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "api/v2/api.proto", diff --git a/api/v2/go.mod b/api/v2/go.mod index dc78ec4d96..7a721647e3 100644 --- a/api/v2/go.mod +++ b/api/v2/go.mod @@ -1,16 +1,15 @@ module github.com/dexidp/dex/api/v2 -go 1.17 +go 1.25.0 require ( - google.golang.org/grpc v1.47.0 - google.golang.org/protobuf v1.28.1 + google.golang.org/grpc v1.82.0 + google.golang.org/protobuf v1.36.11 ) require ( - github.com/golang/protobuf v1.5.2 // indirect - golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect - golang.org/x/text v0.3.7 // indirect - google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect ) diff --git a/api/v2/go.sum b/api/v2/go.sum index 59d53d2d63..78b5eb1715 100644 --- a/api/v2/go.sum +++ b/api/v2/go.sum @@ -1,142 +1,38 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20220607020251-c690dde0001d h1:4SFsTMi4UahlKoloni7L4eYzhFRifURQLw+yv0QDCx8= -golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM= -google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8= -google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 7bb7fbb780..c9ac6c8888 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -1,17 +1,23 @@ package main import ( + "bytes" "encoding/base64" "encoding/json" "fmt" + "log/slog" + "net/http" + "net/netip" "os" - "strconv" "strings" + "github.com/go-jose/go-jose/v4" "golang.org/x/crypto/bcrypt" - "github.com/dexidp/dex/pkg/log" + "github.com/dexidp/dex/pkg/featureflags" "github.com/dexidp/dex/server" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/ent" "github.com/dexidp/dex/storage/etcd" @@ -20,6 +26,15 @@ import ( "github.com/dexidp/dex/storage/sql" ) +func configUnmarshaller(b []byte, v interface{}) error { + if !featureflags.ConfigDisallowUnknownFields.Enabled() { + return json.Unmarshal(b, v) + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + return dec.Decode(v) +} + // Config is the config format for the main application. type Config struct { Issuer string `json:"issuer"` @@ -33,6 +48,9 @@ type Config struct { Frontend server.WebConfig `json:"frontend"` + // Signer configuration controls signing of JWT tokens issued by Dex. + Signer Signer `json:"signer"` + // StaticConnectors are user defined connectors specified in the ConfigMap // Write operations, like updating a connector, will fail. StaticConnectors []Connector `json:"connectors"` @@ -49,6 +67,23 @@ type Config struct { // querying the storage. Cannot be specified without enabling a passwords // database. StaticPasswords []password `json:"staticPasswords"` + + // Sessions holds authentication session configuration. + // Requires DEX_SESSIONS_ENABLED=true feature flag. + Sessions *Sessions `json:"sessions"` + + // MFA holds multi-factor authentication configuration. + MFA MFAConfig `json:"mfa"` +} + +// MFAConfig holds multi-factor authentication settings. +type MFAConfig struct { + // Authenticators defines MFA providers available for clients to reference. + Authenticators []MFAAuthenticator `json:"authenticators"` + + // DefaultMFAChain is the default ordered list of authenticator IDs applied + // to clients that don't specify their own mfaChain. Empty means no MFA by default. + DefaultMFAChain []string `json:"defaultMFAChain"` } // Validate the configuration @@ -64,10 +99,16 @@ func (c Config) Validate() error { {c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS address to listen on"}, {c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"}, {c.Web.HTTPS != "" && c.Web.TLSKey == "", "no private key specified for HTTPS"}, + {c.Web.TLSMinVersion != "" && c.Web.TLSMinVersion != "1.2" && c.Web.TLSMinVersion != "1.3", "supported TLS versions are: 1.2, 1.3"}, + {c.Web.TLSMaxVersion != "" && c.Web.TLSMaxVersion != "1.2" && c.Web.TLSMaxVersion != "1.3", "supported TLS versions are: 1.2, 1.3"}, + {c.Web.TLSMaxVersion != "" && c.Web.TLSMinVersion != "" && c.Web.TLSMinVersion > c.Web.TLSMaxVersion, "TLSMinVersion greater than TLSMaxVersion"}, {c.GRPC.TLSCert != "" && c.GRPC.Addr == "", "no address specified for gRPC"}, {c.GRPC.TLSKey != "" && c.GRPC.Addr == "", "no address specified for gRPC"}, {(c.GRPC.TLSCert == "") != (c.GRPC.TLSKey == ""), "must specific both a gRPC TLS cert and key"}, {c.GRPC.TLSCert == "" && c.GRPC.TLSClientCA != "", "cannot specify gRPC TLS client CA without a gRPC TLS cert"}, + {c.GRPC.TLSMinVersion != "" && c.GRPC.TLSMinVersion != "1.2" && c.GRPC.TLSMinVersion != "1.3", "supported TLS versions are: 1.2, 1.3"}, + {c.GRPC.TLSMaxVersion != "" && c.GRPC.TLSMaxVersion != "1.2" && c.GRPC.TLSMaxVersion != "1.3", "supported TLS versions are: 1.2, 1.3"}, + {c.GRPC.TLSMaxVersion != "" && c.GRPC.TLSMinVersion != "" && c.GRPC.TLSMinVersion > c.GRPC.TLSMaxVersion, "TLSMinVersion greater than TLSMaxVersion"}, } var checkErrors []string @@ -77,9 +118,69 @@ func (c Config) Validate() error { checkErrors = append(checkErrors, check.errMsg) } } + if len(checkErrors) != 0 { return fmt.Errorf("invalid Config:\n\t-\t%s", strings.Join(checkErrors, "\n\t-\t")) } + + if c.Sessions != nil && !featureflags.SessionsEnabled.Enabled() { + return fmt.Errorf("sessions config requires sessions to be enabled (DEX_SESSIONS_ENABLED=true)") + } + + if err := c.validateMFA(); err != nil { + return err + } + + for _, client := range c.StaticClients { + if err := storage.ValidateRefreshTokenLifetime(client.RefreshTokenLifetime); err != nil { + return fmt.Errorf("staticClients: client %q: %w", client.ID, err) + } + } + + return nil +} + +func (c Config) validateMFA() error { + mfa := c.MFA + if len(mfa.Authenticators) == 0 && len(mfa.DefaultMFAChain) == 0 { + return nil + } + + if !featureflags.SessionsEnabled.Enabled() { + return fmt.Errorf("mfa requires sessions to be enabled (DEX_SESSIONS_ENABLED=true)") + } + + knownTypes := map[string]bool{"TOTP": true, "WebAuthn": true} + ids := make(map[string]bool, len(mfa.Authenticators)) + + for _, auth := range mfa.Authenticators { + if auth.ID == "" { + return fmt.Errorf("mfa.authenticators: authenticator must have an id") + } + if ids[auth.ID] { + return fmt.Errorf("mfa.authenticators: duplicate authenticator id %q", auth.ID) + } + ids[auth.ID] = true + + if !knownTypes[auth.Type] { + return fmt.Errorf("mfa.authenticators: unknown type %q for authenticator %q", auth.Type, auth.ID) + } + } + + for _, authID := range mfa.DefaultMFAChain { + if !ids[authID] { + return fmt.Errorf("mfa.defaultMFAChain: references unknown authenticator %q", authID) + } + } + + for _, client := range c.StaticClients { + for _, authID := range client.MFAChain { + if !ids[authID] { + return fmt.Errorf("staticClients: client %q references unknown MFA authenticator %q", client.ID, authID) + } + } + } + return nil } @@ -87,19 +188,27 @@ type password storage.Password func (p *password) UnmarshalJSON(b []byte) error { var data struct { - Email string `json:"email"` - Username string `json:"username"` - UserID string `json:"userID"` - Hash string `json:"hash"` - HashFromEnv string `json:"hashFromEnv"` + Email string `json:"email"` + Username string `json:"username"` + Name string `json:"name"` + PreferredUsername string `json:"preferredUsername"` + EmailVerified *bool `json:"emailVerified"` + UserID string `json:"userID"` + Hash string `json:"hash"` + HashFromEnv string `json:"hashFromEnv"` + Groups []string `json:"groups"` } - if err := json.Unmarshal(b, &data); err != nil { + if err := configUnmarshaller(b, &data); err != nil { return err } *p = password(storage.Password{ - Email: data.Email, - Username: data.Username, - UserID: data.UserID, + Email: data.Email, + Username: data.Username, + Name: data.Name, + PreferredUsername: data.PreferredUsername, + EmailVerified: data.EmailVerified, + UserID: data.UserID, + Groups: data.Groups, }) if len(data.Hash) == 0 && len(data.HashFromEnv) > 0 { data.Hash = os.Getenv(data.HashFromEnv) @@ -129,6 +238,10 @@ func (p *password) UnmarshalJSON(b []byte) error { // OAuth2 describes enabled OAuth2 extensions. type OAuth2 struct { + // list of allowed grant types, + // defaults to all supported types + GrantTypes []string `json:"grantTypes"` + ResponseTypes []string `json:"responseTypes"` // If specified, do not prompt the user to approve client authorization. The // act of logging in implies authorization. @@ -137,15 +250,98 @@ type OAuth2 struct { AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"` // This is the connector that can be used for password grant PasswordConnector string `json:"passwordConnector"` + // PKCE configuration + PKCE PKCE `json:"pkce"` +} + +// PKCE holds the PKCE (Proof Key for Code Exchange) configuration. +type PKCE struct { + // If true, PKCE is required for all authorization code flows. + Enforce bool `json:"enforce"` + // Supported code challenge methods. Defaults to ["S256", "plain"]. + CodeChallengeMethodsSupported []string `json:"codeChallengeMethodsSupported"` } // Web is the config format for the HTTP server. type Web struct { - HTTP string `json:"http"` - HTTPS string `json:"https"` - TLSCert string `json:"tlsCert"` - TLSKey string `json:"tlsKey"` - AllowedOrigins []string `json:"allowedOrigins"` + HTTP string `json:"http"` + HTTPS string `json:"https"` + Headers Headers `json:"headers"` + TLSCert string `json:"tlsCert"` + TLSKey string `json:"tlsKey"` + TLSMinVersion string `json:"tlsMinVersion"` + TLSMaxVersion string `json:"tlsMaxVersion"` + AllowedOrigins []string `json:"allowedOrigins"` + AllowedHeaders []string `json:"allowedHeaders"` + ClientRemoteIP ClientRemoteIP `json:"clientRemoteIP"` +} + +type ClientRemoteIP struct { + Header string `json:"header"` + TrustedProxies []string `json:"trustedProxies"` +} + +func (cr *ClientRemoteIP) ParseTrustedProxies() ([]netip.Prefix, error) { + if cr == nil { + return nil, nil + } + + trusted := make([]netip.Prefix, 0, len(cr.TrustedProxies)) + for _, cidr := range cr.TrustedProxies { + ipNet, err := netip.ParsePrefix(cidr) + if err != nil { + return nil, fmt.Errorf("failed to parse CIDR %q: %v", cidr, err) + } + trusted = append(trusted, ipNet) + } + + return trusted, nil +} + +type Headers struct { + // Set the Content-Security-Policy header to HTTP responses. + // Unset if blank. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + ContentSecurityPolicy string `json:"Content-Security-Policy"` + // Set the X-Frame-Options header to HTTP responses. + // Unset if blank. Accepted values are deny and sameorigin. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options + XFrameOptions string `json:"X-Frame-Options"` + // Set the X-Content-Type-Options header to HTTP responses. + // Unset if blank. Accepted value is nosniff. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + XContentTypeOptions string `json:"X-Content-Type-Options"` + // Set the X-XSS-Protection header to all responses. + // Unset if blank. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection + XXSSProtection string `json:"X-XSS-Protection"` + // Set the Strict-Transport-Security header to HTTP responses. + // Unset if blank. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security + StrictTransportSecurity string `json:"Strict-Transport-Security"` +} + +func (h *Headers) ToHTTPHeader() http.Header { + if h == nil { + return make(map[string][]string) + } + header := make(map[string][]string) + if h.ContentSecurityPolicy != "" { + header["Content-Security-Policy"] = []string{h.ContentSecurityPolicy} + } + if h.XFrameOptions != "" { + header["X-Frame-Options"] = []string{h.XFrameOptions} + } + if h.XContentTypeOptions != "" { + header["X-Content-Type-Options"] = []string{h.XContentTypeOptions} + } + if h.XXSSProtection != "" { + header["X-XSS-Protection"] = []string{h.XXSSProtection} + } + if h.StrictTransportSecurity != "" { + header["Strict-Transport-Security"] = []string{h.StrictTransportSecurity} + } + return header } // Telemetry is the config format for telemetry including the HTTP server config. @@ -158,11 +354,13 @@ type Telemetry struct { // GRPC is the config for the gRPC API. type GRPC struct { // The port to listen on. - Addr string `json:"addr"` - TLSCert string `json:"tlsCert"` - TLSKey string `json:"tlsKey"` - TLSClientCA string `json:"tlsClientCA"` - Reflection bool `json:"reflection"` + Addr string `json:"addr"` + TLSCert string `json:"tlsCert"` + TLSKey string `json:"tlsKey"` + TLSClientCA string `json:"tlsClientCA"` + TLSMinVersion string `json:"tlsMinVersion"` + TLSMaxVersion string `json:"tlsMaxVersion"` + Reflection bool `json:"reflection"` } // Storage holds app's storage configuration. @@ -173,7 +371,7 @@ type Storage struct { // StorageConfig is a configuration that can create a storage. type StorageConfig interface { - Open(logger log.Logger) (storage.Storage, error) + Open(logger *slog.Logger) (storage.Storage, error) } var ( @@ -188,13 +386,32 @@ var ( _ StorageConfig = (*ent.MySQL)(nil) ) -func getORMBasedSQLStorage(normal, entBased StorageConfig) func() StorageConfig { +func getORMBasedSQLStorage(normal, entBased func() StorageConfig) func() StorageConfig { return func() StorageConfig { - switch os.Getenv("DEX_ENT_ENABLED") { - case "true", "yes": - return entBased - default: - return normal + if featureflags.EntEnabled.Enabled() { + return entBased() + } + return normal() + } +} + +// Recursively expand environment variables in the map to avoid +// issues with JSON special characters and escapes +func expandEnvInMap(m map[string]interface{}) { + for k, v := range m { + switch vt := v.(type) { + case string: + m[k] = os.ExpandEnv(vt) + case map[string]interface{}: + expandEnvInMap(vt) + case []interface{}: + for i, item := range vt { + if itemMap, ok := item.(map[string]interface{}); ok { + expandEnvInMap(itemMap) + } else if itemString, ok := item.(string); ok { + vt[i] = os.ExpandEnv(itemString) + } + } } } } @@ -203,22 +420,9 @@ var storages = map[string]func() StorageConfig{ "etcd": func() StorageConfig { return new(etcd.Etcd) }, "kubernetes": func() StorageConfig { return new(kubernetes.Config) }, "memory": func() StorageConfig { return new(memory.Config) }, - "sqlite3": getORMBasedSQLStorage(&sql.SQLite3{}, &ent.SQLite3{}), - "postgres": getORMBasedSQLStorage(&sql.Postgres{}, &ent.Postgres{}), - "mysql": getORMBasedSQLStorage(&sql.MySQL{}, &ent.MySQL{}), -} - -// isExpandEnvEnabled returns if os.ExpandEnv should be used for each storage and connector config. -// Disabling this feature avoids surprises e.g. if the LDAP bind password contains a dollar character. -// Returns false if the env variable "DEX_EXPAND_ENV" is a falsy string, e.g. "false". -// Returns true if the env variable is unset or a truthy string, e.g. "true", or can't be parsed as bool. -func isExpandEnvEnabled() bool { - enabled, err := strconv.ParseBool(os.Getenv("DEX_EXPAND_ENV")) - if err != nil { - // Unset, empty string or can't be parsed as bool: Default = true. - return true - } - return enabled + "sqlite3": getORMBasedSQLStorage(func() StorageConfig { return new(sql.SQLite3) }, func() StorageConfig { return new(ent.SQLite3) }), + "postgres": getORMBasedSQLStorage(func() StorageConfig { return new(sql.Postgres) }, func() StorageConfig { return new(ent.Postgres) }), + "mysql": getORMBasedSQLStorage(func() StorageConfig { return new(sql.MySQL) }, func() StorageConfig { return new(ent.MySQL) }), } // UnmarshalJSON allows Storage to implement the unmarshaler interface to @@ -228,7 +432,7 @@ func (s *Storage) UnmarshalJSON(b []byte) error { Type string `json:"type"` Config json.RawMessage `json:"config"` } - if err := json.Unmarshal(b, &store); err != nil { + if err := configUnmarshaller(b, &store); err != nil { return fmt.Errorf("parse storage: %v", err) } f, ok := storages[store.Type] @@ -239,11 +443,26 @@ func (s *Storage) UnmarshalJSON(b []byte) error { storageConfig := f() if len(store.Config) != 0 { data := []byte(store.Config) - if isExpandEnvEnabled() { - // Caution, we're expanding in the raw JSON/YAML source. This may not be what the admin expects. - data = []byte(os.ExpandEnv(string(store.Config))) + if featureflags.ExpandEnv.Enabled() { + var rawMap map[string]interface{} + if err := configUnmarshaller(store.Config, &rawMap); err != nil { + return fmt.Errorf("unmarshal config for env expansion: %v", err) + } + + // Recursively expand environment variables in the map to avoid + // issues with JSON special characters and escapes + expandEnvInMap(rawMap) + + // Marshal the expanded map back to JSON + expandedData, err := json.Marshal(rawMap) + if err != nil { + return fmt.Errorf("marshal expanded config: %v", err) + } + + data = expandedData } - if err := json.Unmarshal(data, storageConfig); err != nil { + + if err := configUnmarshaller(data, storageConfig); err != nil { return fmt.Errorf("parse storage config: %v", err) } } @@ -254,6 +473,90 @@ func (s *Storage) UnmarshalJSON(b []byte) error { return nil } +// Signer holds app's signer configuration. +type Signer struct { + Type string `json:"type"` + Config SignerConfig `json:"config"` +} + +// SignerConfig is a configuration that can create a signer. +type SignerConfig interface{} + +var ( + _ SignerConfig = (*signer.LocalConfig)(nil) + _ SignerConfig = (*signer.VaultConfig)(nil) +) + +var signerConfigs = map[string]func() SignerConfig{ + "local": func() SignerConfig { return new(signer.LocalConfig) }, + "vault": func() SignerConfig { return new(signer.VaultConfig) }, +} + +// UnmarshalJSON allows Signer to implement the unmarshaler interface to +// dynamically determine the type of the signer config. +func (s *Signer) UnmarshalJSON(b []byte) error { + var signerData struct { + Type string `json:"type"` + Config json.RawMessage `json:"config"` + } + if err := json.Unmarshal(b, &signerData); err != nil { + return fmt.Errorf("parse signer: %v", err) + } + + f, ok := signerConfigs[signerData.Type] + if !ok { + return fmt.Errorf("unknown signer type %q", signerData.Type) + } + + signerConfig := f() + if len(signerData.Config) != 0 { + data := []byte(signerData.Config) + if featureflags.ExpandEnv.Enabled() { + var rawMap map[string]interface{} + if err := json.Unmarshal(signerData.Config, &rawMap); err != nil { + return fmt.Errorf("unmarshal config for env expansion: %v", err) + } + + // Recursively expand environment variables in the map + expandEnvInMap(rawMap) + + // Marshal the expanded map back to JSON + expandedData, err := json.Marshal(rawMap) + if err != nil { + return fmt.Errorf("marshal expanded config: %v", err) + } + + data = expandedData + } + + if err := json.Unmarshal(data, signerConfig); err != nil { + return fmt.Errorf("parse signer config: %v", err) + } + } + if localConfig, ok := signerConfig.(*signer.LocalConfig); ok { + if err := normalizeLocalSignerConfig(localConfig); err != nil { + return fmt.Errorf("parse signer config: %v", err) + } + } + + *s = Signer{ + Type: signerData.Type, + Config: signerConfig, + } + return nil +} + +func normalizeLocalSignerConfig(c *signer.LocalConfig) error { + if c.Algorithm == "" { + c.Algorithm = jose.RS256 + return nil + } + if c.Algorithm == jose.RS256 || c.Algorithm == jose.ES256 { + return nil + } + return fmt.Errorf("unsupported local signer algorithm %q", c.Algorithm) +} + // Connector is a magical type that can unmarshal YAML dynamically. The // Type field determines the connector type, which is then customized for Config. type Connector struct { @@ -261,7 +564,8 @@ type Connector struct { Name string `json:"name"` ID string `json:"id"` - Config server.ConnectorConfig `json:"config"` + Config connectors.ConnectorConfig `json:"config"` + GrantTypes []string `json:"grantTypes"` } // UnmarshalJSON allows Connector to implement the unmarshaler interface to @@ -272,9 +576,10 @@ func (c *Connector) UnmarshalJSON(b []byte) error { Name string `json:"name"` ID string `json:"id"` - Config json.RawMessage `json:"config"` + Config json.RawMessage `json:"config"` + GrantTypes []string `json:"grantTypes"` } - if err := json.Unmarshal(b, &conn); err != nil { + if err := configUnmarshaller(b, &conn); err != nil { return fmt.Errorf("parse connector: %v", err) } f, ok := server.ConnectorsConfig[conn.Type] @@ -285,19 +590,36 @@ func (c *Connector) UnmarshalJSON(b []byte) error { connConfig := f() if len(conn.Config) != 0 { data := []byte(conn.Config) - if isExpandEnvEnabled() { - // Caution, we're expanding in the raw JSON/YAML source. This may not be what the admin expects. - data = []byte(os.ExpandEnv(string(conn.Config))) + if featureflags.ExpandEnv.Enabled() { + var rawMap map[string]interface{} + if err := configUnmarshaller(conn.Config, &rawMap); err != nil { + return fmt.Errorf("unmarshal config for env expansion: %v", err) + } + + // Recursively expand environment variables in the map to avoid + // issues with JSON special characters and escapes + expandEnvInMap(rawMap) + + // Marshal the expanded map back to JSON + expandedData, err := json.Marshal(rawMap) + if err != nil { + return fmt.Errorf("marshal expanded config: %v", err) + } + + data = expandedData } - if err := json.Unmarshal(data, connConfig); err != nil { + + if err := configUnmarshaller(data, connConfig); err != nil { return fmt.Errorf("parse connector config: %v", err) } } + *c = Connector{ - Type: conn.Type, - Name: conn.Name, - ID: conn.ID, - Config: connConfig, + Type: conn.Type, + Name: conn.Name, + ID: conn.ID, + Config: connConfig, + GrantTypes: conn.GrantTypes, } return nil } @@ -310,10 +632,11 @@ func ToStorageConnector(c Connector) (storage.Connector, error) { } return storage.Connector{ - ID: c.ID, - Type: c.Type, - Name: c.Name, - Config: data, + ID: c.ID, + Type: c.Type, + Name: c.Name, + Config: data, + GrantTypes: c.GrantTypes, }, nil } @@ -338,10 +661,16 @@ type Expiry struct { // Logger holds configuration required to customize logging for dex. type Logger struct { // Level sets logging level severity. - Level string `json:"level"` + Level slog.Level `json:"level"` // Format specifies the format to be used for logging. Format string `json:"format"` + + // ExcludeFields specifies log attribute keys that should be dropped from all + // log output. This is useful for suppressing PII fields like email, username, + // preferred_username, or groups in environments subject to GDPR or similar + // data-handling constraints. + ExcludeFields []string `json:"excludeFields"` } type RefreshToken struct { @@ -350,3 +679,72 @@ type RefreshToken struct { AbsoluteLifetime string `json:"absoluteLifetime"` ValidIfNotUsedFor string `json:"validIfNotUsedFor"` } + +// Sessions holds authentication session configuration. +type Sessions struct { + // CookieName is the name of the session cookie. Defaults to "dex_session". + CookieName string `json:"cookieName"` + // AbsoluteLifetime is the maximum session lifetime from creation. Defaults to "24h". + AbsoluteLifetime string `json:"absoluteLifetime"` + // ValidIfNotUsedFor is the idle timeout. Defaults to "1h". + ValidIfNotUsedFor string `json:"validIfNotUsedFor"` + // RememberMeCheckedByDefault controls the default state of the "remember me" checkbox. + RememberMeCheckedByDefault *bool `json:"rememberMeCheckedByDefault"` + // CookieEncryptionKey is the AES key for encrypting session cookies. + // Must be 16, 24, or 32 bytes for AES-128, AES-192, or AES-256. + // If empty, cookies are not encrypted. + CookieEncryptionKey string `json:"cookieEncryptionKey"` + // SSOSharedWithDefault is the default SSO sharing policy for clients without explicit ssoSharedWith. + // "all" = share with all clients, "none" = share with no one (default: "none"). + SSOSharedWithDefault string `json:"ssoSharedWithDefault"` +} + +// MFAAuthenticator defines a multi-factor authentication provider. +type MFAAuthenticator struct { + ID string `json:"id"` + Type string `json:"type"` + Config json.RawMessage `json:"config"` + + // ConnectorTypes limits this authenticator to specific connector types (e.g., "ldap", "oidc", "saml"). + // If empty, the authenticator applies to all connector types. + ConnectorTypes []string `json:"connectorTypes"` +} + +// TOTPConfig holds configuration for a TOTP authenticator. +type TOTPConfig struct { + // Issuer is the name of the service shown in the authenticator app. + Issuer string `json:"issuer"` +} + +// WebAuthnConfig holds configuration for a WebAuthn authenticator. +type WebAuthnConfig struct { + // RPDisplayName is the human-readable relying party name shown in the browser + // dialog during key registration and authentication (e.g., "My Company SSO"). + RPDisplayName string `json:"rpDisplayName"` + // RPID is the relying party identifier โ€” must match the domain in the browser + // address bar. If empty, derived from the issuer URL hostname. + // Example: "auth.example.com" + RPID string `json:"rpID"` + // RPOrigins is the list of allowed origins for WebAuthn ceremonies. + // If empty, derived from the issuer URL (scheme + host). + // Example: ["https://auth.example.com"] + RPOrigins []string `json:"rpOrigins"` + // AttestationPreference controls what attestation data the authenticator should provide: + // "none" โ€” don't request attestation (simpler, more private) + // "indirect" โ€” authenticator may anonymize attestation (default) + // "direct" โ€” request full attestation (for enterprise key model verification) + AttestationPreference string `json:"attestationPreference"` + // UserVerification controls whether PIN or biometric verification is required: + // "required" โ€” always require (PIN, fingerprint, etc.) + // "preferred" โ€” request if the authenticator supports it (default) + // "discouraged" โ€” skip verification, presence check only + UserVerification string `json:"userVerification"` + // AuthenticatorAttachment restricts which authenticator types are allowed: + // "platform" โ€” built-in only (Touch ID, Windows Hello) + // "cross-platform" โ€” external only (YubiKey, USB security keys) + // "" โ€” any authenticator (default) + AuthenticatorAttachment string `json:"authenticatorAttachment"` + // Timeout is the duration allowed for the browser WebAuthn ceremony + // (registration or login). Defaults to "60s". + Timeout string `json:"timeout"` +} diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go index 8ee02d5aa2..27f5d6ff44 100644 --- a/cmd/dex/config_test.go +++ b/cmd/dex/config_test.go @@ -1,21 +1,31 @@ package main import ( + "encoding/json" + "log/slog" "os" + "strings" "testing" "github.com/ghodss/yaml" + "github.com/go-jose/go-jose/v4" "github.com/kylelemons/godebug/pretty" + "github.com/stretchr/testify/require" "github.com/dexidp/dex/connector/mock" "github.com/dexidp/dex/connector/oidc" "github.com/dexidp/dex/server" + "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/sql" ) var _ = yaml.YAMLToJSON +func boolPtr(v bool) *bool { + return &v +} + func TestValidConfiguration(t *testing.T) { configuration := Config{ Issuer: "http://127.0.0.1:5556/dex", @@ -37,6 +47,7 @@ func TestValidConfiguration(t *testing.T) { }, }, } + if err := configuration.Validate(); err != nil { t.Fatalf("this configuration should have been valid: %v", err) } @@ -58,6 +69,23 @@ func TestInvalidConfiguration(t *testing.T) { } } +// TestInvalidRefreshTokenLifetime: a misspelled lifetime must not read as the +// default, leaving tokens the client wanted bound outliving the session. +func TestInvalidRefreshTokenLifetime(t *testing.T) { + configuration := Config{ + Issuer: "http://127.0.0.1:5556/dex", + Storage: Storage{Type: "sqlite3", Config: &sql.SQLite3{File: "examples/dex.db"}}, + Web: Web{HTTP: "127.0.0.1:5556"}, + StaticClients: []storage.Client{ + {ID: "proxy", RefreshTokenLifetime: "sessions"}, + }, + } + + err := configuration.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), `client "proxy"`) +} + func TestUnmarshalConfig(t *testing.T) { rawConfig := []byte(` issuer: http://127.0.0.1:5556/dex @@ -71,7 +99,11 @@ storage: connMaxLifetime: 30 connectionTimeout: 3 web: - http: 127.0.0.1:5556 + https: 127.0.0.1:5556 + tlsMinVersion: 1.3 + tlsMaxVersion: 1.2 + headers: + Strict-Transport-Security: "max-age=31536000; includeSubDomains" frontend: dir: ./web @@ -87,11 +119,17 @@ staticClients: oauth2: alwaysShowLoginScreen: true + grantTypes: + - refresh_token + - "urn:ietf:params:oauth:grant-type:token-exchange" connectors: - type: mockCallback id: mock name: Example + grantTypes: + - authorization_code + - "urn:ietf:params:oauth:grant-type:token-exchange" - type: oidc id: google name: Google @@ -107,6 +145,12 @@ staticPasswords: # bcrypt hash of the string "password" hash: "$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy" username: "admin" + name: "Admin User" + emailVerified: false + preferredUsername: "admin-public" + groups: + - "team-a" + - "team-a/admins" userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" - email: "foo@example.com" # base64'd value of the same bcrypt hash above. We want to be able to parse both of these @@ -123,6 +167,10 @@ expiry: logger: level: "debug" format: "json" + +additionalFeatures: [ + "ConnectorsCRUD" +] `) want := Config{ @@ -141,7 +189,12 @@ logger: }, }, Web: Web{ - HTTP: "127.0.0.1:5556", + HTTPS: "127.0.0.1:5556", + TLSMinVersion: "1.3", + TLSMaxVersion: "1.2", + Headers: Headers{ + StrictTransportSecurity: "max-age=31536000; includeSubDomains", + }, }, Frontend: server.WebConfig{ Dir: "./web", @@ -161,6 +214,10 @@ logger: }, OAuth2: OAuth2{ AlwaysShowLoginScreen: true, + GrantTypes: []string{ + "refresh_token", + "urn:ietf:params:oauth:grant-type:token-exchange", + }, }, StaticConnectors: []Connector{ { @@ -168,6 +225,10 @@ logger: ID: "mock", Name: "Example", Config: &mock.CallbackConfig{}, + GrantTypes: []string{ + "authorization_code", + "urn:ietf:params:oauth:grant-type:token-exchange", + }, }, { Type: "oidc", @@ -184,10 +245,17 @@ logger: EnablePasswordDB: true, StaticPasswords: []password{ { - Email: "admin@example.com", - Hash: []byte("$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy"), - Username: "admin", - UserID: "08a8684b-db88-4b73-90a9-3cd1661f5466", + Email: "admin@example.com", + Hash: []byte("$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy"), + Username: "admin", + Name: "Admin User", + EmailVerified: boolPtr(false), + PreferredUsername: "admin-public", + UserID: "08a8684b-db88-4b73-90a9-3cd1661f5466", + Groups: []string{ + "team-a", + "team-a/admins", + }, }, { Email: "foo@example.com", @@ -203,7 +271,7 @@ logger: DeviceRequests: "10m", }, Logger: Logger{ - Level: "debug", + Level: slog.LevelDebug, Format: "json", }, } @@ -212,6 +280,7 @@ logger: if err := yaml.Unmarshal(rawConfig, &c); err != nil { t.Fatalf("failed to decode config: %v", err) } + if diff := pretty.Compare(c, want); diff != "" { t.Errorf("got!=want: %s", diff) } @@ -250,7 +319,8 @@ func checkUnmarshalConfigWithEnv(t *testing.T, dexExpandEnv string, wantExpandEn os.Setenv("DEX_FOO_USER_PASSWORD", "$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy") // For os.ExpandEnv ($VAR -> value_of_VAR): os.Setenv("DEX_FOO_POSTGRES_HOST", "10.0.0.1") - os.Setenv("DEX_FOO_OIDC_CLIENT_SECRET", "bar") + os.Setenv("DEX_FOO_POSTGRES_PASSWORD", `psql"test\pass`) + os.Setenv("DEX_FOO_OIDC_CLIENT_SECRET", `abc"def\ghi`) if dexExpandEnv != "UNSET" { os.Setenv("DEX_EXPAND_ENV", dexExpandEnv) } else { @@ -265,6 +335,7 @@ storage: # Env variables are expanded in raw YAML source. # Single quotes work fine, as long as the env variable doesn't contain any. host: '$DEX_FOO_POSTGRES_HOST' + password: '$DEX_FOO_POSTGRES_PASSWORD' port: 65432 maxOpenConns: 5 maxIdleConns: 3 @@ -327,10 +398,12 @@ logger: // This is not a valid hostname. It's only used to check whether os.ExpandEnv was applied or not. wantPostgresHost := "$DEX_FOO_POSTGRES_HOST" + wantPostgresPassword := "$DEX_FOO_POSTGRES_PASSWORD" wantOidcClientSecret := "$DEX_FOO_OIDC_CLIENT_SECRET" if wantExpandEnv { wantPostgresHost = "10.0.0.1" - wantOidcClientSecret = "bar" + wantPostgresPassword = `psql"test\pass` + wantOidcClientSecret = `abc"def\ghi` } want := Config{ @@ -340,6 +413,7 @@ logger: Config: &sql.Postgres{ NetworkDB: sql.NetworkDB{ Host: wantPostgresHost, + Password: wantPostgresPassword, Port: 65432, MaxOpenConns: 5, MaxIdleConns: 3, @@ -410,7 +484,7 @@ logger: AuthRequests: "25h", }, Logger: Logger{ - Level: "debug", + Level: slog.LevelDebug, Format: "json", }, } @@ -419,7 +493,203 @@ logger: if err := yaml.Unmarshal(rawConfig, &c); err != nil { t.Fatalf("failed to decode config: %v", err) } + if diff := pretty.Compare(c, want); diff != "" { t.Errorf("got!=want: %s", diff) } } + +func TestSignerConfigUnmarshal(t *testing.T) { + tests := []struct { + name string + config string + wantErr bool + errContains string + check func(*Config) error + }{ + { + name: "local signer with rotation period", + config: ` +issuer: http://127.0.0.1:5556/dex +storage: + type: memory +web: + http: 0.0.0.0:5556 +signer: + type: local + config: + keysRotationPeriod: 6h +enablePasswordDB: true +`, + wantErr: false, + check: func(c *Config) error { + if c.Signer.Type != "local" { + t.Errorf("expected signer type 'local', got %q", c.Signer.Type) + } + if localConfig, ok := c.Signer.Config.(*signer.LocalConfig); !ok { + t.Error("expected LocalConfig") + } else { + if localConfig.KeysRotationPeriod != "6h" { + t.Errorf("expected keys rotation period '6h', got %q", localConfig.KeysRotationPeriod) + } + if localConfig.Algorithm != jose.RS256 { + t.Errorf("expected default algorithm 'RS256', got %q", localConfig.Algorithm) + } + } + return nil + }, + }, + { + name: "local signer with ES256 algorithm", + config: ` +issuer: http://127.0.0.1:5556/dex +storage: + type: memory +web: + http: 0.0.0.0:5556 +signer: + type: local + config: + keysRotationPeriod: 6h + algorithm: ES256 +enablePasswordDB: true +`, + wantErr: false, + check: func(c *Config) error { + localConfig, ok := c.Signer.Config.(*signer.LocalConfig) + if !ok { + t.Error("expected LocalConfig") + return nil + } + if localConfig.Algorithm != jose.ES256 { + t.Errorf("expected algorithm 'ES256', got %q", localConfig.Algorithm) + } + return nil + }, + }, + { + name: "local signer with invalid algorithm", + config: ` +issuer: http://127.0.0.1:5556/dex +storage: + type: memory +web: + http: 0.0.0.0:5556 +signer: + type: local + config: + keysRotationPeriod: 6h + algorithm: ES512 +enablePasswordDB: true +`, + wantErr: true, + errContains: `parse signer config: unsupported local signer algorithm "ES512"`, + }, + { + name: "local signer without config", + config: ` +issuer: http://127.0.0.1:5556/dex +storage: + type: memory +web: + http: 0.0.0.0:5556 +signer: + type: local +enablePasswordDB: true +`, + wantErr: false, + check: func(c *Config) error { + localConfig, ok := c.Signer.Config.(*signer.LocalConfig) + if !ok { + t.Error("expected LocalConfig") + return nil + } + if localConfig.Algorithm != jose.RS256 { + t.Errorf("expected default algorithm 'RS256', got %q", localConfig.Algorithm) + } + return nil + }, + }, + { + name: "vault signer", + config: ` +issuer: http://127.0.0.1:5556/dex +storage: + type: memory +web: + http: 0.0.0.0:5556 +signer: + type: vault + config: + addr: http://localhost:8200 + token: test-token + keyName: test-key +enablePasswordDB: true +`, + wantErr: false, + check: func(c *Config) error { + if c.Signer.Type != "vault" { + t.Errorf("expected signer type 'vault', got %q", c.Signer.Type) + } + if vaultConfig, ok := c.Signer.Config.(*signer.VaultConfig); !ok { + t.Error("expected VaultConfig") + } else { + if vaultConfig.Addr != "http://localhost:8200" { + t.Errorf("expected addr 'http://localhost:8200', got %q", vaultConfig.Addr) + } + if vaultConfig.Token != "test-token" { + t.Errorf("expected token 'test-token', got %q", vaultConfig.Token) + } + if vaultConfig.KeyName != "test-key" { + t.Errorf("expected keyName 'test-key', got %q", vaultConfig.KeyName) + } + } + return nil + }, + }, + { + name: "default to local when no signer specified", + config: ` +issuer: http://127.0.0.1:5556/dex +storage: + type: memory +web: + http: 0.0.0.0:5556 +enablePasswordDB: true +`, + wantErr: false, + check: func(c *Config) error { + if c.Signer.Type != "" { + t.Errorf("expected signer type '', got %q", c.Signer.Type) + } + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c Config + data, err := yaml.YAMLToJSON([]byte(tt.config)) + if err != nil { + t.Fatalf("failed to convert yaml to json: %v", err) + } + + err = json.Unmarshal(data, &c) + if (err != nil) != tt.wantErr { + t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.errContains != "" && (err == nil || !strings.Contains(err.Error(), tt.errContains)) { + t.Errorf("Unmarshal() error = %v, want substring %q", err, tt.errContains) + return + } + + if err == nil && tt.check != nil { + if err := tt.check(&c); err != nil { + t.Errorf("check failed: %v", err) + } + } + }) + } +} diff --git a/cmd/dex/excluding_handler.go b/cmd/dex/excluding_handler.go new file mode 100644 index 0000000000..c5d03e44e5 --- /dev/null +++ b/cmd/dex/excluding_handler.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "log/slog" +) + +// excludingHandler is an slog.Handler wrapper that drops log attributes +// whose keys match a configured set. This allows PII fields like email, +// username, or groups to be redacted at the logger level rather than +// requiring per-callsite suppression logic. +type excludingHandler struct { + inner slog.Handler + exclude map[string]bool +} + +func newExcludingHandler(inner slog.Handler, fields []string) slog.Handler { + if len(fields) == 0 { + return inner + } + m := make(map[string]bool, len(fields)) + for _, f := range fields { + m[f] = true + } + return &excludingHandler{inner: inner, exclude: m} +} + +func (h *excludingHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.inner.Enabled(ctx, level) +} + +func (h *excludingHandler) Handle(ctx context.Context, record slog.Record) error { + // Rebuild the record without excluded attributes. + filtered := slog.NewRecord(record.Time, record.Level, record.Message, record.PC) + record.Attrs(func(a slog.Attr) bool { + if !h.exclude[a.Key] { + filtered.AddAttrs(a) + } + return true + }) + return h.inner.Handle(ctx, filtered) +} + +func (h *excludingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + var kept []slog.Attr + for _, a := range attrs { + if !h.exclude[a.Key] { + kept = append(kept, a) + } + } + return &excludingHandler{inner: h.inner.WithAttrs(kept), exclude: h.exclude} +} + +func (h *excludingHandler) WithGroup(name string) slog.Handler { + return &excludingHandler{inner: h.inner.WithGroup(name), exclude: h.exclude} +} diff --git a/cmd/dex/excluding_handler_test.go b/cmd/dex/excluding_handler_test.go new file mode 100644 index 0000000000..e0306d6034 --- /dev/null +++ b/cmd/dex/excluding_handler_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" +) + +func TestExcludingHandler(t *testing.T) { + tests := []struct { + name string + exclude []string + logAttrs []slog.Attr + wantKeys []string + absentKeys []string + }{ + { + name: "no exclusions", + exclude: nil, + logAttrs: []slog.Attr{ + slog.String("email", "user@example.com"), + slog.String("connector_id", "github"), + }, + wantKeys: []string{"email", "connector_id"}, + }, + { + name: "exclude email", + exclude: []string{"email"}, + logAttrs: []slog.Attr{ + slog.String("email", "user@example.com"), + slog.String("connector_id", "github"), + }, + wantKeys: []string{"connector_id"}, + absentKeys: []string{"email"}, + }, + { + name: "exclude multiple fields", + exclude: []string{"email", "username", "groups"}, + logAttrs: []slog.Attr{ + slog.String("email", "user@example.com"), + slog.String("username", "johndoe"), + slog.String("connector_id", "github"), + slog.Any("groups", []string{"admin"}), + }, + wantKeys: []string{"connector_id"}, + absentKeys: []string{"email", "username", "groups"}, + }, + { + name: "exclude non-existent field is harmless", + exclude: []string{"nonexistent"}, + logAttrs: []slog.Attr{ + slog.String("email", "user@example.com"), + }, + wantKeys: []string{"email"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + handler := newExcludingHandler(inner, tt.exclude) + logger := slog.New(handler) + + attrs := make([]any, 0, len(tt.logAttrs)*2) + for _, a := range tt.logAttrs { + attrs = append(attrs, a) + } + logger.Info("test message", attrs...) + + var result map[string]any + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("failed to parse log output: %v", err) + } + + for _, key := range tt.wantKeys { + if _, ok := result[key]; !ok { + t.Errorf("expected key %q in log output", key) + } + } + for _, key := range tt.absentKeys { + if _, ok := result[key]; ok { + t.Errorf("expected key %q to be absent from log output", key) + } + } + }) + } +} + +func TestExcludingHandlerWithAttrs(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + handler := newExcludingHandler(inner, []string{"email"}) + logger := slog.New(handler) + + // Pre-bind an excluded attr via With + child := logger.With("email", "user@example.com", "connector_id", "github") + child.Info("login successful") + + var result map[string]any + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("failed to parse log output: %v", err) + } + + if _, ok := result["email"]; ok { + t.Error("expected email to be excluded from WithAttrs output") + } + if _, ok := result["connector_id"]; !ok { + t.Error("expected connector_id to be present") + } +} + +func TestExcludingHandlerEnabled(t *testing.T) { + inner := slog.NewJSONHandler(&bytes.Buffer{}, &slog.HandlerOptions{Level: slog.LevelWarn}) + handler := newExcludingHandler(inner, []string{"email"}) + + if handler.Enabled(context.Background(), slog.LevelInfo) { + t.Error("expected Info to be disabled when handler level is Warn") + } + if !handler.Enabled(context.Background(), slog.LevelWarn) { + t.Error("expected Warn to be enabled") + } +} + +func TestExcludingHandlerNilFields(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + + // With nil/empty fields, should return the inner handler directly + handler := newExcludingHandler(inner, nil) + if _, ok := handler.(*excludingHandler); ok { + t.Error("expected nil fields to return inner handler directly, not wrap it") + } + + handler = newExcludingHandler(inner, []string{}) + if _, ok := handler.(*excludingHandler); ok { + t.Error("expected empty fields to return inner handler directly, not wrap it") + } +} diff --git a/cmd/dex/logger.go b/cmd/dex/logger.go new file mode 100644 index 0000000000..980bf55c23 --- /dev/null +++ b/cmd/dex/logger.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/dexidp/dex/server/reqctx" +) + +var logFormats = []string{"json", "text"} + +func newLogger(level slog.Level, format string, excludeFields []string) (*slog.Logger, error) { + var handler slog.Handler + switch strings.ToLower(format) { + case "", "text": + handler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + }) + case "json": + handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + }) + default: + return nil, fmt.Errorf("log format is not one of the supported values (%s): %s", strings.Join(logFormats, ", "), format) + } + + handler = newExcludingHandler(handler, excludeFields) + + return slog.New(newRequestContextHandler(handler)), nil +} + +var _ slog.Handler = requestContextHandler{} + +type requestContextHandler struct { + handler slog.Handler +} + +func newRequestContextHandler(handler slog.Handler) slog.Handler { + return requestContextHandler{ + handler: handler, + } +} + +func (h requestContextHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.handler.Enabled(ctx, level) +} + +func (h requestContextHandler) Handle(ctx context.Context, record slog.Record) error { + if v, ok := ctx.Value(reqctx.RequestKeyRemoteIP).(string); ok { + record.AddAttrs(slog.String(string(reqctx.RequestKeyRemoteIP), v)) + } + + if v, ok := ctx.Value(reqctx.RequestKeyRequestID).(string); ok { + record.AddAttrs(slog.String(string(reqctx.RequestKeyRequestID), v)) + } + + return h.handler.Handle(ctx, record) +} + +func (h requestContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return requestContextHandler{h.handler.WithAttrs(attrs)} +} + +func (h requestContextHandler) WithGroup(name string) slog.Handler { + return requestContextHandler{h.handler.WithGroup(name)} +} diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index c8fb95eb16..3cfa8980f7 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -4,35 +4,47 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/json" "errors" "fmt" + "log/slog" "net" "net/http" "net/http/pprof" "os" + "os/signal" + "path/filepath" "runtime" "strings" + "sync/atomic" "syscall" "time" gosundheit "github.com/AppsFlyer/go-sundheit" "github.com/AppsFlyer/go-sundheit/checks" gosundheithttp "github.com/AppsFlyer/go-sundheit/http" + "github.com/fsnotify/fsnotify" "github.com/ghodss/yaml" grpcprometheus "github.com/grpc-ecosystem/go-grpc-prometheus" "github.com/oklog/run" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/reflection" "github.com/dexidp/dex/api/v2" - "github.com/dexidp/dex/pkg/log" + "github.com/dexidp/dex/pkg/featureflags" "github.com/dexidp/dex/server" + "github.com/dexidp/dex/server/apiserver" + "github.com/dexidp/dex/server/authflow" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/mfa" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/server/tokens" "github.com/dexidp/dex/storage" ) @@ -47,6 +59,15 @@ type serveOptions struct { grpcAddr string } +var buildInfo = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "build_info", + Namespace: "dex", + Help: "A metric with a constant '1' value labeled by version from which Dex was built.", + }, + []string{"version", "go_version", "platform"}, +) + func commandServe() *cobra.Command { options := serveOptions{} @@ -83,35 +104,47 @@ func runServe(options serveOptions) error { } var c Config - if err := yaml.Unmarshal(configData, &c); err != nil { + + jsonConfigData, err := yaml.YAMLToJSON(configData) + if err != nil { return fmt.Errorf("error parse config file %s: %v", configFile, err) } + if err := configUnmarshaller(jsonConfigData, &c); err != nil { + return fmt.Errorf("error unmarshalling config file %s: %v", configFile, err) + } + applyConfigOverrides(options, &c) - logger, err := newLogger(c.Logger.Level, c.Logger.Format) + logger, err := newLogger(c.Logger.Level, c.Logger.Format, c.Logger.ExcludeFields) if err != nil { return fmt.Errorf("invalid config: %v", err) } - logger.Infof( - "Dex Version: %s, Go Version: %s, Go OS/ARCH: %s %s", - version, - runtime.Version(), - runtime.GOOS, - runtime.GOARCH, + logger.Info( + "Version info", + "dex_version", version, + slog.Group("go", + "version", runtime.Version(), + "os", runtime.GOOS, + "arch", runtime.GOARCH, + ), ) - if c.Logger.Level != "" { - logger.Infof("config using log level: %s", c.Logger.Level) + if c.Logger.Level != slog.LevelInfo { + logger.Info("config using log level", "level", c.Logger.Level) } if err := c.Validate(); err != nil { return err } - logger.Infof("config issuer: %s", c.Issuer) + logger.Info("config issuer", "issuer", c.Issuer) prometheusRegistry := prometheus.NewRegistry() + + prometheusRegistry.MustRegister(buildInfo) + recordBuildInfo() + err = prometheusRegistry.Register(collectors.NewGoCollector()) if err != nil { return fmt.Errorf("failed to register Go runtime metrics: %v", err) @@ -141,34 +174,33 @@ func runServe(options serveOptions) error { tls.TLS_RSA_WITH_AES_256_GCM_SHA384, } + allowedTLSVersions := map[string]int{ + "1.2": tls.VersionTLS12, + "1.3": tls.VersionTLS13, + } + if c.GRPC.TLSCert != "" { - // Parse certificates from certificate file and key file for server. - cert, err := tls.LoadX509KeyPair(c.GRPC.TLSCert, c.GRPC.TLSKey) - if err != nil { - return fmt.Errorf("invalid config: error parsing gRPC certificate file: %v", err) + tlsMinVersion := tls.VersionTLS12 + if c.GRPC.TLSMinVersion != "" { + tlsMinVersion = allowedTLSVersions[c.GRPC.TLSMinVersion] } - - tlsConfig := tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, + tlsMaxVersion := 0 // default for max is whatever Go defaults to + if c.GRPC.TLSMaxVersion != "" { + tlsMaxVersion = allowedTLSVersions[c.GRPC.TLSMaxVersion] + } + baseTLSConfig := &tls.Config{ + MinVersion: uint16(tlsMinVersion), + MaxVersion: uint16(tlsMaxVersion), CipherSuites: allowedTLSCiphers, PreferServerCipherSuites: true, } - if c.GRPC.TLSClientCA != "" { - // Parse certificates from client CA file to a new CertPool. - cPool := x509.NewCertPool() - clientCert, err := os.ReadFile(c.GRPC.TLSClientCA) - if err != nil { - return fmt.Errorf("invalid config: reading from client CA file: %v", err) - } - if !cPool.AppendCertsFromPEM(clientCert) { - return errors.New("invalid config: failed to parse client CA") - } - - tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert - tlsConfig.ClientCAs = cPool + tlsConfig, err := newTLSReloader(logger, c.GRPC.TLSCert, c.GRPC.TLSKey, c.GRPC.TLSClientCA, baseTLSConfig) + if err != nil { + return fmt.Errorf("invalid config: get gRPC TLS: %v", err) + } + if c.GRPC.TLSClientCA != "" { // Only add metrics if client auth is enabled grpcOptions = append(grpcOptions, grpc.StreamInterceptor(grpcMetrics.StreamServerInterceptor()), @@ -176,7 +208,7 @@ func runServe(options serveOptions) error { ) } - grpcOptions = append(grpcOptions, grpc.Creds(credentials.NewTLS(&tlsConfig))) + grpcOptions = append(grpcOptions, grpc.Creds(credentials.NewTLS(tlsConfig))) } s, err := c.Storage.Config.Open(logger) @@ -185,7 +217,7 @@ func runServe(options serveOptions) error { } defer s.Close() - logger.Infof("config storage: %s", c.Storage.Type) + logger.Info("config storage", "storage_type", c.Storage.Type) if len(c.StaticClients) > 0 { for i, client := range c.StaticClients { @@ -210,7 +242,7 @@ func runServe(options serveOptions) error { } c.StaticClients[i].Secret = os.Getenv(client.SecretEnv) } - logger.Infof("config static client: %s", client.Name) + logger.Info("config static client", "client_name", client.Name) } s = storage.WithStaticClients(s, c.StaticClients) } @@ -230,7 +262,12 @@ func runServe(options serveOptions) error { if c.Config == nil { return fmt.Errorf("invalid config: no config field for connector %q", c.ID) } - logger.Infof("config connector: %s", c.ID) + for _, gt := range c.GrantTypes { + if !connectors.ConnectorGrantTypes[gt] { + return fmt.Errorf("invalid config: unknown grant type %q for connector %q", gt, c.ID) + } + } + logger.Info("config connector", "connector_id", c.ID) // convert to a storage connector object conn, err := ToStorageConnector(c) @@ -242,26 +279,29 @@ func runServe(options serveOptions) error { if c.EnablePasswordDB { storageConnectors = append(storageConnectors, storage.Connector{ - ID: server.LocalConnector, + ID: connectors.LocalConnector, Name: "Email", - Type: server.LocalConnector, + Type: connectors.LocalConnector, }) - logger.Infof("config connector: local passwords enabled") + logger.Info("config connector: local passwords enabled") } s = storage.WithStaticConnectors(s, storageConnectors) if len(c.OAuth2.ResponseTypes) > 0 { - logger.Infof("config response types accepted: %s", c.OAuth2.ResponseTypes) + logger.Info("config response types accepted", "response_types", c.OAuth2.ResponseTypes) } if c.OAuth2.SkipApprovalScreen { - logger.Infof("config skipping approval screen") + logger.Info("config skipping approval screen") } if c.OAuth2.PasswordConnector != "" { - logger.Infof("config using password grant connector: %s", c.OAuth2.PasswordConnector) + logger.Info("config using password grant connector", "password_connector", c.OAuth2.PasswordConnector) } if len(c.Web.AllowedOrigins) > 0 { - logger.Infof("config allowed origins: %s", c.Web.AllowedOrigins) + logger.Info("config allowed origins", "origins", c.Web.AllowedOrigins) + } + if featureflags.ContinueOnConnectorFailure.Enabled() { + logger.Info("continue on connector failure feature flag enabled") } // explicitly convert to UTC. @@ -269,53 +309,109 @@ func runServe(options serveOptions) error { healthChecker := gosundheit.New() - serverConfig := server.Config{ - SupportedResponseTypes: c.OAuth2.ResponseTypes, - SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, - AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen, - PasswordConnector: c.OAuth2.PasswordConnector, - AllowedOrigins: c.Web.AllowedOrigins, - Issuer: c.Issuer, - Storage: s, - Web: c.Frontend, - Logger: logger, - Now: now, - PrometheusRegistry: prometheusRegistry, - HealthChecker: healthChecker, - } - if c.Expiry.SigningKeys != "" { - signingKeys, err := time.ParseDuration(c.Expiry.SigningKeys) + // Parse expiry durations + idTokensValidFor := 24 * time.Hour // default + if c.Expiry.IDTokens != "" { + var err error + idTokensValidFor, err = time.ParseDuration(c.Expiry.IDTokens) if err != nil { - return fmt.Errorf("invalid config value %q for signing keys expiry: %v", c.Expiry.SigningKeys, err) + return fmt.Errorf("invalid config value %q for id token expiry: %v", c.Expiry.IDTokens, err) } - logger.Infof("config signing keys expire after: %v", signingKeys) - serverConfig.RotateKeysAfter = signingKeys + logger.Info("config id tokens", "valid_for", idTokensValidFor) } - if c.Expiry.IDTokens != "" { - idTokens, err := time.ParseDuration(c.Expiry.IDTokens) + + // Create signer + var signerInstance signer.Signer + switch c.Signer.Type { + case "vault": + vaultConfig, ok := c.Signer.Config.(*signer.VaultConfig) + if !ok { + return fmt.Errorf("invalid vault signer config") + } + signerInstance, err = vaultConfig.Open(context.Background()) if err != nil { - return fmt.Errorf("invalid config value %q for id token expiry: %v", c.Expiry.IDTokens, err) + return fmt.Errorf("failed to open vault signer: %v", err) + } + logger.Info("signer configured", "type", "vault") + case "local": + localConfig, ok := c.Signer.Config.(*signer.LocalConfig) + if !ok { + return fmt.Errorf("invalid local signer config") + } + if localConfig.KeysRotationPeriod == "" { + return fmt.Errorf("failed to open local signer: signer.config.keysRotationPeriod must be specified") + } + if c.Expiry.SigningKeys != "" { + logger.Warn("both expiry.signingKeys and signer.config.keysRotationPeriod specified, using signer.config.keysRotationPeriod") } - logger.Infof("config id tokens valid for: %v", idTokens) - serverConfig.IDTokensValidFor = idTokens + signerInstance, err = localConfig.Open(context.Background(), s, idTokensValidFor, now, logger) + if err != nil { + return fmt.Errorf("failed to open local signer: %v", err) + } + logger.Info("signer configured", "type", "local", "keys_rotation_period", localConfig.KeysRotationPeriod) + case "": // Default to local signer + // Handle deprecated expiry.signingKeys configuration + if c.Expiry.SigningKeys != "" { + logger.Warn("config expiry.signingKeys will be removed in a future release", + "use_instead", "signer.config.keysRotationPeriod", + "current_value", c.Expiry.SigningKeys, "deprecated", true) + } else { + c.Expiry.SigningKeys = "6h" + } + localConfig := signer.LocalConfig{KeysRotationPeriod: c.Expiry.SigningKeys} + signerInstance, err = localConfig.Open(context.Background(), s, idTokensValidFor, now, logger) + if err != nil { + return fmt.Errorf("failed to open local signer: %v", err) + } + logger.Info("signer configured", "type", "local", "keys_rotation_period", localConfig.KeysRotationPeriod) + default: + return fmt.Errorf("unknown signer type %q", c.Signer.Type) } + + serverConfig := server.Config{ + AllowedGrantTypes: c.OAuth2.GrantTypes, + SupportedResponseTypes: c.OAuth2.ResponseTypes, + SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, + AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen, + PasswordConnector: c.OAuth2.PasswordConnector, + PKCE: authflow.PKCEConfig{ + Enforce: c.OAuth2.PKCE.Enforce, + CodeChallengeMethodsSupported: c.OAuth2.PKCE.CodeChallengeMethodsSupported, + }, + Headers: c.Web.Headers.ToHTTPHeader(), + AllowedOrigins: c.Web.AllowedOrigins, + AllowedHeaders: c.Web.AllowedHeaders, + Issuer: c.Issuer, + Storage: s, + Web: c.Frontend, + Logger: logger, + Now: now, + PrometheusRegistry: prometheusRegistry, + HealthChecker: healthChecker, + ContinueOnConnectorFailure: featureflags.ContinueOnConnectorFailure.Enabled(), + Signer: signerInstance, + IDTokensValidFor: idTokensValidFor, + MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger), + DefaultMFAChain: c.MFA.DefaultMFAChain, + } + if c.Expiry.AuthRequests != "" { authRequests, err := time.ParseDuration(c.Expiry.AuthRequests) if err != nil { return fmt.Errorf("invalid config value %q for auth request expiry: %v", c.Expiry.AuthRequests, err) } - logger.Infof("config auth requests valid for: %v", authRequests) + logger.Info("config auth requests", "valid_for", authRequests) serverConfig.AuthRequestsValidFor = authRequests } if c.Expiry.DeviceRequests != "" { deviceRequests, err := time.ParseDuration(c.Expiry.DeviceRequests) if err != nil { - return fmt.Errorf("invalid config value %q for device request expiry: %v", c.Expiry.AuthRequests, err) + return fmt.Errorf("invalid config value %q for device request expiry: %v", c.Expiry.DeviceRequests, err) } - logger.Infof("config device requests valid for: %v", deviceRequests) + logger.Info("config device requests", "valid_for", deviceRequests) serverConfig.DeviceRequestsValidFor = deviceRequests } - refreshTokenPolicy, err := server.NewRefreshTokenPolicy( + refreshTokenPolicy, err := tokens.NewRefreshTokenPolicy( logger, c.Expiry.RefreshTokens.DisableRotation, c.Expiry.RefreshTokens.ValidIfNotUsedFor, @@ -327,6 +423,31 @@ func runServe(options serveOptions) error { } serverConfig.RefreshTokenPolicy = refreshTokenPolicy + + if featureflags.SessionsEnabled.Enabled() { + sessionConfig, err := parseSessionConfig(c.Sessions) + if err != nil { + return fmt.Errorf("invalid session config: %v", err) + } + serverConfig.SessionConfig = sessionConfig + logger.Info("config sessions", + "cookie_name", sessionConfig.CookieName, + "absolute_lifetime", sessionConfig.AbsoluteLifetime, + "valid_if_not_used_for", sessionConfig.ValidIfNotUsedFor, + ) + } + + serverConfig.RealIPHeader = c.Web.ClientRemoteIP.Header + serverConfig.TrustedRealIPCIDRs, err = c.Web.ClientRemoteIP.ParseTrustedProxies() + if err != nil { + return fmt.Errorf("failed to parse client remote IP settings: %v", err) + } + if serverConfig.RealIPHeader != "" && len(serverConfig.TrustedRealIPCIDRs) == 0 { + logger.Warn("web.clientRemoteIP.header is set without web.clientRemoteIP.trustedProxies; "+ + "the header is ignored because any client could spoof it", + "header", serverConfig.RealIPHeader) + } + serv, err := server.NewServer(context.Background(), serverConfig) if err != nil { return fmt.Errorf("failed to initialize server: %v", err) @@ -362,7 +483,7 @@ func runServe(options serveOptions) error { if c.Telemetry.HTTP != "" { const name = "telemetry" - logger.Infof("listening (%s) on %s", name, c.Telemetry.HTTP) + logger.Info("listening on", "server", name, "address", c.Telemetry.HTTP) l, err := net.Listen("tcp", c.Telemetry.HTTP) if err != nil { @@ -384,9 +505,9 @@ func runServe(options serveOptions) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - logger.Debugf("starting graceful shutdown (%s)", name) + logger.Debug("starting graceful shutdown", "server", name) if err := server.Shutdown(ctx); err != nil { - logger.Errorf("graceful shutdown (%s): %v", name, err) + logger.Error("graceful shutdown", "server", name, "err", err) } }) } @@ -395,7 +516,7 @@ func runServe(options serveOptions) error { if c.Web.HTTP != "" { const name = "http" - logger.Infof("listening (%s) on %s", name, c.Web.HTTP) + logger.Info("listening on", "server", name, "address", c.Web.HTTP) l, err := net.Listen("tcp", c.Web.HTTP) if err != nil { @@ -413,9 +534,9 @@ func runServe(options serveOptions) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - logger.Debugf("starting graceful shutdown (%s)", name) + logger.Debug("starting graceful shutdown", "server", name) if err := server.Shutdown(ctx); err != nil { - logger.Errorf("graceful shutdown (%s): %v", name, err) + logger.Error("graceful shutdown", "server", name, "err", err) } }) } @@ -424,47 +545,76 @@ func runServe(options serveOptions) error { if c.Web.HTTPS != "" { const name = "https" - logger.Infof("listening (%s) on %s", name, c.Web.HTTPS) + logger.Info("listening on", "server", name, "address", c.Web.HTTPS) l, err := net.Listen("tcp", c.Web.HTTPS) if err != nil { return fmt.Errorf("listening (%s) on %s: %v", name, c.Web.HTTPS, err) } + tlsMinVersion := tls.VersionTLS12 + if c.Web.TLSMinVersion != "" { + tlsMinVersion = allowedTLSVersions[c.Web.TLSMinVersion] + } + tlsMaxVersion := 0 // default for max is whatever Go defaults to + if c.Web.TLSMaxVersion != "" { + tlsMaxVersion = allowedTLSVersions[c.Web.TLSMaxVersion] + } + + baseTLSConfig := &tls.Config{ + MinVersion: uint16(tlsMinVersion), + MaxVersion: uint16(tlsMaxVersion), + CipherSuites: allowedTLSCiphers, + PreferServerCipherSuites: true, + } + + tlsConfig, err := newTLSReloader(logger, c.Web.TLSCert, c.Web.TLSKey, "", baseTLSConfig) + if err != nil { + return fmt.Errorf("invalid config: get HTTP TLS: %v", err) + } + server := &http.Server{ - Handler: serv, - TLSConfig: &tls.Config{ - CipherSuites: allowedTLSCiphers, - PreferServerCipherSuites: true, - MinVersion: tls.VersionTLS12, - }, + Handler: serv, + TLSConfig: tlsConfig, } defer server.Close() group.Add(func() error { - return server.ServeTLS(l, c.Web.TLSCert, c.Web.TLSKey) + return server.ServeTLS(l, "", "") }, func(err error) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - logger.Debugf("starting graceful shutdown (%s)", name) + logger.Debug("starting graceful shutdown", "server", name) if err := server.Shutdown(ctx); err != nil { - logger.Errorf("graceful shutdown (%s): %v", name, err) + logger.Error("graceful shutdown", "server", name, "err", err) } }) } // Set up grpc server if c.GRPC.Addr != "" { - logger.Infof("listening (grpc) on %s", c.GRPC.Addr) + logger.Info("listening on", "server", "grpc", "address", c.GRPC.Addr) + + if c.GRPC.TLSClientCA == "" { + // The gRPC API grants full administrative access (client secret reads, + // password/connector/identity CRUD). Its only built-in caller + // authentication is mutual TLS via grpc.tlsClientCA; without it, anyone + // who can reach the port controls the identity provider. This is a valid + // setup only when a trusted front-facing service authenticates callers. + logger.Warn("grpc: API server has no client certificate authentication (grpc.tlsClientCA is unset); "+ + "it exposes full administrative access. Ensure the port is only reachable by an authenticated, trusted service, "+ + "or set grpc.tlsClientCA to require mutual TLS.", + "tls", c.GRPC.TLSCert != "") + } grpcListener, err := net.Listen("tcp", c.GRPC.Addr) if err != nil { - return fmt.Errorf("listening (grcp) on %s: %w", c.GRPC.Addr, err) + return fmt.Errorf("listening (grpc) on %s: %w", c.GRPC.Addr, err) } grpcSrv := grpc.NewServer(grpcOptions...) - api.RegisterDexServer(grpcSrv, server.NewAPI(serverConfig.Storage, logger, version)) + api.RegisterDexServer(grpcSrv, apiserver.NewAPI(serverConfig.Storage, logger, version, serv.Connectors(), serv.Discovery(), serv.Backchannel())) grpcMetrics.InitializeMetrics(grpcSrv) if c.GRPC.Reflection { @@ -475,7 +625,7 @@ func runServe(options serveOptions) error { group.Add(func() error { return grpcSrv.Serve(grpcListener) }, func(err error) { - logger.Debugf("starting graceful shutdown (grpc)") + logger.Debug("starting graceful shutdown", "server", "grpc") grpcSrv.GracefulStop() }) } @@ -485,55 +635,11 @@ func runServe(options serveOptions) error { if _, ok := err.(run.SignalError); !ok { return fmt.Errorf("run groups: %w", err) } - logger.Infof("%v, shutdown now", err) + logger.Info("shutdown now", "err", err) } return nil } -var ( - logLevels = []string{"debug", "info", "error"} - logFormats = []string{"json", "text"} -) - -type utcFormatter struct { - f logrus.Formatter -} - -func (f *utcFormatter) Format(e *logrus.Entry) ([]byte, error) { - e.Time = e.Time.UTC() - return f.f.Format(e) -} - -func newLogger(level string, format string) (log.Logger, error) { - var logLevel logrus.Level - switch strings.ToLower(level) { - case "debug": - logLevel = logrus.DebugLevel - case "", "info": - logLevel = logrus.InfoLevel - case "error": - logLevel = logrus.ErrorLevel - default: - return nil, fmt.Errorf("log level is not one of the supported values (%s): %s", strings.Join(logLevels, ", "), level) - } - - var formatter utcFormatter - switch strings.ToLower(format) { - case "", "text": - formatter.f = &logrus.TextFormatter{DisableColors: true} - case "json": - formatter.f = &logrus.JSONFormatter{} - default: - return nil, fmt.Errorf("log format is not one of the supported values (%s): %s", strings.Join(logFormats, ", "), format) - } - - return &logrus.Logger{ - Out: os.Stderr, - Formatter: &formatter, - Level: logLevel, - }, nil -} - func applyConfigOverrides(options serveOptions, config *Config) { if options.webHTTPAddr != "" { config.Web.HTTP = options.webHTTPAddr @@ -554,6 +660,20 @@ func applyConfigOverrides(options serveOptions, config *Config) { if config.Frontend.Dir == "" { config.Frontend.Dir = os.Getenv("DEX_FRONTEND_DIR") } + + if len(config.OAuth2.GrantTypes) == 0 { + config.OAuth2.GrantTypes = []string{ + "authorization_code", + "implicit", + "password", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:grant-type:token-exchange", + } + if featureflags.ClientCredentialGrantEnabledByDefault.Enabled() { + config.OAuth2.GrantTypes = append(config.OAuth2.GrantTypes, "client_credentials") + } + } } func pprofHandler(router *http.ServeMux) { @@ -563,3 +683,211 @@ func pprofHandler(router *http.ServeMux) { router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) router.HandleFunc("/debug/pprof/trace", pprof.Trace) } + +// newTLSReloader returns a [tls.Config] with GetCertificate or GetConfigForClient set +// to reload certificates from the given paths on SIGHUP or on file creates (atomic update via rename). +func newTLSReloader(logger *slog.Logger, certFile, keyFile, caFile string, baseConfig *tls.Config) (*tls.Config, error) { + // trigger reload on channel + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, syscall.SIGHUP) + + // files to watch + watchFiles := map[string]struct{}{ + certFile: {}, + keyFile: {}, + } + if caFile != "" { + watchFiles[caFile] = struct{}{} + } + watchDirs := make(map[string]struct{}) // dedupe dirs + for f := range watchFiles { + dir := filepath.Dir(f) + if !strings.HasPrefix(f, dir) { + // normalize name to have ./ prefix if only a local path was provided + // can't pass "" to watcher.Add + watchFiles[dir+string(filepath.Separator)+f] = struct{}{} + } + watchDirs[dir] = struct{}{} + } + // trigger reload on file change + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("create watcher for TLS reloader: %v", err) + } + // recommended by fsnotify: watch the dir to handle renames + // https://pkg.go.dev/github.com/fsnotify/fsnotify#hdr-Watching_files + for dir := range watchDirs { + logger.Debug("watching dir", "dir", dir) + err := watcher.Add(dir) + if err != nil { + return nil, fmt.Errorf("watch dir for TLS reloader: %v", err) + } + } + + // load once outside the goroutine so we can return an error on misconfig + initialConfig, err := loadTLSConfig(certFile, keyFile, caFile, baseConfig) + if err != nil { + return nil, fmt.Errorf("load TLS config: %v", err) + } + + // stored version of current tls config + ptr := &atomic.Pointer[tls.Config]{} + ptr.Store(initialConfig) + + // start background worker to reload certs + go func() { + loop: + for { + select { + case sig := <-sigc: + logger.Debug("reloading cert from signal", "signal", sig) + case evt := <-watcher.Events: + if _, ok := watchFiles[evt.Name]; !ok || !evt.Has(fsnotify.Create) { + continue loop + } + logger.Debug("reloading cert from fsnotify", "event", evt.Name, "operation", evt.Op.String()) + case err := <-watcher.Errors: + logger.Error("TLS reloader watch", "err", err) + } + + loaded, err := loadTLSConfig(certFile, keyFile, caFile, baseConfig) + if err != nil { + logger.Error("reload TLS config", "err", err) + } + ptr.Store(loaded) + } + }() + + // https://pkg.go.dev/crypto/tls#baseConfig + // Server configurations must set one of Certificates, GetCertificate or GetConfigForClient. + if caFile != "" { + // grpc will use this via tls.Server for mTLS + initialConfig.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { return ptr.Load(), nil } + } else { + // net/http only uses Certificates or GetCertificate + initialConfig.GetCertificate = func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { return &ptr.Load().Certificates[0], nil } + } + return initialConfig, nil +} + +// loadTLSConfig loads the given file paths into a [tls.Config] +func loadTLSConfig(certFile, keyFile, caFile string, baseConfig *tls.Config) (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("loading TLS keypair: %v", err) + } + loadedConfig := baseConfig.Clone() // copy + loadedConfig.Certificates = []tls.Certificate{cert} + if caFile != "" { + cPool := x509.NewCertPool() + clientCert, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("reading from client CA file: %v", err) + } + if !cPool.AppendCertsFromPEM(clientCert) { + return nil, errors.New("failed to parse client CA") + } + + loadedConfig.ClientAuth = tls.RequireAndVerifyClientCert + loadedConfig.ClientCAs = cPool + } + return loadedConfig, nil +} + +// recordBuildInfo publishes information about Dex version and runtime info through an info metric (gauge). +func recordBuildInfo() { + buildInfo.WithLabelValues(version, runtime.Version(), fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)).Set(1) +} + +func parseSessionConfig(s *Sessions) (*session.Config, error) { + sc := &session.Config{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: 1 * time.Hour, + RememberMeCheckedByDefault: true, + } + if s != nil { + if s.CookieName != "" { + sc.CookieName = s.CookieName + } + if s.AbsoluteLifetime != "" { + d, err := time.ParseDuration(s.AbsoluteLifetime) + if err != nil { + return nil, fmt.Errorf("invalid absoluteLifetime %q: %v", s.AbsoluteLifetime, err) + } + sc.AbsoluteLifetime = d + } + if s.ValidIfNotUsedFor != "" { + d, err := time.ParseDuration(s.ValidIfNotUsedFor) + if err != nil { + return nil, fmt.Errorf("invalid validIfNotUsedFor %q: %v", s.ValidIfNotUsedFor, err) + } + sc.ValidIfNotUsedFor = d + } + if s.RememberMeCheckedByDefault != nil { + sc.RememberMeCheckedByDefault = *s.RememberMeCheckedByDefault + } + if s.CookieEncryptionKey != "" { + sc.CookieEncryptionKey = []byte(s.CookieEncryptionKey) + } + if s.SSOSharedWithDefault != "" { + sc.SSOSharedWithDefault = s.SSOSharedWithDefault + } + } + if sc.AbsoluteLifetime <= 0 { + return nil, fmt.Errorf("absoluteLifetime must be positive, got %v", sc.AbsoluteLifetime) + } + if sc.ValidIfNotUsedFor <= 0 { + return nil, fmt.Errorf("validIfNotUsedFor must be positive, got %v", sc.ValidIfNotUsedFor) + } + if sc.ValidIfNotUsedFor > sc.AbsoluteLifetime { + return nil, fmt.Errorf("validIfNotUsedFor (%v) must not exceed absoluteLifetime (%v)", sc.ValidIfNotUsedFor, sc.AbsoluteLifetime) + } + if k := len(sc.CookieEncryptionKey); k > 0 && k != 16 && k != 24 && k != 32 { + return nil, fmt.Errorf("cookieEncryptionKey must be 16, 24, or 32 bytes (AES-128/192/256), got %d", k) + } + switch sc.SSOSharedWithDefault { + case "", "none", "all": + // valid + default: + return nil, fmt.Errorf("ssoSharedWithDefault must be \"none\" or \"all\", got %q", sc.SSOSharedWithDefault) + } + return sc, nil +} + +func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]mfa.Provider { + if len(authenticators) == 0 { + return nil + } + + providers := make(map[string]mfa.Provider, len(authenticators)) + for _, auth := range authenticators { + switch auth.Type { + case "TOTP": + var cfg TOTPConfig + if err := json.Unmarshal(auth.Config, &cfg); err != nil { + logger.Error("failed to parse TOTP config", "id", auth.ID, "err", err) + continue + } + providers[auth.ID] = mfa.NewTOTPProvider(cfg.Issuer, auth.ConnectorTypes) + logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type) + case "WebAuthn": + var cfg WebAuthnConfig + if err := json.Unmarshal(auth.Config, &cfg); err != nil { + logger.Error("failed to parse WebAuthn config", "id", auth.ID, "err", err) + continue + } + provider, err := mfa.NewWebAuthnProvider(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins, + cfg.AttestationPreference, cfg.Timeout, issuerURL, auth.ConnectorTypes) + if err != nil { + logger.Error("failed to create WebAuthn provider", "id", auth.ID, "err", err) + continue + } + providers[auth.ID] = provider + logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type) + default: + logger.Error("unknown MFA authenticator type, skipping", "id", auth.ID, "type", auth.Type) + } + } + return providers +} diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go new file mode 100644 index 0000000000..12d0c0fff4 --- /dev/null +++ b/cmd/dex/serve_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewLogger(t *testing.T) { + t.Run("JSON", func(t *testing.T) { + logger, err := newLogger(slog.LevelInfo, "json", nil) + require.NoError(t, err) + require.NotEqual(t, (*slog.Logger)(nil), logger) + }) + + t.Run("Text", func(t *testing.T) { + logger, err := newLogger(slog.LevelError, "text", nil) + require.NoError(t, err) + require.NotEqual(t, (*slog.Logger)(nil), logger) + }) + + t.Run("Unknown", func(t *testing.T) { + logger, err := newLogger(slog.LevelError, "gofmt", nil) + require.Error(t, err) + require.Equal(t, "log format is not one of the supported values (json, text): gofmt", err.Error()) + require.Equal(t, (*slog.Logger)(nil), logger) + }) +} diff --git a/cmd/docker-entrypoint/main.go b/cmd/docker-entrypoint/main.go index 0c507d1712..14d837e5ee 100644 --- a/cmd/docker-entrypoint/main.go +++ b/cmd/docker-entrypoint/main.go @@ -17,21 +17,18 @@ func main() { // Note that this docker-entrypoint program is args[0], and it is provided with the true process // args. args := os.Args[1:] + if len(args) == 0 { + fmt.Println("error: no args passed to entrypoint") + os.Exit(1) + } - if err := run(args, realExec, realWhich); err != nil { + if err := run(args, realExec, realWhich, realGomplate); err != nil { fmt.Println("error:", err.Error()) os.Exit(1) } } -func realExec(fork bool, args ...string) error { - if fork { - if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { - return fmt.Errorf("cannot fork/exec command %s: %w (output: %q)", args, err, string(output)) - } - return nil - } - +func realExec(args ...string) error { argv0, err := exec.LookPath(args[0]) if err != nil { return fmt.Errorf("cannot lookup path for command %s: %w", args[0], err) @@ -52,34 +49,49 @@ func realWhich(path string) string { return fullPath } -func run(args []string, execFunc func(bool, ...string) error, whichFunc func(string) string) error { +func realGomplate(path string) (string, error) { + tmpFile, err := os.CreateTemp("/tmp", "dex.config.yaml-*") + if err != nil { + return "", fmt.Errorf("cannot create temp file: %w", err) + } + + cmd := exec.Command("gomplate", "-f", path, "-o", tmpFile.Name()) + // TODO(nabokihms): Workaround to run gomplate from a non-root directory in distroless images + // gomplate tries to access CWD on start, see: https://github.com/hairyhenderson/gomplate/pull/2202 + cmd.Dir = "/etc/dex" + + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("error executing gomplate: %w, (output: %q)", err, string(output)) + } + + return tmpFile.Name(), nil +} + +func run(args []string, execFunc func(...string) error, whichFunc func(string) string, gomplateFunc func(string) (string, error)) error { if args[0] != "dex" && args[0] != whichFunc("dex") { - return execFunc(false, args...) + return execFunc(args...) } if args[1] != "serve" { - return execFunc(false, args...) + return execFunc(args...) } newArgs := []string{} for _, tplCandidate := range args { if hasSuffixes(tplCandidate, ".tpl", ".tmpl", ".yaml") { - tmpFile, err := os.CreateTemp("/tmp", "dex.config.yaml-*") + fileName, err := gomplateFunc(tplCandidate) if err != nil { - return fmt.Errorf("cannot create temp file: %w", err) - } - - if err := execFunc(true, "gomplate", "-f", tplCandidate, "-o", tmpFile.Name()); err != nil { return err } - newArgs = append(newArgs, tmpFile.Name()) + newArgs = append(newArgs, fileName) } else { newArgs = append(newArgs, tplCandidate) } } - return execFunc(false, newArgs...) + return execFunc(newArgs...) } func hasSuffixes(s string, suffixes ...string) bool { diff --git a/cmd/docker-entrypoint/main_test.go b/cmd/docker-entrypoint/main_test.go index c8aef16979..49da3b5f02 100644 --- a/cmd/docker-entrypoint/main_test.go +++ b/cmd/docker-entrypoint/main_test.go @@ -6,7 +6,7 @@ import ( ) type execArgs struct { - fork bool + gomplate bool argPrefixes []string } @@ -16,98 +16,89 @@ func TestRun(t *testing.T) { args []string execReturns error whichReturns string - wantExecArgs []execArgs + wantExecArgs execArgs wantErr error }{ { name: "executable not dex", args: []string{"tuna", "fish"}, - wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"tuna", "fish"}}}, + wantExecArgs: execArgs{gomplate: false, argPrefixes: []string{"tuna", "fish"}}, }, { name: "executable is full path to dex", args: []string{"/usr/local/bin/dex", "marshmallow", "zelda"}, whichReturns: "/usr/local/bin/dex", - wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"/usr/local/bin/dex", "marshmallow", "zelda"}}}, + wantExecArgs: execArgs{gomplate: false, argPrefixes: []string{"/usr/local/bin/dex", "marshmallow", "zelda"}}, }, { name: "command is not serve", args: []string{"dex", "marshmallow", "zelda"}, - wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "marshmallow", "zelda"}}}, + wantExecArgs: execArgs{gomplate: false, argPrefixes: []string{"dex", "marshmallow", "zelda"}}, }, { name: "no templates", args: []string{"dex", "serve", "config.yaml.not-a-template"}, - wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}}, + wantExecArgs: execArgs{gomplate: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}, }, { name: "no templates", args: []string{"dex", "serve", "config.yaml.not-a-template"}, - wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}}, + wantExecArgs: execArgs{gomplate: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}, }, { - name: ".tpl template", - args: []string{"dex", "serve", "config.tpl"}, - wantExecArgs: []execArgs{ - {fork: true, argPrefixes: []string{"gomplate", "-f", "config.tpl", "-o", "/tmp/dex.config.yaml-"}}, - {fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}}, - }, + name: ".tpl template", + args: []string{"dex", "serve", "config.tpl"}, + wantExecArgs: execArgs{gomplate: true, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}}, }, { - name: ".tmpl template", - args: []string{"dex", "serve", "config.tmpl"}, - wantExecArgs: []execArgs{ - {fork: true, argPrefixes: []string{"gomplate", "-f", "config.tmpl", "-o", "/tmp/dex.config.yaml-"}}, - {fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}}, - }, + name: ".tmpl template", + args: []string{"dex", "serve", "config.tmpl"}, + wantExecArgs: execArgs{gomplate: true, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}}, }, { - name: ".yaml template", - args: []string{"dex", "serve", "some/path/config.yaml"}, - wantExecArgs: []execArgs{ - {fork: true, argPrefixes: []string{"gomplate", "-f", "some/path/config.yaml", "-o", "/tmp/dex.config.yaml-"}}, - {fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}}, - }, + name: ".yaml template", + args: []string{"dex", "serve", "some/path/config.yaml"}, + wantExecArgs: execArgs{gomplate: true, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - var gotExecForks []bool - var gotExecArgs [][]string - fakeExec := func(fork bool, args ...string) error { - gotExecForks = append(gotExecForks, fork) - gotExecArgs = append(gotExecArgs, args) + var gotExecArgs []string + var runsGomplate bool + + fakeExec := func(args ...string) error { + gotExecArgs = append(args, gotExecArgs...) return test.execReturns } fakeWhich := func(_ string) string { return test.whichReturns } - gotErr := run(test.args, fakeExec, fakeWhich) + fakeGomplate := func(file string) (string, error) { + runsGomplate = true + return "/tmp/dex.config.yaml-", nil + } + + gotErr := run(test.args, fakeExec, fakeWhich, fakeGomplate) if (test.wantErr == nil) != (gotErr == nil) { t.Errorf("wanted error %s, got %s", test.wantErr, gotErr) } - if !execArgsMatch(test.wantExecArgs, gotExecForks, gotExecArgs) { - t.Errorf("wanted exec args %+v, got %+v %+v", test.wantExecArgs, gotExecForks, gotExecArgs) + + if !execArgsMatch(test.wantExecArgs, runsGomplate, gotExecArgs) { + t.Errorf("wanted exec args %+v (running gomplate: %+v), got %+v (running gomplate: %+v)", + test.wantExecArgs.argPrefixes, test.wantExecArgs.gomplate, gotExecArgs, runsGomplate) } }) } } -func execArgsMatch(wantExecArgs []execArgs, gotForks []bool, gotExecArgs [][]string) bool { - if len(wantExecArgs) != len(gotForks) { +func execArgsMatch(wantExecArgs execArgs, gomplate bool, gotExecArgs []string) bool { + if wantExecArgs.gomplate != gomplate { return false } - - for i := range wantExecArgs { - if wantExecArgs[i].fork != gotForks[i] { + for i := range wantExecArgs.argPrefixes { + if !strings.HasPrefix(gotExecArgs[i], wantExecArgs.argPrefixes[i]) { return false } - for j := range wantExecArgs[i].argPrefixes { - if !strings.HasPrefix(gotExecArgs[i][j], wantExecArgs[i].argPrefixes[j]) { - return false - } - } } - return true } diff --git a/config.dev.yaml b/config.dev.yaml index dda65e08f7..bdbc2718d3 100644 --- a/config.dev.yaml +++ b/config.dev.yaml @@ -32,4 +32,10 @@ staticPasswords: - email: "admin@example.com" hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: "admin" + name: "Admin User" + emailVerified: true + preferredUsername: "admin" + groups: + - "team-a" + - "team-a/admins" userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" diff --git a/config.docker.yaml b/config.docker.yaml index c5d2a47bfd..cc3a99ed39 100644 --- a/config.docker.yaml +++ b/config.docker.yaml @@ -22,10 +22,15 @@ telemetry: expiry: deviceRequests: {{ getenv "DEX_EXPIRY_DEVICE_REQUESTS" "5m" }} - signingKeys: {{ getenv "DEX_EXPIRY_SIGNING_KEYS" "6h" }} idTokens: {{ getenv "DEX_EXPIRY_ID_TOKENS" "24h" }} authRequests: {{ getenv "DEX_EXPIRY_AUTH_REQUESTS" "24h" }} +signer: + type: local + config: + keysRotationPeriod: {{ getenv "DEX_EXPIRY_SIGNING_KEYS" "6h" }} + algorithm: {{ getenv "DEX_SIGNER_LOCAL_ALGORITHM" "RS256" }} + logger: level: {{ getenv "DEX_LOG_LEVEL" "info" }} format: {{ getenv "DEX_LOG_FORMAT" "text" }} diff --git a/config.yaml.dist b/config.yaml.dist index ba7bad68e0..d2d31c80a9 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -55,6 +55,8 @@ web: # https: 127.0.0.1:5554 # tlsCert: /etc/dex/tls.crt # tlsKey: /etc/dex/tls.key + # tlsMinVersion: 1.2 + # tlsMaxVersion: 1.3 # Dex UI configuration # frontend: @@ -70,6 +72,8 @@ web: # logger: # level: "debug" # format: "text" # can also be "json" +# # Drop these attribute keys from all log output (useful for GDPR/PII suppression). +# # excludeFields: [email, username, preferred_username, groups] # gRPC API configuration # Uncomment this block to enable the gRPC API. @@ -83,13 +87,33 @@ web: # Expiration configuration for tokens, signing keys, etc. # expiry: # deviceRequests: "5m" -# signingKeys: "6h" +# signingKeys: "6h" # deprecated, use signer.config.keysRotationPeriod # idTokens: "24h" # refreshTokens: # disableRotation: false # reuseInterval: "3s" # validIfNotUsedFor: "2160h" # 90 days # absoluteLifetime: "3960h" # 165 days +# +# signer: +# type: local +# config: +# keysRotationPeriod: "6h" +# algorithm: "RS256" # supported values: "RS256" (default) and "ES256"; changes apply on the next key rotation + +# Authentication sessions configuration. +# Requires DEX_SESSIONS_ENABLED=true feature flag. +# sessions: +# cookieName: "dex_session" +# absoluteLifetime: "24h" +# validIfNotUsedFor: "1h" +# rememberMeCheckedByDefault: false +# # AES key for encrypting session cookies. Must be 16, 24, or 32 bytes. +# # If empty, cookies are not encrypted. +# cookieEncryptionKey: "" +# # Default SSO sharing policy for clients without explicit ssoSharedWith. +# # "all" = share with all clients (Keycloak-like), "none" = no sharing (default). +# ssoSharedWithDefault: "none" # OAuth2 configuration # oauth2: @@ -107,6 +131,13 @@ web: # # # Uncomment to use a specific connector for password grants # passwordConnector: local +# +# # PKCE (Proof Key for Code Exchange) configuration +# pkce: +# # If true, PKCE is required for all authorization code flows (OAuth 2.1). +# enforce: false +# # Supported code challenge methods. Defaults to ["S256", "plain"]. +# codeChallengeMethodsSupported: ["S256", "plain"] # Static clients registered in Dex by default. # @@ -117,10 +148,51 @@ web: # - 'http://127.0.0.1:5555/callback' # name: 'Example App' # secret: ZXhhbXBsZS1hcHAtc2VjcmV0 +# +# # Example using environment variables +# # These fields are mutually exclusive with id and secret respectively. +# - idEnv: DEX_CLIENT_ID +# secretEnv: DEX_CLIENT_SECRET +# redirectURIs: +# - 'https://app.example.com/callback' +# name: 'Production App' +# +# # Example of a public client (no secret required) +# - id: example-device-client +# redirectURIs: +# - /device/callback +# name: 'Static Client for Device Flow' +# public: true +# +# # Example of a client restricted to specific connectors +# - id: restricted-client +# secret: restricted-client-secret +# redirectURIs: +# - 'https://app.example.com/callback' +# name: 'Restricted Client' +# allowedConnectors: +# - github +# - google +# +# # Example of SSO sharing between clients. +# # ssoSharedWith defines which other clients can reuse this client's session. +# # ["*"] = share with all, [] = share with no one. +# # If omitted, ssoSharedWithDefault from sessions config is used. +# - id: portal-app +# secret: portal-secret +# redirectURIs: +# - 'https://portal.example.com/callback' +# name: 'Portal' +# ssoSharedWith: +# - "dashboard-app" +# - "admin-app" -# Connectors are used to authenticate users agains upstream identity providers. +# Connectors are used to authenticate users against upstream identity providers. # # See the documentation (https://dexidp.io/docs/connectors/) for further information. +# +# For LDAP nested group resolution, set groupSearch.userMatchers[].recursionGroupAttr +# in the connector config. See: https://dexidp.io/docs/connectors/ldap/ # connectors: [] # Enable the password database. @@ -133,4 +205,19 @@ enablePasswordDB: true # A static list of passwords for the password connector. # # Alternatively, passwords my be added/updated through the gRPC API. -# staticPasswords: [] +# staticPasswords: +# - email: "user@example.com" +# # bcrypt hash of the string "password" +# hash: "$2a$10$examplehash..." +# username: "user-login" +# # Optional. Maps to OIDC "name" claim. Defaults to username. +# name: "User Full Name" +# # Optional. Maps to OIDC "email_verified" claim. Defaults to true. +# emailVerified: true +# # Optional. Maps to OIDC "preferred_username" claim. +# preferredUsername: "user-public" +# # Optional. Maps to OIDC "groups" claim (when 'groups' scope is requested). +# groups: +# - "team-a" +# - "team-a/admins" +# userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" diff --git a/connector/atlassiancrowd/atlassiancrowd.go b/connector/atlassiancrowd/atlassiancrowd.go index e2ca94b0de..ca92214785 100644 --- a/connector/atlassiancrowd/atlassiancrowd.go +++ b/connector/atlassiancrowd/atlassiancrowd.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net" "net/http" "strings" @@ -14,7 +15,6 @@ import ( "github.com/dexidp/dex/connector" "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" ) // Config holds configuration options for Atlassian Crowd connector. @@ -24,18 +24,17 @@ import ( // // An example config: // -// type: atlassian-crowd -// config: -// baseURL: https://crowd.example.com/context -// clientID: applogin -// clientSecret: appP4$$w0rd -// # users can be restricted by a list of groups -// groups: -// - admin -// # Prompt for username field -// usernamePrompt: Login -// preferredUsernameField: name -// +// type: atlassian-crowd +// config: +// baseURL: https://crowd.example.com/context +// clientID: applogin +// clientSecret: appP4$$w0rd +// # users can be restricted by a list of groups +// groups: +// - admin +// # Prompt for username field +// usernamePrompt: Login +// preferredUsernameField: name type Config struct { BaseURL string `json:"baseURL"` ClientID string `json:"clientID"` @@ -81,16 +80,11 @@ type crowdAuthenticationError struct { } // Open returns a strategy for logging in through Atlassian Crowd -func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { if c.BaseURL == "" { return nil, fmt.Errorf("crowd: no baseURL provided for crowd connector") } - return &crowdConnector{Config: *c, logger: logger}, nil -} - -type crowdConnector struct { - Config - logger log.Logger + return &crowdConnector{Config: *c, logger: logger.With(slog.Group("connector", "type", "atlassiancrowd", "id", id))}, nil } var ( @@ -98,6 +92,11 @@ var ( _ connector.RefreshConnector = (*crowdConnector)(nil) ) +type crowdConnector struct { + Config + logger *slog.Logger +} + type refreshData struct { Username string `json:"username"` } @@ -376,7 +375,7 @@ func (c *crowdConnector) identityFromCrowdUser(user crowdUser) connector.Identit identity.PreferredUsername = user.Email default: if c.PreferredUsernameField != "" { - c.logger.Warnf("preferred_username left empty. Invalid crowd field mapped to preferred_username: %s", c.PreferredUsernameField) + c.logger.Warn("preferred_username left empty. Invalid crowd field mapped to preferred_username", "field", c.PreferredUsernameField) } } @@ -437,12 +436,12 @@ func (c *crowdConnector) validateCrowdResponse(resp *http.Response) ([]byte, err } if resp.StatusCode == http.StatusForbidden && strings.Contains(string(body), "The server understood the request but refuses to authorize it.") { - c.logger.Debugf("crowd response validation failed: %s", string(body)) + c.logger.Debug("crowd response validation failed", "response", string(body)) return nil, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", c.BaseURL) } if resp.StatusCode == http.StatusUnauthorized && string(body) == "Application failed to authenticate" { - c.logger.Debugf("crowd response validation failed: %s", string(body)) + c.logger.Debug("crowd response validation failed", "response", string(body)) return nil, fmt.Errorf("dex failed to authenticate Crowd Application with ID %q", c.ClientID) } return body, nil diff --git a/connector/atlassiancrowd/atlassiancrowd_test.go b/connector/atlassiancrowd/atlassiancrowd_test.go index 36789a3919..17d0422ac8 100644 --- a/connector/atlassiancrowd/atlassiancrowd_test.go +++ b/connector/atlassiancrowd/atlassiancrowd_test.go @@ -6,13 +6,11 @@ import ( "crypto/tls" "encoding/json" "fmt" - "io" + "log/slog" "net/http" "net/http/httptest" "reflect" "testing" - - "github.com/sirupsen/logrus" ) func TestUserGroups(t *testing.T) { @@ -115,7 +113,7 @@ func TestIdentityFromCrowdUser(t *testing.T) { expectEquals(t, user.Name, "testuser") expectEquals(t, user.Email, "testuser@example.com") - // Test unconfigured behaviour + // Test unconfigured behavior i := c.identityFromCrowdUser(user) expectEquals(t, i.UserID, "12345") expectEquals(t, i.Username, "testuser") @@ -151,11 +149,7 @@ type TestServerResponse struct { func newTestCrowdConnector(baseURL string) crowdConnector { connector := crowdConnector{} connector.BaseURL = baseURL - connector.logger = &logrus.Logger{ - Out: io.Discard, - Level: logrus.DebugLevel, - Formatter: &logrus.TextFormatter{DisableColors: true}, - } + connector.logger = slog.New(slog.DiscardHandler) return connector } diff --git a/connector/authproxy/authproxy.go b/connector/authproxy/authproxy.go index 8715412146..5756a0d401 100644 --- a/connector/authproxy/authproxy.go +++ b/connector/authproxy/authproxy.go @@ -5,12 +5,12 @@ package authproxy import ( "fmt" + "log/slog" "net/http" "net/url" "strings" "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" ) // Config holds the configuration parameters for a connector which returns an @@ -19,67 +19,117 @@ import ( // Headers retrieved to fetch user's email and group can be configured // with userHeader and groupHeader. type Config struct { - UserHeader string `json:"userHeader"` - GroupHeader string `json:"groupHeader"` - Groups []string `json:"staticGroups"` + UserIDHeader string `json:"userIDHeader"` + UserHeader string `json:"userHeader"` + UserNameHeader string `json:"userNameHeader"` + EmailHeader string `json:"emailHeader"` + GroupHeader string `json:"groupHeader"` + GroupHeaderSeparator string `json:"groupHeaderSeparator"` + Groups []string `json:"staticGroups"` } // Open returns an authentication strategy which requires no user interaction. -func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { + userIDHeader := c.UserIDHeader + if userIDHeader == "" { + userIDHeader = "X-Remote-User-Id" + } userHeader := c.UserHeader if userHeader == "" { userHeader = "X-Remote-User" } + userNameHeader := c.UserNameHeader + if userNameHeader == "" { + userNameHeader = "X-Remote-User-Name" + } + emailHeader := c.EmailHeader + if emailHeader == "" { + emailHeader = "X-Remote-User-Email" + } groupHeader := c.GroupHeader if groupHeader == "" { groupHeader = "X-Remote-Group" } + groupHeaderSeparator := c.GroupHeaderSeparator + if groupHeaderSeparator == "" { + groupHeaderSeparator = "," + } - return &callback{userHeader: userHeader, groupHeader: groupHeader, logger: logger, pathSuffix: "/" + id, groups: c.Groups}, nil + return &callback{ + userIDHeader: userIDHeader, + userHeader: userHeader, + userNameHeader: userNameHeader, + emailHeader: emailHeader, + groupHeader: groupHeader, + groupHeaderSeparator: groupHeaderSeparator, + groups: c.Groups, + logger: logger.With(slog.Group("connector", "type", "authproxy", "id", id)), + pathSuffix: "/" + id, + }, nil } +var _ connector.CallbackConnector = (*callback)(nil) + // Callback is a connector which returns an identity with the HTTP header // X-Remote-User as verified email. type callback struct { - userHeader string - groupHeader string - groups []string - logger log.Logger - pathSuffix string + userIDHeader string + userNameHeader string + userHeader string + emailHeader string + groupHeader string + groupHeaderSeparator string + groups []string + logger *slog.Logger + pathSuffix string } // LoginURL returns the URL to redirect the user to login with. -func (m *callback) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) { +func (m *callback) LoginURL(s connector.Scopes, callbackURL, state string) (string, []byte, error) { u, err := url.Parse(callbackURL) if err != nil { - return "", fmt.Errorf("failed to parse callbackURL %q: %v", callbackURL, err) + return "", nil, fmt.Errorf("failed to parse callbackURL %q: %v", callbackURL, err) } u.Path += m.pathSuffix v := u.Query() v.Set("state", state) u.RawQuery = v.Encode() - return u.String(), nil + return u.String(), nil, nil } // HandleCallback parses the request and returns the user's identity -func (m *callback) HandleCallback(s connector.Scopes, r *http.Request) (connector.Identity, error) { +func (m *callback) HandleCallback(s connector.Scopes, _ []byte, r *http.Request) (connector.Identity, error) { remoteUser := r.Header.Get(m.userHeader) if remoteUser == "" { return connector.Identity{}, fmt.Errorf("required HTTP header %s is not set", m.userHeader) } + remoteUserName := r.Header.Get(m.userNameHeader) + if remoteUserName == "" { + remoteUserName = remoteUser + } + remoteUserID := r.Header.Get(m.userIDHeader) + if remoteUserID == "" { + remoteUserID = remoteUser + } + remoteUserEmail := r.Header.Get(m.emailHeader) + if remoteUserEmail == "" { + remoteUserEmail = remoteUser + } groups := m.groups headerGroup := r.Header.Get(m.groupHeader) if headerGroup != "" { - splitheaderGroup := strings.Split(headerGroup, ",") + splitheaderGroup := strings.Split(headerGroup, m.groupHeaderSeparator) for i, v := range splitheaderGroup { splitheaderGroup[i] = strings.TrimSpace(v) } groups = append(splitheaderGroup, groups...) } return connector.Identity{ - UserID: remoteUser, // TODO: figure out if this is a bad ID value. - Email: remoteUser, - EmailVerified: true, - Groups: groups, + UserID: remoteUserID, + Username: remoteUser, + PreferredUsername: remoteUserName, + Email: remoteUserEmail, + EmailVerified: true, + Groups: groups, }, nil } diff --git a/connector/authproxy/authproxy_test.go b/connector/authproxy/authproxy_test.go index 5d42530e07..bd8b4f3671 100644 --- a/connector/authproxy/authproxy_test.go +++ b/connector/authproxy/authproxy_test.go @@ -1,55 +1,82 @@ package authproxy import ( - "io" + "log/slog" "net/http" "reflect" "testing" - "github.com/sirupsen/logrus" - "github.com/dexidp/dex/connector" ) const ( - testEmail = "testuser@example.com" - testGroup1 = "group1" - testGroup2 = "group2" - testGroup3 = "group 3" - testGroup4 = "group 4" - testStaticGroup1 = "static1" - testStaticGroup2 = "static 2" + testEmail = "testuser@example.com" + testGroup1 = "group1" + testGroup2 = "group2" + testGroup3 = "group 3" + testGroup4 = "group 4" + testStaticGroup1 = "static1" + testStaticGroup2 = "static 2" + testUsername = "Test User" + testPreferredUsername = "testuser" + testUserID = "1234567890" ) -var logger = &logrus.Logger{Out: io.Discard, Formatter: &logrus.TextFormatter{}} +var logger = slog.New(slog.DiscardHandler) func TestUser(t *testing.T) { - config := Config{ - UserHeader: "X-Remote-User", + config := Config{} + + conn, _ := config.Open("test", logger) + callback := conn.(*callback) + + req, err := http.NewRequest("GET", "/", nil) + expectNil(t, err) + req.Header = map[string][]string{ + "X-Remote-User": {testUsername}, } - conn := callback{userHeader: config.UserHeader, logger: logger, pathSuffix: "/test"} + + ident, err := callback.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, nil, req) + expectNil(t, err) + + // If not specified, the userID and email should fall back to the remote user + expectEquals(t, ident.UserID, testUsername) + expectEquals(t, ident.PreferredUsername, testUsername) + expectEquals(t, ident.Username, testUsername) + expectEquals(t, ident.Email, testUsername) + expectEquals(t, len(ident.Groups), 0) +} + +func TestExtraHeaders(t *testing.T) { + config := Config{} + + conn, _ := config.Open("test", logger) + callback := conn.(*callback) req, err := http.NewRequest("GET", "/", nil) expectNil(t, err) req.Header = map[string][]string{ - "X-Remote-User": {testEmail}, + "X-Remote-User-Id": {testUserID}, + "X-Remote-User": {testUsername}, + "X-Remote-User-Name": {testPreferredUsername}, + "X-Remote-User-Email": {testEmail}, } - ident, err := conn.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, req) + ident, err := callback.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, nil, req) expectNil(t, err) - expectEquals(t, ident.UserID, testEmail) + expectEquals(t, ident.UserID, testUserID) + expectEquals(t, ident.PreferredUsername, testPreferredUsername) + expectEquals(t, ident.Username, testUsername) expectEquals(t, ident.Email, testEmail) expectEquals(t, len(ident.Groups), 0) } func TestSingleGroup(t *testing.T) { - config := Config{ - UserHeader: "X-Remote-User", - GroupHeader: "X-Remote-Group", - } + config := Config{} - conn := callback{userHeader: config.UserHeader, groupHeader: config.GroupHeader, logger: logger, pathSuffix: "/test"} + conn, _ := config.Open("test", logger) + callback := conn.(*callback) req, err := http.NewRequest("GET", "/", nil) expectNil(t, err) @@ -58,7 +85,7 @@ func TestSingleGroup(t *testing.T) { "X-Remote-Group": {testGroup1}, } - ident, err := conn.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, req) + ident, err := callback.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, nil, req) expectNil(t, err) expectEquals(t, ident.UserID, testEmail) @@ -67,21 +94,45 @@ func TestSingleGroup(t *testing.T) { } func TestMultipleGroup(t *testing.T) { + config := Config{} + + conn, _ := config.Open("test", logger) + callback := conn.(*callback) + + req, err := http.NewRequest("GET", "/", nil) + expectNil(t, err) + req.Header = map[string][]string{ + "X-Remote-User": {testEmail}, + "X-Remote-Group": {testGroup1 + ", " + testGroup2 + ", " + testGroup3 + ", " + testGroup4}, + } + + ident, err := callback.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, nil, req) + expectNil(t, err) + + expectEquals(t, ident.UserID, testEmail) + expectEquals(t, len(ident.Groups), 4) + expectEquals(t, ident.Groups[0], testGroup1) + expectEquals(t, ident.Groups[1], testGroup2) + expectEquals(t, ident.Groups[2], testGroup3) + expectEquals(t, ident.Groups[3], testGroup4) +} + +func TestMultipleGroupWithCustomSeparator(t *testing.T) { config := Config{ - UserHeader: "X-Remote-User", - GroupHeader: "X-Remote-Group", + GroupHeaderSeparator: ";", } - conn := callback{userHeader: config.UserHeader, groupHeader: config.GroupHeader, logger: logger, pathSuffix: "/test"} + conn, _ := config.Open("test", logger) + callback := conn.(*callback) req, err := http.NewRequest("GET", "/", nil) expectNil(t, err) req.Header = map[string][]string{ "X-Remote-User": {testEmail}, - "X-Remote-Group": {testGroup1 + ", " + testGroup2 + ", " + testGroup3 + ", " + testGroup4}, + "X-Remote-Group": {testGroup1 + ";" + testGroup2 + ";" + testGroup3 + ";" + testGroup4}, } - ident, err := conn.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, req) + ident, err := callback.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, nil, req) expectNil(t, err) expectEquals(t, ident.UserID, testEmail) @@ -94,12 +145,11 @@ func TestMultipleGroup(t *testing.T) { func TestStaticGroup(t *testing.T) { config := Config{ - UserHeader: "X-Remote-User", - GroupHeader: "X-Remote-Group", - Groups: []string{"static1", "static 2"}, + Groups: []string{"static1", "static 2"}, } - conn := callback{userHeader: config.UserHeader, groupHeader: config.GroupHeader, groups: config.Groups, logger: logger, pathSuffix: "/test"} + conn, _ := config.Open("test", logger) + callback := conn.(*callback) req, err := http.NewRequest("GET", "/", nil) expectNil(t, err) @@ -108,7 +158,7 @@ func TestStaticGroup(t *testing.T) { "X-Remote-Group": {testGroup1 + ", " + testGroup2 + ", " + testGroup3 + ", " + testGroup4}, } - ident, err := conn.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, req) + ident, err := callback.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, nil, req) expectNil(t, err) expectEquals(t, ident.UserID, testEmail) diff --git a/connector/bitbucketcloud/bitbucketcloud.go b/connector/bitbucketcloud/bitbucketcloud.go index 27eafb5299..dcf104a34a 100644 --- a/connector/bitbucketcloud/bitbucketcloud.go +++ b/connector/bitbucketcloud/bitbucketcloud.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "sync" "time" @@ -16,42 +17,48 @@ import ( "github.com/dexidp/dex/connector" "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" ) const ( apiURL = "https://api.bitbucket.org/2.0" - // Switch to API v2.0 when the Atlassian platform services are fully available in Bitbucket - legacyAPIURL = "https://api.bitbucket.org/1.0" // Bitbucket requires this scope to access '/user' API endpoints. scopeAccount = "account" // Bitbucket requires this scope to access '/user/emails' API endpoints. scopeEmail = "email" - // Bitbucket requires this scope to access '/teams' API endpoints - // which are used when a client includes the 'groups' scope. - scopeTeams = "team" ) // Config holds configuration options for Bitbucket logins. type Config struct { - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - RedirectURI string `json:"redirectURI"` - Teams []string `json:"teams"` - IncludeTeamGroups bool `json:"includeTeamGroups,omitempty"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` + Teams []string `json:"teams"` + + // Deprecated: The Bitbucket 1.0 API (/1.0/groups/{team}) that this feature + // relied on has been removed by Atlassian. This option is ignored; if set, + // a warning is logged at startup. Consider using getWorkspacePermissions. + IncludeTeamGroups bool `json:"includeTeamGroups,omitempty"` + + // When enabled, appends workspace permission suffixes (e.g. "workspace:owner", + // "workspace:member") to the groups claim, similar to GitLab's getGroupsPermission. + GetWorkspacePermissions bool `json:"getWorkspacePermissions,omitempty"` } // Open returns a strategy for logging in through Bitbucket. -func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { + if c.IncludeTeamGroups { + logger.Warn("bitbucket: includeTeamGroups is deprecated and has no effect; " + + "the Bitbucket 1.0 API it relied on has been removed by Atlassian") + } + b := bitbucketConnector{ - redirectURI: c.RedirectURI, - teams: c.Teams, - clientID: c.ClientID, - clientSecret: c.ClientSecret, - includeTeamGroups: c.IncludeTeamGroups, - apiURL: apiURL, - legacyAPIURL: legacyAPIURL, - logger: logger, + redirectURI: c.RedirectURI, + teams: c.Teams, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + getWorkspacePermissions: c.GetWorkspacePermissions, + apiURL: apiURL, + logger: logger.With(slog.Group("connector", "type", "bitbucketcloud", "id", id)), } return &b, nil @@ -69,31 +76,26 @@ var ( ) type bitbucketConnector struct { - redirectURI string - teams []string - clientID string - clientSecret string - logger log.Logger - apiURL string - legacyAPIURL string + redirectURI string + teams []string + clientID string + clientSecret string + logger *slog.Logger + apiURL string + getWorkspacePermissions bool // the following are used only for tests hostName string httpClient *http.Client - - includeTeamGroups bool } -// groupsRequired returns whether dex requires Bitbucket's 'team' scope. +// groupsRequired returns whether dex needs to fetch Bitbucket workspace membership. func (b *bitbucketConnector) groupsRequired(groupScope bool) bool { return len(b.teams) > 0 || groupScope } func (b *bitbucketConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { bitbucketScopes := []string{scopeAccount, scopeEmail} - if b.groupsRequired(scopes.Groups) { - bitbucketScopes = append(bitbucketScopes, scopeTeams) - } endpoint := bitbucket.Endpoint if b.hostName != "" { @@ -111,12 +113,12 @@ func (b *bitbucketConnector) oauth2Config(scopes connector.Scopes) *oauth2.Confi } } -func (b *bitbucketConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { +func (b *bitbucketConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, []byte, error) { if b.redirectURI != callbackURL { - return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, b.redirectURI) + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, b.redirectURI) } - return b.oauth2Config(scopes).AuthCodeURL(state), nil + return b.oauth2Config(scopes).AuthCodeURL(state), nil, nil } type oauth2Error struct { @@ -131,7 +133,7 @@ func (e *oauth2Error) Error() string { return e.error + ": " + e.errorDescription } -func (b *bitbucketConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { +func (b *bitbucketConnector) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} @@ -344,6 +346,7 @@ func (b *bitbucketConnector) userEmail(ctx context.Context, client *http.Client) if response.Next == nil { break } + apiURL = *response.Next } return "", errors.New("bitbucket: user has no confirmed, primary email") @@ -369,29 +372,33 @@ func (b *bitbucketConnector) getGroups(ctx context.Context, client *http.Client, return nil, nil } -type workspaceSlug struct { +type workspaceRef struct { Slug string `json:"slug"` } -type workspace struct { - Workspace workspaceSlug `json:"workspace"` +type workspaceAccess struct { + Workspace workspaceRef `json:"workspace"` } -type userWorkspacesResponse struct { +type workspacesResponse struct { pagedResponse - Values []workspace `json:"values"` + Values []workspaceAccess `json:"values"` +} + +type workspacePermission struct { + Permission string `json:"permission"` } func (b *bitbucketConnector) userWorkspaces(ctx context.Context, client *http.Client) ([]string, error) { var teams []string - apiURL := b.apiURL + "/user/permissions/workspaces" + apiURL := b.apiURL + "/user/workspaces" for { - // https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-workspaces-get - var response userWorkspacesResponse + // https://developer.atlassian.com/cloud/bitbucket/rest/api-group-user/#api-user-workspaces-get + var response workspacesResponse if err := get(ctx, client, apiURL, &response); err != nil { - return nil, fmt.Errorf("bitbucket: get user teams: %v", err) + return nil, fmt.Errorf("bitbucket: get user workspaces: %v", err) } for _, value := range response.Values { @@ -401,39 +408,33 @@ func (b *bitbucketConnector) userWorkspaces(ctx context.Context, client *http.Cl if response.Next == nil { break } + apiURL = *response.Next } - if b.includeTeamGroups { + if b.getWorkspacePermissions { + var permissionGroups []string for _, team := range teams { - teamGroups, err := b.userTeamGroups(ctx, client, team) + perm, err := b.userWorkspacePermission(ctx, client, team) if err != nil { - return nil, fmt.Errorf("bitbucket: %v", err) + b.logger.Warn("bitbucket: failed to get permission for workspace, skipping permission suffix", + "workspace", team, "error", err) + continue } - teams = append(teams, teamGroups...) + permissionGroups = append(permissionGroups, team+":"+perm) } + teams = append(teams, permissionGroups...) } return teams, nil } -type group struct { - Slug string `json:"slug"` -} - -func (b *bitbucketConnector) userTeamGroups(ctx context.Context, client *http.Client, teamName string) ([]string, error) { - apiURL := b.legacyAPIURL + "/groups/" + teamName - - var response []group +func (b *bitbucketConnector) userWorkspacePermission(ctx context.Context, client *http.Client, workspaceSlug string) (string, error) { + apiURL := b.apiURL + "/user/workspaces/" + workspaceSlug + "/permission" + var response workspacePermission if err := get(ctx, client, apiURL, &response); err != nil { - return nil, fmt.Errorf("get user team %q groups: %v", teamName, err) + return "", fmt.Errorf("get workspace %q permission: %v", workspaceSlug, err) } - - teamGroups := make([]string, 0, len(response)) - for _, group := range response { - teamGroups = append(teamGroups, teamName+"/"+group.Slug) - } - - return teamGroups, nil + return response.Permission, nil } // get creates a "GET `apiURL`" request with context, sends the request using diff --git a/connector/bitbucketcloud/bitbucketcloud_test.go b/connector/bitbucketcloud/bitbucketcloud_test.go index 9545ff09c5..1ae1286fb6 100644 --- a/connector/bitbucketcloud/bitbucketcloud_test.go +++ b/connector/bitbucketcloud/bitbucketcloud_test.go @@ -1,40 +1,40 @@ package bitbucketcloud import ( + "bytes" "context" "crypto/tls" "encoding/json" + "log/slog" "net/http" "net/http/httptest" "net/url" "reflect" + "strings" "testing" "github.com/dexidp/dex/connector" ) func TestUserGroups(t *testing.T) { - teamsResponse := userWorkspacesResponse{ + workspacesResponse := workspacesResponse{ pagedResponse: pagedResponse{ Size: 3, Page: 1, PageLen: 10, }, - Values: []workspace{ - {Workspace: workspaceSlug{Slug: "team-1"}}, - {Workspace: workspaceSlug{Slug: "team-2"}}, - {Workspace: workspaceSlug{Slug: "team-3"}}, + Values: []workspaceAccess{ + {Workspace: workspaceRef{Slug: "team-1"}}, + {Workspace: workspaceRef{Slug: "team-2"}}, + {Workspace: workspaceRef{Slug: "team-3"}}, }, } s := newTestServer(map[string]interface{}{ - "/user/permissions/workspaces": teamsResponse, - "/groups/team-1": []group{{Slug: "administrators"}, {Slug: "members"}}, - "/groups/team-2": []group{{Slug: "everyone"}}, - "/groups/team-3": []group{}, + "/user/workspaces": workspacesResponse, }) - connector := bitbucketConnector{apiURL: s.URL, legacyAPIURL: s.URL} + connector := bitbucketConnector{apiURL: s.URL} groups, err := connector.userWorkspaces(context.Background(), newClient()) expectNil(t, err) @@ -44,25 +44,12 @@ func TestUserGroups(t *testing.T) { "team-3", }) - connector.includeTeamGroups = true - groups, err = connector.userWorkspaces(context.Background(), newClient()) - - expectNil(t, err) - expectEquals(t, groups, []string{ - "team-1", - "team-2", - "team-3", - "team-1/administrators", - "team-1/members", - "team-2/everyone", - }) - s.Close() } func TestUserWithoutTeams(t *testing.T) { s := newTestServer(map[string]interface{}{ - "/user/permissions/workspaces": userWorkspacesResponse{}, + "/user/workspaces": workspacesResponse{}, }) connector := bitbucketConnector{apiURL: s.URL} @@ -74,6 +61,58 @@ func TestUserWithoutTeams(t *testing.T) { s.Close() } +func TestUserGroupsWithPermissions(t *testing.T) { + workspacesResp := workspacesResponse{ + pagedResponse: pagedResponse{ + Size: 2, + Page: 1, + PageLen: 10, + }, + Values: []workspaceAccess{ + {Workspace: workspaceRef{Slug: "team-1"}}, + {Workspace: workspaceRef{Slug: "team-2"}}, + }, + } + + s := newTestServer(map[string]interface{}{ + "/user/workspaces": workspacesResp, + "/user/workspaces/team-1/permission": workspacePermission{Permission: "owner"}, + "/user/workspaces/team-2/permission": workspacePermission{Permission: "member"}, + }) + defer s.Close() + + logger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)) + c := bitbucketConnector{apiURL: s.URL, getWorkspacePermissions: true, logger: logger} + groups, err := c.userWorkspaces(context.Background(), newClient()) + + expectNil(t, err) + expectEquals(t, groups, []string{ + "team-1", + "team-2", + "team-1:owner", + "team-2:member", + }) +} + +func TestDeprecatedIncludeTeamGroups(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + cfg := Config{ + ClientID: "id", + ClientSecret: "secret", + RedirectURI: "http://localhost", + IncludeTeamGroups: true, + } + + _, err := cfg.Open("test", logger) + expectNil(t, err) + + if !strings.Contains(buf.String(), "includeTeamGroups is deprecated") { + t.Fatal("expected deprecation warning for includeTeamGroups") + } +} + func TestUsernameIncludedInFederatedIdentity(t *testing.T) { s := newTestServer(map[string]interface{}{ "/user": user{Username: "some-login"}, @@ -102,7 +141,7 @@ func TestUsernameIncludedInFederatedIdentity(t *testing.T) { expectNil(t, err) bitbucketConnector := bitbucketConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient()} - identity, err := bitbucketConnector.HandleCallback(connector.Scopes{}, req) + identity, err := bitbucketConnector.HandleCallback(connector.Scopes{}, nil, req) expectNil(t, err) expectEquals(t, identity.Username, "some-login") diff --git a/connector/connector.go b/connector/connector.go index aab994b468..f95c62e4a2 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -3,9 +3,22 @@ package connector import ( "context" + "fmt" "net/http" ) +// UserNotInRequiredGroupsError is returned by a connector when a user +// successfully authenticates but is not a member of any of the required groups. +// The server will respond with HTTP 403 Forbidden instead of 500. +type UserNotInRequiredGroupsError struct { + UserID string + Groups []string +} + +func (e *UserNotInRequiredGroupsError) Error() string { + return fmt.Sprintf("user %q is not in any of the required groups %v", e.UserID, e.Groups) +} + // Connector is a mechanism for federating login to a remote identity service. // // Implementations are expected to implement either the PasswordConnector or @@ -63,14 +76,15 @@ type CallbackConnector interface { // requested if one has already been issues. There's no good general answer // for these kind of restrictions, and may require this package to become more // aware of the global set of user/connector interactions. - LoginURL(s Scopes, callbackURL, state string) (string, error) + LoginURL(s Scopes, callbackURL, state string) (string, []byte, error) // Handle the callback to the server and return an identity. - HandleCallback(s Scopes, r *http.Request) (identity Identity, err error) + HandleCallback(s Scopes, connData []byte, r *http.Request) (identity Identity, err error) } // SAMLConnector represents SAML connectors which implement the HTTP POST binding. -// RelayState is handled by the server. +// +// RelayState is handled by the server. // // See: https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf // "3.5 HTTP POST Binding" @@ -98,3 +112,26 @@ type RefreshConnector interface { // changes since the token was last refreshed. Refresh(ctx context.Context, s Scopes, identity Identity) (Identity, error) } + +type TokenIdentityConnector interface { + TokenIdentity(ctx context.Context, subjectTokenType, subjectToken string) (Identity, error) +} + +// LogoutCallbackConnector is a connector that can initiate upstream logout and +// optionally validate the upstream provider's logout response. +// Connectors that implement this interface support RP-Initiated Logout by +// returning a URL that Dex should redirect the user to in order to terminate +// the upstream session. +type LogoutCallbackConnector interface { + // LogoutURL returns the upstream provider's logout URL. + // postLogoutRedirectURI is the URL the upstream provider should redirect back to after logout. + // Returns the upstream logout URL or empty string if upstream logout is not available. + LogoutURL(ctx context.Context, postLogoutRedirectURI string) (string, error) + + // HandleLogoutCallback validates the upstream provider's logout response + // received in the callback request. For example, SAML connectors should + // verify the LogoutResponse signature and status code here. + // Connectors that don't receive a structured response (e.g. OIDC) should + // return nil. + HandleLogoutCallback(ctx context.Context, r *http.Request) error +} diff --git a/connector/gitea/gitea.go b/connector/gitea/gitea.go index 6b02099414..059c861705 100644 --- a/connector/gitea/gitea.go +++ b/connector/gitea/gitea.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "strconv" "sync" @@ -15,7 +16,6 @@ import ( "golang.org/x/oauth2" "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" ) // Config holds configuration options for gitea logins. @@ -51,7 +51,7 @@ type giteaUser struct { } // Open returns a strategy for logging in through Gitea -func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { if c.BaseURL == "" { c.BaseURL = "https://gitea.com" } @@ -61,7 +61,7 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) orgs: c.Orgs, clientID: c.ClientID, clientSecret: c.ClientSecret, - logger: logger, + logger: logger.With(slog.Group("connector", "type", "gitea", "id", id)), loadAllGroups: c.LoadAllGroups, useLoginAsID: c.UseLoginAsID, }, nil @@ -84,7 +84,7 @@ type giteaConnector struct { orgs []Org clientID string clientSecret string - logger log.Logger + logger *slog.Logger httpClient *http.Client // if set to true and no orgs are configured then connector loads all user claims (all orgs and team) loadAllGroups bool @@ -102,11 +102,11 @@ func (c *giteaConnector) oauth2Config(_ connector.Scopes) *oauth2.Config { } } -func (c *giteaConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { +func (c *giteaConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, []byte, error) { if c.redirectURI != callbackURL { - return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", c.redirectURI, callbackURL) + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", c.redirectURI, callbackURL) } - return c.oauth2Config(scopes).AuthCodeURL(state), nil + return c.oauth2Config(scopes).AuthCodeURL(state), nil, nil } type oauth2Error struct { @@ -121,7 +121,7 @@ func (e *oauth2Error) Error() string { return e.error + ": " + e.errorDescription } -func (c *giteaConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { +func (c *giteaConnector) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} diff --git a/connector/gitea/gitea_test.go b/connector/gitea/gitea_test.go index a71d79956e..4fe7768901 100644 --- a/connector/gitea/gitea_test.go +++ b/connector/gitea/gitea_test.go @@ -30,14 +30,14 @@ func TestUsernameIncludedInFederatedIdentity(t *testing.T) { expectNil(t, err) c := giteaConnector{baseURL: s.URL, httpClient: newClient()} - identity, err := c.HandleCallback(connector.Scopes{}, req) + identity, err := c.HandleCallback(connector.Scopes{}, nil, req) expectNil(t, err) expectEquals(t, identity.Username, "some@email.com") expectEquals(t, identity.UserID, "12345678") c = giteaConnector{baseURL: s.URL, httpClient: newClient()} - identity, err = c.HandleCallback(connector.Scopes{}, req) + identity, err = c.HandleCallback(connector.Scopes{}, nil, req) expectNil(t, err) expectEquals(t, identity.Username, "some@email.com") diff --git a/connector/github/github.go b/connector/github/github.go index ef8d418fa8..a9672fb532 100644 --- a/connector/github/github.go +++ b/connector/github/github.go @@ -3,18 +3,16 @@ package github import ( "context" - "crypto/tls" - "crypto/x509" "encoding/json" "errors" "fmt" "io" - "net" + "log/slog" "net/http" - "os" "regexp" "strconv" "strings" + "sync" "time" "golang.org/x/oauth2" @@ -22,7 +20,7 @@ import ( "github.com/dexidp/dex/connector" groups_pkg "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/dexidp/dex/pkg/httpclient" ) const ( @@ -32,6 +30,8 @@ const ( // GitHub requires this scope to access '/user/teams' and '/orgs' API endpoints // which are used when a client includes the 'groups' scope. scopeOrgs = "read:org" + // githubAPIVersion pins the GitHub REST API version used in requests. + githubAPIVersion = "2022-11-28" ) // Pagination URL patterns @@ -43,16 +43,17 @@ var ( // Config holds configuration options for github logins. type Config struct { - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - RedirectURI string `json:"redirectURI"` - Org string `json:"org"` - Orgs []Org `json:"orgs"` - HostName string `json:"hostName"` - RootCA string `json:"rootCA"` - TeamNameField string `json:"teamNameField"` - LoadAllGroups bool `json:"loadAllGroups"` - UseLoginAsID bool `json:"useLoginAsID"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` + Org string `json:"org"` + Orgs []Org `json:"orgs"` + HostName string `json:"hostName"` + RootCA string `json:"rootCA"` + TeamNameField string `json:"teamNameField"` + LoadAllGroups bool `json:"loadAllGroups"` + UseLoginAsID bool `json:"useLoginAsID"` + PreferredEmailDomain string `json:"preferredEmailDomain"` } // Org holds org-team filters, in which teams are optional. @@ -69,7 +70,7 @@ type Org struct { } // Open returns a strategy for logging in through GitHub. -func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { if c.Org != "" { // Return error if both 'org' and 'orgs' fields are used. if len(c.Orgs) > 0 { @@ -79,14 +80,15 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) } g := githubConnector{ - redirectURI: c.RedirectURI, - org: c.Org, - orgs: c.Orgs, - clientID: c.ClientID, - clientSecret: c.ClientSecret, - apiURL: apiURL, - logger: logger, - useLoginAsID: c.UseLoginAsID, + redirectURI: c.RedirectURI, + org: c.Org, + orgs: c.Orgs, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + apiURL: apiURL, + logger: logger.With(slog.Group("connector", "type", "github", "id", id)), + useLoginAsID: c.UseLoginAsID, + preferredEmailDomain: c.PreferredEmailDomain, } if c.HostName != "" { @@ -106,7 +108,7 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) g.rootCA = c.RootCA var err error - if g.httpClient, err = newHTTPClient(g.rootCA); err != nil { + if g.httpClient, err = httpclient.NewHTTPClient([]string{g.rootCA}, false); err != nil { return nil, fmt.Errorf("failed to create HTTP client: %v", err) } } @@ -119,12 +121,21 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) return nil, fmt.Errorf("invalid connector config: unsupported team name field value `%s`", c.TeamNameField) } + if c.PreferredEmailDomain != "" { + if strings.HasSuffix(c.PreferredEmailDomain, "*") { + return nil, errors.New("invalid PreferredEmailDomain: glob pattern cannot end with \"*\"") + } + } + return &g, nil } type connectorData struct { - // GitHub's OAuth2 tokens never expire. We don't need a refresh token. - AccessToken string `json:"accessToken"` + // GitHub OAuth Apps return long-lived access tokens. GitHub Apps can return + // expiring user access tokens with refresh tokens. + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken,omitempty"` + Expiry time.Time `json:"expiry,omitempty"` } var ( @@ -138,7 +149,7 @@ type githubConnector struct { orgs []Org clientID string clientSecret string - logger log.Logger + logger *slog.Logger // apiURL defaults to "https://api.github.com" apiURL string // hostName of the GitHub enterprise account. @@ -153,6 +164,8 @@ type githubConnector struct { loadAllGroups bool // if set to true will use the user's handle rather than their numeric id as the ID useLoginAsID bool + // the domain to be preferred among the user's emails. e.g. "github.com" + preferredEmailDomain string } // groupsRequired returns whether dex requires GitHub's 'read:org' scope. Dex @@ -188,12 +201,12 @@ func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { } } -func (c *githubConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { +func (c *githubConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, []byte, error) { if c.redirectURI != callbackURL { - return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) } - return c.oauth2Config(scopes).AuthCodeURL(state), nil + return c.oauth2Config(scopes).AuthCodeURL(state), nil, nil } type oauth2Error struct { @@ -208,35 +221,7 @@ func (e *oauth2Error) Error() string { return e.error + ": " + e.errorDescription } -// newHTTPClient returns a new HTTP client that trusts the custom declared rootCA cert. -func newHTTPClient(rootCA string) (*http.Client, error) { - tlsConfig := tls.Config{RootCAs: x509.NewCertPool()} - rootCABytes, err := os.ReadFile(rootCA) - if err != nil { - return nil, fmt.Errorf("failed to read root-ca: %v", err) - } - if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { - return nil, fmt.Errorf("no certs found in root CA file %q", rootCA) - } - - return &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tlsConfig, - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - DualStack: true, - }).DialContext, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - }, nil -} - -func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { +func (c *githubConnector) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} @@ -288,7 +273,14 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i } if s.OfflineAccess { - data := connectorData{AccessToken: token.AccessToken} + data := connectorData{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + Expiry: token.Expiry, + } + if token.RefreshToken != "" && c.logger != nil { + c.logger.DebugContext(ctx, "github: received upstream refresh token", "access_token_expiry", token.Expiry) + } connData, err := json.Marshal(data) if err != nil { return identity, fmt.Errorf("marshal connector data: %v", err) @@ -299,6 +291,34 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i return identity, nil } +// Refreshing tokens +// https://github.com/golang/oauth2/issues/84#issuecomment-332860871 +type tokenNotifyFunc func(*oauth2.Token) error + +// notifyRefreshTokenSource is essentially `oauth2.ReuseTokenSource` with `TokenNotifyFunc` added. +type notifyRefreshTokenSource struct { + new oauth2.TokenSource + mu sync.Mutex // guards t + t *oauth2.Token + f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted +} + +// Token returns the current token if it's still valid, else will +// refresh the current token and return the new one. +func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.t.Valid() { + return s.t, nil + } + t, err := s.new.Token() + if err != nil { + return nil, err + } + s.t = t + return t, s.f(t) +} + func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { if len(identity.ConnectorData) == 0 { return identity, errors.New("no upstream access token found") @@ -309,7 +329,52 @@ func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, ident return identity, fmt.Errorf("github: unmarshal access token: %v", err) } - client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken}) + if data.AccessToken == "" && data.RefreshToken == "" { + return identity, errors.New("no upstream access token found") + } + + oauth2Config := c.oauth2Config(s) + if c.httpClient != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) + } + + tok := &oauth2.Token{ + AccessToken: data.AccessToken, + RefreshToken: data.RefreshToken, + Expiry: data.Expiry, + } + oldRefreshToken := data.RefreshToken + if oldRefreshToken != "" && c.logger != nil { + c.logger.DebugContext(ctx, "github: upstream refresh token available", + "will_refresh_access_token", !tok.Valid(), + "access_token_expiry", data.Expiry, + ) + } + + client := oauth2.NewClient(ctx, ¬ifyRefreshTokenSource{ + new: oauth2Config.TokenSource(ctx, tok), + t: tok, + f: func(tok *oauth2.Token) error { + if c.logger != nil { + c.logger.DebugContext(ctx, "github: refreshed upstream access token with refresh token", + "access_token_expiry", tok.Expiry, + "refresh_token_rotated", oldRefreshToken != "" && tok.RefreshToken != "" && tok.RefreshToken != oldRefreshToken, + ) + } + + data := connectorData{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Expiry: tok.Expiry, + } + connData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("github: marshal connector data: %v", err) + } + identity.ConnectorData = connData + return nil + }, + }) user, err := c.user(ctx, client) if err != nil { return identity, fmt.Errorf("github: get user: %v", err) @@ -356,9 +421,11 @@ func formatTeamName(org string, team string) string { // groupsForOrgs enforces org and team constraints on user authorization // Cases in which user is authorized: -// N orgs, no teams: user is member of at least 1 org -// N orgs, M teams per org: user is member of any team from at least 1 org -// N-1 orgs, M teams per org, 1 org with no teams: user is member of any team +// +// N orgs, no teams: user is member of at least 1 org +// N orgs, M teams per org: user is member of any team from at least 1 org +// N-1 orgs, M teams per org, 1 org with no teams: user is member of any team +// // from at least 1 org, or member of org with no teams func (c *githubConnector) groupsForOrgs(ctx context.Context, client *http.Client, userName string) ([]string, error) { groups := make([]string, 0) @@ -382,7 +449,7 @@ func (c *githubConnector) groupsForOrgs(ctx context.Context, client *http.Client if len(org.Teams) == 0 { inOrgNoTeams = true } else if teams = groups_pkg.Filter(teams, org.Teams); len(teams) == 0 { - c.logger.Infof("github: user %q in org %q but no teams", userName, org.Name) + c.logger.Info("user in org but no teams", "user", userName, "org", org.Name) } for _, teamName := range teams { @@ -482,6 +549,7 @@ func get(ctx context.Context, client *http.Client, apiURL string, v interface{}) return "", fmt.Errorf("github: new req: %v", err) } req = req.WithContext(ctx) + req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("github: get URL %v", err) @@ -551,9 +619,10 @@ func (c *githubConnector) user(ctx context.Context, client *http.Client) (user, return u, err } - // Only public user emails are returned by 'GET /user'. u.Email will be empty - // if a users' email is private. We must retrieve private emails explicitly. - if u.Email == "" { + // Only public user emails are returned by 'GET /user'. + // If a user has no public email, we must retrieve private emails explicitly. + // If preferredEmailDomain is set, we always need to retrieve all emails. + if u.Email == "" || c.preferredEmailDomain != "" { var err error if u.Email, err = c.userEmail(ctx, client); err != nil { return u, err @@ -578,7 +647,13 @@ type userEmail struct { // The HTTP client is expected to be constructed by the golang.org/x/oauth2 package, // which inserts a bearer token as part of the request. func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (string, error) { + var ( + primaryEmail userEmail + preferredEmails []userEmail + ) + apiURL := c.apiURL + "/user/emails" + for { // https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user var ( @@ -605,7 +680,17 @@ func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (s } if email.Verified && email.Primary { - return email.Email, nil + primaryEmail = email + } + + if c.preferredEmailDomain != "" { + _, domainPart, ok := strings.Cut(email.Email, "@") + if !ok { + return "", errors.New("github: invalid format email is detected") + } + if email.Verified && c.isPreferredEmailDomain(domainPart) { + preferredEmails = append(preferredEmails, email) + } } } @@ -614,7 +699,36 @@ func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (s } } - return "", errors.New("github: user has no verified, primary email") + if len(preferredEmails) > 0 { + return preferredEmails[0].Email, nil + } + + if primaryEmail.Email != "" { + return primaryEmail.Email, nil + } + + return "", errors.New("github: user has no verified, primary email or preferred-domain email") +} + +// isPreferredEmailDomain checks the domain is matching with preferredEmailDomain. +func (c *githubConnector) isPreferredEmailDomain(domain string) bool { + if domain == c.preferredEmailDomain { + return true + } + + preferredDomainParts := strings.Split(c.preferredEmailDomain, ".") + domainParts := strings.Split(domain, ".") + + if len(preferredDomainParts) != len(domainParts) { + return false + } + + for i, v := range preferredDomainParts { + if domainParts[i] != v && v != "*" { + return false + } + } + return true } // userInOrg queries the GitHub API for a users' org membership. @@ -633,6 +747,7 @@ func (c *githubConnector) userInOrg(ctx context.Context, client *http.Client, us return false, fmt.Errorf("github: new req: %v", err) } req = req.WithContext(ctx) + req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) resp, err := client.Do(req) if err != nil { return false, fmt.Errorf("github: get teams: %v", err) @@ -642,7 +757,7 @@ func (c *githubConnector) userInOrg(ctx context.Context, client *http.Client, us switch resp.StatusCode { case http.StatusNoContent: case http.StatusFound, http.StatusNotFound: - c.logger.Infof("github: user %q not in org %q or application not authorized to read org data", userName, orgName) + c.logger.Info("user not in org or application not authorized to read org data", "user", userName, "org", orgName) default: err = fmt.Errorf("github: unexpected return status: %q", resp.Status) } diff --git a/connector/github/github_test.go b/connector/github/github_test.go index 76d7463cf6..1426c682fd 100644 --- a/connector/github/github_test.go +++ b/connector/github/github_test.go @@ -4,13 +4,17 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" "fmt" + "log/slog" "net/http" "net/http/httptest" "net/url" "reflect" "strings" + "sync/atomic" "testing" + "time" "github.com/dexidp/dex/connector" ) @@ -150,7 +154,7 @@ func TestUsernameIncludedInFederatedIdentity(t *testing.T) { expectNil(t, err) c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient()} - identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := c.HandleCallback(connector.Scopes{Groups: true}, nil, req) expectNil(t, err) expectEquals(t, identity.Username, "some-login") @@ -158,7 +162,7 @@ func TestUsernameIncludedInFederatedIdentity(t *testing.T) { expectEquals(t, 0, len(identity.Groups)) c = githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient(), loadAllGroups: true} - identity, err = c.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err = c.HandleCallback(connector.Scopes{Groups: true}, nil, req) expectNil(t, err) expectEquals(t, identity.Username, "some-login") @@ -191,13 +195,455 @@ func TestLoginUsedAsIDWhenConfigured(t *testing.T) { expectNil(t, err) c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient(), useLoginAsID: true} - identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := c.HandleCallback(connector.Scopes{Groups: true}, nil, req) expectNil(t, err) expectEquals(t, identity.UserID, "some-login") expectEquals(t, identity.Username, "Joe Bloggs") } +func TestHandleCallbackStoresRefreshToken(t *testing.T) { + s := newTestServer(map[string]testResponse{ + "/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs", Email: "some@email.com"}}, + "/login/oauth/access_token": {data: map[string]interface{}{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_in": 28800, + }}, + }) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + req, err := http.NewRequest("GET", hostURL.String(), nil) + expectNil(t, err) + + c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient()} + identity, err := c.HandleCallback(connector.Scopes{OfflineAccess: true}, nil, req) + expectNil(t, err) + + var data connectorData + if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { + t.Fatalf("failed to unmarshal connector data: %v", err) + } + expectEquals(t, data.AccessToken, "access-token") + expectEquals(t, data.RefreshToken, "refresh-token") + if data.Expiry.IsZero() || !data.Expiry.After(time.Now()) { + t.Fatalf("expected future token expiry, got %v", data.Expiry) + } +} + +func TestRefreshUsesRefreshToken(t *testing.T) { + var tokenRefreshCalled atomic.Bool + s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/login/oauth/access_token": + tokenRefreshCalled.Store(true) + if err := r.ParseForm(); err != nil { + t.Fatalf("failed to parse token refresh form: %v", err) + } + if got := r.Form.Get("grant_type"); got != "refresh_token" { + t.Fatalf("expected refresh_token grant type, got %q", got) + } + if got := r.Form.Get("refresh_token"); got != "old-refresh-token" { + t.Fatalf("expected old refresh token, got %q", got) + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + "expires_in": 28800, + }) + case "/user": + if got := r.Header.Get("Authorization"); got != "Bearer new-access-token" { + t.Fatalf("expected refreshed access token, got %q", got) + } + json.NewEncoder(w).Encode(user{Login: "new-login", ID: 12345678, Name: "New User", Email: "new@email.com"}) + default: + http.NotFound(w, r) + } + })) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + connData, err := json.Marshal(connectorData{ + AccessToken: "old-access-token", + RefreshToken: "old-refresh-token", + Expiry: time.Now().Add(-time.Hour), + }) + expectNil(t, err) + + c := githubConnector{ + apiURL: s.URL, + hostName: hostURL.Host, + httpClient: newClient(), + clientID: "client-id", + clientSecret: "client-secret", + } + identity, err := c.Refresh(context.Background(), connector.Scopes{OfflineAccess: true}, connector.Identity{ConnectorData: connData}) + expectNil(t, err) + + if !tokenRefreshCalled.Load() { + t.Fatal("expected refresh token request") + } + expectEquals(t, identity.Username, "New User") + expectEquals(t, identity.PreferredUsername, "new-login") + expectEquals(t, identity.Email, "new@email.com") + + var data connectorData + if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { + t.Fatalf("failed to unmarshal connector data: %v", err) + } + expectEquals(t, data.AccessToken, "new-access-token") + expectEquals(t, data.RefreshToken, "new-refresh-token") + if data.Expiry.IsZero() || !data.Expiry.After(time.Now()) { + t.Fatalf("expected future token expiry, got %v", data.Expiry) + } +} + +func TestRefreshWithAccessTokenOnlyConnectorData(t *testing.T) { + var tokenEndpointCalled atomic.Bool + s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/login/oauth/access_token": + tokenEndpointCalled.Store(true) + http.Error(w, "unexpected token refresh", http.StatusInternalServerError) + case "/user": + if got := r.Header.Get("Authorization"); got != "Bearer old-access-token" { + t.Fatalf("expected old access token, got %q", got) + } + json.NewEncoder(w).Encode(user{Login: "some-login", ID: 12345678, Name: "Some User", Email: "some@email.com"}) + default: + http.NotFound(w, r) + } + })) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + connData, err := json.Marshal(connectorData{AccessToken: "old-access-token"}) + expectNil(t, err) + + c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient()} + identity, err := c.Refresh(context.Background(), connector.Scopes{}, connector.Identity{ConnectorData: connData}) + expectNil(t, err) + + if tokenEndpointCalled.Load() { + t.Fatal("did not expect token refresh request") + } + expectEquals(t, identity.Username, "Some User") + expectEquals(t, identity.PreferredUsername, "some-login") + expectEquals(t, identity.Email, "some@email.com") + expectEquals(t, identity.ConnectorData, connData) +} + +func TestPreferredEmailDomainConfigured(t *testing.T) { + ctx := context.Background() + s := newTestServer(map[string]testResponse{ + "/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}}, + "/user/emails": { + data: []userEmail{ + { + Email: "some@email.com", + Verified: true, + Primary: true, + }, + { + Email: "another@email.com", + Verified: true, + Primary: false, + }, + { + Email: "some@preferred-domain.com", + Verified: true, + Primary: false, + }, + { + Email: "another@preferred-domain.com", + Verified: true, + Primary: false, + }, + }, + }, + }) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + client := newClient() + c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client, preferredEmailDomain: "preferred-domain.com"} + + u, err := c.user(ctx, client) + expectNil(t, err) + expectEquals(t, u.Email, "some@preferred-domain.com") +} + +func TestPreferredEmailDomainConfiguredWithGlob(t *testing.T) { + ctx := context.Background() + s := newTestServer(map[string]testResponse{ + "/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}}, + "/user/emails": { + data: []userEmail{ + { + Email: "some@email.com", + Verified: true, + Primary: true, + }, + { + Email: "another@email.com", + Verified: true, + Primary: false, + }, + { + Email: "some@another.preferred-domain.com", + Verified: true, + Primary: false, + }, + { + Email: "some@sub-domain.preferred-domain.co", + Verified: true, + Primary: false, + }, + }, + }, + }) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + client := newClient() + c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client, preferredEmailDomain: "*.preferred-domain.co"} + + u, err := c.user(ctx, client) + expectNil(t, err) + expectEquals(t, u.Email, "some@sub-domain.preferred-domain.co") +} + +func TestPreferredEmailDomainConfigured_UserHasNoPreferredDomainEmail(t *testing.T) { + ctx := context.Background() + s := newTestServer(map[string]testResponse{ + "/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}}, + "/user/emails": { + data: []userEmail{ + { + Email: "some@email.com", + Verified: true, + Primary: true, + }, + { + Email: "another@email.com", + Verified: true, + Primary: false, + }, + }, + }, + }) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + client := newClient() + c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client, preferredEmailDomain: "preferred-domain.com"} + + u, err := c.user(ctx, client) + expectNil(t, err) + expectEquals(t, u.Email, "some@email.com") +} + +func TestPreferredEmailDomainNotConfigured(t *testing.T) { + ctx := context.Background() + s := newTestServer(map[string]testResponse{ + "/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}}, + "/user/emails": { + data: []userEmail{ + { + Email: "some@email.com", + Verified: true, + Primary: true, + }, + { + Email: "another@email.com", + Verified: true, + Primary: false, + }, + { + Email: "some@preferred-domain.com", + Verified: true, + Primary: false, + }, + }, + }, + }) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + client := newClient() + c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client} + + u, err := c.user(ctx, client) + expectNil(t, err) + expectEquals(t, u.Email, "some@email.com") +} + +func TestPreferredEmailDomainConfigured_Error_BothPrimaryAndPreferredDomainEmailNotFound(t *testing.T) { + ctx := context.Background() + s := newTestServer(map[string]testResponse{ + "/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}}, + "/user/emails": { + data: []userEmail{ + { + Email: "some@email.com", + Verified: true, + Primary: false, + }, + { + Email: "another@email.com", + Verified: true, + Primary: false, + }, + { + Email: "some@preferred-domain.com", + Verified: true, + Primary: false, + }, + }, + }, + }) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + client := newClient() + c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client, preferredEmailDomain: "foo.bar"} + + _, err = c.user(ctx, client) + expectNotNil(t, err, "Email not found error") + expectEquals(t, err.Error(), "github: user has no verified, primary email or preferred-domain email") +} + +func Test_isPreferredEmailDomain(t *testing.T) { + client := newClient() + tests := []struct { + preferredEmailDomain string + email string + expected bool + }{ + { + preferredEmailDomain: "example.com", + email: "test@example.com", + expected: true, + }, + { + preferredEmailDomain: "example.com", + email: "test@another.com", + expected: false, + }, + { + preferredEmailDomain: "*.example.com", + email: "test@my.example.com", + expected: true, + }, + { + preferredEmailDomain: "*.example.com", + email: "test@my.another.com", + expected: false, + }, + { + preferredEmailDomain: "*.example.com", + email: "test@my.domain.example.com", + expected: false, + }, + { + preferredEmailDomain: "*.example.com", + email: "test@sub.domain.com", + expected: false, + }, + { + preferredEmailDomain: "*.*.example.com", + email: "test@sub.my.example.com", + expected: true, + }, + { + preferredEmailDomain: "*.*.example.com", + email: "test@a.my.google.com", + expected: false, + }, + } + for _, test := range tests { + t.Run(test.preferredEmailDomain, func(t *testing.T) { + c := githubConnector{apiURL: "apiURL", hostName: "github.com", httpClient: client, preferredEmailDomain: test.preferredEmailDomain} + _, domainPart, _ := strings.Cut(test.email, "@") + res := c.isPreferredEmailDomain(domainPart) + + expectEquals(t, res, test.expected) + }) + } +} + +func Test_Open_PreferredDomainConfig(t *testing.T) { + log := slog.New(slog.DiscardHandler) + tests := []struct { + preferredEmailDomain string + email string + expected error + }{ + { + preferredEmailDomain: "example.com", + expected: nil, + }, + { + preferredEmailDomain: "*.example.com", + expected: nil, + }, + { + preferredEmailDomain: "*.*.example.com", + expected: nil, + }, + { + preferredEmailDomain: "example.*", + expected: errors.New("invalid PreferredEmailDomain: glob pattern cannot end with \"*\""), + }, + } + for _, test := range tests { + t.Run(test.preferredEmailDomain, func(t *testing.T) { + c := Config{ + PreferredEmailDomain: test.preferredEmailDomain, + } + _, err := c.Open("id", log) + + expectEquals(t, err, test.expected) + }) + } +} + +func TestGetSendsAPIVersionHeader(t *testing.T) { + var gotHeader string + s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("X-GitHub-Api-Version") + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode([]org{}) + })) + defer s.Close() + + var result []org + _, err := get(context.Background(), newClient(), s.URL+"/user/orgs", &result) + expectNil(t, err) + expectEquals(t, gotHeader, githubAPIVersion) +} + func newTestServer(responses map[string]testResponse) *httptest.Server { var s *httptest.Server s = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -231,6 +677,12 @@ func expectNil(t *testing.T, a interface{}) { } } +func expectNotNil(t *testing.T, a interface{}, msg string) { + if a == nil { + t.Errorf("Expected %+v to not to be nil", msg) + } +} + func expectEquals(t *testing.T, a interface{}, b interface{}) { if !reflect.DeepEqual(a, b) { t.Errorf("Expected %+v to equal %+v", a, b) diff --git a/connector/gitlab/gitlab.go b/connector/gitlab/gitlab.go index f35ac35753..85783b71b9 100644 --- a/connector/gitlab/gitlab.go +++ b/connector/gitlab/gitlab.go @@ -1,4 +1,4 @@ -// Package gitlab provides authentication strategies using Gitlab. +// Package gitlab provides authentication strategies using GitLab. package gitlab import ( @@ -7,33 +7,64 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "strconv" + "strings" "time" "golang.org/x/oauth2" "github.com/dexidp/dex/connector" "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/dexidp/dex/pkg/httpclient" ) const ( // read operations of the /api/v4/user endpoint scopeUser = "read_user" + // read operations of the REST API, including /api/v4/groups + scopeReadAPI = "read_api" // used to retrieve groups from /oauth/userinfo // https://docs.gitlab.com/ee/integration/openid_connect_provider.html scopeOpenID = "openid" ) +const ( + // constants for inheritedGroups flag + inheritedGroupsPerPage = 100 + accessLevelMinimalAccess = 5 + accessLevelGuest = 10 + accessLevelPlanner = 15 + accessLevelReporter = 20 + accessLevelSecurityMgr = 25 + accessLevelDeveloper = 30 + accessLevelMaintainer = 40 + accessLevelOwner = 50 + accessLevelAdmin = 60 +) + // Config holds configuration options for gitlab logins. type Config struct { - BaseURL string `json:"baseURL"` - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - RedirectURI string `json:"redirectURI"` - Groups []string `json:"groups"` - UseLoginAsID bool `json:"useLoginAsID"` + // BaseURL is the root URL of the GitLab instance. Defaults to https://gitlab.com. + BaseURL string `json:"baseURL"` + // ClientID is the OAuth client ID registered in GitLab. + ClientID string `json:"clientID"` + // ClientSecret is the OAuth client secret registered in GitLab. + ClientSecret string `json:"clientSecret"` + // RedirectURI is the callback URL configured for the GitLab OAuth application. + RedirectURI string `json:"redirectURI"` + // Groups limits logins to users who belong to at least one of the configured GitLab groups. + Groups []string `json:"groups"` + // UseLoginAsID uses the GitLab username as the Dex user ID instead of the numeric GitLab user ID. + UseLoginAsID bool `json:"useLoginAsID"` + // GetGroupsPermission appends role-qualified entries, such as group:owner, to the groups claim. + GetGroupsPermission bool `json:"getGroupsPermission"` + // When enabled, Dex uses /api/v4/groups as the source of truth for group names so + // inherited memberships are included as well. This requires GitLab's read_api scope. + InheritedGroups bool `json:"inheritedGroups"` + // RootCAData is a PEM-encoded CA bundle used to trust custom TLS certificates on the GitLab instance. + RootCAData []byte `json:"rootCAData,omitempty"` } type gitlabUser struct { @@ -46,18 +77,34 @@ type gitlabUser struct { } // Open returns a strategy for logging in through GitLab. -func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { if c.BaseURL == "" { c.BaseURL = "https://gitlab.com" } + var httpClient *http.Client + if len(c.RootCAData) > 0 { + var err error + httpClient, err = httpclient.NewHTTPClient([]string{string(c.RootCAData)}, false) + if err != nil { + // Keep backward-compatible error semantics for invalid PEM input. + if strings.Contains(err.Error(), "not in PEM format") { + return nil, fmt.Errorf("gitlab: invalid rootCAData") + } + return nil, fmt.Errorf("gitlab: failed to create HTTP client: %v", err) + } + httpClient.Timeout = 30 * time.Second + } return &gitlabConnector{ - baseURL: c.BaseURL, - redirectURI: c.RedirectURI, - clientID: c.ClientID, - clientSecret: c.ClientSecret, - logger: logger, - groups: c.Groups, - useLoginAsID: c.UseLoginAsID, + baseURL: c.BaseURL, + redirectURI: c.RedirectURI, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + logger: logger.With(slog.Group("connector", "type", "gitlab", "id", id)), + groups: c.Groups, + useLoginAsID: c.UseLoginAsID, + getGroupsPermission: c.GetGroupsPermission, + inheritedGroups: c.InheritedGroups, + httpClient: httpClient, }, nil } @@ -68,8 +115,9 @@ type connectorData struct { } var ( - _ connector.CallbackConnector = (*gitlabConnector)(nil) - _ connector.RefreshConnector = (*gitlabConnector)(nil) + _ connector.CallbackConnector = (*gitlabConnector)(nil) + _ connector.RefreshConnector = (*gitlabConnector)(nil) + _ connector.TokenIdentityConnector = (*gitlabConnector)(nil) ) type gitlabConnector struct { @@ -78,16 +126,26 @@ type gitlabConnector struct { groups []string clientID string clientSecret string - logger log.Logger + logger *slog.Logger httpClient *http.Client // if set to true will use the user's handle rather than their numeric id as the ID useLoginAsID bool + + // if set to true permissions will be added to list of groups + getGroupsPermission bool + + // if set to true inherited groups will be retrieved from /api/v4/groups + inheritedGroups bool } +// oauth2Config builds the OAuth2 client configuration and scopes for this connector. func (c *gitlabConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { gitlabScopes := []string{scopeUser} if c.groupsRequired(scopes.Groups) { - gitlabScopes = []string{scopeUser, scopeOpenID} + gitlabScopes = append(gitlabScopes, scopeOpenID) + if c.inheritedGroups { + gitlabScopes = append(gitlabScopes, scopeReadAPI) + } } gitlabEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/oauth/authorize", TokenURL: c.baseURL + "/oauth/token"} @@ -100,11 +158,12 @@ func (c *gitlabConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { } } -func (c *gitlabConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { +// LoginURL returns the GitLab authorization URL for the requested scopes. +func (c *gitlabConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, []byte, error) { if c.redirectURI != callbackURL { - return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", c.redirectURI, callbackURL) + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", c.redirectURI, callbackURL) } - return c.oauth2Config(scopes).AuthCodeURL(state), nil + return c.oauth2Config(scopes).AuthCodeURL(state), nil, nil } type oauth2Error struct { @@ -112,6 +171,7 @@ type oauth2Error struct { errorDescription string } +// Error formats the OAuth error returned by GitLab during the callback flow. func (e *oauth2Error) Error() string { if e.errorDescription == "" { return e.error @@ -119,7 +179,8 @@ func (e *oauth2Error) Error() string { return e.error + ": " + e.errorDescription } -func (c *gitlabConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { +// HandleCallback exchanges the authorization code and resolves the authenticated identity. +func (c *gitlabConnector) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} @@ -140,6 +201,7 @@ func (c *gitlabConnector) HandleCallback(s connector.Scopes, r *http.Request) (i return c.identity(ctx, s, token) } +// identity resolves the Dex identity fields from a GitLab access token. func (c *gitlabConnector) identity(ctx context.Context, s connector.Scopes, token *oauth2.Token) (identity connector.Identity, err error) { oauth2Config := c.oauth2Config(s) client := oauth2Config.Client(ctx, token) @@ -166,7 +228,7 @@ func (c *gitlabConnector) identity(ctx context.Context, s connector.Scopes, toke } if c.groupsRequired(s.Groups) { - groups, err := c.getGroups(ctx, client, s.Groups, user.Username) + groups, err := c.resolveIdentityGroups(ctx, client, s.Groups, user.Username, user.ID) if err != nil { return identity, fmt.Errorf("gitlab: get groups: %v", err) } @@ -185,6 +247,7 @@ func (c *gitlabConnector) identity(ctx context.Context, s connector.Scopes, toke return identity, nil } +// Refresh rebuilds the identity using the stored refresh token or access token. func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { var data connectorData if err := json.Unmarshal(ident.ConnectorData, &data); err != nil { @@ -221,6 +284,35 @@ func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident } } +// TokenIdentity is used for token exchange, verifying a GitLab access token +// and returning the associated user identity. This enables direct authentication +// with Dex using an existing GitLab token without going through the OAuth flow. +// +// Note: The connector decides whether to fetch groups based on its configuration +// (groups filter, getGroupsPermission), not on the scopes from the token exchange request. +// The server will then decide whether to include groups in the final token based on +// the requested scopes. This matches the behavior of other connectors (e.g., OIDC). +func (c *gitlabConnector) TokenIdentity(ctx context.Context, _, subjectToken string) (connector.Identity, error) { + if c.httpClient != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) + } + + token := &oauth2.Token{ + AccessToken: subjectToken, + TokenType: "Bearer", // GitLab tokens are typically Bearer tokens even if the type is not explicitly provided. + } + + // For token exchange, we determine if groups should be fetched based on connector configuration. + // If the connector has groups filter or getGroupsPermission enabled, we fetch groups. + scopes := connector.Scopes{ + // Scopes are not provided in token exchange, so we request groups every time and return only if configured. + Groups: true, + } + + return c.identity(ctx, scopes, token) +} + +// groupsRequired reports whether this request needs group resolution. func (c *gitlabConnector) groupsRequired(groupScope bool) bool { return len(c.groups) > 0 || groupScope } @@ -256,55 +348,325 @@ func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlab } type userInfo struct { - Groups []string + Groups []string `json:"groups"` + OwnerPermission []string `json:"https://gitlab.org/claims/groups/owner"` + MaintainerPermission []string `json:"https://gitlab.org/claims/groups/maintainer"` + DeveloperPermission []string `json:"https://gitlab.org/claims/groups/developer"` +} + +type gitlabGroup struct { + ID int `json:"id"` + FullPath string `json:"full_path"` + Path string `json:"path"` +} + +type gitlabGroupMember struct { + AccessLevel int `json:"access_level"` +} + +// resolveIdentityGroups resolves group claims and applies configured group filtering. +func (c *gitlabConnector) resolveIdentityGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string, userID int) ([]string, error) { + gitlabGroups, err := c.resolveGroupClaims(ctx, client, userID) + if err != nil { + return nil, err + } + + if len(c.groups) > 0 { + filteredGroups := groups.Filter(gitlabGroups, c.groups) + if len(filteredGroups) == 0 { + return nil, fmt.Errorf("gitlab: user %q is not in any of the required groups", userLogin) + } + return filteredGroups, nil + } else if groupScope { + return gitlabGroups, nil + } + + return nil, nil +} + +// resolveGroupClaims selects the direct or inherited group resolution path. +func (c *gitlabConnector) resolveGroupClaims(ctx context.Context, client *http.Client, userID int) ([]string, error) { + if c.inheritedGroups { + return c.resolveInheritedGroupClaims(ctx, client, userID) + } + + return c.resolveDirectGroupClaims(ctx, client) +} + +// resolveDirectGroupClaims returns group claims from the OIDC userinfo response. +func (c *gitlabConnector) resolveDirectGroupClaims(ctx context.Context, client *http.Client) ([]string, error) { + u, err := c.fetchUserInfo(ctx, client) + if err != nil { + return nil, err + } + + if !c.getGroupsPermission { + return u.Groups, nil + } + + return appendPermissionsFromUserInfo(u.Groups, u), nil +} + +// resolveInheritedGroupClaims returns group claims from the GitLab groups API. +func (c *gitlabConnector) resolveInheritedGroupClaims(ctx context.Context, client *http.Client, userID int) ([]string, error) { + groupRecords, err := c.fetchInheritedGroupRecords(ctx, client) + if err != nil { + return nil, err + } + + groupClaims := groupPathsFromRecords(groupRecords) + if !c.getGroupsPermission { + return groupClaims, nil + } + + u, err := c.fetchUserInfo(ctx, client) + if err != nil { + return nil, err + } + + groupClaims = appendPermissionsFromUserInfo(groupClaims, u) + if userID == 0 { + return nil, errors.New("gitlab: user id is required to fetch effective group permissions") + } + + for _, groupRecord := range groupRecords { + groupPath := groupPathFromRecord(groupRecord) + if groupPath == "" { + continue + } + + if _, ok := permissionFromUserInfo(groupPath, u); ok { + continue + } + + permission, ok, err := c.fetchEffectiveGroupPermission(ctx, client, groupRecord.ID, userID) + if err != nil { + return nil, err + } + if ok { + groupClaims = append(groupClaims, fmt.Sprintf("%s:%s", groupPath, permission)) + } + } + + return groupClaims, nil } -// userGroups queries the GitLab API for group membership. +// fetchUserInfo queries the GitLab OIDC userinfo endpoint for profile and direct group membership. // // The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package, // which inserts a bearer token as part of the request. -func (c *gitlabConnector) userGroups(ctx context.Context, client *http.Client) ([]string, error) { +func (c *gitlabConnector) fetchUserInfo(ctx context.Context, client *http.Client) (userInfo, error) { + var u userInfo req, err := http.NewRequest("GET", c.baseURL+"/oauth/userinfo", nil) if err != nil { - return nil, fmt.Errorf("gitlab: new req: %v", err) + return u, fmt.Errorf("gitlab: new req: %v", err) } req = req.WithContext(ctx) resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("gitlab: get URL %v", err) + return u, fmt.Errorf("gitlab: get URL %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("gitlab: read body: %v", err) + return u, fmt.Errorf("gitlab: read body: %v", err) } - return nil, fmt.Errorf("%s: %s", resp.Status, body) + return u, fmt.Errorf("%s: %s", resp.Status, body) } - var u userInfo if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { - return nil, fmt.Errorf("failed to decode response: %v", err) + return u, fmt.Errorf("failed to decode response: %v", err) + } + return u, nil +} + +// fetchInheritedGroupRecords queries the GitLab groups API for all groups the current user is a member of. +// When inheritedGroups is enabled, this becomes the source of truth for group names. +func (c *gitlabConnector) fetchInheritedGroupRecords(ctx context.Context, client *http.Client) ([]gitlabGroup, error) { + groupRecords := make([]gitlabGroup, 0) + for page := 1; ; page++ { + req, err := http.NewRequest("GET", c.baseURL+"/api/v4/groups", nil) + if err != nil { + return nil, fmt.Errorf("gitlab: new req: %v", err) + } + + q := req.URL.Query() + q.Set("all_available", "false") + q.Set("per_page", strconv.Itoa(inheritedGroupsPerPage)) + q.Set("page", strconv.Itoa(page)) + req.URL.RawQuery = q.Encode() + req = req.WithContext(ctx) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("gitlab: get URL %v", err) + } + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("gitlab: read body: %v", err) + } + return nil, fmt.Errorf("%s: %s", resp.Status, body) + } + + var pageGroupRecords []gitlabGroup + if err := json.NewDecoder(resp.Body).Decode(&pageGroupRecords); err != nil { + _ = resp.Body.Close() + return nil, fmt.Errorf("failed to decode response: %v", err) + } + _ = resp.Body.Close() + + groupRecords = append(groupRecords, pageGroupRecords...) + + if len(pageGroupRecords) < inheritedGroupsPerPage { + break + } } - return u.Groups, nil + return groupRecords, nil } -func (c *gitlabConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) { - gitlabGroups, err := c.userGroups(ctx, client) +// fetchEffectiveGroupPermission returns the effective permission for a user within a GitLab group. +func (c *gitlabConnector) fetchEffectiveGroupPermission(ctx context.Context, client *http.Client, groupID, userID int) (string, bool, error) { + if groupID == 0 { + return "", false, errors.New("gitlab: group id is required to fetch effective group permissions") + } + + req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v4/groups/%d/members/all/%d", c.baseURL, groupID, userID), nil) if err != nil { - return nil, err + return "", false, fmt.Errorf("gitlab: new req: %v", err) } - if len(c.groups) > 0 { - filteredGroups := groups.Filter(gitlabGroups, c.groups) - if len(filteredGroups) == 0 { - return nil, fmt.Errorf("gitlab: user %q is not in any of the required groups", userLogin) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return "", false, fmt.Errorf("gitlab: get URL %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", false, fmt.Errorf("gitlab: read body: %v", err) } - return filteredGroups, nil - } else if groupScope { - return gitlabGroups, nil + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound { + if c.logger != nil { + c.logger.Debug("gitlab: skipping effective group permission lookup", "groupID", groupID, "userID", userID, "status", resp.Status) + } + return "", false, nil + } + return "", false, fmt.Errorf("%s: %s", resp.Status, body) } - return nil, nil + var member gitlabGroupMember + if err := json.NewDecoder(resp.Body).Decode(&member); err != nil { + return "", false, fmt.Errorf("failed to decode response: %v", err) + } + + permission, ok := permissionFromAccessLevel(member.AccessLevel) + return permission, ok, nil +} + +// groupPathsFromRecords extracts claim-ready group paths from GitLab group records. +func groupPathsFromRecords(groupRecords []gitlabGroup) []string { + groupPaths := make([]string, 0, len(groupRecords)) + for _, groupRecord := range groupRecords { + if groupPath := groupPathFromRecord(groupRecord); groupPath != "" { + groupPaths = append(groupPaths, groupPath) + } + } + + return groupPaths +} + +// groupPathFromRecord returns the canonical path for a GitLab group record. +func groupPathFromRecord(groupRecord gitlabGroup) string { + if groupRecord.FullPath != "" { + return groupRecord.FullPath + } + return groupRecord.Path +} + +// appendPermissionsFromUserInfo adds permission-qualified group claims derived from userinfo. +func appendPermissionsFromUserInfo(groupPaths []string, u userInfo) []string { + groupsWithPermissions := append([]string(nil), groupPaths...) + for _, groupPath := range groupPaths { + if permission, ok := permissionFromUserInfo(groupPath, u); ok { + groupsWithPermissions = append(groupsWithPermissions, fmt.Sprintf("%s:%s", groupPath, permission)) + } + } + + return groupsWithPermissions +} + +// permissionFromUserInfo resolves a permission suffix for a group path from userinfo claims. +func permissionFromUserInfo(groupPath string, u userInfo) (string, bool) { + if matchesGroupPathOrAncestor(groupPath, u.OwnerPermission) { + return "owner", true + } + if matchesGroupPathOrAncestor(groupPath, u.MaintainerPermission) { + return "maintainer", true + } + if matchesGroupPathOrAncestor(groupPath, u.DeveloperPermission) { + return "developer", true + } + return "", false +} + +// matchesGroupPathOrAncestor reports whether a permission path applies to the given group path. +func matchesGroupPathOrAncestor(groupPath string, permissionPaths []string) bool { + for _, permissionPath := range permissionPaths { + // Exact group match, for example "ops" matches "ops". + if groupPath == permissionPath { + return true + } + + // A parent-group permission cannot match a shorter or equally long path. + if len(groupPath) <= len(permissionPath) { + continue + } + + // The permission path must be a prefix of the subgroup path. + if groupPath[0:len(permissionPath)] != permissionPath { + continue + } + + // Require a path separator so "dev" does not match "developer". + if string(groupPath[len(permissionPath)]) != "/" { + continue + } + + // Parent-group permissions apply to descendant subgroups. + return true + } + return false +} + +// permissionFromAccessLevel maps GitLab numeric access levels to permission suffix strings. +func permissionFromAccessLevel(accessLevel int) (string, bool) { + switch accessLevel { + case accessLevelMinimalAccess: + return "minimal_access", true + case accessLevelGuest: + return "guest", true + case accessLevelPlanner: + return "planner", true + case accessLevelReporter: + return "reporter", true + case accessLevelSecurityMgr: + return "security_manager", true + case accessLevelDeveloper: + return "developer", true + case accessLevelMaintainer: + return "maintainer", true + case accessLevelOwner: + return "owner", true + case accessLevelAdmin: + return "admin", true + default: + return "", false + } } diff --git a/connector/gitlab/gitlab_test.go b/connector/gitlab/gitlab_test.go index d828b8bd16..aba2e96056 100644 --- a/connector/gitlab/gitlab_test.go +++ b/connector/gitlab/gitlab_test.go @@ -4,15 +4,196 @@ import ( "context" "crypto/tls" "encoding/json" + "fmt" + "io" + "log/slog" "net/http" "net/http/httptest" "net/url" + "os" "reflect" + "strings" "testing" + "time" "github.com/dexidp/dex/connector" ) +func readValidRootCAData(t *testing.T) []byte { + t.Helper() + b, err := os.ReadFile("testdata/rootCA.pem") + if err != nil { + t.Fatalf("failed to read rootCA.pem testdata: %v", err) + } + return b +} + +func newLocalHTTPSTestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + + ts := httptest.NewUnstartedServer(handler) + cert, err := tls.LoadX509KeyPair("testdata/server.crt", "testdata/server.key") + if err != nil { + t.Fatalf("failed to load TLS test cert/key: %v", err) + } + ts.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + ts.StartTLS() + return ts +} + +func TestOpenWithRootCADataCreatesHTTPClient(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + cfg := &Config{ + RootCAData: readValidRootCAData(t), + } + + conn, err := cfg.Open("test", logger) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + gc, ok := conn.(*gitlabConnector) + if !ok { + t.Fatalf("expected *gitlabConnector, got %T", conn) + } + if gc.httpClient == nil { + t.Fatalf("expected httpClient to be non-nil") + } + if gc.httpClient.Timeout != 30*time.Second { + t.Fatalf("expected httpClient timeout %v, got %v", 30*time.Second, gc.httpClient.Timeout) + } + tr, ok := gc.httpClient.Transport.(*http.Transport) + if !ok { + t.Fatalf("expected transport to be *http.Transport, got %T", gc.httpClient.Transport) + } + // ProxyFromEnvironment is expected to be enabled (non-nil proxy func). + if tr.Proxy == nil { + t.Fatalf("expected transport.Proxy to be set (ProxyFromEnvironment)") + } + if tr.TLSClientConfig == nil || tr.TLSClientConfig.RootCAs == nil { + t.Fatalf("expected transport TLS root CAs to be configured") + } +} + +func TestOpenWithInvalidRootCADataReturnsError(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + cfg := &Config{ + RootCAData: []byte("not a pem"), + } + + _, err := cfg.Open("test", logger) + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), "invalid rootCAData") { + t.Fatalf("expected error to contain %q, got %q", "invalid rootCAData", err.Error()) + } +} + +func TestHandleCallbackCustomRootCADataEnablesTLSRequests(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + ts := newLocalHTTPSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + switch r.URL.Path { + case "/oauth/token": + // oauth2.Exchange expects an access token in response. + fmt.Fprint(w, `{"access_token":"abc","token_type":"bearer","expires_in":30}`) + case "/api/v4/user": + json.NewEncoder(w).Encode(gitlabUser{Email: "some@email.com", ID: 12345678}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + cfg := &Config{ + BaseURL: ts.URL, + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURI: "https://example.invalid/callback", + RootCAData: readValidRootCAData(t), + } + + conn, err := cfg.Open("test", logger) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + + hostURL, err := url.Parse(ts.URL) + expectNil(t, err) + req, err := http.NewRequest("GET", hostURL.String()+"?code=testcode", nil) + expectNil(t, err) + + identity, err := conn.(connector.CallbackConnector).HandleCallback(connector.Scopes{Groups: false}, nil, req) + if err != nil { + t.Fatalf("HandleCallback() error: %v", err) + } + if identity.Email != "some@email.com" || identity.UserID != "12345678" { + t.Fatalf("unexpected identity: %#v", identity) + } +} + +func TestHandleCallbackWithoutRootCADataFailsTLS(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + ts := newLocalHTTPSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + switch r.URL.Path { + case "/oauth/token": + fmt.Fprint(w, `{"access_token":"abc","token_type":"bearer","expires_in":30}`) + case "/api/v4/user": + json.NewEncoder(w).Encode(gitlabUser{Email: "some@email.com", ID: 12345678}) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + cfg := &Config{ + BaseURL: ts.URL, + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURI: "https://example.invalid/callback", + // RootCAData intentionally omitted: should fail TLS verification against our custom server cert. + } + + conn, err := cfg.Open("test", logger) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + + hostURL, err := url.Parse(ts.URL) + expectNil(t, err) + req, err := http.NewRequest("GET", hostURL.String()+"?code=testcode", nil) + expectNil(t, err) + + _, err = conn.(connector.CallbackConnector).HandleCallback(connector.Scopes{Groups: false}, nil, req) + if err == nil { + t.Fatalf("expected TLS error, got nil") + } +} + +func TestOAuth2ConfigScopesForInheritedGroups(t *testing.T) { + c := gitlabConnector{inheritedGroups: true} + + cfg := c.oauth2Config(connector.Scopes{}) + expectEquals(t, cfg.Scopes, []string{scopeUser}) + + cfg = c.oauth2Config(connector.Scopes{Groups: true}) + expectEquals(t, cfg.Scopes, []string{scopeUser, scopeOpenID, scopeReadAPI}) + + c.groups = []string{"team-1"} + cfg = c.oauth2Config(connector.Scopes{}) + expectEquals(t, cfg.Scopes, []string{scopeUser, scopeOpenID, scopeReadAPI}) + + c.getGroupsPermission = true + cfg = c.oauth2Config(connector.Scopes{Groups: true}) + expectEquals(t, cfg.Scopes, []string{scopeUser, scopeOpenID, scopeReadAPI}) +} + func TestUserGroups(t *testing.T) { s := newTestServer(map[string]interface{}{ "/oauth/userinfo": userInfo{ @@ -22,7 +203,7 @@ func TestUserGroups(t *testing.T) { defer s.Close() c := gitlabConnector{baseURL: s.URL} - groups, err := c.getGroups(context.Background(), newClient(), true, "joebloggs") + groups, err := c.resolveIdentityGroups(context.Background(), newClient(), true, "joebloggs", 12345678) expectNil(t, err) expectEquals(t, groups, []string{ @@ -31,6 +212,56 @@ func TestUserGroups(t *testing.T) { }) } +func TestUserGroupsWithInheritedGroups(t *testing.T) { + s := newTestServer(map[string]interface{}{ + "/oauth/userinfo": userInfo{ + Groups: []string{"team-legacy"}, + }, + "/api/v4/groups?all_available=false&page=1&per_page=100": []gitlabGroup{ + {FullPath: "team-1"}, + {FullPath: "team-2/sub"}, + }, + }) + defer s.Close() + + c := gitlabConnector{baseURL: s.URL, inheritedGroups: true} + groups, err := c.resolveIdentityGroups(context.Background(), newClient(), true, "joebloggs", 12345678) + + expectNil(t, err) + expectEquals(t, groups, []string{ + "team-1", + "team-2/sub", + }) +} + +func TestUserGroupsWithInheritedGroupsPagination(t *testing.T) { + pageOneGroups := make([]gitlabGroup, 0, inheritedGroupsPerPage) + expectedGroups := make([]string, 0, inheritedGroupsPerPage+1) + for i := 0; i < inheritedGroupsPerPage; i++ { + group := fmt.Sprintf("team-%03d", i) + pageOneGroups = append(pageOneGroups, gitlabGroup{FullPath: group}) + expectedGroups = append(expectedGroups, group) + } + expectedGroups = append(expectedGroups, "team-100") + + s := newTestServer(map[string]interface{}{ + "/oauth/userinfo": userInfo{ + Groups: []string{}, + }, + "/api/v4/groups?all_available=false&page=1&per_page=100": pageOneGroups, + "/api/v4/groups?all_available=false&page=2&per_page=100": []gitlabGroup{ + {FullPath: "team-100"}, + }, + }) + defer s.Close() + + c := gitlabConnector{baseURL: s.URL, inheritedGroups: true} + groups, err := c.resolveIdentityGroups(context.Background(), newClient(), true, "joebloggs", 12345678) + + expectNil(t, err) + expectEquals(t, groups, expectedGroups) +} + func TestUserGroupsWithFiltering(t *testing.T) { s := newTestServer(map[string]interface{}{ "/oauth/userinfo": userInfo{ @@ -40,7 +271,7 @@ func TestUserGroupsWithFiltering(t *testing.T) { defer s.Close() c := gitlabConnector{baseURL: s.URL, groups: []string{"team-1"}} - groups, err := c.getGroups(context.Background(), newClient(), true, "joebloggs") + groups, err := c.resolveIdentityGroups(context.Background(), newClient(), true, "joebloggs", 12345678) expectNil(t, err) expectEquals(t, groups, []string{ @@ -48,6 +279,31 @@ func TestUserGroupsWithFiltering(t *testing.T) { }) } +func TestUserGroupsWithInheritedGroupsFiltering(t *testing.T) { + s := newTestServer(map[string]interface{}{ + "/oauth/userinfo": userInfo{ + Groups: []string{"team-legacy"}, + }, + "/api/v4/groups?all_available=false&page=1&per_page=100": []gitlabGroup{ + {FullPath: "team-1"}, + {FullPath: "team-2/sub"}, + }, + }) + defer s.Close() + + c := gitlabConnector{ + baseURL: s.URL, + groups: []string{"team-2/sub"}, + inheritedGroups: true, + } + groups, err := c.resolveIdentityGroups(context.Background(), newClient(), true, "joebloggs", 12345678) + + expectNil(t, err) + expectEquals(t, groups, []string{ + "team-2/sub", + }) +} + func TestUserGroupsWithoutOrgs(t *testing.T) { s := newTestServer(map[string]interface{}{ "/oauth/userinfo": userInfo{ @@ -57,7 +313,7 @@ func TestUserGroupsWithoutOrgs(t *testing.T) { defer s.Close() c := gitlabConnector{baseURL: s.URL} - groups, err := c.getGroups(context.Background(), newClient(), true, "joebloggs") + groups, err := c.resolveIdentityGroups(context.Background(), newClient(), true, "joebloggs", 12345678) expectNil(t, err) expectEquals(t, len(groups), 0) @@ -84,7 +340,7 @@ func TestUsernameIncludedInFederatedIdentity(t *testing.T) { expectNil(t, err) c := gitlabConnector{baseURL: s.URL, httpClient: newClient()} - identity, err := c.HandleCallback(connector.Scopes{Groups: false}, req) + identity, err := c.HandleCallback(connector.Scopes{Groups: false}, nil, req) expectNil(t, err) expectEquals(t, identity.Username, "some@email.com") @@ -92,7 +348,7 @@ func TestUsernameIncludedInFederatedIdentity(t *testing.T) { expectEquals(t, 0, len(identity.Groups)) c = gitlabConnector{baseURL: s.URL, httpClient: newClient()} - identity, err = c.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err = c.HandleCallback(connector.Scopes{Groups: true}, nil, req) expectNil(t, err) expectEquals(t, identity.Username, "some@email.com") @@ -120,7 +376,7 @@ func TestLoginUsedAsIDWhenConfigured(t *testing.T) { expectNil(t, err) c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), useLoginAsID: true} - identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := c.HandleCallback(connector.Scopes{Groups: true}, nil, req) expectNil(t, err) expectEquals(t, identity.UserID, "joebloggs") @@ -147,7 +403,7 @@ func TestLoginWithTeamWhitelisted(t *testing.T) { expectNil(t, err) c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), groups: []string{"team-1"}} - identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := c.HandleCallback(connector.Scopes{Groups: true}, nil, req) expectNil(t, err) expectEquals(t, identity.UserID, "12345678") @@ -174,7 +430,7 @@ func TestLoginWithTeamNonWhitelisted(t *testing.T) { expectNil(t, err) c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), groups: []string{"team-2"}} - _, err = c.HandleCallback(connector.Scopes{Groups: true}, req) + _, err = c.HandleCallback(connector.Scopes{Groups: true}, nil, req) expectNotNil(t, err, "HandleCallback error") expectEquals(t, err.Error(), "gitlab: get groups: gitlab: user \"joebloggs\" is not in any of the required groups") @@ -208,7 +464,7 @@ func TestRefresh(t *testing.T) { }) expectNil(t, err) - identity, err := c.HandleCallback(connector.Scopes{OfflineAccess: true}, req) + identity, err := c.HandleCallback(connector.Scopes{OfflineAccess: true}, nil, req) expectNil(t, err) expectEquals(t, identity.Username, "some@email.com") expectEquals(t, identity.UserID, "12345678") @@ -249,6 +505,156 @@ func TestRefreshWithEmptyConnectorData(t *testing.T) { expectEquals(t, emptyIdentity, identity) } +func TestGroupsWithPermission(t *testing.T) { + s := newTestServer(map[string]interface{}{ + "/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678, Name: "Joe Bloggs", Username: "joebloggs"}, + "/oauth/token": map[string]interface{}{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9", + "expires_in": "30", + }, + "/oauth/userinfo": userInfo{ + Groups: []string{"ops", "dev", "ops-test", "ops/project", "dev/project1", "dev/project2"}, + OwnerPermission: []string{"ops"}, + DeveloperPermission: []string{"dev"}, + MaintainerPermission: []string{"dev/project1"}, + }, + }) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + req, err := http.NewRequest("GET", hostURL.String(), nil) + expectNil(t, err) + + c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), getGroupsPermission: true} + identity, err := c.HandleCallback(connector.Scopes{Groups: true}, nil, req) + expectNil(t, err) + + expectEquals(t, identity.Groups, []string{ + "ops", + "dev", + "ops-test", + "ops/project", + "dev/project1", + "dev/project2", + "ops:owner", + "dev:developer", + "ops/project:owner", + "dev/project1:maintainer", + "dev/project2:developer", + }) +} + +func TestGroupsWithPermissionAndInheritedGroups(t *testing.T) { + s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + + switch r.RequestURI { + case "/api/v4/user": + json.NewEncoder(w).Encode(gitlabUser{Email: "some@email.com", ID: 12345678, Name: "Joe Bloggs", Username: "joebloggs"}) + case "/oauth/token": + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9", + "expires_in": "30", + }) + case "/oauth/userinfo": + json.NewEncoder(w).Encode(userInfo{ + Groups: []string{"ignored-direct-group"}, + OwnerPermission: []string{"ops"}, + }) + case "/api/v4/groups?all_available=false&page=1&per_page=100": + json.NewEncoder(w).Encode([]gitlabGroup{ + {ID: 1, FullPath: "ops"}, + {ID: 2, FullPath: "ops/project"}, + {ID: 3, FullPath: "analytics"}, + }) + case "/api/v4/groups/3/members/all/12345678": + json.NewEncoder(w).Encode(gitlabGroupMember{AccessLevel: accessLevelReporter}) + default: + http.NotFound(w, r) + } + })) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + req, err := http.NewRequest("GET", hostURL.String(), nil) + expectNil(t, err) + + c := gitlabConnector{ + baseURL: s.URL, + httpClient: newClient(), + getGroupsPermission: true, + inheritedGroups: true, + } + identity, err := c.HandleCallback(connector.Scopes{Groups: true}, nil, req) + expectNil(t, err) + + expectEquals(t, identity.Groups, []string{ + "ops", + "ops/project", + "analytics", + "ops:owner", + "ops/project:owner", + "analytics:reporter", + }) +} + +func TestGroupsWithPermissionAndInheritedGroupsSkipsForbiddenPermissionLookup(t *testing.T) { + s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + + switch r.RequestURI { + case "/api/v4/user": + json.NewEncoder(w).Encode(gitlabUser{Email: "some@email.com", ID: 12345678, Name: "Joe Bloggs", Username: "joebloggs"}) + case "/oauth/token": + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9", + "expires_in": "30", + }) + case "/oauth/userinfo": + json.NewEncoder(w).Encode(userInfo{ + Groups: []string{"ignored-direct-group"}, + OwnerPermission: []string{"ops"}, + }) + case "/api/v4/groups?all_available=false&page=1&per_page=100": + json.NewEncoder(w).Encode([]gitlabGroup{ + {ID: 1, FullPath: "ops"}, + {ID: 2, FullPath: "private/analytics"}, + }) + case "/api/v4/groups/2/members/all/12345678": + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"403 Forbidden"}`)) + default: + http.NotFound(w, r) + } + })) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + req, err := http.NewRequest("GET", hostURL.String(), nil) + expectNil(t, err) + + c := gitlabConnector{ + baseURL: s.URL, + httpClient: newClient(), + getGroupsPermission: true, + inheritedGroups: true, + } + identity, err := c.HandleCallback(connector.Scopes{Groups: true}, nil, req) + expectNil(t, err) + + expectEquals(t, identity.Groups, []string{ + "ops", + "private/analytics", + "ops:owner", + }) +} + func newTestServer(responses map[string]interface{}) *httptest.Server { return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := responses[r.RequestURI] @@ -281,3 +687,88 @@ func expectEquals(t *testing.T, a interface{}, b interface{}) { t.Errorf("Expected %+v to equal %+v", a, b) } } + +func TestTokenIdentity(t *testing.T) { + // Note: These tests verify that the connector returns groups based on its configuration. + // The actual inclusion of groups in the final Dex token depends on the 'groups' scope + // in the token exchange request, which is handled by the Dex server, not the connector. + tests := []struct { + name string + userInfo userInfo + groups []string + getGroupsPermission bool + useLoginAsID bool + expectUserID string + expectGroups []string + }{ + { + name: "without groups config", + expectUserID: "12345678", + expectGroups: nil, + }, + { + name: "with groups filter", + userInfo: userInfo{ + Groups: []string{"team-1", "team-2"}, + }, + groups: []string{"team-1"}, + expectUserID: "12345678", + expectGroups: []string{"team-1"}, + }, + { + name: "with groups permission", + userInfo: userInfo{ + Groups: []string{"ops", "dev"}, + OwnerPermission: []string{"ops"}, + DeveloperPermission: []string{"dev"}, + MaintainerPermission: []string{}, + }, + getGroupsPermission: true, + expectUserID: "12345678", + expectGroups: []string{"ops", "dev", "ops:owner", "dev:developer"}, + }, + { + name: "with useLoginAsID", + useLoginAsID: true, + expectUserID: "joebloggs", + expectGroups: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + responses := map[string]interface{}{ + "/api/v4/user": gitlabUser{ + Email: "some@email.com", + ID: 12345678, + Name: "Joe Bloggs", + Username: "joebloggs", + }, + "/oauth/userinfo": tc.userInfo, + } + + s := newTestServer(responses) + defer s.Close() + + c := gitlabConnector{ + baseURL: s.URL, + httpClient: newClient(), + groups: tc.groups, + getGroupsPermission: tc.getGroupsPermission, + useLoginAsID: tc.useLoginAsID, + } + + accessToken := "test-access-token" + ctx := context.Background() + identity, err := c.TokenIdentity(ctx, "urn:ietf:params:oauth:token-type:access_token", accessToken) + + expectNil(t, err) + expectEquals(t, identity.UserID, tc.expectUserID) + expectEquals(t, identity.Username, "Joe Bloggs") + expectEquals(t, identity.PreferredUsername, "joebloggs") + expectEquals(t, identity.Email, "some@email.com") + expectEquals(t, identity.EmailVerified, true) + expectEquals(t, identity.Groups, tc.expectGroups) + }) + } +} diff --git a/connector/gitlab/testdata/rootCA.pem b/connector/gitlab/testdata/rootCA.pem new file mode 100644 index 0000000000..c03bdac0c0 --- /dev/null +++ b/connector/gitlab/testdata/rootCA.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID1jCCAr4CCQCG4JBeSi6cDjANBgkqhkiG9w0BAQsFADCBrDELMAkGA1UEBhMC +VVMxFDASBgNVBAgMC1JhbmRvbVN0YXRlMRMwEQYDVQQHDApSYW5kb21DaXR5MRsw +GQYDVQQKDBJSYW5kb21Pcmdhbml6YXRpb24xHzAdBgNVBAsMFlJhbmRvbU9yZ2Fu +aXphdGlvblVuaXQxIDAeBgkqhkiG9w0BCQEWEWhlbGxvQGV4YW1wbGUuY29tMRIw +EAYDVQQDDAlsb2NhbGhvc3QwHhcNMjIxMDA3MjIwNjQwWhcNMzIxMDA0MjIwNjQw +WjCBrDELMAkGA1UEBhMCVVMxFDASBgNVBAgMC1JhbmRvbVN0YXRlMRMwEQYDVQQH +DApSYW5kb21DaXR5MRswGQYDVQQKDBJSYW5kb21Pcmdhbml6YXRpb24xHzAdBgNV +BAsMFlJhbmRvbU9yZ2FuaXphdGlvblVuaXQxIDAeBgkqhkiG9w0BCQEWEWhlbGxv +QGV4YW1wbGUuY29tMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDh0HlpAKMKYyxbvW70XRY2bVNiNdAFninug1P4FDAJ +z8xnbFzk17FLY7zqdtGTDmPDJ8AAxIwpGv2zYWW5VMeqKWfvyuD5dSCauY1Pdmug +uZbpAvoJrx1sw+TL61ByVmy8x3ccB4LLKuzil/vAzUDJQkPsfTECVUPV+yiGSDuO +EEVR9X6rZUwx2expXm8Wtb/a88FbPVI09b9eb4iWfLvGD2eNAtw8w21W0X7sQ8Hq +zEPqquMEL4qPnNDdtk592uHvLLrd1uH8qH7c1JyA76T7H3YeUCNEi+PnLgqtsZmX +sKY62HnLt8/LAClVsN9lFYkKEjU9V+U7IN2cL6+EwtsdAgMBAAEwDQYJKoZIhvcN +AQELBQADggEBAN6g0qit/3R2X+KdR0LgRXF/h4qQFgcV6cxnhRAmLIDNJlxKSHqN +IE5+bxzCbkblzGfr/jNPqW0s+yaN4CyMgKNYSzkLBPE4FF+19Uv+dyYfFms3mDJ7 +0rGjS5bCscThWhpaSw20LcwQcr/+X+/fGzJ01dVFK1UOjBKg4d4dMwxklbIkZqIq +siRW0GMy26mgVZ/BSjeh5kEjs6h6H3cJsGl7xYT+BI7wnxHwGeT9tkBgiyT5FwaS +vtdZkBpQ9q8f7FwsEm3woLHdWuOnrtUtVpY/oc6WFGdROQdGzjSk0D3kHs9YhueC +GSzZKrqX+TSIgpPrLYNHX4uxlo5TAwP/5GM= +-----END CERTIFICATE----- diff --git a/connector/gitlab/testdata/server.crt b/connector/gitlab/testdata/server.crt new file mode 100644 index 0000000000..9b0f12ec58 --- /dev/null +++ b/connector/gitlab/testdata/server.crt @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE5TCCA82gAwIBAgIJAMGzXwBRpkG7MA0GCSqGSIb3DQEBCwUAMIGsMQswCQYD +VQQGEwJVUzEUMBIGA1UECAwLUmFuZG9tU3RhdGUxEzARBgNVBAcMClJhbmRvbUNp +dHkxGzAZBgNVBAoMElJhbmRvbU9yZ2FuaXphdGlvbjEfMB0GA1UECwwWUmFuZG9t +T3JnYW5pemF0aW9uVW5pdDEgMB4GCSqGSIb3DQEJARYRaGVsbG9AZXhhbXBsZS5j +b20xEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMjEwMDcyMjA3MDhaFw0zMjEwMDQy +MjA3MDhaMIGsMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLUmFuZG9tU3RhdGUxEzAR +BgNVBAcMClJhbmRvbUNpdHkxGzAZBgNVBAoMElJhbmRvbU9yZ2FuaXphdGlvbjEf +MB0GA1UECwwWUmFuZG9tT3JnYW5pemF0aW9uVW5pdDEgMB4GCSqGSIb3DQEJARYR +aGVsbG9AZXhhbXBsZS5jb20xEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAMuKdpXP87Q7Kg3iafXzvBuVIyV1K5UmMYiN +koztkC5XrCzHaQRS/CoIb7/nUqmtAxx7RL0jzhZ93zBN4HY/Zcnrd9tXoPPxi0mG +ZZWfFU6nN8nOkMHWzEbHVBmhxpfGtwmLcajQ4HrK1TZwJUn6GqclHQRy/gjxkiw5 +KPqzfVOVlA6ht4KdKstKazQkWZ5gdWT4d8yrEy/IT4oaW05xALBMQ7YGjkzWKsSF +6ygXI7xqF9rg9jCnUsPYg4f8ut3N0c00KjsfKOOj2dF/ZyjedQ5c0u4hHmxSo3Ka +0ZTmIrMfbVXgGjxRG2HZXLpPvQKoCf/fOX8Irdr+lahFVKASxN0CAwEAAaOCAQYw +ggECMIHLBgNVHSMEgcMwgcChgbKkga8wgawxCzAJBgNVBAYTAlVTMRQwEgYDVQQI +DAtSYW5kb21TdGF0ZTETMBEGA1UEBwwKUmFuZG9tQ2l0eTEbMBkGA1UECgwSUmFu +ZG9tT3JnYW5pemF0aW9uMR8wHQYDVQQLDBZSYW5kb21Pcmdhbml6YXRpb25Vbml0 +MSAwHgYJKoZIhvcNAQkBFhFoZWxsb0BleGFtcGxlLmNvbTESMBAGA1UEAwwJbG9j +YWxob3N0ggkAhuCQXkounA4wCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwGgYDVR0R +BBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCWmh5ebpkm +v2B1yQgarSCSSkLZ5DZSAJjrPgW2IJqCW2q2D1HworbW1Yn5jqrM9FKGnJfjCyve +zBB5AOlGp+0bsZGgMRMCavgv4QhTThXUoJqqHcfEu4wHndcgrqSadxmV5aisSR4u +gXnjW43o3akby+h1K40RR3vVkpzPaoC3/bgk7WVpfpPiP32E24a01gETozRb/of/ +ATN3JBe0xh+e63CrPX1sago5+u3UETIoOr0fW8M/gU9GApmJiFAXwHag6j54hLCG +23EtVDwmlarG8Pj+i0yru8s22QqzAJi5E0OwR4aB8tqicLKYBVfzyLCOielIBUrK +OkuFKp+VjxQX +-----END CERTIFICATE----- diff --git a/connector/gitlab/testdata/server.key b/connector/gitlab/testdata/server.key new file mode 100644 index 0000000000..9708e1e6ea --- /dev/null +++ b/connector/gitlab/testdata/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLinaVz/O0OyoN +4mn187wblSMldSuVJjGIjZKM7ZAuV6wsx2kEUvwqCG+/51KprQMce0S9I84Wfd8w +TeB2P2XJ63fbV6Dz8YtJhmWVnxVOpzfJzpDB1sxGx1QZocaXxrcJi3Go0OB6ytU2 +cCVJ+hqnJR0Ecv4I8ZIsOSj6s31TlZQOobeCnSrLSms0JFmeYHVk+HfMqxMvyE+K +GltOcQCwTEO2Bo5M1irEhesoFyO8ahfa4PYwp1LD2IOH/LrdzdHNNCo7Hyjjo9nR +f2co3nUOXNLuIR5sUqNymtGU5iKzH21V4Bo8URth2Vy6T70CqAn/3zl/CK3a/pWo +RVSgEsTdAgMBAAECggEAU6cxu7q+54kVbKVsdThaTF/MFR4F7oPHAd9lpuQQSOuh +iLngMHXGy6OyAgYZlEDWMYN8KdwoXFgZPaoUIaVGuWk8Vnq6XOgeHfbNk2PRhwT0 +yc1K80/Lnx9XMj2p+EEkgxi7eu12BSGN5ZTLzo6rG50GQwjb3WMjd2d6rybL0GjC +wg2arcBk3sSMYmvZOqlAsaQmtgwkJhvhVkVfEQSD3VKF7g0dh/h3LIPyM0Ff4M67 +KpLMPPwzUJ/0Z4ewAP06mMKUA86R93M+dWs2eh1oBGnRkVQdhCJLXJpuGHZ6BTiB +Ry0AeorHfnVXPbtpUeAq6m5/BBl6qX0ooB08BIFwAQKBgQDqJpTZS/ZzqL6Kcs14 +MyFu+7DungSxQ5oK9ju7EFSosanSk4UEa/lw992kM6nsIMwgSVQgba5zKcVMeSmk +AVbpznegQD1BYCwOGwbGvkJ8jbhPy+WLbbRjWT/E6AItZgUK+fyTIcNvSehcQqsT +fhgWsK7ueZCmLQfVhK1AxtvY3QKBgQDeiKuo8plsH/7IxDn7KVHBOHKPC2ZPzg03 +i7La6zomiRckwwPnhicRSYsjtfCCW6Ms+uzjTEItgFM+5PdrXheeku+z/sExRtZu +emqPqDomixlXDRQ6RN3gnBSk4RU+ROB1u1uBLWXqRz8Gp2zJGRxhHfYt2zefBv4w +/cIuPC3cAQKBgD2UsAkGJWb9tj8LOmama+CYaUwYWvuT3+uKHuNvxBQpxZQQICet +jgjb53rL66Cib4z+PBXbQsoe7jjSlNUBVS5gkq2et31+IZgEG6AhYbMIQrUZ1uD4 +lTybuF289vWhoynj3T2E37VhJq89CWky/HrbNOabKiPKLAlHv5kNs7wxAoGBANEJ +XQbU7J2O6Iy7FyQBSlTQq3wHX1Iz4mJ9DcNrFzK/sEfOEMrZT7WDefpPm984KW3F +P+S766ZGVuxLtMbcmh9RM23HLr8VJbSdtZ/AjO9L1r/Y/1lE+49TzmibLpNRq++r +0WbkuEl8J44ek6fLuMbZmDi3JeZycTCgDlnUGdgBAoGAYdliovtURZCm46t1uE3F +idCLCXCccjkt1hcNGNjck/b0trHA7wOEqICIguoWDlEBTc0PDvHEq6PfKyqptGkj +AgaZTMF/aZiGqlT7VRpBuzxM/uV5xzCg+i2ViaW/p3xq0z2PRljVZiEfe5aWcjiM +ouTtnC3TgmcjhTgGmb48QQE= +-----END PRIVATE KEY----- diff --git a/connector/google/google.go b/connector/google/google.go index 72cc6a18a5..68bdf18ccd 100644 --- a/connector/google/google.go +++ b/connector/google/google.go @@ -5,23 +5,28 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "os" + "strings" "time" + "cloud.google.com/go/compute/metadata" "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/exp/slices" "golang.org/x/oauth2" "golang.org/x/oauth2/google" admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/impersonate" "google.golang.org/api/option" "github.com/dexidp/dex/connector" pkg_groups "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" ) const ( - issuerURL = "https://accounts.google.com" + issuerURL = "https://accounts.google.com" + wildcardDomainToAdminEmail = "*" ) // Config holds configuration options for Google logins. @@ -45,17 +50,33 @@ type Config struct { // check groups with the admin directory api ServiceAccountFilePath string `json:"serviceAccountFilePath"` + // Deprecated: Use DomainToAdminEmail + AdminEmail string + // Required if ServiceAccountFilePath - // The email of a GSuite super user which the service account will impersonate + // The map workspace domain to email of a GSuite super user which the service account will impersonate // when listing groups - AdminEmail string + DomainToAdminEmail map[string]string // If this field is true, fetch direct group membership and transitive group membership FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership"` + + // Optional value for the prompt parameter, defaults to consent when offline_access + // scope is requested + PromptType *string `json:"promptType"` } // Open returns a connector which can be used to login users through Google. -func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) { +func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, err error) { + logger = logger.With(slog.Group("connector", "type", "google", "id", id)) + if c.AdminEmail != "" { + logger.Warn(`use "domainToAdminEmail.*" option instead of "adminEmail"`, "deprecated", true) + if c.DomainToAdminEmail == nil { + c.DomainToAdminEmail = make(map[string]string) + } + + c.DomainToAdminEmail[wildcardDomainToAdminEmail] = c.AdminEmail + } ctx, cancel := context.WithCancel(context.Background()) provider, err := oidc.NewProvider(ctx, issuerURL) @@ -71,10 +92,30 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e scopes = append(scopes, "profile", "email") } - srv, err := createDirectoryService(c.ServiceAccountFilePath, c.AdminEmail, logger) - if err != nil { + adminSrv := make(map[string]*admin.Service) + + // We know impersonation is required when using a service account credential + // TODO: or is it? + if len(c.DomainToAdminEmail) == 0 && c.ServiceAccountFilePath != "" { cancel() - return nil, fmt.Errorf("could not create directory service: %v", err) + return nil, fmt.Errorf("directory service requires the domainToAdminEmail option to be configured") + } + + if (len(c.DomainToAdminEmail) > 0) || slices.Contains(scopes, "groups") { + for domain, adminEmail := range c.DomainToAdminEmail { + srv, err := createDirectoryService(c.ServiceAccountFilePath, adminEmail, logger) + if err != nil { + cancel() + return nil, fmt.Errorf("could not create directory service: %v", err) + } + + adminSrv[domain] = srv + } + } + + promptType := "consent" + if c.PromptType != nil { + promptType = *c.PromptType } clientID := c.ClientID @@ -95,9 +136,10 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e hostedDomains: c.HostedDomains, groups: c.Groups, serviceAccountFilePath: c.ServiceAccountFilePath, - adminEmail: c.AdminEmail, + domainToAdminEmail: c.DomainToAdminEmail, fetchTransitiveGroupMembership: c.FetchTransitiveGroupMembership, - adminSrv: srv, + adminSrv: adminSrv, + promptType: promptType, }, nil } @@ -111,13 +153,14 @@ type googleConnector struct { oauth2Config *oauth2.Config verifier *oidc.IDTokenVerifier cancel context.CancelFunc - logger log.Logger + logger *slog.Logger hostedDomains []string groups []string serviceAccountFilePath string - adminEmail string + domainToAdminEmail map[string]string fetchTransitiveGroupMembership bool - adminSrv *admin.Service + adminSrv map[string]*admin.Service + promptType string } func (c *googleConnector) Close() error { @@ -125,9 +168,9 @@ func (c *googleConnector) Close() error { return nil } -func (c *googleConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) { +func (c *googleConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, []byte, error) { if c.redirectURI != callbackURL { - return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) } var opts []oauth2.AuthCodeOption @@ -140,9 +183,10 @@ func (c *googleConnector) LoginURL(s connector.Scopes, callbackURL, state string } if s.OfflineAccess { - opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent")) + opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", c.promptType)) } - return c.oauth2Config.AuthCodeURL(state, opts...), nil + + return c.oauth2Config.AuthCodeURL(state, opts...), nil, nil } type oauth2Error struct { @@ -157,7 +201,7 @@ func (e *oauth2Error) Error() string { return e.error + ": " + e.errorDescription } -func (c *googleConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { +func (c *googleConnector) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} @@ -203,6 +247,15 @@ func (c *googleConnector) createIdentity(ctx context.Context, identity connector return identity, fmt.Errorf("oidc: failed to decode claims: %v", err) } + // Google sometimes do not return username and other claims. It is correct, according to OIDC spec. + // One option to solve this is to call the user endpoint, but Google throttles it more aggressive than + // the token endpoint. For concurrent refreshes it is an unwanted behavior. + // As a tradeoff, dex preserves previous username and preferred username if absent in the ide token + // as a way to keep the claims and do not call the userinfo endpoint. + if claims.Username == "" { + claims.Username = identity.Username + } + if len(c.hostedDomains) > 0 { found := false for _, domain := range c.hostedDomains { @@ -218,8 +271,9 @@ func (c *googleConnector) createIdentity(ctx context.Context, identity connector } var groups []string - if s.Groups && c.adminSrv != nil { - groups, err = c.getGroups(claims.Email, c.fetchTransitiveGroupMembership) + if s.Groups && len(c.adminSrv) > 0 { + checkedGroups := make(map[string]struct{}) + groups, err = c.getGroups(claims.Email, c.fetchTransitiveGroupMembership, checkedGroups) if err != nil { return identity, fmt.Errorf("google: could not retrieve groups: %v", err) } @@ -245,30 +299,43 @@ func (c *googleConnector) createIdentity(ctx context.Context, identity connector // getGroups creates a connection to the admin directory service and lists // all groups the user is a member of -func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership bool) ([]string, error) { +func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership bool, checkedGroups map[string]struct{}) ([]string, error) { var userGroups []string var err error groupsList := &admin.Groups{} + domain := c.extractDomainFromEmail(email) + adminSrv, err := c.findAdminService(domain) + if err != nil { + return nil, err + } + for { - groupsList, err = c.adminSrv.Groups.List(). + groupsList, err = adminSrv.Groups.List(). UserKey(email).PageToken(groupsList.NextPageToken).Do() if err != nil { return nil, fmt.Errorf("could not list groups: %v", err) } for _, group := range groupsList.Groups { + if _, exists := checkedGroups[group.Email]; exists { + continue + } + + checkedGroups[group.Email] = struct{}{} // TODO (joelspeed): Make desired group key configurable userGroups = append(userGroups, group.Email) - // getGroups takes a user's email/alias as well as a group's email/alias - if fetchTransitiveGroupMembership { - transitiveGroups, err := c.getGroups(group.Email, fetchTransitiveGroupMembership) - if err != nil { - return nil, fmt.Errorf("could not list transitive groups: %v", err) - } + if !fetchTransitiveGroupMembership { + continue + } - userGroups = append(userGroups, transitiveGroups...) + // getGroups takes a user's email/alias as well as a group's email/alias + transitiveGroups, err := c.getGroups(group.Email, fetchTransitiveGroupMembership, checkedGroups) + if err != nil { + return nil, fmt.Errorf("could not list transitive groups: %v", err) } + + userGroups = append(userGroups, transitiveGroups...) } if groupsList.NextPageToken == "" { @@ -276,51 +343,124 @@ func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership } } - return uniqueGroups(userGroups), nil + return userGroups, nil +} + +func (c *googleConnector) findAdminService(domain string) (*admin.Service, error) { + adminSrv, ok := c.adminSrv[domain] + if !ok { + adminSrv, ok = c.adminSrv[wildcardDomainToAdminEmail] + c.logger.Debug("using wildcard admin email to fetch groups", "admin_email", c.domainToAdminEmail[wildcardDomainToAdminEmail]) + } + + if !ok { + return nil, fmt.Errorf("unable to find super admin email, domainToAdminEmail for domain: %s not set, %s is also empty", domain, wildcardDomainToAdminEmail) + } + + return adminSrv, nil +} + +// extracts the domain name from an email input. If the email is valid, it returns the domain name after the "@" symbol. +// However, in the case of a broken or invalid email, it returns a wildcard symbol. +func (c *googleConnector) extractDomainFromEmail(email string) string { + at := strings.LastIndex(email, "@") + if at >= 0 { + _, domain := email[:at], email[at+1:] + + return domain + } + + return wildcardDomainToAdminEmail +} + +// getCredentialsFromFilePath reads and returns the service account credentials from the file at the provided path. +// If an error occurs during the read, it is returned. +func getCredentialsFromFilePath(serviceAccountFilePath string) ([]byte, error) { + jsonCredentials, err := os.ReadFile(serviceAccountFilePath) + if err != nil { + return nil, fmt.Errorf("error reading credentials from file: %v", err) + } + return jsonCredentials, nil +} + +// getCredentialsFromDefault retrieves the application's default credentials. +// If the default credential is empty, it attempts to create a new service with metadata credentials. +// If successful, it returns the service and nil error. +// If unsuccessful, it returns the error and a nil service. +func getCredentialsFromDefault(ctx context.Context, email string, logger *slog.Logger) ([]byte, *admin.Service, error) { + credential, err := google.FindDefaultCredentials(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch application default credentials: %w", err) + } + + if credential.JSON == nil { + logger.Info("JSON is empty, using flow for GCE") + service, err := createServiceWithMetadataServer(ctx, email, logger) + if err != nil { + return nil, nil, err + } + return nil, service, nil + } + + return credential.JSON, nil, nil +} + +// createServiceWithMetadataServer creates a new service using metadata server. +// If an error occurs during the process, it is returned along with a nil service. +func createServiceWithMetadataServer(ctx context.Context, adminEmail string, logger *slog.Logger) (*admin.Service, error) { + serviceAccountEmail, err := metadata.EmailWithContext(ctx, "default") + logger.Info("discovered serviceAccountEmail", "email", serviceAccountEmail) + + if err != nil { + return nil, fmt.Errorf("unable to get service account email from metadata server: %v", err) + } + + config := impersonate.CredentialsConfig{ + TargetPrincipal: serviceAccountEmail, + Scopes: []string{admin.AdminDirectoryGroupReadonlyScope}, + Lifetime: 0, + Subject: adminEmail, + } + + tokenSource, err := impersonate.CredentialsTokenSource(ctx, config) + if err != nil { + return nil, fmt.Errorf("unable to impersonate with %s, error: %v", adminEmail, err) + } + + return admin.NewService(ctx, option.WithHTTPClient(oauth2.NewClient(ctx, tokenSource))) } // createDirectoryService sets up super user impersonation and creates an admin client for calling // the google admin api. If no serviceAccountFilePath is defined, the application default credential // is used. -func createDirectoryService(serviceAccountFilePath, email string, logger log.Logger) (*admin.Service, error) { - if email == "" { - return nil, fmt.Errorf("directory service requires adminEmail") - } - +func createDirectoryService(serviceAccountFilePath, email string, logger *slog.Logger) (service *admin.Service, err error) { var jsonCredentials []byte - var err error ctx := context.Background() if serviceAccountFilePath == "" { logger.Warn("the application default credential is used since the service account file path is not used") - credential, err := google.FindDefaultCredentials(ctx) + jsonCredentials, service, err = getCredentialsFromDefault(ctx, email, logger) if err != nil { - return nil, fmt.Errorf("failed to fetch application default credentials: %w", err) + return + } + if service != nil { + return } - jsonCredentials = credential.JSON } else { - jsonCredentials, err = os.ReadFile(serviceAccountFilePath) + jsonCredentials, err = getCredentialsFromFilePath(serviceAccountFilePath) if err != nil { - return nil, fmt.Errorf("error reading credentials from file: %v", err) + return } } config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope) if err != nil { - return nil, fmt.Errorf("unable to parse credentials to config: %v", err) + return nil, fmt.Errorf("unable to parse client secret file to config: %v", err) } - config.Subject = email - return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx))) -} -// uniqueGroups returns the unique groups of a slice -func uniqueGroups(groups []string) []string { - keys := make(map[string]struct{}) - unique := []string{} - for _, group := range groups { - if _, exists := keys[group]; !exists { - keys[group] = struct{}{} - unique = append(unique, group) - } + // Only attempt impersonation when there is a user configured + if email != "" { + config.Subject = email } - return unique + + return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx))) } diff --git a/connector/google/google_test.go b/connector/google/google_test.go index 5cecbec994..ce0e017cf8 100644 --- a/connector/google/google_test.go +++ b/connector/google/google_test.go @@ -1,31 +1,57 @@ package google import ( + "context" "encoding/json" "fmt" + "log/slog" "net/http" "net/http/httptest" + "net/url" "os" + "strings" "testing" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/option" + + "github.com/dexidp/dex/connector" +) + +var ( + // groups_0 + // โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค + // groups_2 groups_1 + // โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + // โ””โ”€โ”€ user_1 user_2 + testGroups = map[string][]*admin.Group{ + "user_1@dexidp.com": {{Email: "groups_2@dexidp.com"}, {Email: "groups_1@dexidp.com"}}, + "user_2@dexidp.com": {{Email: "groups_1@dexidp.com"}}, + "groups_1@dexidp.com": {{Email: "groups_0@dexidp.com"}}, + "groups_2@dexidp.com": {{Email: "groups_0@dexidp.com"}}, + "groups_0@dexidp.com": {}, + } + callCounter = make(map[string]int) ) -func testSetup(t *testing.T) *httptest.Server { +func testSetup() *httptest.Server { mux := http.NewServeMux() - // TODO: mock calls - // mux.HandleFunc("/admin/directory/v1/groups", func(w http.ResponseWriter, r *http.Request) { - // w.Header().Add("Content-Type", "application/json") - // json.NewEncoder(w).Encode(&admin.Groups{ - // Groups: []*admin.Group{}, - // }) - // }) + + mux.HandleFunc("/admin/directory/v1/groups/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + userKey := r.URL.Query().Get("userKey") + if groups, ok := testGroups[userKey]; ok { + json.NewEncoder(w).Encode(admin.Groups{Groups: groups}) + callCounter[userKey]++ + } + }) + return httptest.NewServer(mux) } -func newConnector(config *Config, serverURL string) (*googleConnector, error) { - log := logrus.New() +func newConnector(config *Config) (*googleConnector, error) { + log := slog.New(slog.DiscardHandler) conn, err := config.Open("id", log) if err != nil { return nil, err @@ -56,7 +82,7 @@ func tempServiceAccountKey() (string, error) { } func TestOpen(t *testing.T) { - ts := testSetup(t) + ts := testSetup() defer ts.Close() type testCase struct { @@ -64,7 +90,7 @@ func TestOpen(t *testing.T) { expectedErr string // string to set in GOOGLE_APPLICATION_CREDENTIALS. As local development environments can - // already contain ADC, test cases will be built uppon this setting this env variable + // already contain ADC, test cases will be built upon this setting this env variable adc string } @@ -74,12 +100,13 @@ func TestOpen(t *testing.T) { for name, reference := range map[string]testCase{ "missing_admin_email": { config: &Config{ - ClientID: "testClient", - ClientSecret: "testSecret", - RedirectURI: ts.URL + "/callback", - Scopes: []string{"openid", "groups"}, + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + ServiceAccountFilePath: serviceAccountFilePath, }, - expectedErr: "requires adminEmail", + expectedErr: "requires the domainToAdminEmail", }, "service_account_key_not_found": { config: &Config{ @@ -87,7 +114,7 @@ func TestOpen(t *testing.T) { ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, - AdminEmail: "foo@bar.com", + DomainToAdminEmail: map[string]string{"*": "foo@bar.com"}, ServiceAccountFilePath: "not_found.json", }, expectedErr: "error reading credentials", @@ -98,18 +125,18 @@ func TestOpen(t *testing.T) { ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, - AdminEmail: "foo@bar.com", + DomainToAdminEmail: map[string]string{"bar.com": "foo@bar.com"}, ServiceAccountFilePath: serviceAccountFilePath, }, expectedErr: "", }, "adc": { config: &Config{ - ClientID: "testClient", - ClientSecret: "testSecret", - RedirectURI: ts.URL + "/callback", - Scopes: []string{"openid", "groups"}, - AdminEmail: "foo@bar.com", + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + DomainToAdminEmail: map[string]string{"*": "foo@bar.com"}, }, adc: serviceAccountFilePath, expectedErr: "", @@ -120,7 +147,7 @@ func TestOpen(t *testing.T) { ClientSecret: "testSecret", RedirectURI: ts.URL + "/callback", Scopes: []string{"openid", "groups"}, - AdminEmail: "foo@bar.com", + DomainToAdminEmail: map[string]string{"*": "foo@bar.com"}, ServiceAccountFilePath: serviceAccountFilePath, }, adc: "/dev/null", @@ -132,7 +159,7 @@ func TestOpen(t *testing.T) { assert := assert.New(t) os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", reference.adc) - conn, err := newConnector(reference.config, ts.URL) + conn, err := newConnector(reference.config) if reference.expectedErr == "" { assert.Nil(err) @@ -143,3 +170,282 @@ func TestOpen(t *testing.T) { }) } } + +func TestGetGroups(t *testing.T) { + ts := testSetup() + defer ts.Close() + + serviceAccountFilePath, err := tempServiceAccountKey() + assert.Nil(t, err) + + os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountFilePath) + conn, err := newConnector(&Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + DomainToAdminEmail: map[string]string{"*": "admin@dexidp.com"}, + }) + assert.Nil(t, err) + + conn.adminSrv[wildcardDomainToAdminEmail], err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL)) + assert.Nil(t, err) + type testCase struct { + userKey string + fetchTransitiveGroupMembership bool + shouldErr bool + expectedGroups []string + } + + for name, testCase := range map[string]testCase{ + "user1_non_transitive_lookup": { + userKey: "user_1@dexidp.com", + fetchTransitiveGroupMembership: false, + shouldErr: false, + expectedGroups: []string{"groups_1@dexidp.com", "groups_2@dexidp.com"}, + }, + "user1_transitive_lookup": { + userKey: "user_1@dexidp.com", + fetchTransitiveGroupMembership: true, + shouldErr: false, + expectedGroups: []string{"groups_0@dexidp.com", "groups_1@dexidp.com", "groups_2@dexidp.com"}, + }, + "user2_non_transitive_lookup": { + userKey: "user_2@dexidp.com", + fetchTransitiveGroupMembership: false, + shouldErr: false, + expectedGroups: []string{"groups_1@dexidp.com"}, + }, + "user2_transitive_lookup": { + userKey: "user_2@dexidp.com", + fetchTransitiveGroupMembership: true, + shouldErr: false, + expectedGroups: []string{"groups_0@dexidp.com", "groups_1@dexidp.com"}, + }, + } { + testCase := testCase + callCounter = map[string]int{} + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + lookup := make(map[string]struct{}) + + groups, err := conn.getGroups(testCase.userKey, testCase.fetchTransitiveGroupMembership, lookup) + if testCase.shouldErr { + assert.NotNil(err) + } else { + assert.Nil(err) + } + assert.ElementsMatch(testCase.expectedGroups, groups) + t.Logf("[%s] Amount of API calls per userKey: %+v\n", t.Name(), callCounter) + }) + } +} + +func TestDomainToAdminEmailConfig(t *testing.T) { + ts := testSetup() + defer ts.Close() + + serviceAccountFilePath, err := tempServiceAccountKey() + assert.Nil(t, err) + + os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountFilePath) + conn, err := newConnector(&Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"}, + }) + assert.Nil(t, err) + + conn.adminSrv["dexidp.com"], err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL)) + assert.Nil(t, err) + type testCase struct { + userKey string + expectedErr string + } + + for name, testCase := range map[string]testCase{ + "correct_user_request": { + userKey: "user_1@dexidp.com", + expectedErr: "", + }, + "wrong_user_request": { + userKey: "user_1@foo.bar", + expectedErr: "unable to find super admin email", + }, + "wrong_connector_response": { + userKey: "user_1_foo.bar", + expectedErr: "unable to find super admin email", + }, + } { + testCase := testCase + callCounter = map[string]int{} + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + lookup := make(map[string]struct{}) + + _, err := conn.getGroups(testCase.userKey, true, lookup) + if testCase.expectedErr != "" { + assert.ErrorContains(err, testCase.expectedErr) + } else { + assert.Nil(err) + } + t.Logf("[%s] Amount of API calls per userKey: %+v\n", t.Name(), callCounter) + }) + } +} + +var gceMetadataFlags = map[string]bool{ + "failOnEmailRequest": false, +} + +func mockGCEMetadataServer() *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("/computeMetadata/v1/instance/service-accounts/default/email", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + if gceMetadataFlags["failOnEmailRequest"] { + w.WriteHeader(http.StatusBadRequest) + } + json.NewEncoder(w).Encode("my-service-account@example-project.iam.gserviceaccount.com") + }) + mux.HandleFunc("/computeMetadata/v1/instance/service-accounts/default/token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct { + AccessToken string `json:"access_token"` + ExpiresInSec int `json:"expires_in"` + TokenType string `json:"token_type"` + }{ + AccessToken: "my-example.token", + ExpiresInSec: 3600, + TokenType: "Bearer", + }) + }) + + return httptest.NewServer(mux) +} + +func TestGCEWorkloadIdentity(t *testing.T) { + ts := testSetup() + defer ts.Close() + + metadataServer := mockGCEMetadataServer() + defer metadataServer.Close() + metadataServerHost := strings.Replace(metadataServer.URL, "http://", "", 1) + + os.Setenv("GCE_METADATA_HOST", metadataServerHost) + os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + os.Setenv("HOME", "/tmp") + + gceMetadataFlags["failOnEmailRequest"] = true + _, err := newConnector(&Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"}, + }) + assert.Error(t, err) + + gceMetadataFlags["failOnEmailRequest"] = false + conn, err := newConnector(&Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups"}, + DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"}, + }) + assert.Nil(t, err) + + conn.adminSrv["dexidp.com"], err = admin.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(ts.URL)) + assert.Nil(t, err) + type testCase struct { + userKey string + expectedErr string + } + + for name, testCase := range map[string]testCase{ + "correct_user_request": { + userKey: "user_1@dexidp.com", + expectedErr: "", + }, + "wrong_user_request": { + userKey: "user_1@foo.bar", + expectedErr: "unable to find super admin email", + }, + "wrong_connector_response": { + userKey: "user_1_foo.bar", + expectedErr: "unable to find super admin email", + }, + } { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + lookup := make(map[string]struct{}) + + _, err := conn.getGroups(testCase.userKey, true, lookup) + if testCase.expectedErr != "" { + assert.ErrorContains(err, testCase.expectedErr) + } else { + assert.Nil(err) + } + }) + } +} + +func TestPromptTypeConfig(t *testing.T) { + promptTypeLogin := "login" + cases := []struct { + name string + promptType *string + expectedPromptTypeValue string + }{ + { + name: "prompt type is nil", + promptType: nil, + expectedPromptTypeValue: "consent", + }, + { + name: "prompt type is empty", + promptType: new(string), + expectedPromptTypeValue: "", + }, + { + name: "prompt type is set", + promptType: &promptTypeLogin, + expectedPromptTypeValue: "login", + }, + } + + ts := testSetup() + defer ts.Close() + + serviceAccountFilePath, err := tempServiceAccountKey() + assert.Nil(t, err) + + os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountFilePath) + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + conn, err := newConnector(&Config{ + ClientID: "testClient", + ClientSecret: "testSecret", + RedirectURI: ts.URL + "/callback", + Scopes: []string{"openid", "groups", "offline_access"}, + DomainToAdminEmail: map[string]string{"dexidp.com": "admin@dexidp.com"}, + PromptType: test.promptType, + }) + + assert.Nil(t, err) + assert.Equal(t, test.expectedPromptTypeValue, conn.promptType) + + loginURL, _, err := conn.LoginURL(connector.Scopes{OfflineAccess: true}, ts.URL+"/callback", "state") + assert.Nil(t, err) + + urlp, err := url.Parse(loginURL) + assert.Nil(t, err) + + assert.Equal(t, test.expectedPromptTypeValue, urlp.Query().Get("prompt")) + }) + } +} diff --git a/connector/keystone/keystone.go b/connector/keystone/keystone.go index db97b5a71f..7d3084b238 100644 --- a/connector/keystone/keystone.go +++ b/connector/keystone/keystone.go @@ -7,18 +7,26 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" + "github.com/google/uuid" + "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" +) + +var ( + _ connector.PasswordConnector = (*conn)(nil) + _ connector.RefreshConnector = (*conn)(nil) ) type conn struct { - Domain string + Domain domainKeystone Host string AdminUsername string AdminPassword string - Logger log.Logger + client *http.Client + Logger *slog.Logger } type userKeystone struct { @@ -28,13 +36,14 @@ type userKeystone struct { } type domainKeystone struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` } // Config holds the configuration parameters for Keystone connector. // Keystone should expose API v3 // An example config: +// // connectors: // type: keystone // id: keystone @@ -69,13 +78,9 @@ type password struct { } type user struct { - Name string `json:"name"` - Domain domain `json:"domain"` - Password string `json:"password"` -} - -type domain struct { - ID string `json:"id"` + Name string `json:"name"` + Domain domainKeystone `json:"domain"` + Password string `json:"password"` } type token struct { @@ -103,19 +108,29 @@ type userResponse struct { } `json:"user"` } -var ( - _ connector.PasswordConnector = &conn{} - _ connector.RefreshConnector = &conn{} -) - // Open returns an authentication strategy using Keystone. -func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { + _, err := uuid.Parse(c.Domain) + var domain domainKeystone + // check if the supplied domain is a UUID or the special "default" value + // which is treated as an ID and not a name + if err == nil || c.Domain == "default" { + domain = domainKeystone{ + ID: c.Domain, + } + } else { + domain = domainKeystone{ + Name: c.Domain, + } + } + return &conn{ - c.Domain, - c.Host, - c.AdminUsername, - c.AdminPassword, - logger, + Domain: domain, + Host: c.Host, + AdminUsername: c.AdminUsername, + AdminPassword: c.AdminPassword, + Logger: logger.With(slog.Group("connector", "type", "keystone", "id", id)), + client: http.DefaultClient, }, nil } @@ -192,7 +207,6 @@ func (p *conn) Refresh( } func (p *conn) getTokenResponse(ctx context.Context, username, pass string) (response *http.Response, err error) { - client := &http.Client{} jsonData := loginRequestData{ auth: auth{ Identity: identity{ @@ -200,7 +214,7 @@ func (p *conn) getTokenResponse(ctx context.Context, username, pass string) (res Password: password{ User: user{ Name: username, - Domain: domain{ID: p.Domain}, + Domain: p.Domain, Password: pass, }, }, @@ -221,7 +235,7 @@ func (p *conn) getTokenResponse(ctx context.Context, username, pass string) (res req.Header.Set("Content-Type", "application/json") req = req.WithContext(ctx) - return client.Do(req) + return p.client.Do(req) } func (p *conn) getAdminToken(ctx context.Context) (string, error) { @@ -243,7 +257,6 @@ func (p *conn) checkIfUserExists(ctx context.Context, userID string, token strin func (p *conn) getUser(ctx context.Context, userID string, token string) (*userResponse, error) { // https://developer.openstack.org/api-ref/identity/v3/#show-user-details userURL := p.Host + "/v3/users/" + userID - client := &http.Client{} req, err := http.NewRequest("GET", userURL, nil) if err != nil { return nil, err @@ -251,7 +264,7 @@ func (p *conn) getUser(ctx context.Context, userID string, token string) (*userR req.Header.Set("X-Auth-Token", token) req = req.WithContext(ctx) - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { return nil, err } @@ -276,7 +289,6 @@ func (p *conn) getUser(ctx context.Context, userID string, token string) (*userR } func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ([]string, error) { - client := &http.Client{} // https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs groupsURL := p.Host + "/v3/users/" + userID + "/groups" req, err := http.NewRequest("GET", groupsURL, nil) @@ -285,9 +297,9 @@ func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ( } req.Header.Set("X-Auth-Token", token) req = req.WithContext(ctx) - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { - p.Logger.Errorf("keystone: error while fetching user %q groups\n", userID) + p.Logger.Error("error while fetching user groups", "user_id", userID, "err", err) return nil, err } diff --git a/connector/keystone/keystone_test.go b/connector/keystone/keystone_test.go index fc6c01e229..9b0590df12 100644 --- a/connector/keystone/keystone_test.go +++ b/connector/keystone/keystone_test.go @@ -17,11 +17,13 @@ import ( const ( invalidPass = "WRONG_PASS" - testUser = "test_user" - testPass = "test_pass" - testEmail = "test@example.com" - testGroup = "test_group" - testDomain = "default" + testUser = "test_user" + testPass = "test_pass" + testEmail = "test@example.com" + testGroup = "test_group" + testDomainAltName = "altdomain" + testDomainID = "default" + testDomainName = "Default" ) var ( @@ -32,8 +34,26 @@ var ( authTokenURL = "" usersURL = "" groupsURL = "" + domainsURL = "" ) +type userReq struct { + Name string `json:"name"` + Email string `json:"email"` + Enabled bool `json:"enabled"` + Password string `json:"password"` + Roles []string `json:"roles"` + DomainID string `json:"domain_id,omitempty"` +} + +type domainResponse struct { + Domain domainKeystone `json:"domain"` +} + +type domainsResponse struct { + Domains []domainKeystone `json:"domains"` +} + type groupResponse struct { Group struct { ID string `json:"id"` @@ -42,8 +62,6 @@ type groupResponse struct { func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) { t.Helper() - client := &http.Client{} - jsonData := loginRequestData{ auth: auth{ Identity: identity{ @@ -51,7 +69,7 @@ func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) Password: password{ User: user{ Name: adminName, - Domain: domain{ID: testDomain}, + Domain: domainKeystone{ID: testDomainID}, Password: adminPass, }, }, @@ -70,7 +88,7 @@ func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) } req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } @@ -91,17 +109,91 @@ func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) return token, tokenResp.Token.User.ID } -func createUser(t *testing.T, token, userName, userEmail, userPass string) string { +func getOrCreateDomain(t *testing.T, token, domainName string) string { + t.Helper() + + domainSearchURL := domainsURL + "?name=" + domainName + reqGet, err := http.NewRequest("GET", domainSearchURL, nil) + if err != nil { + t.Fatal(err) + } + + reqGet.Header.Set("X-Auth-Token", token) + reqGet.Header.Add("Content-Type", "application/json") + respGet, err := http.DefaultClient.Do(reqGet) + if err != nil { + t.Fatal(err) + } + + dataGet, err := io.ReadAll(respGet.Body) + if err != nil { + t.Fatal(err) + } + defer respGet.Body.Close() + + domainsResp := new(domainsResponse) + err = json.Unmarshal(dataGet, &domainsResp) + if err != nil { + t.Fatal(err) + } + + if len(domainsResp.Domains) >= 1 { + return domainsResp.Domains[0].ID + } + + createDomainData := map[string]interface{}{ + "domain": map[string]interface{}{ + "name": domainName, + "enabled": true, + }, + } + + body, err := json.Marshal(createDomainData) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest("POST", domainsURL, bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Auth-Token", token) + req.Header.Add("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + + if resp.StatusCode != 201 { + t.Fatalf("failed to create domain %s", domainName) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + domainResp := new(domainResponse) + err = json.Unmarshal(data, &domainResp) + if err != nil { + t.Fatal(err) + } + + return domainResp.Domain.ID +} + +func createUser(t *testing.T, token, domainID, userName, userEmail, userPass string) string { t.Helper() - client := &http.Client{} createUserData := map[string]interface{}{ - "user": map[string]interface{}{ - "name": userName, - "email": userEmail, - "enabled": true, - "password": userPass, - "roles": []string{"admin"}, + "user": userReq{ + DomainID: domainID, + Name: userName, + Email: userEmail, + Enabled: true, + Password: userPass, + Roles: []string{"admin"}, }, } @@ -116,7 +208,7 @@ func createUser(t *testing.T, token, userName, userEmail, userPass string) strin } req.Header.Set("X-Auth-Token", token) req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } @@ -139,7 +231,6 @@ func createUser(t *testing.T, token, userName, userEmail, userPass string) strin // delete group or user func deleteResource(t *testing.T, token, id, uri string) { t.Helper() - client := &http.Client{} deleteURI := uri + id req, err := http.NewRequest("DELETE", deleteURI, nil) @@ -148,7 +239,7 @@ func deleteResource(t *testing.T, token, id, uri string) { } req.Header.Set("X-Auth-Token", token) - resp, err := client.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("error: %v", err) } @@ -157,7 +248,6 @@ func deleteResource(t *testing.T, token, id, uri string) { func createGroup(t *testing.T, token, description, name string) string { t.Helper() - client := &http.Client{} createGroupData := map[string]interface{}{ "group": map[string]interface{}{ @@ -177,7 +267,7 @@ func createGroup(t *testing.T, token, description, name string) string { } req.Header.Set("X-Auth-Token", token) req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } @@ -200,14 +290,13 @@ func createGroup(t *testing.T, token, description, name string) string { func addUserToGroup(t *testing.T, token, groupID, userID string) error { t.Helper() uri := groupsURL + groupID + "/users/" + userID - client := &http.Client{} req, err := http.NewRequest("PUT", uri, nil) if err != nil { return err } req.Header.Set("X-Auth-Token", token) - resp, err := client.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("error: %v", err) } @@ -219,7 +308,8 @@ func addUserToGroup(t *testing.T, token, groupID, userID string) error { func TestIncorrectCredentialsLogin(t *testing.T) { setupVariables(t) c := conn{ - Host: keystoneURL, Domain: testDomain, + client: http.DefaultClient, + Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -243,10 +333,11 @@ func TestValidUserLogin(t *testing.T) { token, _ := getAdminToken(t, adminUser, adminPass) type tUser struct { - username string - domain string - email string - password string + createDomain bool + domain domainKeystone + username string + email string + password string } type expect struct { @@ -263,10 +354,11 @@ func TestValidUserLogin(t *testing.T) { { name: "test with email address", input: tUser{ - username: testUser, - domain: testDomain, - email: testEmail, - password: testPass, + createDomain: false, + domain: domainKeystone{ID: testDomainID}, + username: testUser, + email: testEmail, + password: testPass, }, expected: expect{ username: testUser, @@ -277,10 +369,11 @@ func TestValidUserLogin(t *testing.T) { { name: "test without email address", input: tUser{ - username: testUser, - domain: testDomain, - email: "", - password: testPass, + createDomain: false, + domain: domainKeystone{ID: testDomainID}, + username: testUser, + email: "", + password: testPass, }, expected: expect{ username: testUser, @@ -288,21 +381,77 @@ func TestValidUserLogin(t *testing.T) { verifiedEmail: false, }, }, + { + name: "test with default domain Name", + input: tUser{ + createDomain: false, + domain: domainKeystone{Name: testDomainName}, + username: testUser, + email: testEmail, + password: testPass, + }, + expected: expect{ + username: testUser, + email: testEmail, + verifiedEmail: true, + }, + }, + { + name: "test with custom domain Name", + input: tUser{ + createDomain: true, + domain: domainKeystone{Name: testDomainAltName}, + username: testUser, + email: testEmail, + password: testPass, + }, + expected: expect{ + username: testUser, + email: testEmail, + verifiedEmail: true, + }, + }, + { + name: "test with custom domain ID", + input: tUser{ + createDomain: true, + domain: domainKeystone{}, + username: testUser, + email: testEmail, + password: testPass, + }, + expected: expect{ + username: testUser, + email: testEmail, + verifiedEmail: true, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - userID := createUser(t, token, tt.input.username, tt.input.email, tt.input.password) + domainID := "" + if tt.input.createDomain == true { + domainID = getOrCreateDomain(t, token, testDomainAltName) + t.Logf("getOrCreateDomain ID: %s\n", domainID) + + // if there was nothing set then use the dynamically generated domain ID + if tt.input.domain.ID == "" && tt.input.domain.Name == "" { + tt.input.domain.ID = domainID + } + } + userID := createUser(t, token, domainID, tt.input.username, tt.input.email, tt.input.password) defer deleteResource(t, token, userID, usersURL) c := conn{ - Host: keystoneURL, Domain: tt.input.domain, + client: http.DefaultClient, + Host: keystoneURL, Domain: tt.input.domain, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} identity, validPW, err := c.Login(context.Background(), s, tt.input.username, tt.input.password) if err != nil { - t.Fatal(err.Error()) + t.Fatalf("Login failed for user %s: %v", tt.input.username, err.Error()) } t.Log(identity) if identity.Username != tt.expected.username { @@ -333,7 +482,8 @@ func TestUseRefreshToken(t *testing.T) { defer deleteResource(t, token, groupID, groupsURL) c := conn{ - Host: keystoneURL, Domain: testDomain, + client: http.DefaultClient, + Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -355,10 +505,11 @@ func TestUseRefreshToken(t *testing.T) { func TestUseRefreshTokenUserDeleted(t *testing.T) { setupVariables(t) token, _ := getAdminToken(t, adminUser, adminPass) - userID := createUser(t, token, testUser, testEmail, testPass) + userID := createUser(t, token, "", testUser, testEmail, testPass) c := conn{ - Host: keystoneURL, Domain: testDomain, + client: http.DefaultClient, + Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -384,11 +535,12 @@ func TestUseRefreshTokenUserDeleted(t *testing.T) { func TestUseRefreshTokenGroupsChanged(t *testing.T) { setupVariables(t) token, _ := getAdminToken(t, adminUser, adminPass) - userID := createUser(t, token, testUser, testEmail, testPass) + userID := createUser(t, token, "", testUser, testEmail, testPass) defer deleteResource(t, token, userID, usersURL) c := conn{ - Host: keystoneURL, Domain: testDomain, + client: http.DefaultClient, + Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -420,11 +572,12 @@ func TestUseRefreshTokenGroupsChanged(t *testing.T) { func TestNoGroupsInScope(t *testing.T) { setupVariables(t) token, _ := getAdminToken(t, adminUser, adminPass) - userID := createUser(t, token, testUser, testEmail, testPass) + userID := createUser(t, token, "", testUser, testEmail, testPass) defer deleteResource(t, token, userID, usersURL) c := conn{ - Host: keystoneURL, Domain: testDomain, + client: http.DefaultClient, + Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: false} @@ -474,6 +627,7 @@ func setupVariables(t *testing.T) { authTokenURL = keystoneURL + "/v3/auth/tokens/" usersURL = keystoneAdminURL + "/v3/users/" groupsURL = keystoneAdminURL + "/v3/groups/" + domainsURL = keystoneAdminURL + "/v3/domains/" } func expectEquals(t *testing.T, a interface{}, b interface{}) { diff --git a/connector/ldap/kerberos.go b/connector/ldap/kerberos.go new file mode 100644 index 0000000000..1d5931d2b6 --- /dev/null +++ b/connector/ldap/kerberos.go @@ -0,0 +1,398 @@ +// Package ldap implements strategies for authenticating using the LDAP protocol. +// This file contains Kerberos/SPNEGO authentication support for LDAP connector. +package ldap + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/jcmturner/goidentity/v6" + "github.com/jcmturner/gokrb5/v8/credentials" + "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/jcmturner/gokrb5/v8/service" + "github.com/jcmturner/gokrb5/v8/spnego" + + "github.com/dexidp/dex/connector" +) + +// bufferedResponse is a minimal http.ResponseWriter that buffers status, +// headers, and body in memory so the SPNEGO middleware's response can be +// inspected and conditionally forwarded. spnego.SPNEGOKRB5Authenticate is a +// "terminal" middleware (it commits failure responses directly to the +// ResponseWriter), but Dex needs to layer policy on top โ€” FallbackToPassword +// may want to discard a 401 so the password form can render, and +// ExpectedRealm checks happen after a successful auth. Buffering decouples +// the middleware's output from the eventual client response. +type bufferedResponse struct { + header http.Header + body bytes.Buffer + code int + wroteHeader bool +} + +func newBufferedResponse() *bufferedResponse { + return &bufferedResponse{ + header: make(http.Header), + code: http.StatusOK, + } +} + +func (r *bufferedResponse) Header() http.Header { + return r.header +} + +// WriteHeader mirrors net/http: the first call wins, subsequent calls are +// silently ignored (stdlib emits a "superfluous WriteHeader" warning and +// keeps the original status; we just drop the override). +func (r *bufferedResponse) WriteHeader(code int) { + if r.wroteHeader { + return + } + r.code = code + r.wroteHeader = true +} + +// Write mirrors net/http: a Write before any WriteHeader implicitly commits +// status 200, locking the status against later overrides. +func (r *bufferedResponse) Write(b []byte) (int, error) { + if !r.wroteHeader { + r.WriteHeader(http.StatusOK) + } + return r.body.Write(b) +} + +// krbState holds Kerberos/SPNEGO configuration bound to a ldapConnector. +// +// The authenticate field is the SPNEGO HTTP middleware factory: it wraps an +// inner http.Handler so that inner runs only after successful SPNEGO auth, +// with an authenticated *credentials.Credentials attached to the request +// context under goidentity.CTXKey. In production it delegates to +// spnego.SPNEGOKRB5Authenticate. Unit tests replace it with a stub that +// injects a fake credential without any real Kerberos exchange. +type krbState struct { + authenticate func(inner http.Handler) http.Handler +} + +// loadKerberosState validates the keytab and returns a krbState wired to +// spnego.SPNEGOKRB5Authenticate with the requested service settings. +func loadKerberosState(cfg kerberosConfig) (*krbState, error) { + fi, err := os.Stat(cfg.KeytabPath) + if err != nil { + return nil, fmt.Errorf("keytab file not found: %w", err) + } + if fi.IsDir() { + return nil, fmt.Errorf("keytab path is a directory: %s", cfg.KeytabPath) + } + kt, err := keytab.Load(cfg.KeytabPath) + if err != nil { + return nil, fmt.Errorf("failed to load keytab: %w", err) + } + + settings := []func(*service.Settings){service.DecodePAC(false)} + if cfg.SPN != "" { + settings = append(settings, service.SName(cfg.SPN)) + } + if cfg.KeytabPrincipal != "" { + settings = append(settings, service.KeytabPrincipal(cfg.KeytabPrincipal)) + } + if cfg.MaxClockSkew > 0 { + settings = append(settings, service.MaxClockSkew(time.Duration(cfg.MaxClockSkew)*time.Second)) + } + + return &krbState{ + authenticate: func(inner http.Handler) http.Handler { + return spnego.SPNEGOKRB5Authenticate(inner, kt, settings...) + }, + }, nil +} + +// mapPrincipal builds the LDAP-side username from the Kerberos credentials +// according to the configured mapping. Inputs come from gokrb5: +// username is credentials.UserName() (bare, no realm); realm is Domain(). +// +// - "userprincipalname": "username@realm". +// - "localpart"/"samaccountname" (default): bare username. +// +// Case of the output is preserved; callers rely on the LDAP server's +// attribute matching rules (typically case-insensitive) for comparisons. +func mapPrincipal(username, realm, mapping string) string { + // Defensive: if username somehow carries an '@', trim to before it; we + // always derive the realm from the separate Domain() field. + if i := strings.IndexByte(username, '@'); i >= 0 { + username = username[:i] + } + if strings.EqualFold(mapping, "userprincipalname") { + if realm == "" { + return username + } + return username + "@" + realm + } + return username +} + +// TrySPNEGO attempts SPNEGO authentication against the LDAP connector's keytab. +// +// Behavior: +// - Kerberos disabled: returns (nil, false, nil) so the caller renders the +// password form. +// - FallbackToPassword=true with no Authorization header: a cookie probe +// gives the browser exactly one round to negotiate. The first such +// request sets a short-lived "tried" cookie and forwards the middleware's +// 401 Negotiate challenge so a Kerberos-aware client can respond. If the +// follow-up request still has no Authorization header (cookie present), +// we treat the client as unable to SPNEGO and render the password form. +// - FallbackToPassword=true with an Authorization header that the +// middleware rejects: render the password form (the client tried and +// failed; do not loop 401s). +// - FallbackToPassword=false: forward the middleware's response verbatim. +// - On success, the authenticated principal is resolved in LDAP and a +// connector.Identity is returned. Any prior probe cookie is cleared. +func (c *ldapConnector) TrySPNEGO(ctx context.Context, s connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) { + if c.krb == nil { + return nil, false, nil + } + + hasNegotiate := strings.HasPrefix(r.Header.Get("Authorization"), "Negotiate ") + + // Cookie probe: see the package-level doc on spnegoProbeCookieName. Only + // applies when fallback is enabled and the client has not (yet) sent a + // SPNEGO token; "Authorization present" paths bypass the probe entirely. + if c.krbConf.FallbackToPassword && !hasNegotiate { + if hasSPNEGOProbeCookie(r) { + c.logger.Info("kerberos: SPNEGO probe cookie present and no Negotiate header; falling back to password form") + return nil, false, nil + } + setSPNEGOProbeCookie(w, r) + } + + // Run the SPNEGO middleware with a capturing recorder so we can decide + // whether to forward its response or fall back to the password form. + var ( + id *credentials.Credentials + innerErr error + ) + inner := http.HandlerFunc(func(_ http.ResponseWriter, rr *http.Request) { + ident := goidentity.FromHTTPRequestContext(rr) + if ident == nil { + innerErr = fmt.Errorf("kerberos: no identity in request context after SPNEGO") + return + } + creds, ok := ident.(*credentials.Credentials) + if !ok { + innerErr = fmt.Errorf("kerberos: unexpected identity type %T", ident) + return + } + id = creds + }) + + rec := newBufferedResponse() + c.krb.authenticate(inner).ServeHTTP(rec, r) + + if innerErr != nil { + c.logger.Error("kerberos: SPNEGO middleware completed with unusable credentials", "err", innerErr) + return nil, true, innerErr + } + + if id == nil { + // Client offered a token and the middleware rejected it: under + // fallback semantics this is "tried and failed" โ€” render the form + // rather than looping 401s. + if c.krbConf.FallbackToPassword && hasNegotiate { + c.logger.Info("kerberos: SPNEGO rejected client token; falling back to password form") + return nil, false, nil + } + // Otherwise (fallback=false, OR fallback=true probe round): forward + // the middleware-authored response (challenge token, error payload, + // etc.) verbatim โ€” the protocol decided to reject and we preserve + // its wire details. + c.logger.Info("kerberos: SPNEGO did not authenticate; forwarding middleware response", "status", rec.code) + copyBuffered(rec, w) + return nil, true, nil + } + + if c.krbConf.ExpectedRealm != "" && !strings.EqualFold(c.krbConf.ExpectedRealm, id.Domain()) { + c.logger.Info("kerberos: realm mismatch", "expected", c.krbConf.ExpectedRealm, "actual", id.Domain()) + if c.krbConf.FallbackToPassword { + // Intentionally do NOT clear the probe cookie here: the + // client's TGT is for a realm we will never accept. Clearing + // would re-arm a probe round on the next no-Authorization GET, + // re-issue 401 Negotiate, the browser would resend the same + // wrong-realm token, and we'd land back here โ€” an infinite + // flap. Keeping the cookie pins the client to the password + // form until the cookie's Max-Age elapses. + return nil, false, nil + } + // SPNEGO authenticated successfully, but our ExpectedRealm policy + // rejects it. The middleware's buffered response is irrelevant here + // (it represents the post-success path through the inner handler); + // emit our own bare Negotiate challenge so the client knows to + // retry with a different realm. + writeBareNegotiateChallenge(w) + return nil, true, nil + } + + mapped := mapPrincipal(id.UserName(), id.Domain(), c.krbConf.UsernameFromPrincipal) + c.logger.Info("kerberos: principal authenticated", + "principal", id.UserName(), + "realm", id.Domain(), + "auth_time", id.AuthTime(), + "mapped_username", mapped, + ) + + userEntry, err := c.lookupKerberosUser(ctx, mapped) + if err != nil { + c.logger.Error("kerberos: LDAP user lookup failed", + "principal", id.UserName(), "mapped", mapped, "err", err) + return nil, true, fmt.Errorf("ldap: user lookup failed for kerberos principal %q: %w", id.UserName(), err) + } + c.logger.Info("kerberos: LDAP user found", "dn", userEntry.DN) + + ident, err := c.identityFromEntry(userEntry) + if err != nil { + c.logger.Error("kerberos: failed to build identity from LDAP entry", "err", err) + return nil, true, err + } + if s.Groups { + groups, err := c.groups(ctx, userEntry) + if err != nil { + c.logger.Error("kerberos: failed to query groups", "err", err) + return nil, true, fmt.Errorf("ldap: failed to query groups: %w", err) + } + ident.Groups = groups + } + + // No user-bind has happened; only materialize ConnectorData when refresh + // needs the LDAP entry later (OfflineAccess). Mirror (*ldapConnector).Login: + // a marshal failure here would silently break a subsequent Refresh call + // (Unmarshal on nil), so fail the login instead of letting that happen. + if s.OfflineAccess { + refresh := refreshData{Username: mapped, Entry: userEntry} + data, mErr := json.Marshal(refresh) + if mErr != nil { + c.logger.Error("kerberos: failed to marshal refresh data", "err", mErr) + return nil, true, fmt.Errorf("ldap: marshal refresh data: %w", mErr) + } + ident.ConnectorData = data + } + + // If the client carried a stale probe cookie from a prior fallback + // round, clear it so a future logout/re-login starts negotiation fresh. + if hasSPNEGOProbeCookie(r) { + clearSPNEGOProbeCookie(w, r) + } + + c.logger.Info("kerberos: SPNEGO login succeeded", + "username", ident.Username, "email", ident.Email, "groups_count", len(ident.Groups)) + return &ident, true, nil +} + +// copyBuffered forwards a buffered handler response to the real client. +// Used when we want the middleware-authored bytes to reach the user agent +// unchanged (e.g. SPNEGO continuation/reject tokens). +func copyBuffered(rec *bufferedResponse, w http.ResponseWriter) { + for k, vv := range rec.header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(rec.code) + _, _ = w.Write(rec.body.Bytes()) +} + +// writeBareNegotiateChallenge emits an unsolicited "WWW-Authenticate: +// Negotiate" 401 directly to the client. This is reserved for cases where +// Dex itself rejects an otherwise-successful SPNEGO exchange (currently +// only ExpectedRealm mismatch); in those cases the middleware's buffered +// output does not represent the answer we want to send. +func writeBareNegotiateChallenge(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", "Negotiate") + w.WriteHeader(http.StatusUnauthorized) +} + +// spnegoProbeCookieName names the short-lived cookie that records "we +// already issued a 401 Negotiate challenge to this client". It implements +// the fallback-to-password semantics: the very first GET without an +// Authorization header gets a real Negotiate challenge so a Kerberos-aware +// browser can SSO; if the client comes back without a token we treat that +// as "client cannot/will not negotiate" and render the password form +// instead of looping 401s. The cookie is bounded by a small Max-Age so a +// later visit gets a fresh chance to negotiate. +const ( + spnegoProbeCookieName = "dex_spnego_tried" + spnegoProbeMaxAge = 60 // seconds; long enough for one challenge round-trip +) + +func hasSPNEGOProbeCookie(r *http.Request) bool { + _, err := r.Cookie(spnegoProbeCookieName) + return err == nil +} + +// newSPNEGOProbeCookie returns a probe-cookie carrier with the attributes +// shared between "set" and "clear" forms (Path, HttpOnly, Secure, SameSite), +// so the two callers cannot accidentally drift apart. +func newSPNEGOProbeCookie(r *http.Request, value string, maxAge int) *http.Cookie { + return &http.Cookie{ + Name: spnegoProbeCookieName, + Value: value, + // Scope to "/" so the cookie reaches every Dex auth endpoint + // regardless of issuer prefix; the cookie carries no secret and + // expires within spnegoProbeMaxAge seconds, so the broad path is + // not a privacy or security boundary concern. + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: isSecureRequest(r), + SameSite: http.SameSiteLaxMode, + } +} + +func setSPNEGOProbeCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, newSPNEGOProbeCookie(r, "1", spnegoProbeMaxAge)) +} + +func clearSPNEGOProbeCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, newSPNEGOProbeCookie(r, "", -1)) +} + +// isSecureRequest reports whether the original client connection is HTTPS. +// It honors X-Forwarded-Proto so the cookie's Secure attribute is correct +// when Dex sits behind a TLS-terminating proxy. The header is treated as +// advisory: misbehaving/spoofed values can only flip Secure to true on a +// plain-HTTP setup (which makes browsers refuse to echo the cookie back โ€” +// a graceful no-op for the probe), never to false on a real HTTPS link. +func isSecureRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +// lookupKerberosUser resolves an LDAP user entry by username. When +// krbLookupUserHook is non-nil it is used exclusively and the real LDAP +// search is skipped entirely โ€” this keeps unit tests hermetic. +func (c *ldapConnector) lookupKerberosUser(ctx context.Context, username string) (ldap.Entry, error) { + if c.krbLookupUserHook != nil { + return c.krbLookupUserHook(ctx, c, username) + } + + var userEntry ldap.Entry + err := c.do(ctx, func(conn *ldap.Conn) error { + entry, found, err := c.userEntry(conn, username) + if err != nil { + return err + } + if !found { + return fmt.Errorf("user not found for principal") + } + userEntry = entry + return nil + }) + return userEntry, err +} diff --git a/connector/ldap/kerberos_test.go b/connector/ldap/kerberos_test.go new file mode 100644 index 0000000000..8b9df0ade8 --- /dev/null +++ b/connector/ldap/kerberos_test.go @@ -0,0 +1,602 @@ +package ldap + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + ldaplib "github.com/go-ldap/ldap/v3" + "github.com/jcmturner/goidentity/v6" + "github.com/jcmturner/gokrb5/v8/credentials" + + "github.com/dexidp/dex/connector" +) + +// fakeAuthenticate returns a krbState whose authenticate middleware immediately +// invokes inner with the given credentials attached to the request context +// under goidentity.CTXKey โ€” mimicking a successful SPNEGO handshake. +func fakeAuthenticate(creds *credentials.Credentials) *krbState { + return &krbState{ + authenticate: func(inner http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if creds == nil { + // Emulate a failed SPNEGO exchange: middleware writes 401 + // without invoking the inner handler. + w.Header().Set("WWW-Authenticate", "Negotiate") + http.Error(w, "auth failed\n", http.StatusUnauthorized) + return + } + rr := r.WithContext(context.WithValue(r.Context(), goidentity.CTXKey, goidentity.Identity(creds))) + inner.ServeHTTP(w, rr) + }) + }, + } +} + +// fakeAuthenticateWithResponse returns a krbState whose middleware writes an +// arbitrary response and does not invoke inner. Useful for testing the +// forward-vs-discard branch on failed SPNEGO. +func fakeAuthenticateWithResponse(status int, headers map[string]string, body string) *krbState { + return &krbState{ + authenticate: func(_ http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + for k, v := range headers { + w.Header().Set(k, v) + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + }) + }, + } +} + +func newCreds(username, realm string) *credentials.Credentials { + c := credentials.New(username, realm) + c.SetAuthenticated(true) + return c +} + +func TestKerberos_NoHeader_NoFallback_ForwardsMiddleware401(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}, + krb: fakeAuthenticate(nil), + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if w.Result().StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Result().StatusCode) + } + if hdr := w.Header().Get("WWW-Authenticate"); hdr != "Negotiate" { + t.Fatalf("expected bare Negotiate challenge, got %q", hdr) + } +} + +func TestKerberos_NoHeader_Fallback_NoCookie_ChallengesAndSetsCookie(t *testing.T) { + // First contact under fallback: no Authorization header, no probe cookie. + // We expect the middleware to run, its 401 Negotiate to be forwarded to + // the client, and a probe cookie to be set so the *next* round (still + // without an Authorization header) can short-circuit to the form. + middlewareRan := false + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}, + krb: &krbState{authenticate: func(_ http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + middlewareRan = true + w.Header().Set("WWW-Authenticate", "Negotiate") + w.WriteHeader(http.StatusUnauthorized) + }) + }}, + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled (probe round forwards 401)") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if !middlewareRan { + t.Fatalf("SPNEGO middleware should run on the probe round") + } + if w.Result().StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Result().StatusCode) + } + if hdr := w.Header().Get("WWW-Authenticate"); hdr != "Negotiate" { + t.Fatalf("expected Negotiate challenge, got %q", hdr) + } + if !setCookieHasName(w, spnegoProbeCookieName) { + t.Fatalf("expected probe cookie %q to be set, got %q", spnegoProbeCookieName, w.Header().Values("Set-Cookie")) + } +} + +func TestKerberos_NoHeader_Fallback_WithCookie_RendersForm(t *testing.T) { + // Follow-up round: probe cookie is already in the request and the client + // still hasn't sent a Negotiate token. Treat that as "client cannot/will + // not SPNEGO" and short-circuit to the password form without running the + // middleware (otherwise w would get tainted with another 401). + middlewareRan := false + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}, + krb: &krbState{authenticate: func(_ http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + middlewareRan = true + w.WriteHeader(http.StatusUnauthorized) + }) + }}, + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + r.AddCookie(&http.Cookie{Name: spnegoProbeCookieName, Value: "1"}) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if bool(handled) { + t.Fatalf("expected not handled (caller should render form)") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if middlewareRan { + t.Fatalf("SPNEGO middleware should not run on probe-follow-up round") + } + if w.Code != 200 && w.Code != 0 { + t.Fatalf("expected w untouched, got status=%d", w.Code) + } +} + +func TestKerberos_MiddlewareFails_Fallback_DiscardsResponse(t *testing.T) { + // Authorization header is present (so we enter middleware), middleware + // rejects the ticket, fallback is on: TrySPNEGO must return (nil, false, nil) + // and must not forward the middleware's 401 to the real ResponseWriter. + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}, + krb: fakeAuthenticateWithResponse( + http.StatusUnauthorized, + map[string]string{"WWW-Authenticate": "Negotiate oQcwBaADCgEC"}, + "auth failed\n", + ), + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + r.Header.Set("Authorization", "Negotiate deadbeef") + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if bool(handled) { + t.Fatalf("expected not handled (caller should render form)") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if w.Code != 200 && w.Code != 0 { + t.Fatalf("expected w untouched, got status=%d", w.Code) + } + if hdr := w.Header().Get("WWW-Authenticate"); hdr != "" { + t.Fatalf("expected no WWW-Authenticate on fallback, got %q", hdr) + } +} + +func TestKerberos_MiddlewareFails_NoFallback_ForwardsResponse(t *testing.T) { + // Middleware writes a reject token; no fallback: forward to client verbatim. + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}, + krb: fakeAuthenticateWithResponse( + http.StatusUnauthorized, + map[string]string{"WWW-Authenticate": "Negotiate oQcwBaADCgEC"}, + "auth failed\n", + ), + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + r.Header.Set("Authorization", "Negotiate deadbeef") + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if ident != nil { + t.Fatalf("expected no identity") + } + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 forwarded, got %d", w.Code) + } + if hdr := w.Header().Get("WWW-Authenticate"); !strings.HasPrefix(hdr, "Negotiate ") { + t.Fatalf("expected reject token forwarded, got %q", hdr) + } +} + +func TestKerberos_Success_BuildsIdentity(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}, + krb: fakeAuthenticate(newCreds("jdoe", "EXAMPLE.COM")), + } + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + lc.krbLookupUserHook = func(_ context.Context, c *ldapConnector, username string) (ldaplib.Entry, error) { + if username != "jdoe" { + t.Fatalf("expected username=jdoe, got %q", username) + } + e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ + c.UserSearch.IDAttr: {"uid-jdoe"}, + c.UserSearch.EmailAttr: {"jdoe@example.com"}, + c.UserSearch.NameAttr: {"John Doe"}, + }) + return *e, nil + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) || ident == nil { + t.Fatalf("expected handled with identity") + } + if ident.Username == "" || ident.Email == "" || ident.UserID == "" { + t.Fatalf("expected populated identity, got %+v", *ident) + } + // No probe cookie was on the request, so the success path must not emit + // a clearing Set-Cookie either (avoids needless header noise). + if setCookieHasName(w, spnegoProbeCookieName) { + t.Fatalf("did not expect Set-Cookie %q on success without prior probe cookie, got %q", + spnegoProbeCookieName, w.Header().Values("Set-Cookie")) + } +} + +// TestKerberos_Success_ClearsProbeCookie verifies that a successful SPNEGO +// login on a request that *did* carry a probe cookie clears it, so a future +// no-Authorization GET starts a fresh negotiation round instead of going +// straight to the password form. +func TestKerberos_Success_ClearsProbeCookie(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}, + krb: fakeAuthenticate(newCreds("jdoe", "EXAMPLE.COM")), + } + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + lc.krbLookupUserHook = func(_ context.Context, c *ldapConnector, _ string) (ldaplib.Entry, error) { + e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ + c.UserSearch.IDAttr: {"uid-jdoe"}, + c.UserSearch.EmailAttr: {"jdoe@example.com"}, + c.UserSearch.NameAttr: {"John Doe"}, + }) + return *e, nil + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + r.Header.Set("Authorization", "Negotiate deadbeef") + r.AddCookie(&http.Cookie{Name: spnegoProbeCookieName, Value: "1"}) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) || ident == nil { + t.Fatalf("expected handled with identity") + } + if !setCookieClearsName(w, spnegoProbeCookieName) { + t.Fatalf("expected probe cookie %q to be cleared, got %q", + spnegoProbeCookieName, w.Header().Values("Set-Cookie")) + } +} + +// setCookieHasName reports whether any Set-Cookie response header sets a +// cookie with the given name and a non-empty value (i.e. an actual set, not +// a clear). Order-tolerant; useful for assertions that don't care about +// exact attributes. +func setCookieHasName(w *httptest.ResponseRecorder, name string) bool { + for _, sc := range w.Result().Cookies() { + if sc.Name == name && sc.Value != "" && sc.MaxAge >= 0 { + return true + } + } + return false +} + +// setCookieClearsName reports whether any Set-Cookie response header clears +// the cookie with the given name (Max-Age<=0 or empty value). +func setCookieClearsName(w *httptest.ResponseRecorder, name string) bool { + for _, sc := range w.Result().Cookies() { + if sc.Name == name && (sc.MaxAge < 0 || sc.Value == "") { + return true + } + } + return false +} + +// TestKerberos_UserNotFound_ReturnsError pins the behavior when the LDAP +// directory has no entry for an authenticated Kerberos principal. The hook +// is authoritative, so the test is hermetic (no network call to :636). +func TestKerberos_UserNotFound_ReturnsError(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}, + krb: fakeAuthenticate(newCreds("jdoe", "EXAMPLE.COM")), + } + lc.krbLookupUserHook = func(_ context.Context, _ *ldapConnector, _ string) (ldaplib.Entry, error) { + return ldaplib.Entry{}, fmt.Errorf("user not found for principal") + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + _, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err == nil { + t.Fatalf("expected error for user not found") + } + if !bool(handled) { + t.Fatalf("expected handled") + } + if !strings.Contains(err.Error(), "user lookup failed") { + t.Fatalf("expected 'user lookup failed' error, got: %v", err) + } + if !strings.Contains(err.Error(), "user not found for principal") { + t.Fatalf("expected wrapped hook error, got: %v", err) + } +} + +// TestKerberos_LookupHookError_Wrapped verifies hook errors propagate through +// TrySPNEGO with %w wrapping so callers can errors.Is against the original. +func TestKerberos_LookupHookError_Wrapped(t *testing.T) { + sentinel := errors.New("boom: LDAP server tantrum") + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}, + krb: fakeAuthenticate(newCreds("jdoe", "EXAMPLE.COM")), + } + lc.krbLookupUserHook = func(_ context.Context, _ *ldapConnector, _ string) (ldaplib.Entry, error) { + return ldaplib.Entry{}, sentinel + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + _, _, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, sentinel) { + t.Fatalf("expected errors.Is(err, sentinel), got: %v", err) + } +} + +func TestKerberos_ExpectedRealmMismatch_NoFallback_401(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{ + FallbackToPassword: false, + UsernameFromPrincipal: "localpart", + ExpectedRealm: "EXAMPLE.COM", + }, + krb: fakeAuthenticate(newCreds("jdoe", "OTHER.COM")), + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) || ident != nil { + t.Fatalf("expected handled with no identity") + } + if w.Result().StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Result().StatusCode) + } + if hdr := w.Header().Get("WWW-Authenticate"); hdr != "Negotiate" { + t.Fatalf("expected bare Negotiate, got %q", hdr) + } +} + +func TestKerberos_ExpectedRealmMismatch_Fallback_RendersForm(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{ + FallbackToPassword: true, + UsernameFromPrincipal: "localpart", + ExpectedRealm: "EXAMPLE.COM", + }, + krb: fakeAuthenticate(newCreds("jdoe", "OTHER.COM")), + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + r.Header.Set("Authorization", "Negotiate deadbeef") + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if bool(handled) || ident != nil { + t.Fatalf("expected not handled, got handled=%v ident=%v", handled, ident) + } +} + +func TestKerberos_ExpectedRealm_CaseInsensitive(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{ + FallbackToPassword: false, + UsernameFromPrincipal: "localpart", + ExpectedRealm: "ExAmPlE.CoM", + }, + krb: fakeAuthenticate(newCreds("user", "EXAMPLE.COM")), + } + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + lc.krbLookupUserHook = func(_ context.Context, c *ldapConnector, _ string) (ldaplib.Entry, error) { + e := ldaplib.NewEntry("cn=user,dc=example,dc=com", map[string][]string{ + c.UserSearch.IDAttr: {"uid-user"}, + c.UserSearch.EmailAttr: {"user@example.com"}, + c.UserSearch.NameAttr: {"User"}, + }) + return *e, nil + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) || ident == nil { + t.Fatalf("expected handled with identity") + } +} + +func TestKerberos_UserPrincipalName_Mapping(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "userPrincipalName"}, + krb: fakeAuthenticate(newCreds("J.Doe", "Example.COM")), + } + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + lc.krbLookupUserHook = func(_ context.Context, c *ldapConnector, username string) (ldaplib.Entry, error) { + // userPrincipalName mapping reconstructs "username@realm" from the + // gokrb5 credentials; LDAP server handles case according to the + // attribute's matching rule. + if username != "J.Doe@Example.COM" { + t.Fatalf("expected reconstructed UPN, got %q", username) + } + e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ + c.UserSearch.IDAttr: {"uid-jdoe"}, + c.UserSearch.EmailAttr: {"jdoe@example.com"}, + c.UserSearch.NameAttr: {"John Doe"}, + }) + return *e, nil + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) || ident == nil { + t.Fatalf("expected handled with identity") + } +} + +func TestKerberos_sAMAccountName_EqualsLocalpart(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "sAMAccountName"}, + krb: fakeAuthenticate(newCreds("Admin", "REALM.LOCAL")), + } + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + lc.krbLookupUserHook = func(_ context.Context, c *ldapConnector, username string) (ldaplib.Entry, error) { + if username != "Admin" { + t.Fatalf("expected localpart-derived username Admin, got %q", username) + } + e := ldaplib.NewEntry("cn=admin,dc=local", map[string][]string{ + c.UserSearch.IDAttr: {"uid-admin"}, + c.UserSearch.EmailAttr: {"admin@local"}, + c.UserSearch.NameAttr: {"Admin"}, + }) + return *e, nil + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) || ident == nil { + t.Fatalf("expected handled with identity") + } +} + +func TestKerberos_OfflineAccess_SetsConnectorData(t *testing.T) { + lc := &ldapConnector{ + logger: slog.Default(), + krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}, + krb: fakeAuthenticate(newCreds("jdoe", "EXAMPLE.COM")), + } + lc.Config.UserSearch.IDAttr = "uid" + lc.Config.UserSearch.EmailAttr = "mail" + lc.Config.UserSearch.NameAttr = "cn" + lc.krbLookupUserHook = func(_ context.Context, c *ldapConnector, _ string) (ldaplib.Entry, error) { + e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ + c.UserSearch.IDAttr: {"uid-jdoe"}, + c.UserSearch.EmailAttr: {"jdoe@example.com"}, + c.UserSearch.NameAttr: {"John Doe"}, + }) + return *e, nil + } + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{OfflineAccess: true}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !bool(handled) || ident == nil { + t.Fatalf("expected handled with identity") + } + if len(ident.ConnectorData) == 0 { + t.Fatalf("expected connector data for offline access") + } +} + +func TestKerberos_Disabled_PassThrough(t *testing.T) { + // krb==nil: TrySPNEGO must leave w untouched and return (nil, false, nil). + lc := &ldapConnector{logger: slog.Default()} + r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) + w := httptest.NewRecorder() + ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if bool(handled) || ident != nil { + t.Fatalf("expected not handled with no identity") + } +} + +func TestKerberos_mapPrincipal(t *testing.T) { + cases := []struct { + username, realm, mode, want string + }{ + {"JDoe", "EXAMPLE.COM", "localpart", "JDoe"}, + {"JDoe", "EXAMPLE.COM", "sAMAccountName", "JDoe"}, + {"JDoe", "EXAMPLE.COM", "userPrincipalName", "JDoe@EXAMPLE.COM"}, + {"JDoe", "EXAMPLE.COM", "", "JDoe"}, + // Defensive cases: username accidentally contains '@'. + {"JDoe@EXAMPLE.COM", "EXAMPLE.COM", "localpart", "JDoe"}, + {"JDoe@EXAMPLE.COM", "EXAMPLE.COM", "userPrincipalName", "JDoe@EXAMPLE.COM"}, + // Empty realm for userPrincipalName degrades to bare username. + {"jdoe", "", "userPrincipalName", "jdoe"}, + {"", "EXAMPLE.COM", "localpart", ""}, + } + for _, c := range cases { + got := mapPrincipal(c.username, c.realm, c.mode) + if got != c.want { + t.Fatalf("mapPrincipal(%q,%q,%q)=%q; want %q", c.username, c.realm, c.mode, got, c.want) + } + } +} diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go index 543402718c..27f93e3393 100644 --- a/connector/ldap/ldap.go +++ b/connector/ldap/ldap.go @@ -7,13 +7,15 @@ import ( "crypto/x509" "encoding/json" "fmt" + "log/slog" "net" + "net/url" "os" + "strings" "github.com/go-ldap/ldap/v3" "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" ) // Config holds the configuration parameters for the LDAP connector. The LDAP @@ -32,10 +34,12 @@ import ( // bindDN: uid=serviceaccount,cn=users,dc=example,dc=com // bindPW: password // userSearch: -// # Would translate to the query "(&(objectClass=person)(uid=))" +// # Would translate to the query "(&(objectClass=person)(|(uid=)(mail=)))" // baseDN: cn=users,dc=example,dc=com // filter: "(objectClass=person)" -// username: uid +// username: +// - uid +// - mail // idAttr: uid // emailAttr: mail // nameAttr: name @@ -56,10 +60,33 @@ import ( // nameAttr: name // +// UsernameAttributes represents one or more LDAP attributes to match against +// the username input. It supports unmarshaling from both a single string +// (e.g. "uid") and a list of strings (e.g. ["uid", "mail"]). +type UsernameAttributes []string + +func (u *UsernameAttributes) UnmarshalJSON(data []byte) error { + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *u = arr + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("username must be a string or list of strings") + } + if s != "" { + *u = UsernameAttributes{s} + } + return nil +} + // UserMatcher holds information about user and group matching. type UserMatcher struct { UserAttr string `json:"userAttr"` GroupAttr string `json:"groupAttr"` + // Look for parent groups + RecursionGroupAttr string `json:"recursionGroupAttr"` } // Config holds configuration options for LDAP logins. @@ -98,6 +125,9 @@ type Config struct { // "Username". UsernamePrompt string `json:"usernamePrompt"` + // Optional Kerberos (SPNEGO) SSO configuration. + Kerberos *kerberosConfig `json:"kerberos"` + // User entry search configuration. UserSearch struct { // BaseDN to start the search from. For example "cn=users,dc=example,dc=com" @@ -106,9 +136,10 @@ type Config struct { // Optional filter to apply when searching the directory. For example "(objectClass=person)" Filter string `json:"filter"` - // Attribute to match against the inputted username. This will be translated and combined - // with the other filter as "(=)". - Username string `json:"username"` + // Attribute(s) to match against the inputted username. Accepts a single string + // or a list of strings. When multiple attributes are specified, an OR filter is + // constructed: "(|(=)(=))". + Username UsernameAttributes `json:"username"` // Can either be: // * "sub" - search the whole sub tree @@ -142,6 +173,8 @@ type Config struct { UserAttr string `json:"userAttr"` GroupAttr string `json:"groupAttr"` + RecursionGroupAttr string `json:"recursionGroupAttr"` + // Array of the field pairs used to match a user to a group. // See the "UserMatcher" struct for the exact field names // @@ -158,6 +191,33 @@ type Config struct { } `json:"groupSearch"` } +// kerberosConfig defines optional Kerberos (SPNEGO) SSO settings for LDAP. +// +// Required: +// - Enabled, KeytabPath. +// +// Optional tuning: +// - SPN: overrides the service principal name extracted from the keytab +// (useful when Dex sits behind a reverse proxy that rewrites Host). +// - KeytabPrincipal: selects a specific principal out of a multi-entry +// keytab (e.g. "HTTP/dex.example.com"). +// - MaxClockSkew: seconds of acceptable clock skew between the KDC, the +// client and Dex; defaults to gokrb5's 300s when unset. +// - ExpectedRealm: rejects tickets from realms other than this one. +// - UsernameFromPrincipal: "localpart" (default) or "userPrincipalName". +// - FallbackToPassword: render the password form when SPNEGO is absent or +// fails, instead of returning 401. +type kerberosConfig struct { + Enabled bool `json:"enabled"` + KeytabPath string `json:"keytabPath"` + SPN string `json:"spn"` + KeytabPrincipal string `json:"keytabPrincipal"` + MaxClockSkew int `json:"maxClockSkew"` + ExpectedRealm string `json:"expectedRealm"` + UsernameFromPrincipal string `json:"usernameFromPrincipal"` + FallbackToPassword bool `json:"fallbackToPassword"` +} + func scopeString(i int) string { switch i { case ldap.ScopeBaseObject: @@ -187,26 +247,48 @@ func parseScope(s string) (int, bool) { // Function exists here to allow backward compatibility between old and new // group to user matching implementations. // See "Config.GroupSearch.UserMatchers" comments for the details -func userMatchers(c *Config, logger log.Logger) []UserMatcher { +func userMatchers(c *Config, logger *slog.Logger) []UserMatcher { if len(c.GroupSearch.UserMatchers) > 0 && c.GroupSearch.UserMatchers[0].UserAttr != "" { return c.GroupSearch.UserMatchers } - log.Deprecated(logger, `LDAP: use groupSearch.userMatchers option instead of "userAttr/groupAttr" fields.`) + if c.GroupSearch.UserAttr != "" || c.GroupSearch.GroupAttr != "" { + logger.Warn(`use "groupSearch.userMatchers" option instead of "userAttr/groupAttr" fields`, "deprecated", true) + } return []UserMatcher{ { - UserAttr: c.GroupSearch.UserAttr, - GroupAttr: c.GroupSearch.GroupAttr, + UserAttr: c.GroupSearch.UserAttr, + GroupAttr: c.GroupSearch.GroupAttr, + RecursionGroupAttr: c.GroupSearch.RecursionGroupAttr, }, } } // Open returns an authentication strategy using LDAP. -func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { + logger = logger.With(slog.Group("connector", "type", "ldap", "id", id)) conn, err := c.OpenConnector(logger) if err != nil { return nil, err } + // If Kerberos is enabled, load the keytab and bind SPNEGO middleware. + // The presence of a non-nil krb on ldapConnector is the single source of + // truth for "SPNEGO is live"; TrySPNEGO short-circuits when it's nil. + if lc, ok := conn.(*ldapConnector); ok && lc.krbConf.Enabled && lc.krb == nil { + st, kerr := loadKerberosState(lc.krbConf) + if kerr != nil { + logger.Warn("failed to initialize kerberos; disabling kerberos", "err", kerr) + } else { + lc.krb = st + logger.Info("kerberos SPNEGO enabled for LDAP connector", + "keytab", lc.krbConf.KeytabPath, + "spn", lc.krbConf.SPN, + "keytab_principal", lc.krbConf.KeytabPrincipal, + "expected_realm", lc.krbConf.ExpectedRealm, + "fallback_to_password", lc.krbConf.FallbackToPassword, + ) + } + } return connector.Connector(conn), nil } @@ -216,7 +298,7 @@ type refreshData struct { } // OpenConnector is the same as Open but returns a type with all implemented connector interfaces. -func (c *Config) OpenConnector(logger log.Logger) (interface { +func (c *Config) OpenConnector(logger *slog.Logger) (interface { connector.Connector connector.PasswordConnector connector.RefreshConnector @@ -225,14 +307,13 @@ func (c *Config) OpenConnector(logger log.Logger) (interface { return c.openConnector(logger) } -func (c *Config) openConnector(logger log.Logger) (*ldapConnector, error) { +func (c *Config) openConnector(logger *slog.Logger) (*ldapConnector, error) { requiredFields := []struct { name string val string }{ {"host", c.Host}, {"userSearch.baseDN", c.UserSearch.BaseDN}, - {"userSearch.username", c.UserSearch.Username}, } for _, field := range requiredFields { @@ -241,6 +322,10 @@ func (c *Config) openConnector(logger log.Logger) (*ldapConnector, error) { } } + if len(c.UserSearch.Username) == 0 { + return nil, fmt.Errorf("ldap: missing required field %q", "userSearch.username") + } + var ( host string err error @@ -288,9 +373,41 @@ func (c *Config) openConnector(logger log.Logger) (*ldapConnector, error) { // TODO(nabokihms): remove it after deleting deprecated groupSearch options c.GroupSearch.UserMatchers = userMatchers(c, logger) - return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig, logger}, nil + + // Normalize Kerberos defaults. We persist krbConf only when it is + // usable (Enabled + KeytabPath set); otherwise it stays at the zero + // value and Open() will skip loadKerberosState โ€” which keeps krb nil + // and effectively disables SPNEGO without any extra flag. + var krbConf kerberosConfig + if c.Kerberos != nil && c.Kerberos.Enabled { + krbConf = *c.Kerberos + if krbConf.UsernameFromPrincipal == "" { + krbConf.UsernameFromPrincipal = "localpart" + } + if krbConf.KeytabPath == "" { + logger.Warn("kerberos enabled but keytabPath is empty; disabling kerberos") + krbConf = kerberosConfig{} + } + } + + lc := &ldapConnector{ + Config: *c, + userSearchScope: userSearchScope, + groupSearchScope: groupSearchScope, + tlsConfig: tlsConfig, + usernameAttrs: c.UserSearch.Username, + logger: logger, + krbConf: krbConf, + } + return lc, nil } +var ( + _ connector.PasswordConnector = (*ldapConnector)(nil) + _ connector.RefreshConnector = (*ldapConnector)(nil) + _ connector.SPNEGOAware = (*ldapConnector)(nil) +) + type ldapConnector struct { Config @@ -299,13 +416,21 @@ type ldapConnector struct { tlsConfig *tls.Config - logger log.Logger -} + usernameAttrs []string -var ( - _ connector.PasswordConnector = (*ldapConnector)(nil) - _ connector.RefreshConnector = (*ldapConnector)(nil) -) + logger *slog.Logger + + // Kerberos/SPNEGO support. krb is nil until the keytab has been loaded + // successfully in Open(); TrySPNEGO uses (krb == nil) as the single + // "is SPNEGO available" check, so no separate enable flag is needed. + krbConf kerberosConfig + krb *krbState + // krbLookupUserHook allows tests to replace the LDAP user lookup entirely. + // When set it is authoritative: lookupKerberosUser does not fall through + // to a real LDAP search. Tests signal "user not found" by returning an + // error, not by returning a zero entry with a nil error. + krbLookupUserHook func(ctx context.Context, c *ldapConnector, username string) (ldap.Entry, error) +} // do initializes a connection to the LDAP directory and passes it to the // provided function. It then performs appropriate teardown or reuse before @@ -316,11 +441,14 @@ func (c *ldapConnector) do(_ context.Context, f func(c *ldap.Conn) error) error conn *ldap.Conn err error ) + switch { case c.InsecureNoSSL: - conn, err = ldap.Dial("tcp", c.Host) + u := url.URL{Scheme: "ldap", Host: c.Host} + conn, err = ldap.DialURL(u.String()) case c.StartTLS: - conn, err = ldap.Dial("tcp", c.Host) + u := url.URL{Scheme: "ldap", Host: c.Host} + conn, err = ldap.DialURL(u.String()) if err != nil { return fmt.Errorf("failed to connect: %v", err) } @@ -328,7 +456,8 @@ func (c *ldapConnector) do(_ context.Context, f func(c *ldap.Conn) error) error return fmt.Errorf("start TLS failed: %v", err) } default: - conn, err = ldap.DialTLS("tcp", c.Host, c.tlsConfig) + u := url.URL{Scheme: "ldaps", Host: c.Host} + conn, err = ldap.DialURL(u.String(), ldap.DialWithTLSConfig(c.tlsConfig)) } if err != nil { return fmt.Errorf("failed to connect: %v", err) @@ -347,21 +476,23 @@ func (c *ldapConnector) do(_ context.Context, f func(c *ldap.Conn) error) error return f(conn) } -func getAttrs(e ldap.Entry, name string) []string { +func (c *ldapConnector) getAttrs(e ldap.Entry, name string) []string { for _, a := range e.Attributes { if a.Name != name { continue } return a.Values } - if name == "DN" { + if strings.ToLower(name) == "dn" { return []string{e.DN} } + + c.logger.Debug("attribute is not fround in entry", "attribute", name) return nil } -func getAttr(e ldap.Entry, name string) string { - if a := getAttrs(e, name); len(a) > 0 { +func (c *ldapConnector) getAttr(e ldap.Entry, name string) string { + if a := c.getAttrs(e, name); len(a) > 0 { return a[0] } return "" @@ -373,25 +504,25 @@ func (c *ldapConnector) identityFromEntry(user ldap.Entry) (ident connector.Iden missing := []string{} // Fill the identity struct using the attributes from the user entry. - if ident.UserID = getAttr(user, c.UserSearch.IDAttr); ident.UserID == "" { + if ident.UserID = c.getAttr(user, c.UserSearch.IDAttr); ident.UserID == "" { missing = append(missing, c.UserSearch.IDAttr) } if c.UserSearch.NameAttr != "" { - if ident.Username = getAttr(user, c.UserSearch.NameAttr); ident.Username == "" { + if ident.Username = c.getAttr(user, c.UserSearch.NameAttr); ident.Username == "" { missing = append(missing, c.UserSearch.NameAttr) } } if c.UserSearch.PreferredUsernameAttrAttr != "" { - if ident.PreferredUsername = getAttr(user, c.UserSearch.PreferredUsernameAttrAttr); ident.PreferredUsername == "" { + if ident.PreferredUsername = c.getAttr(user, c.UserSearch.PreferredUsernameAttrAttr); ident.PreferredUsername == "" { missing = append(missing, c.UserSearch.PreferredUsernameAttrAttr) } } if c.UserSearch.EmailSuffix != "" { ident.Email = ident.Username + "@" + c.UserSearch.EmailSuffix - } else if ident.Email = getAttr(user, c.UserSearch.EmailAttr); ident.Email == "" { + } else if ident.Email = c.getAttr(user, c.UserSearch.EmailAttr); ident.Email == "" { missing = append(missing, c.UserSearch.EmailAttr) } // TODO(ericchiang): Let this value be set from an attribute. @@ -405,7 +536,19 @@ func (c *ldapConnector) identityFromEntry(user ldap.Entry) (ident connector.Iden } func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.Entry, found bool, err error) { - filter := fmt.Sprintf("(%s=%s)", c.UserSearch.Username, ldap.EscapeFilter(username)) + var filter string + escapedUsername := ldap.EscapeFilter(username) + + attrFilters := make([]string, 0, len(c.usernameAttrs)) + for _, attr := range c.usernameAttrs { + attrFilters = append(attrFilters, fmt.Sprintf("(%s=%s)", attr, escapedUsername)) + } + if len(attrFilters) == 1 { + filter = attrFilters[0] // Skip OR wrapper for single attribute + } else { + filter = fmt.Sprintf("(|%s)", strings.Join(attrFilters, "")) + } + if c.UserSearch.Filter != "" { filter = fmt.Sprintf("(&%s%s)", c.UserSearch.Filter, filter) } @@ -423,6 +566,8 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E }, } + req.Attributes = append(req.Attributes, c.usernameAttrs...) + for _, matcher := range c.GroupSearch.UserMatchers { req.Attributes = append(req.Attributes, matcher.UserAttr) } @@ -435,8 +580,8 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E req.Attributes = append(req.Attributes, c.UserSearch.PreferredUsernameAttrAttr) } - c.logger.Infof("performing ldap search %s %s %s", - req.BaseDN, scopeString(req.Scope), req.Filter) + c.logger.Info("performing ldap search", + "base_dn", req.BaseDN, "scope", scopeString(req.Scope), "filter", req.Filter) resp, err := conn.Search(req) if err != nil { return ldap.Entry{}, false, fmt.Errorf("ldap: search with filter %q failed: %v", req.Filter, err) @@ -444,11 +589,11 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E switch n := len(resp.Entries); n { case 0: - c.logger.Errorf("ldap: no results returned for filter: %q", filter) + c.logger.Error("no results returned for filter", "filter", filter) return ldap.Entry{}, false, nil case 1: user = *resp.Entries[0] - c.logger.Infof("username %q mapped to entry %s", username, user.DN) + c.logger.Info("username mapped to entry", "username", username, "user_dn", user.DN) return user, true, nil default: return ldap.Entry{}, false, fmt.Errorf("ldap: filter returned multiple (%d) results: %q", n, filter) @@ -457,6 +602,7 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) { // make this check to avoid unauthenticated bind to the LDAP server. + if password == "" { return connector.Identity{}, false, nil } @@ -468,6 +614,8 @@ func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username, user ldap.Entry ) + username = ldap.EscapeFilter(username) + err = c.do(ctx, func(conn *ldap.Conn) error { entry, found, err := c.userEntry(conn, username) if err != nil { @@ -485,11 +633,11 @@ func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username, if ldapErr, ok := err.(*ldap.Error); ok { switch ldapErr.ResultCode { case ldap.LDAPResultInvalidCredentials: - c.logger.Errorf("ldap: invalid password for user %q", user.DN) + c.logger.Error("invalid password for user", "user_dn", user.DN) incorrectPass = true return nil case ldap.LDAPResultConstraintViolation: - c.logger.Errorf("ldap: constraint violation for user %q: %s", user.DN, ldapErr.Error()) + c.logger.Error("constraint violation for user", "user_dn", user.DN, "err", ldapErr.Error()) incorrectPass = true return nil } @@ -575,61 +723,124 @@ func (c *ldapConnector) Refresh(ctx context.Context, s connector.Scopes, ident c func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string, error) { if c.GroupSearch.BaseDN == "" { - c.logger.Debugf("No groups returned for %q because no groups baseDN has been configured.", getAttr(user, c.UserSearch.NameAttr)) + c.logger.Debug("No groups returned because no groups baseDN has been configured.", "base_dn", c.getAttr(user, c.UserSearch.NameAttr)) return nil, nil } - var groups []*ldap.Entry + var groupNames []string + for _, matcher := range c.GroupSearch.UserMatchers { - for _, attr := range getAttrs(user, matcher.UserAttr) { - filter := fmt.Sprintf("(%s=%s)", matcher.GroupAttr, ldap.EscapeFilter(attr)) - if c.GroupSearch.Filter != "" { - filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter) + // Initial Search + var groups []*ldap.Entry + for _, attr := range c.getAttrs(user, matcher.UserAttr) { + obtained, filter, err := c.queryGroups(ctx, matcher.GroupAttr, attr) + if err != nil { + return nil, err } + gotGroups := len(obtained) != 0 + if !gotGroups { + // TODO(ericchiang): Is this going to spam the logs? + c.logger.Error("ldap: groups search returned no groups", "filter", filter) + } + groups = append(groups, obtained...) + } - req := &ldap.SearchRequest{ - BaseDN: c.GroupSearch.BaseDN, - Filter: filter, - Scope: c.groupSearchScope, - Attributes: []string{c.GroupSearch.NameAttr}, + // If RecursionGroupAttr is not set, convert direct groups into names and return + if matcher.RecursionGroupAttr == "" { + for _, group := range groups { + name := c.getAttr(*group, c.GroupSearch.NameAttr) + if name == "" { + return nil, fmt.Errorf( + "ldap: group entity %q missing required attribute %q", + group.DN, c.GroupSearch.NameAttr, + ) + } + groupNames = append(groupNames, name) } + continue + } - gotGroups := false - if err := c.do(ctx, func(conn *ldap.Conn) error { - c.logger.Infof("performing ldap search %s %s %s", - req.BaseDN, scopeString(req.Scope), req.Filter) - resp, err := conn.Search(req) + // Recursive Search + c.logger.Info("Recursive group search enabled", "groupAttr", matcher.GroupAttr, "recursionAttr", matcher.RecursionGroupAttr) + for { + var nextLevel []*ldap.Entry + for _, group := range groups { + name := c.getAttr(*group, c.GroupSearch.NameAttr) + if name == "" { + return nil, fmt.Errorf("ldap: group entity %q missing required attribute %q", + group.DN, c.GroupSearch.NameAttr) + } + + // Prevent duplicates and circular references. + duplicate := false + for _, existingName := range groupNames { + if name == existingName { + c.logger.Debug("Found duplicate group", "name", name) + duplicate = true + break + } + } + if duplicate { + continue + } + + groupNames = append(groupNames, name) + + // Search for parent groups using the group's DN. + parents, filter, err := c.queryGroups(ctx, matcher.RecursionGroupAttr, group.DN) if err != nil { - return fmt.Errorf("ldap: search failed: %v", err) + return nil, err + } + if len(parents) == 0 { + c.logger.Debug("No parent groups found", "filter", filter) + } else { + nextLevel = append(nextLevel, parents...) } - gotGroups = len(resp.Entries) != 0 - groups = append(groups, resp.Entries...) - return nil - }); err != nil { - return nil, err } - if !gotGroups { - // TODO(ericchiang): Is this going to spam the logs? - c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter) + if len(nextLevel) == 0 { + break } + groups = nextLevel } } + return groupNames, nil +} - groupNames := make([]string, 0, len(groups)) - for _, group := range groups { - name := getAttr(*group, c.GroupSearch.NameAttr) - if name == "" { - // Be obnoxious about missing missing attributes. If the group entry is - // missing its name attribute, that indicates a misconfiguration. - // - // In the future we can add configuration options to just log these errors. - return nil, fmt.Errorf("ldap: group entity %q missing required attribute %q", - group.DN, c.GroupSearch.NameAttr) - } +func (c *ldapConnector) queryGroups(ctx context.Context, memberAttr, dn string) ([]*ldap.Entry, string, error) { + filter := fmt.Sprintf("(%s=%s)", memberAttr, ldap.EscapeFilter(dn)) + if c.GroupSearch.Filter != "" { + filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter) + } - groupNames = append(groupNames, name) + req := &ldap.SearchRequest{ + BaseDN: c.GroupSearch.BaseDN, + Filter: filter, + Scope: c.groupSearchScope, + Attributes: []string{c.GroupSearch.NameAttr}, + } + + var entries []*ldap.Entry + if err := c.do(ctx, func(conn *ldap.Conn) error { + c.logger.Info( + "performing ldap search", + "base_dn", req.BaseDN, + "scope", scopeString(req.Scope), + "filter", req.Filter, + ) + resp, err := conn.Search(req) + if err != nil { + if ldapErr, ok := err.(*ldap.Error); ok && ldapErr.ResultCode == ldap.LDAPResultNoSuchObject { + c.logger.Info("LDAP search returned no groups", "filter", filter) + return nil + } + return fmt.Errorf("ldap: search failed: %v", err) + } + entries = append(entries, resp.Entries...) + return nil + }); err != nil { + return nil, filter, err } - return groupNames, nil + return entries, filter, nil } func (c *ldapConnector) Prompt() string { diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go index 83f9f4790c..3335d56b5e 100644 --- a/connector/ldap/ldap_test.go +++ b/connector/ldap/ldap_test.go @@ -3,12 +3,11 @@ package ldap import ( "context" "fmt" - "io" + "log/slog" "os" "testing" "github.com/kylelemons/godebug/pretty" - "github.com/sirupsen/logrus" "github.com/dexidp/dex/connector" ) @@ -46,7 +45,7 @@ func TestQuery(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -83,6 +82,18 @@ func TestQuery(t *testing.T) { password: "foo", wantBadPW: true, // Want invalid password, not a query error. }, + { + name: "invalid wildcard username", + username: "a*", // wildcard query is not allowed + password: "foo", + wantBadPW: true, // Want invalid password, not a query error. + }, + { + name: "invalid wildcard password", + username: "john", + password: "*", // wildcard password is not allowed + wantBadPW: true, // Want invalid password, not a query error. + }, } runTests(t, connectLDAP, c, tests) @@ -94,7 +105,7 @@ func TestQueryWithEmailSuffix(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailSuffix = "test.example.com" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -130,7 +141,7 @@ func TestUserFilter(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.UserSearch.Filter = "(ou:dn:=Seattle)" tests := []subtest{ @@ -173,13 +184,50 @@ func TestUserFilter(t *testing.T) { runTests(t, connectLDAP, c, tests) } +func TestUsernameWithMultipleAttributes(t *testing.T) { + c := &Config{} + c.UserSearch.BaseDN = "ou=TestUsernameWithMultipleAttributes,dc=example,dc=org" + c.UserSearch.NameAttr = "cn" + c.UserSearch.EmailAttr = "mail" + c.UserSearch.IDAttr = "DN" + c.UserSearch.Username = UsernameAttributes{"cn", "mail"} + c.UserSearch.Filter = "(ou:dn:=Seattle)" + + tests := []subtest{ + { + name: "cn", + username: "jane", + password: "foo", + want: connector.Identity{ + UserID: "cn=jane,ou=People,ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org", + Username: "jane", + Email: "janedoe@example.com", + EmailVerified: true, + }, + }, + { + name: "mail", + username: "janedoe@example.com", + password: "foo", + want: connector.Identity{ + UserID: "cn=jane,ou=People,ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org", + Username: "jane", + Email: "janedoe@example.com", + EmailVerified: true, + }, + }, + } + + runTests(t, connectLDAP, c, tests) +} + func TestGroupQuery(t *testing.T) { c := &Config{} c.UserSearch.BaseDN = "ou=People,ou=TestGroupQuery,dc=example,dc=org" c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=Groups,ou=TestGroupQuery,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ { @@ -227,7 +275,7 @@ func TestGroupsOnUserEntity(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=Groups,ou=TestGroupsOnUserEntity,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ { @@ -273,11 +321,11 @@ func TestGroupFilter(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=TestGroupFilter,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ { - UserAttr: "DN", + UserAttr: "dn", GroupAttr: "member", }, } @@ -322,7 +370,7 @@ func TestGroupToUserMatchers(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=TestGroupToUserMatchers,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ { @@ -378,7 +426,7 @@ func TestDeprecatedGroupToUserMatcher(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org" c.GroupSearch.UserAttr = "DN" c.GroupSearch.GroupAttr = "member" @@ -423,7 +471,7 @@ func TestStartTLS(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -447,7 +495,7 @@ func TestInsecureSkipVerify(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -471,7 +519,7 @@ func TestLDAPS(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -514,6 +562,86 @@ func TestUsernamePrompt(t *testing.T) { } } +func TestUsernameAttributesUnmarshal(t *testing.T) { + tests := []struct { + name string + json string + want UsernameAttributes + wantErr bool + }{ + {name: "single string", json: `"uid"`, want: UsernameAttributes{"uid"}}, + {name: "array of strings", json: `["uid","mail"]`, want: UsernameAttributes{"uid", "mail"}}, + {name: "single element array", json: `["cn"]`, want: UsernameAttributes{"cn"}}, + {name: "empty string", json: `""`, want: nil}, + {name: "invalid type", json: `123`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got UsernameAttributes + err := got.UnmarshalJSON([]byte(tt.json)) + if (err != nil) != tt.wantErr { + t.Fatalf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr { + if diff := pretty.Compare(tt.want, got); diff != "" { + t.Errorf("unexpected result: %s", diff) + } + } + }) + } +} + +func TestNestedGroups(t *testing.T) { + c := &Config{} + c.UserSearch.BaseDN = "ou=People,ou=TestNestedGroups,dc=example,dc=org" + c.UserSearch.NameAttr = "cn" + c.UserSearch.EmailAttr = "mail" + c.UserSearch.IDAttr = "DN" + c.UserSearch.Username = UsernameAttributes{"cn"} + + c.GroupSearch.BaseDN = "ou=TestNestedGroups,dc=example,dc=org" + c.GroupSearch.UserMatchers = []UserMatcher{ + { + UserAttr: "DN", + GroupAttr: "member", + // Enable Recursive Search + RecursionGroupAttr: "member", + }, + } + c.GroupSearch.NameAttr = "cn" + + tests := []subtest{ + { + name: "nestedgroups_jane", + username: "jane", + password: "foo", + groups: true, + want: connector.Identity{ + UserID: "cn=jane,ou=People,ou=TestNestedGroups,dc=example,dc=org", + Username: "jane", + Email: "janedoe@example.com", + EmailVerified: true, + Groups: []string{"childGroup", "circularGroup1", "intermediateGroup", "circularGroup2", "parentGroup"}, + }, + }, + { + name: "nestedgroups_john", + username: "john", + password: "bar", + groups: true, + want: connector.Identity{ + UserID: "cn=john,ou=People,ou=TestNestedGroups,dc=example,dc=org", + Username: "john", + Email: "johndoe@example.com", + EmailVerified: true, + Groups: []string{"circularGroup2", "intermediateGroup", "circularGroup1", "parentGroup"}, + }, + }, + } + runTests(t, connectLDAP, c, tests) +} + func getenv(key, defaultVal string) string { if val := os.Getenv(key); val != "" { return val @@ -523,7 +651,7 @@ func getenv(key, defaultVal string) string { // runTests runs a set of tests against an LDAP schema. // -// The tests require LDAP to be runnning. +// The tests require LDAP to be running. // You can use the provided docker-compose file to setup an LDAP server. func runTests(t *testing.T, connMethod connectionMethod, config *Config, tests []subtest) { ldapHost := os.Getenv("DEX_LDAP_HOST") @@ -555,7 +683,7 @@ func runTests(t *testing.T, connMethod connectionMethod, config *Config, tests [ c.BindDN = "cn=admin,dc=example,dc=org" c.BindPW = "admin" - l := &logrus.Logger{Out: io.Discard, Formatter: &logrus.TextFormatter{}} + l := slog.New(slog.DiscardHandler) conn, err := c.openConnector(l) if err != nil { diff --git a/connector/ldap/testdata/schema.ldif b/connector/ldap/testdata/schema.ldif index 69c7b3ff64..ab133543b4 100644 --- a/connector/ldap/testdata/schema.ldif +++ b/connector/ldap/testdata/schema.ldif @@ -445,3 +445,85 @@ sn: doe cn: jane mail: janedoe@example.com userpassword: foo + +######################################################################## + +dn: ou=TestNestedGroups,dc=example,dc=org +objectClass: organizationalUnit +ou: TestNestedGroups + +dn: ou=People,ou=TestNestedGroups,dc=example,dc=org +objectClass: organizationalUnit +ou: People + +dn: cn=jane,ou=People,ou=TestNestedGroups,dc=example,dc=org +objectClass: person +objectClass: inetOrgPerson +sn: doe +cn: jane +mail: janedoe@example.com +userpassword: foo + +dn: cn=john,ou=People,ou=TestNestedGroups,dc=example,dc=org +objectClass: person +objectClass: inetOrgPerson +sn: doe +cn: john +mail: johndoe@example.com +userpassword: bar + +# Group definitions. + +dn: ou=Groups,ou=TestNestedGroups,dc=example,dc=org +objectClass: organizationalUnit +ou: Groups + +dn: cn=childGroup,ou=Groups,ou=TestNestedGroups,dc=example,dc=org +objectClass: groupOfNames +cn: childGroup +member: cn=jane,ou=People,ou=TestNestedGroups,dc=example,dc=org + +dn: cn=intermediateGroup,ou=Groups,ou=TestNestedGroups,dc=example,dc=org +objectClass: groupOfNames +cn: intermediateGroup +member: cn=childGroup,ou=Groups,ou=TestNestedGroups,dc=example,dc=org +member: cn=john,ou=People,ou=TestNestedGroups,dc=example,dc=org + +dn: cn=parentGroup,ou=Groups,ou=TestNestedGroups,dc=example,dc=org +objectClass: groupOfNames +cn: parentGroup +member: cn=intermediateGroup,ou=Groups,ou=TestNestedGroups,dc=example,dc=org + +dn: cn=circularGroup1,ou=Groups,ou=TestNestedGroups,dc=example,dc=org +objectClass: groupOfNames +cn: circularGroup1 +member: cn=circularGroup2,ou=Groups,ou=TestNestedGroups,dc=example,dc=org +member: cn=jane,ou=People,ou=TestNestedGroups,dc=example,dc=org + +dn: cn=circularGroup2,ou=Groups,ou=TestNestedGroups,dc=example,dc=org +objectClass: groupOfNames +cn: circularGroup2 +member: cn=circularGroup1,ou=Groups,ou=TestNestedGroups,dc=example,dc=org +member: cn=john,ou=People,ou=TestNestedGroups,dc=example,dc=org + +######################################################################## + +dn: ou=TestUsernameWithMultipleAttributes,dc=example,dc=org +objectClass: organizationalUnit +ou: TestUsernameWithMultipleAttributes + +dn: ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org +objectClass: organizationalUnit +ou: Seattle + +dn: ou=People,ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org +objectClass: organizationalUnit +ou: People + +dn: cn=jane,ou=People,ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org +objectClass: person +objectClass: inetOrgPerson +sn: doe +cn: jane +mail: janedoe@example.com +userpassword: foo \ No newline at end of file diff --git a/connector/linkedin/linkedin.go b/connector/linkedin/linkedin.go index f79f1c49d8..32e33aeaca 100644 --- a/connector/linkedin/linkedin.go +++ b/connector/linkedin/linkedin.go @@ -6,13 +6,13 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "strings" "golang.org/x/oauth2" "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" ) const ( @@ -29,7 +29,7 @@ type Config struct { } // Open returns a strategy for logging in through LinkedIn -func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { return &linkedInConnector{ oauth2Config: &oauth2.Config{ ClientID: c.ClientID, @@ -41,7 +41,7 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) Scopes: []string{"r_liteprofile", "r_emailaddress"}, RedirectURL: c.RedirectURI, }, - logger: logger, + logger: logger.With(slog.Group("connector", "type", "linkedin", "id", id)), }, nil } @@ -49,30 +49,28 @@ type connectorData struct { AccessToken string `json:"accessToken"` } -type linkedInConnector struct { - oauth2Config *oauth2.Config - logger log.Logger -} - -// LinkedIn doesn't provide refresh tokens, so refresh tokens issued by Dex -// will expire in 60 days (default LinkedIn token lifetime). var ( _ connector.CallbackConnector = (*linkedInConnector)(nil) _ connector.RefreshConnector = (*linkedInConnector)(nil) ) +type linkedInConnector struct { + oauth2Config *oauth2.Config + logger *slog.Logger +} + // LoginURL returns an access token request URL -func (c *linkedInConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { +func (c *linkedInConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, []byte, error) { if c.oauth2Config.RedirectURL != callbackURL { - return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.oauth2Config.RedirectURL) } - return c.oauth2Config.AuthCodeURL(state), nil + return c.oauth2Config.AuthCodeURL(state), nil, nil } // HandleCallback handles HTTP redirect from LinkedIn -func (c *linkedInConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { +func (c *linkedInConnector) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} diff --git a/connector/microsoft/TESTING.md b/connector/microsoft/TESTING.md new file mode 100644 index 0000000000..f9e8e7c8c4 --- /dev/null +++ b/connector/microsoft/TESTING.md @@ -0,0 +1,422 @@ +# Testing the Microsoft Connector + +This guide covers how to test the Microsoft connector with both client secret and client assertion authentication methods. + +## Prerequisites + +- `az` CLI installed and logged in (`az login`) +- `openssl` (for generating keypairs) +- Dex built locally (`make build examples`) +- `jq` (for JSON parsing) +- `kubectl` - for testing using Federated Credential + +## Setup Azure AD Application + +First, create an Azure AD application that will be used for testing: + +```bash +# Set variables +APP_NAME="dex-test-$(date +%s)" +REDIRECT_URI="http://127.0.0.1:5556/dex/callback" + +# Create the application +APP_JSON=$(az ad app create \ + --display-name "${APP_NAME}" \ + --sign-in-audience AzureADMyOrg \ + --web-redirect-uris "${REDIRECT_URI}" \ + --query '{appId:appId,displayName:displayName}' \ + -o json) + +APP_ID=$(echo "${APP_JSON}" | jq -r .appId) + +# Get your tenant ID +TENANT_ID=$(az account show --query tenantId -o tsv) +``` + +Add Microsoft Graph API permissions: + +```bash +# Add User.Read and Directory.Read.All permissions +az ad app permission add \ + --id "${APP_ID}" \ + --api 00000003-0000-0000-c000-000000000000 \ + --api-permissions \ + e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope \ + 06da0dbc-49e2-44d2-8312-53f166ab848a=Scope + +# Note: Admin consent may be required for Directory.Read.All +# You can grant it via Azure Portal or with admin permissions: +az ad app permission admin-consent --id "${APP_ID}" +``` + +## Method 1: Testing with Client Secret + +### Create Client Secret + +```bash +# Create a client secret (expires in 1 year) +SECRET_JSON=$(az ad app credential reset \ + --id "${APP_ID}" \ + --append \ + --years 1 \ + --query '{secret:password}' \ + -o json) + +CLIENT_SECRET=$(echo "${SECRET_JSON}" | jq -r '.secret') +``` + +### Create Dex Configuration for Client Secret + +```bash +# Create temporary config file with client secret +CONFIG_FILE=$(mktemp -t dex-config-secret.yaml) +cat > "${CONFIG_FILE}" < "${KEYS_DIR}/generate-jwt.sh" <<'SCRIPT' +#!/bin/bash +set -e + +PRIVATE_KEY="$1" +CERT="$2" +APP_ID="$3" +TENANT_ID="$4" + +if [[ -z "${PRIVATE_KEY}" || -z "${CERT}" || -z "${APP_ID}" || -z "${TENANT_ID}" ]]; then + echo "Usage: $0 " + exit 1 +fi + +# Base64URL encode function +base64url() { + openssl base64 -e -A | tr '+/' '-_' | tr -d '=' +} + +# Get certificate thumbprint for x5t header +X5T=$(openssl x509 -in "${CERT}" -fingerprint -noout -sha1 | \ + sed 's/://g' | cut -d= -f2 | xxd -r -p | base64url) + +# JWT Header +HEADER=$(echo -n "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"x5t\":\"${X5T}\"}" | base64url) + +# JWT Claims +NOW=$(date +%s) +EXP=$((NOW + 3600)) # Expires in 1 hour +JTI=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "$(date +%s)-$$") + +CLAIMS=$(cat < "${KEYS_DIR}/assertion.jwt" + +``` + +### Create Dex Configuration for Client Assertion + +```bash +# Create temporary config file with client assertion +CONFIG_FILE=$(mktemp -t dex-config-assertion.XXXXXX.yaml) +cat > "${CONFIG_FILE}" < "${KEYS_DIR}/assertion.jwt" + +# Terminal 1: Start Dex +./bin/dex serve "${CONFIG_FILE}" + +# Terminal 2: Start example app +./bin/example-app --issuer http://127.0.0.1:5556/dex + +# Open browser to http://127.0.0.1:5555 and test login +echo "Test the config with the following:" +echo "Authenticate for: example-app" +echo "Connector ID: microsoft-assertion" + +# When done, remove the temporary files +rm "${CONFIG_FILE}" +rm -rf "${KEYS_DIR}" +``` + +## Method 3: Testing with Client Assertion Using Kubernetes Workload Identity + +If you have a Kubernetes cluster with a publicly available OIDC issuer configured for service accounts: + +### Create Service Account + +```bash +kubectl create serviceaccount dex-test -n default +``` + +### Configure Federated Credential in Azure + +```bash +# Get your Kubernetes OIDC issuer +K8S_ISSUER=$(kubectl get --raw /.well-known/openid-configuration | jq -r '.issuer') + +# Create federated credential +az ad app federated-credential create \ + --id "${APP_ID}" \ + --parameters "{ + \"name\": \"k8s-dex-test\", + \"issuer\": \"${K8S_ISSUER}\", + \"subject\": \"system:serviceaccount:default:dex-test\", + \"audiences\": [\"api://AzureADTokenExchange\"] + }" +``` + +### Generate Kubernetes Token + +```bash +# Create temporary file for the Kubernetes token +K8S_TOKEN_FILE=$(mktemp -t k8s-token.jwt) + +# Create token with correct audience +kubectl create token dex-test \ + -n default \ + --duration=1h \ + --audience=api://AzureADTokenExchange \ + > "${K8S_TOKEN_FILE}" +``` + +### Create Dex Configuration for Kubernetes + +```bash +# Create temporary config file with Kubernetes token +CONFIG_FILE=$(mktemp -t dex-config-k8s.yaml) +cat > "${CONFIG_FILE}" < 0 && len(filteredGroups) == 0 { - return nil, fmt.Errorf("microsoft: user %v not in any of the required groups", userID) + return nil, &connector.UserNotInRequiredGroupsError{UserID: userID, Groups: c.groups} } else if c.useGroupsAsWhitelist { return filteredGroups, nil } @@ -419,32 +578,53 @@ func (c *microsoftConnector) getGroupIDs(ctx context.Context, client *http.Clien func (c *microsoftConnector) getGroupNames(ctx context.Context, client *http.Client, ids []string) (groups []string, err error) { if len(ids) == 0 { - return + return nil, nil } - // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/directoryobject_getbyids - in := &struct { - IDs []string `json:"ids"` - Types []string `json:"types"` - }{ids, []string{"group"}} - reqURL := c.graphURL + "/v1.0/directoryObjects/getByIds" - for { - var out []group - var next string + // Graph API caps /directoryObjects/getByIds at 1000 identifiers per request. + // See: https://learn.microsoft.com/en-us/graph/api/directoryobject-getbyids?view=graph-rest-1.0&tabs=http#http-request + const maxBatchSize = 1000 - next, err = c.post(ctx, client, reqURL, in, &out) - if err != nil { - return groups, err - } + // Default to a single request covering all IDs, matching pre-existing + // behavior. Only chunk into multiple requests when BatchGroupLookups is + // enabled, since that trades a login failure (>1000 groups) for + // additional Graph API requests. + batchSize := len(ids) + if c.batchGroupLookups { + batchSize = maxBatchSize + } - for _, g := range out { - groups = append(groups, g.Name) + for i := 0; i < len(ids); i += batchSize { + end := i + batchSize + if end > len(ids) { + end = len(ids) } - if next == "" { - return + + // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/directoryobject_getbyids + in := &struct { + IDs []string `json:"ids"` + Types []string `json:"types"` + }{ids[i:end], []string{"group"}} + reqURL := c.graphURL + "/v1.0/directoryObjects/getByIds" + for { + var out []group + var next string + + next, err = c.post(ctx, client, reqURL, in, &out) + if err != nil { + return groups, err + } + + for _, g := range out { + groups = append(groups, g.Name) + } + if next == "" { + break + } + reqURL = next } - reqURL = next } + return groups, err } func (c *microsoftConnector) post(ctx context.Context, client *http.Client, reqURL string, in interface{}, out interface{}) (string, error) { diff --git a/connector/microsoft/microsoft_test.go b/connector/microsoft/microsoft_test.go index 67be660fce..b01a9ed916 100644 --- a/connector/microsoft/microsoft_test.go +++ b/connector/microsoft/microsoft_test.go @@ -2,12 +2,16 @@ package microsoft import ( "encoding/json" + "errors" "fmt" + "io" + "log/slog" "net/http" "net/http/httptest" "net/url" "os" "reflect" + "sync/atomic" "testing" "github.com/dexidp/dex/connector" @@ -39,7 +43,7 @@ func TestLoginURL(t *testing.T) { tenant: tenant, } - loginURL, _ := conn.LoginURL(connector.Scopes{}, conn.redirectURI, testState) + loginURL, _, _ := conn.LoginURL(connector.Scopes{}, conn.redirectURI, testState) parsedLoginURL, _ := url.Parse(loginURL) queryParams := parsedLoginURL.Query() @@ -70,7 +74,7 @@ func TestLoginURLWithOptions(t *testing.T) { domainHint: domainHint, } - loginURL, _ := conn.LoginURL(connector.Scopes{}, conn.redirectURI, "some-state") + loginURL, _, _ := conn.LoginURL(connector.Scopes{}, conn.redirectURI, "some-state") parsedLoginURL, _ := url.Parse(loginURL) queryParams := parsedLoginURL.Query() @@ -81,7 +85,7 @@ func TestLoginURLWithOptions(t *testing.T) { func TestUserIdentityFromGraphAPI(t *testing.T) { s := newTestServer(map[string]testResponse{ - "/v1.0/me?$select=id,displayName,userPrincipalName": { + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": { data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com"}, }, "/" + tenant + "/oauth2/v2.0/token": dummyToken, @@ -91,7 +95,7 @@ func TestUserIdentityFromGraphAPI(t *testing.T) { req, _ := http.NewRequest("GET", s.URL, nil) c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant} - identity, err := c.HandleCallback(connector.Scopes{Groups: false}, req) + identity, err := c.HandleCallback(connector.Scopes{Groups: false}, nil, req) expectNil(t, err) expectEquals(t, identity.Username, "Jane Doe") expectEquals(t, identity.UserID, "S56767889") @@ -101,9 +105,39 @@ func TestUserIdentityFromGraphAPI(t *testing.T) { expectEquals(t, len(identity.Groups), 0) } +func TestPreferredUsernameField(t *testing.T) { + s := newTestServer(map[string]testResponse{ + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": { + data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com", MailNickname: "janedoe", OnPremisesSamAccountName: "DOMAIN\\janedoe"}, + }, + "/" + tenant + "/oauth2/v2.0/token": dummyToken, + }) + defer s.Close() + + tests := []struct { + field string + expected string + }{ + {"", ""}, + {"name", "Jane Doe"}, + {"email", "jane.doe@example.com"}, + {"mailNickname", "janedoe"}, + {"onPremisesSamAccountName", "DOMAIN\\janedoe"}, + {"invalidstring", ""}, + } + + for _, tt := range tests { + req, _ := http.NewRequest("GET", s.URL, nil) + c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant, preferredUsernameField: tt.field, logger: slog.Default()} + identity, err := c.HandleCallback(connector.Scopes{Groups: false}, nil, req) + expectNil(t, err) + expectEquals(t, identity.PreferredUsername, tt.expected) + } +} + func TestUserGroupsFromGraphAPI(t *testing.T) { s := newTestServer(map[string]testResponse{ - "/v1.0/me?$select=id,displayName,userPrincipalName": {data: user{}}, + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": {data: user{}}, "/v1.0/me/getMemberGroups": {data: map[string]interface{}{ "value": []string{"a", "b"}, }}, @@ -114,11 +148,195 @@ func TestUserGroupsFromGraphAPI(t *testing.T) { req, _ := http.NewRequest("GET", s.URL, nil) c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant} - identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := c.HandleCallback(connector.Scopes{Groups: true}, nil, req) expectNil(t, err) expectEquals(t, identity.Groups, []string{"a", "b"}) } +func TestUserNotInRequiredGroupFromGraphAPI(t *testing.T) { + s := newTestServer(map[string]testResponse{ + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": { + data: user{ID: "user-id-123", Name: "Jane Doe", Email: "jane.doe@example.com"}, + }, + // The user is a member of groups "c" and "d", but the connector only + // allows group "a" โ€” so the user should be denied. + "/v1.0/me/getMemberGroups": {data: map[string]interface{}{ + "value": []string{"c", "d"}, + }}, + "/" + tenant + "/oauth2/v2.0/token": dummyToken, + }) + defer s.Close() + + req, _ := http.NewRequest("GET", s.URL, nil) + + c := microsoftConnector{ + apiURL: s.URL, + graphURL: s.URL, + tenant: tenant, + groups: []string{"a"}, + } + _, err := c.HandleCallback(connector.Scopes{Groups: true}, nil, req) + if err == nil { + t.Fatal("expected error when user is not in any required group, got nil") + } + + var groupsErr *connector.UserNotInRequiredGroupsError + if !errors.As(err, &groupsErr) { + t.Errorf("expected *connector.UserNotInRequiredGroupsError, got %T: %v", err, err) + } +} + +// newGetByIdsBatchServer starts a test server simulating the Graph API's +// /directoryObjects/getByIds endpoint, which rejects requests with more than +// 1000 IDs โ€” the same error Graph returns in production. +func newGetByIdsBatchServer(batchCalls *atomic.Int32) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1.0/directoryObjects/getByIds" { + http.NotFound(w, r) + return + } + batchCalls.Add(1) + + var body struct { + IDs []string `json:"ids"` + Types []string `json:"types"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if len(body.IDs) > 1000 { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error":{"code":"Request_BadRequest","message":"Number of included identifiers cannot exceed '1000'."}}`) + return + } + + out := make([]group, len(body.IDs)) + for i, id := range body.IDs { + out[i] = group{Name: "name-for-" + id} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"value": out}) + })) +} + +// TestGetGroupNamesBatchLimit verifies that, when BatchGroupLookups is +// enabled, getGroupNames splits large ID lists into batches of โ‰ค1000, +// matching the Graph API limit. +func TestGetGroupNamesBatchLimit(t *testing.T) { + const totalGroups = 1500 + + ids := make([]string, totalGroups) + for i := range ids { + ids[i] = fmt.Sprintf("group-id-%d", i) + } + + var batchCalls atomic.Int32 + s := newGetByIdsBatchServer(&batchCalls) + defer s.Close() + + c := microsoftConnector{graphURL: s.URL, logger: slog.Default(), batchGroupLookups: true} + names, err := c.getGroupNames(t.Context(), s.Client(), ids) + if err != nil { + t.Fatalf("getGroupNames returned error: %v", err) + } + expected := make([]string, totalGroups) + for i, id := range ids { + expected[i] = "name-for-" + id + } + if !reflect.DeepEqual(names, expected) { + t.Errorf("got %d names %v, want %d names matching input IDs", len(names), names, totalGroups) + } + // ceil(1500 / 1000) = 2 batches + if batchCalls.Load() != 2 { + t.Errorf("expected 2 batch calls, got %d", batchCalls.Load()) + } +} + +// TestGetGroupNamesSingleRequestByDefault verifies that, without +// BatchGroupLookups enabled, getGroupNames sends all IDs in a single request +// โ€” preserving pre-existing behavior โ€” and that a user in more than 1000 +// groups gets the same Graph API error as before this feature existed. +func TestGetGroupNamesSingleRequestByDefault(t *testing.T) { + const totalGroups = 1500 + + ids := make([]string, totalGroups) + for i := range ids { + ids[i] = fmt.Sprintf("group-id-%d", i) + } + + var batchCalls atomic.Int32 + s := newGetByIdsBatchServer(&batchCalls) + defer s.Close() + + c := microsoftConnector{graphURL: s.URL, logger: slog.Default()} + _, err := c.getGroupNames(t.Context(), s.Client(), ids) + if err == nil { + t.Fatal("expected error for >1000 groups when BatchGroupLookups is disabled, got nil") + } + if batchCalls.Load() != 1 { + t.Errorf("expected exactly 1 request, got %d", batchCalls.Load()) + } +} + +func TestClientAssertionTokenExchange(t *testing.T) { + assertion := "dummy-jwt-assertion" + file, err := os.CreateTemp("", "assertion.jwt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(file.Name()) + file.WriteString(assertion + "\n") + file.Close() + + tokenCalled := false + var receivedAssertion, receivedClientID, receivedSecret, receivedAuthorization string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/testtenant/oauth2/v2.0/token" { + receivedAuthorization = r.Header.Get("Authorization") + bodyBytes, _ := io.ReadAll(r.Body) + r.Body.Close() + form, _ := url.ParseQuery(string(bodyBytes)) + receivedAssertion = form.Get("client_assertion") + receivedClientID = form.Get("client_id") + receivedSecret = form.Get("client_secret") + tokenCalled = true + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"access_token": "token", "expires_in": 3600}`)) + } + })) + defer ts.Close() + + conn := microsoftConnector{ + apiURL: ts.URL, + graphURL: ts.URL, + redirectURI: "https://test.com", + clientID: clientID, + clientSecret: "should-not-be-used", + tenant: "testtenant", + clientAssertion: file.Name(), + } + + req, _ := http.NewRequest("GET", ts.URL, nil) + conn.HandleCallback(connector.Scopes{}, nil, req) + + if !tokenCalled { + t.Errorf("Token endpoint was not called") + } + if receivedAssertion != assertion { + t.Errorf("Expected client_assertion to be %q, got %q", assertion, receivedAssertion) + } + if receivedClientID != clientID { + t.Errorf("Expected client_id to be %q, got %q", clientID, receivedClientID) + } + if receivedSecret != "" { + t.Errorf("Expected client_secret to be empty, got %q", receivedSecret) + } + if receivedAuthorization != "" { + t.Errorf("Expected Authorization header to be empty, got %q", receivedAuthorization) + } +} + func newTestServer(responses map[string]testResponse) *httptest.Server { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response, found := responses[r.RequestURI] diff --git a/connector/mock/connectortest.go b/connector/mock/connectortest.go index e7ee438625..4d9e9e2707 100644 --- a/connector/mock/connectortest.go +++ b/connector/mock/connectortest.go @@ -5,16 +5,16 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "net/url" "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" ) // NewCallbackConnector returns a mock connector which requires no user interaction. It always returns // the same (fake) identity. -func NewCallbackConnector(logger log.Logger) connector.Connector { +func NewCallbackConnector(logger *slog.Logger) connector.Connector { return &Callback{ Identity: connector.Identity{ UserID: "0-385-28089-0", @@ -29,35 +29,34 @@ func NewCallbackConnector(logger log.Logger) connector.Connector { } var ( - _ connector.CallbackConnector = &Callback{} - - _ connector.PasswordConnector = passwordConnector{} - _ connector.RefreshConnector = passwordConnector{} + _ connector.CallbackConnector = &Callback{} + _ connector.RefreshConnector = &Callback{} + _ connector.TokenIdentityConnector = &Callback{} ) // Callback is a connector that requires no user interaction and always returns the same identity. type Callback struct { // The returned identity. Identity connector.Identity - Logger log.Logger + Logger *slog.Logger } // LoginURL returns the URL to redirect the user to login with. -func (m *Callback) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) { +func (m *Callback) LoginURL(s connector.Scopes, callbackURL, state string) (string, []byte, error) { u, err := url.Parse(callbackURL) if err != nil { - return "", fmt.Errorf("failed to parse callbackURL %q: %v", callbackURL, err) + return "", nil, fmt.Errorf("failed to parse callbackURL %q: %v", callbackURL, err) } v := u.Query() v.Set("state", state) u.RawQuery = v.Encode() - return u.String(), nil + return u.String(), nil, nil } var connectorData = []byte("foobar") // HandleCallback parses the request and returns the user's identity -func (m *Callback) HandleCallback(s connector.Scopes, r *http.Request) (connector.Identity, error) { +func (m *Callback) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (connector.Identity, error) { return m.Identity, nil } @@ -66,11 +65,16 @@ func (m *Callback) Refresh(ctx context.Context, s connector.Scopes, identity con return m.Identity, nil } +func (m *Callback) TokenIdentity(ctx context.Context, subjectTokenType, subjectToken string) (connector.Identity, error) { + return m.Identity, nil +} + // CallbackConfig holds the configuration parameters for a connector which requires no interaction. type CallbackConfig struct{} // Open returns an authentication strategy which requires no user interaction. -func (c *CallbackConfig) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *CallbackConfig) Open(id string, logger *slog.Logger) (connector.Connector, error) { + logger = logger.With(slog.Group("connector", "type", "callback", "id", id)) return NewCallbackConnector(logger), nil } @@ -82,7 +86,7 @@ type PasswordConfig struct { } // Open returns an authentication strategy which prompts for a predefined username and password. -func (c *PasswordConfig) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *PasswordConfig) Open(id string, logger *slog.Logger) (connector.Connector, error) { if c.Username == "" { return nil, errors.New("no username supplied") } @@ -92,10 +96,15 @@ func (c *PasswordConfig) Open(id string, logger log.Logger) (connector.Connector return &passwordConnector{c.Username, c.Password, logger}, nil } +var ( + _ connector.PasswordConnector = passwordConnector{} + _ connector.RefreshConnector = passwordConnector{} +) + type passwordConnector struct { username string password string - logger log.Logger + logger *slog.Logger } func (p passwordConnector) Close() error { return nil } diff --git a/connector/oauth/oauth.go b/connector/oauth/oauth.go index 237d075e83..2ae13a693b 100644 --- a/connector/oauth/oauth.go +++ b/connector/oauth/oauth.go @@ -2,24 +2,22 @@ package oauth import ( "context" - "crypto/tls" - "crypto/x509" "encoding/base64" "encoding/json" "errors" "fmt" - "net" + "log/slog" "net/http" - "os" "strings" - "time" "golang.org/x/oauth2" "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" + "github.com/dexidp/dex/pkg/httpclient" ) +var _ connector.CallbackConnector = (*oauthConnector)(nil) + type oauthConnector struct { clientID string clientSecret string @@ -35,7 +33,7 @@ type oauthConnector struct { emailVerifiedKey string groupsKey string httpClient *http.Client - logger log.Logger + logger *slog.Logger } type connectorData struct { @@ -62,7 +60,7 @@ type Config struct { } `json:"claimMapping"` } -func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { var err error userIDKey := c.UserIDKey @@ -103,7 +101,7 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) userInfoURL: c.UserInfoURL, scopes: c.Scopes, redirectURI: c.RedirectURI, - logger: logger, + logger: logger.With(slog.Group("connector", "type", "oauth", "id", id)), userIDKey: userIDKey, userNameKey: userNameKey, preferredUsernameKey: preferredUsernameKey, @@ -112,7 +110,7 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) emailVerifiedKey: emailVerifiedKey, } - oauthConn.httpClient, err = newHTTPClient(c.RootCAs, c.InsecureSkipVerify) + oauthConn.httpClient, err = httpclient.NewHTTPClient(c.RootCAs, c.InsecureSkipVerify) if err != nil { return nil, err } @@ -120,43 +118,9 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) return oauthConn, err } -func newHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) { - pool, err := x509.SystemCertPool() - if err != nil { - return nil, err - } - - tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify} - for _, rootCA := range rootCAs { - rootCABytes, err := os.ReadFile(rootCA) - if err != nil { - return nil, fmt.Errorf("failed to read root-ca: %v", err) - } - if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { - return nil, fmt.Errorf("no certs found in root CA file %q", rootCA) - } - } - - return &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tlsConfig, - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - DualStack: true, - }).DialContext, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - }, nil -} - -func (c *oauthConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { +func (c *oauthConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, []byte, error) { if c.redirectURI != callbackURL { - return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) } oauth2Config := &oauth2.Config{ @@ -167,10 +131,10 @@ func (c *oauthConnector) LoginURL(scopes connector.Scopes, callbackURL, state st Scopes: c.scopes, } - return oauth2Config.AuthCodeURL(state), nil + return oauth2Config.AuthCodeURL(state), nil, nil } -func (c *oauthConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { +func (c *oauthConnector) HandleCallback(s connector.Scopes, _ []byte, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, errors.New(q.Get("error_description")) diff --git a/connector/oauth/oauth_test.go b/connector/oauth/oauth_test.go index 3a5ec6bf59..cdd2d3c687 100644 --- a/connector/oauth/oauth_test.go +++ b/connector/oauth/oauth_test.go @@ -6,15 +6,15 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "net/http/httptest" "net/url" "sort" "testing" - "github.com/sirupsen/logrus" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" - jose "gopkg.in/square/go-jose.v2" "github.com/dexidp/dex/connector" ) @@ -50,7 +50,7 @@ func TestLoginURL(t *testing.T) { conn := newConnector(t, testServer.URL) - loginURL, err := conn.LoginURL(connector.Scopes{}, conn.redirectURI, "some-state") + loginURL, _, err := conn.LoginURL(connector.Scopes{}, conn.redirectURI, "some-state") assert.Equal(t, err, nil) expectedURL, err := url.Parse(testServer.URL + "/authorize") @@ -86,7 +86,7 @@ func TestHandleCallBackForGroupsInUserInfo(t *testing.T) { conn := newConnector(t, testServer.URL) req := newRequestWithAuthCode(t, testServer.URL, "TestHandleCallBackForGroupsInUserInfo") - identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, nil, req) assert.Equal(t, err, nil) sort.Strings(identity.Groups) @@ -122,7 +122,7 @@ func TestHandleCallBackForGroupMapsInUserInfo(t *testing.T) { conn := newConnector(t, testServer.URL) req := newRequestWithAuthCode(t, testServer.URL, "TestHandleCallBackForGroupMapsInUserInfo") - identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, nil, req) assert.Equal(t, err, nil) sort.Strings(identity.Groups) @@ -156,7 +156,7 @@ func TestHandleCallBackForGroupsInToken(t *testing.T) { conn := newConnector(t, testServer.URL) req := newRequestWithAuthCode(t, testServer.URL, "TestHandleCallBackForGroupsInToken") - identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, nil, req) assert.Equal(t, err, nil) assert.Equal(t, len(identity.Groups), 1) @@ -186,7 +186,7 @@ func TestHandleCallbackForNumericUserID(t *testing.T) { conn := newConnector(t, testServer.URL) req := newRequestWithAuthCode(t, testServer.URL, "TestHandleCallbackForNumericUserID") - identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, nil, req) assert.Equal(t, err, nil) assert.Equal(t, identity.UserID, "1000") @@ -270,7 +270,7 @@ func newConnector(t *testing.T, serverURL string) *oauthConnector { testConfig.ClaimMapping.EmailKey = "mail" testConfig.ClaimMapping.EmailVerifiedKey = "has_verified_email" - log := logrus.New() + log := slog.New(slog.DiscardHandler) conn, err := testConfig.Open("id", log) if err != nil { diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index e345dca0b2..c8104dc912 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -6,8 +6,10 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "net/url" + "regexp" "strings" "time" @@ -15,16 +17,40 @@ import ( "golang.org/x/oauth2" "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/pkg/log" + groups_pkg "github.com/dexidp/dex/pkg/groups" + "github.com/dexidp/dex/pkg/httpclient" ) +const ( + codeChallengeMethodPlain = "plain" + codeChallengeMethodS256 = "S256" +) + +func contains(arr []string, item string) bool { + for _, itemFromArray := range arr { + if itemFromArray == item { + return true + } + } + return false +} + // Config holds configuration options for OpenID Connect logins. type Config struct { - Issuer string `json:"issuer"` + Issuer string `json:"issuer"` + // Some offspec providers like Azure, Oracle IDCS have oidc discovery url + // different from issuer url which causes issuerValidation to fail + // IssuerAlias provides a way to override the Issuer url + // from the .well-known/openid-configuration issuer + IssuerAlias string `json:"issuerAlias"` ClientID string `json:"clientID"` ClientSecret string `json:"clientSecret"` RedirectURI string `json:"redirectURI"` + // The section to override options discovered automatically from + // the providers' discovery URL (.well-known/openid-configuration). + ProviderDiscoveryOverrides ProviderDiscoveryOverrides `json:"providerDiscoveryOverrides"` + // Causes client_secret to be passed as POST parameters instead of basic // auth. This is specifically "NOT RECOMMENDED" by the OAuth2 RFC, but some // providers require it. @@ -34,17 +60,32 @@ type Config struct { Scopes []string `json:"scopes"` // defaults to "profile" and "email" + // HostedDomains was an optional list of whitelisted domains when using the OIDC connector with Google. + // Only users from a whitelisted domain were allowed to log in. + // Support for this option was removed from the OIDC connector. + // Consider switching to the Google connector which supports this option. + // + // Deprecated: will be removed in future releases. + HostedDomains []string `json:"hostedDomains"` + + // Certificates for SSL validation + RootCAs []string `json:"rootCAs"` + // Override the value of email_verified to true in the returned claims InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"` // InsecureEnableGroups enables groups claims. This is disabled by default until https://github.com/dexidp/dex/issues/1065 is resolved - InsecureEnableGroups bool `json:"insecureEnableGroups"` + InsecureEnableGroups bool `json:"insecureEnableGroups"` + AllowedGroups []string `json:"allowedGroups"` // AcrValues (Authentication Context Class Reference Values) that specifies the Authentication Context Class Values // within the Authentication Request that the Authorization Server is being requested to use for // processing requests from this Client, with the values appearing in order of preference. AcrValues []string `json:"acrValues"` + // Disable certificate verification + InsecureSkipVerify bool `json:"insecureSkipVerify"` + // GetUserInfo uses the userinfo endpoint to get additional claims for // the token. This is especially useful where upstreams return "thin" // id tokens @@ -54,8 +95,12 @@ type Config struct { UserNameKey string `json:"userNameKey"` - // PromptType will be used fot the prompt parameter (when offline_access, by default prompt=consent) - PromptType string `json:"promptType"` + // PromptType will be used for the prompt parameter (when offline_access, by default prompt=consent) + PromptType *string `json:"promptType"` + + // PKCEChallenge specifies which PKCE algorithm will be used + // If not setted it will be auto-detected the best-fit for the connector. + PKCEChallenge string `json:"pkceChallenge"` // OverrideClaimMapping will be used to override the options defined in claimMappings. // i.e. if there are 'email' and `preferred_email` claims available, by default Dex will always use the `email` claim independent of the ClaimMapping.EmailKey. @@ -72,6 +117,116 @@ type Config struct { // Configurable key which contains the groups claims GroupsKey string `json:"groups"` // defaults to "groups" } `json:"claimMapping"` + + // ClaimMutations holds all claim mutations options + ClaimMutations struct { + NewGroupFromClaims []NewGroupFromClaims `json:"newGroupFromClaims"` + FilterGroupClaims FilterGroupClaims `json:"filterGroupClaims"` + ModifyGroupNames ModifyGroupNames `json:"modifyGroupNames"` + } `json:"claimModifications"` +} + +type ProviderDiscoveryOverrides struct { + // TokenURL provides a way to user overwrite the Token URL + // from the .well-known/openid-configuration token_endpoint + TokenURL string `json:"tokenURL"` + // AuthURL provides a way to user overwrite the Auth URL + // from the .well-known/openid-configuration authorization_endpoint + AuthURL string `json:"authURL"` + // JWKSURL provides a way to user overwrite the JWKS URL + // from the .well-known/openid-configuration jwks_uri + JWKSURL string `json:"jwksURL"` + // UserInfoURL provides a way to override the UserInfo URL + // from the .well-known/openid-configuration userinfo_endpoint + UserInfoURL string `json:"userInfoURL"` + // DeviceAuthURL provides a way to override the Device Authorization URL + // from the .well-known/openid-configuration device_authorization_endpoint + DeviceAuthURL string `json:"deviceAuthURL"` + // EndSessionURL provides a way to override the end_session_endpoint + // from the .well-known/openid-configuration + EndSessionURL string `json:"endSessionURL"` +} + +func (o *ProviderDiscoveryOverrides) Empty() bool { + return o.TokenURL == "" && o.AuthURL == "" && o.JWKSURL == "" && o.UserInfoURL == "" && o.DeviceAuthURL == "" && o.EndSessionURL == "" +} + +func getProvider(ctx context.Context, issuer string, overrides ProviderDiscoveryOverrides) (*oidc.Provider, error) { + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("failed to get provider: %v", err) + } + + if overrides.Empty() { + return provider, nil + } + + v := &struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + DeviceAuthURL string `json:"device_authorization_endpoint"` + JWKSURL string `json:"jwks_uri"` + UserInfoURL string `json:"userinfo_endpoint"` + Algorithms []string `json:"id_token_signing_alg_values_supported"` + }{} + if err := provider.Claims(v); err != nil { + return nil, fmt.Errorf("failed to extract provider discovery claims: %v", err) + } + config := oidc.ProviderConfig{ + IssuerURL: v.Issuer, + AuthURL: v.AuthURL, + TokenURL: v.TokenURL, + DeviceAuthURL: v.DeviceAuthURL, + JWKSURL: v.JWKSURL, + UserInfoURL: v.UserInfoURL, + Algorithms: v.Algorithms, + } + + if overrides.TokenURL != "" { + config.TokenURL = overrides.TokenURL + } + if overrides.AuthURL != "" { + config.AuthURL = overrides.AuthURL + } + if overrides.JWKSURL != "" { + config.JWKSURL = overrides.JWKSURL + } + if overrides.UserInfoURL != "" { + config.UserInfoURL = overrides.UserInfoURL + } + if overrides.DeviceAuthURL != "" { + config.DeviceAuthURL = overrides.DeviceAuthURL + } + return config.NewProvider(context.Background()), nil +} + +// NewGroupFromClaims creates a new group from a list of claims and appends it to the list of existing groups. +type NewGroupFromClaims struct { + // List of claim to join together + Claims []string `json:"claims"` + + // String to separate the claims + Delimiter string `json:"delimiter"` + + // Should Dex remove the Delimiter string from claim values + // This is done to keep resulting claim structure in full control of the Dex operator + ClearDelimiter bool `json:"clearDelimiter"` + + // String to place before the first claim + Prefix string `json:"prefix"` +} + +// FilterGroupClaims is a regex filter for to keep only the matching groups. +// This is useful when the groups list is too large to fit within an HTTP header. +type FilterGroupClaims struct { + GroupsFilter string `json:"groupsFilter"` +} + +// ModifyGroupNames allows to modify the group claims by adding a prefix and/or suffix to each group. +type ModifyGroupNames struct { + Prefix string `json:"prefix"` + Suffix string `json:"suffix"` } // Domains that don't support basic auth. golang.org/x/oauth2 has an internal @@ -102,15 +257,49 @@ func knownBrokenAuthHeaderProvider(issuerURL string) bool { return false } +// PKCEChallengeData is used to store info for PKCE Challenge method and verifier +// in the connectorData +type PKCEChallengeData struct { + CodeChallenge string `json:"codeChallenge"` + CodeChallengeMethod string `json:"codeChallengeMethod"` +} + +// Returns an AuthCodeOption according to the provided codeChallengeMethod +func getAuthCodeOptionForCodeChallenge(codeVerifier, codeChallengeMethod string) (oauth2.AuthCodeOption, error) { + switch codeChallengeMethod { + case codeChallengeMethodPlain: + return oauth2.VerifierOption(codeVerifier), nil + case codeChallengeMethodS256: + return oauth2.S256ChallengeOption(codeVerifier), nil + default: + return nil, fmt.Errorf("unknown challenge method (%v)", codeChallengeMethod) + } +} + // Open returns a connector which can be used to login users through an upstream // OpenID Connect provider. -func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) { - ctx, cancel := context.WithCancel(context.Background()) +func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, err error) { + if len(c.HostedDomains) > 0 { + return nil, fmt.Errorf("support for the Hosted domains option had been deprecated and removed, consider switching to the Google connector") + } + + httpClient, err := httpclient.NewHTTPClient(c.RootCAs, c.InsecureSkipVerify) + if err != nil { + return nil, err + } - provider, err := oidc.NewProvider(ctx, c.Issuer) + bgctx, cancel := context.WithCancel(context.Background()) + ctx := context.WithValue(bgctx, oauth2.HTTPClient, httpClient) + if c.IssuerAlias != "" { + ctx = oidc.InsecureIssuerURLContext(ctx, c.IssuerAlias) + } + provider, err := getProvider(ctx, c.Issuer, c.ProviderDiscoveryOverrides) if err != nil { cancel() - return nil, fmt.Errorf("failed to get provider: %v", err) + return nil, err + } + if !c.ProviderDiscoveryOverrides.Empty() { + logger.Warn("overrides for connector are set, this can be a vulnerability when not properly configured", "connector_id", id) } endpoint := provider.Endpoint() @@ -132,8 +321,55 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e } // PromptType should be "consent" by default, if not set - if c.PromptType == "" { - c.PromptType = "consent" + promptType := "consent" + if c.PromptType != nil { + promptType = *c.PromptType + } + + var groupsFilter *regexp.Regexp + if c.ClaimMutations.FilterGroupClaims.GroupsFilter != "" { + groupsFilter, err = regexp.Compile(c.ClaimMutations.FilterGroupClaims.GroupsFilter) + if err != nil { + logger.Warn("ignoring invalid", "invalid_regex", c.ClaimMutations.FilterGroupClaims.GroupsFilter, "connector_id", id) + } + } + + // Obtain metadata from the provider + var metadata struct { + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err := provider.Claims(&metadata); err != nil { + logger.Warn("failed to parse provider metadata") + } + // if PKCEChallenge method has not been setted in the config, auto-detect the best fit + if c.PKCEChallenge == "" { + if contains(metadata.CodeChallengeMethodsSupported, codeChallengeMethodS256) { + c.PKCEChallenge = codeChallengeMethodS256 + } else if contains(metadata.CodeChallengeMethodsSupported, codeChallengeMethodPlain) { + c.PKCEChallenge = codeChallengeMethodPlain + } + } else { + // if PKCEChallenge method has been setted in the config, check if it is supported + if !contains(metadata.CodeChallengeMethodsSupported, c.PKCEChallenge) { + logger.Warn("provided PKCEChallenge method not supported by the connector") + } + } + + endSessionURL := metadata.EndSessionEndpoint + if c.ProviderDiscoveryOverrides.EndSessionURL != "" { + endSessionURL = c.ProviderDiscoveryOverrides.EndSessionURL + } + if endSessionURL != "" { + endSessionParsed, err := url.Parse(endSessionURL) + if err != nil { + cancel() + return nil, fmt.Errorf("oidc: invalid end_session_endpoint: %v", err) + } + if endSessionParsed.Scheme != "https" && endSessionParsed.Scheme != "http" { + cancel() + return nil, fmt.Errorf("oidc: end_session_endpoint must use http or https scheme, got %q", endSessionParsed.Scheme) + } } clientID := c.ClientID @@ -147,28 +383,39 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e Scopes: scopes, RedirectURL: c.RedirectURI, }, - verifier: provider.Verifier( + verifier: provider.VerifierContext( + ctx, // Pass our ctx with customized http.Client &oidc.Config{ClientID: clientID}, ), - logger: logger, + logger: logger.With(slog.Group("connector", "type", "oidc", "id", id)), cancel: cancel, + httpClient: httpClient, insecureSkipEmailVerified: c.InsecureSkipEmailVerified, insecureEnableGroups: c.InsecureEnableGroups, + allowedGroups: c.AllowedGroups, acrValues: c.AcrValues, getUserInfo: c.GetUserInfo, - promptType: c.PromptType, + promptType: promptType, userIDKey: c.UserIDKey, userNameKey: c.UserNameKey, overrideClaimMapping: c.OverrideClaimMapping, preferredUsernameKey: c.ClaimMapping.PreferredUsernameKey, emailKey: c.ClaimMapping.EmailKey, groupsKey: c.ClaimMapping.GroupsKey, + newGroupFromClaims: c.ClaimMutations.NewGroupFromClaims, + groupsFilter: groupsFilter, + groupsPrefix: c.ClaimMutations.ModifyGroupNames.Prefix, + groupsSuffix: c.ClaimMutations.ModifyGroupNames.Suffix, + pkceChallenge: c.PKCEChallenge, + endSessionURL: endSessionURL, }, nil } var ( - _ connector.CallbackConnector = (*oidcConnector)(nil) - _ connector.RefreshConnector = (*oidcConnector)(nil) + _ connector.CallbackConnector = (*oidcConnector)(nil) + _ connector.RefreshConnector = (*oidcConnector)(nil) + _ connector.TokenIdentityConnector = (*oidcConnector)(nil) + _ connector.LogoutCallbackConnector = (*oidcConnector)(nil) ) type oidcConnector struct { @@ -177,9 +424,11 @@ type oidcConnector struct { oauth2Config *oauth2.Config verifier *oidc.IDTokenVerifier cancel context.CancelFunc - logger log.Logger + logger *slog.Logger + httpClient *http.Client insecureSkipEmailVerified bool insecureEnableGroups bool + allowedGroups []string acrValues []string getUserInfo bool promptType string @@ -189,6 +438,12 @@ type oidcConnector struct { preferredUsernameKey string emailKey string groupsKey string + newGroupFromClaims []NewGroupFromClaims + groupsFilter *regexp.Regexp + groupsPrefix string + groupsSuffix string + pkceChallenge string + endSessionURL string } func (c *oidcConnector) Close() error { @@ -196,12 +451,13 @@ func (c *oidcConnector) Close() error { return nil } -func (c *oidcConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) { +func (c *oidcConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, []byte, error) { if c.redirectURI != callbackURL { - return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) } var opts []oauth2.AuthCodeOption + var connectorData []byte if len(c.acrValues) > 0 { acrValues := strings.Join(c.acrValues, " ") @@ -211,7 +467,25 @@ func (c *oidcConnector) LoginURL(s connector.Scopes, callbackURL, state string) if s.OfflineAccess { opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", c.promptType)) } - return c.oauth2Config.AuthCodeURL(state, opts...), nil + + if c.pkceChallenge != "" { + codeVerifier := oauth2.GenerateVerifier() + authCodeOption, err := getAuthCodeOptionForCodeChallenge(codeVerifier, c.pkceChallenge) + if err != nil { + return "", nil, fmt.Errorf("oidc: failed to get PKCE AuthCodeOption for CodeChallenge: %v", err) + } + data := PKCEChallengeData{ + CodeChallenge: codeVerifier, + CodeChallengeMethod: c.pkceChallenge, + } + connectorData, err = json.Marshal(data) + if err != nil { + return "", nil, fmt.Errorf("oidc: failed to create PKCEChallenge data: %v", err) + } + opts = append(opts, authCodeOption) + } + + return c.oauth2Config.AuthCodeURL(state, opts...), connectorData, nil } type oauth2Error struct { @@ -231,18 +505,34 @@ type caller uint const ( createCaller caller = iota refreshCaller + exchangeCaller ) -func (c *oidcConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { +func (c *oidcConnector) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} } - token, err := c.oauth2Config.Exchange(r.Context(), q.Get("code")) + + ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) + + var opts []oauth2.AuthCodeOption + if c.pkceChallenge != "" { + var data PKCEChallengeData + if err := json.Unmarshal(connData, &data); err != nil { + return identity, fmt.Errorf("oidc: failed to parse PKCEChallenge data: %v", err) + } + if data.CodeChallenge == "" { + return identity, fmt.Errorf("oidc: invalid PKCE CodeChallenge") + } + opts = append(opts, oauth2.VerifierOption(data.CodeChallenge)) + } + + token, err := c.oauth2Config.Exchange(ctx, q.Get("code"), opts...) if err != nil { return identity, fmt.Errorf("oidc: failed to get token: %v", err) } - return c.createIdentity(r.Context(), identity, token, createCaller) + return c.createIdentity(ctx, identity, token, createCaller) } // Refresh is used to refresh a session with the refresh token provided by the IdP @@ -253,6 +543,8 @@ func (c *oidcConnector) Refresh(ctx context.Context, s connector.Scopes, identit return identity, fmt.Errorf("oidc: failed to unmarshal connector data: %v", err) } + ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) + t := &oauth2.Token{ RefreshToken: string(cd.RefreshToken), Expiry: time.Now().Add(-time.Hour), @@ -264,11 +556,22 @@ func (c *oidcConnector) Refresh(ctx context.Context, s connector.Scopes, identit return c.createIdentity(ctx, identity, token, refreshCaller) } +func (c *oidcConnector) TokenIdentity(ctx context.Context, subjectTokenType, subjectToken string) (connector.Identity, error) { + var identity connector.Identity + + ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) + + token := &oauth2.Token{ + AccessToken: subjectToken, + TokenType: subjectTokenType, + } + return c.createIdentity(ctx, identity, token, exchangeCaller) +} + func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.Identity, token *oauth2.Token, caller caller) (connector.Identity, error) { var claims map[string]interface{} - rawIDToken, ok := token.Extra("id_token").(string) - if ok { + if rawIDToken, ok := token.Extra("id_token").(string); ok { idToken, err := c.verifier.Verify(ctx, rawIDToken) if err != nil { return identity, fmt.Errorf("oidc: failed to verify ID Token: %v", err) @@ -277,14 +580,36 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I if err := idToken.Claims(&claims); err != nil { return identity, fmt.Errorf("oidc: failed to decode claims: %v", err) } + } else if caller == exchangeCaller { + switch token.TokenType { + case "urn:ietf:params:oauth:token-type:id_token": + // Verify only works on ID tokens + idToken, err := c.provider.Verifier(&oidc.Config{SkipClientIDCheck: true}).Verify(ctx, token.AccessToken) + if err != nil { + return identity, fmt.Errorf("oidc: failed to verify token: %v", err) + } + if err := idToken.Claims(&claims); err != nil { + return identity, fmt.Errorf("oidc: failed to decode claims: %v", err) + } + case "urn:ietf:params:oauth:token-type:access_token": + if !c.getUserInfo { + return identity, fmt.Errorf("oidc: getUserInfo is required for access token exchange") + } + default: + return identity, fmt.Errorf("unknown token type for token exchange: %s", token.TokenType) + } } else if caller != refreshCaller { // ID tokens aren't mandatory in the reply when using a refresh_token grant return identity, errors.New("oidc: no id_token in token response") } - // We immediately want to run getUserInfo if configured before we validate the claims + // We immediately want to run getUserInfo if configured before we validate the claims. + // For token exchanges with access tokens, this is how we verify the token. if c.getUserInfo { - userInfo, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) + userInfo, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: token.AccessToken, + TokenType: "Bearer", // The UserInfo endpoint requires a bearer token as per RFC6750 + })) if err != nil { return identity, fmt.Errorf("oidc: error loading userinfo: %v", err) } @@ -359,12 +684,65 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I if found { for _, v := range vs { if s, ok := v.(string); ok { + if c.groupsFilter != nil && !c.groupsFilter.MatchString(s) { + continue + } groups = append(groups, s) + } else if groupMap, ok := v.(map[string]interface{}); ok { + if s, ok := groupMap["name"].(string); ok { + if c.groupsFilter != nil && !c.groupsFilter.MatchString(s) { + continue + } + groups = append(groups, s) + } } else { return identity, fmt.Errorf("malformed \"%v\" claim", groupsKey) } } } + + // Validate that the user is part of allowedGroups + if len(c.allowedGroups) > 0 { + groupMatches := groups_pkg.Filter(groups, c.allowedGroups) + + if len(groupMatches) == 0 { + // No group membership matches found, disallowing + return identity, fmt.Errorf("user not a member of allowed groups") + } + + groups = groupMatches + } + } + + // add prefix/suffix to groups + if c.groupsPrefix != "" || c.groupsSuffix != "" { + for i, group := range groups { + groups[i] = c.groupsPrefix + group + c.groupsSuffix + } + } + + for _, config := range c.newGroupFromClaims { + newGroupSegments := []string{ + config.Prefix, + } + for _, claimName := range config.Claims { + claimValue, ok := claims[claimName].(string) + if !ok { // Non string claim value are ignored, concatenating them doesn't really make any sense + continue + } + + if config.ClearDelimiter { + // Removing the delimiter string from the concatenated claim to ensure resulting claim structure + // is in full control of Dex operator + claimValue = strings.ReplaceAll(claimValue, config.Delimiter, "") + } + + newGroupSegments = append(newGroupSegments, claimValue) + } + + if len(newGroupSegments) > 1 { + groups = append(groups, strings.Join(newGroupSegments, config.Delimiter)) + } } cd := connectorData{ @@ -396,3 +774,32 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I return identity, nil } + +// LogoutURL returns the upstream OIDC provider's end_session_endpoint URL. +// Per the OIDC RP-Initiated Logout spec, the post_logout_redirect_uri parameter +// tells the upstream where to redirect after logout. +func (c *oidcConnector) LogoutURL(_ context.Context, postLogoutRedirectURI string) (string, error) { + if c.endSessionURL == "" { + return "", nil + } + + u, err := url.Parse(c.endSessionURL) + if err != nil { + return "", fmt.Errorf("oidc: failed to parse end_session_endpoint: %v", err) + } + + q := u.Query() + if postLogoutRedirectURI != "" { + q.Set("post_logout_redirect_uri", postLogoutRedirectURI) + q.Set("client_id", c.oauth2Config.ClientID) + } + u.RawQuery = q.Encode() + + return u.String(), nil +} + +// HandleLogoutCallback is a no-op for OIDC. The end_session_endpoint simply +// redirects back without a structured response to validate. +func (c *oidcConnector) HandleLogoutCallback(_ context.Context, _ *http.Request) error { + return nil +} diff --git a/connector/oidc/oidc_test.go b/connector/oidc/oidc_test.go index d94af79de8..00be45c6f7 100644 --- a/connector/oidc/oidc_test.go +++ b/connector/oidc/oidc_test.go @@ -2,6 +2,7 @@ package oidc import ( "bytes" + "context" "crypto/rand" "crypto/rsa" "encoding/base64" @@ -9,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "net/http/httptest" "reflect" @@ -16,8 +18,9 @@ import ( "testing" "time" - "github.com/sirupsen/logrus" - "gopkg.in/square/go-jose.v2" + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "github.com/dexidp/dex/connector" ) @@ -61,6 +64,11 @@ func TestHandleCallback(t *testing.T) { expectPreferredUsername string expectedEmailField string token map[string]interface{} + groupsRegex string + newGroupFromClaims []NewGroupFromClaims + groupsPrefix string + groupsSuffix string + pkceChallenge string }{ { name: "simpleCase", @@ -287,6 +295,231 @@ func TestHandleCallback(t *testing.T) { "email_verified": true, }, }, + { + name: "singularGroupResponseAsMap", + userIDKey: "", // not configured + userNameKey: "", // not configured + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"group1"}, + expectedEmailField: "emailvalue", + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": []map[string]string{{"name": "group1"}}, + "email": "emailvalue", + "email_verified": true, + }, + }, + { + name: "multipleGroupResponseAsMap", + userIDKey: "", // not configured + userNameKey: "", // not configured + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"group1", "group2"}, + expectedEmailField: "emailvalue", + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": []map[string]string{{"name": "group1"}, {"name": "group2"}}, + "email": "emailvalue", + "email_verified": true, + }, + }, + { + name: "newGroupFromClaims", + userIDKey: "", // not configured + userNameKey: "", // not configured + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"group1", "gh::acme::pipeline-one", "clr_delim-acme-foobar", "keep_delim-acme-foo-bar", "bk-emailvalue"}, + expectedEmailField: "emailvalue", + newGroupFromClaims: []NewGroupFromClaims{ + { // The basic functionality, should create "gh::acme::pipeline-one". + Claims: []string{ + "organization", + "pipeline", + }, + Delimiter: "::", + Prefix: "gh", + }, + { // Non existing claims, should not generate any any new group claim. + Claims: []string{ + "non-existing1", + "non-existing2", + }, + Delimiter: "::", + Prefix: "tfe", + }, + { // In this case the delimiter character("-") should be removed removed from "claim-with-delimiter" claim to ensure the resulting + // claim structure is in full control of the Dex operator and not the person creating a new pipeline. + // Should create "clr_delim-acme-foobar" and not "tfe-acme-foo-bar". + Claims: []string{ + "organization", + "claim-with-delimiter", + }, + Delimiter: "-", + ClearDelimiter: true, + Prefix: "clr_delim", + }, + { // In this case the delimiter character("-") should be NOT removed from "claim-with-delimiter" claim. + // Should create "keep_delim-acme-foo-bar". + Claims: []string{ + "organization", + "claim-with-delimiter", + }, + Delimiter: "-", + // ClearDelimiter: false, + Prefix: "keep_delim", + }, + { // Ignore non string claims (like arrays), this should result in "bk-emailvalue". + Claims: []string{ + "non-string-claim", + "non-string-claim2", + "email", + }, + Delimiter: "-", + Prefix: "bk", + }, + }, + + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": "group1", + "organization": "acme", + "pipeline": "pipeline-one", + "email": "emailvalue", + "email_verified": true, + "claim-with-delimiter": "foo-bar", + "non-string-claim": []string{ + "element1", + "element2", + }, + "non-string-claim2": 666, + }, + }, + { + name: "prefixGroupNames", + userIDKey: "", // not configured + userNameKey: "", // not configured + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"prefix-group1", "prefix-group2", "prefix-groupA", "prefix-groupB"}, + expectedEmailField: "emailvalue", + groupsPrefix: "prefix-", + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": []string{"group1", "group2", "groupA", "groupB"}, + "email": "emailvalue", + "email_verified": true, + }, + }, + { + name: "suffixGroupNames", + userIDKey: "", // not configured + userNameKey: "", // not configured + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"group1-suffix", "group2-suffix", "groupA-suffix", "groupB-suffix"}, + expectedEmailField: "emailvalue", + groupsSuffix: "-suffix", + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": []string{"group1", "group2", "groupA", "groupB"}, + "email": "emailvalue", + "email_verified": true, + }, + }, + { + name: "preAndSuffixGroupNames", + userIDKey: "", // not configured + userNameKey: "", // not configured + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"prefix-group1-suffix", "prefix-group2-suffix", "prefix-groupA-suffix", "prefix-groupB-suffix"}, + expectedEmailField: "emailvalue", + groupsPrefix: "prefix-", + groupsSuffix: "-suffix", + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": []string{"group1", "group2", "groupA", "groupB"}, + "email": "emailvalue", + "email_verified": true, + }, + }, + { + name: "filterGroupClaims", + userIDKey: "", // not configured + userNameKey: "", // not configured + groupsRegex: `^.*\d$`, + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"group1", "group2"}, + expectedEmailField: "emailvalue", + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": []string{"group1", "group2", "groupA", "groupB"}, + "email": "emailvalue", + "email_verified": true, + }, + }, + { + name: "filterGroupClaimsMap", + userIDKey: "", // not configured + userNameKey: "", // not configured + groupsRegex: `^.*\d$`, + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"group1", "group2"}, + expectedEmailField: "emailvalue", + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": []map[string]string{{"name": "group1"}, {"name": "group2"}, {"name": "groupA"}, {"name": "groupB"}}, + "email": "emailvalue", + "email_verified": true, + }, + }, + { + name: "S256PKCEChallenge", + userIDKey: "", // not configured + userNameKey: "", // not configured + pkceChallenge: "S256", + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"group1", "group2"}, + expectedEmailField: "emailvalue", + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": []string{"group1", "group2"}, + "email": "emailvalue", + "email_verified": true, + }, + }, + { + name: "plainPKCEChallenge", + userIDKey: "", // not configured + userNameKey: "", // not configured + pkceChallenge: "plain", + expectUserID: "subvalue", + expectUserName: "namevalue", + expectGroups: []string{"group1", "group2"}, + expectedEmailField: "emailvalue", + token: map[string]interface{}{ + "sub": "subvalue", + "name": "namevalue", + "groups": []string{"group1", "group2"}, + "email": "emailvalue", + "email_verified": true, + }, + }, } for _, tc := range tests { @@ -318,10 +551,15 @@ func TestHandleCallback(t *testing.T) { InsecureEnableGroups: true, BasicAuthUnsupported: &basicAuth, OverrideClaimMapping: tc.overrideClaimMapping, + PKCEChallenge: tc.pkceChallenge, } config.ClaimMapping.PreferredUsernameKey = tc.preferredUsernameKey config.ClaimMapping.EmailKey = tc.emailKey config.ClaimMapping.GroupsKey = tc.groupsKey + config.ClaimMutations.NewGroupFromClaims = tc.newGroupFromClaims + config.ClaimMutations.FilterGroupClaims.GroupsFilter = tc.groupsRegex + config.ClaimMutations.ModifyGroupNames.Prefix = tc.groupsPrefix + config.ClaimMutations.ModifyGroupNames.Suffix = tc.groupsSuffix conn, err := newConnector(config) if err != nil { @@ -333,7 +571,11 @@ func TestHandleCallback(t *testing.T) { t.Fatal("failed to create request", err) } - identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req) + connectorDataStrTemplate := `{"codeChallenge":"abcdefgh123456qwertuiop89101112uvpwizABC234","codeChallengeMethod":"%s"}` + connectorDataStr := fmt.Sprintf(connectorDataStrTemplate, config.PKCEChallenge) + connectorData := []byte(connectorDataStr) + + identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, connectorData, req) if err != nil { t.Fatal("handle callback failed", err) } @@ -428,6 +670,207 @@ func TestRefresh(t *testing.T) { } } +func TestTokenIdentity(t *testing.T) { + tokenTypeAccess := "urn:ietf:params:oauth:token-type:access_token" + tokenTypeID := "urn:ietf:params:oauth:token-type:id_token" + long2short := map[string]string{ + tokenTypeAccess: "access_token", + tokenTypeID: "id_token", + } + + tests := []struct { + name string + subjectType string + userInfo bool + expectError bool + }{ + { + name: "id_token", + subjectType: tokenTypeID, + }, { + name: "access_token", + subjectType: tokenTypeAccess, + expectError: true, + }, { + name: "id_token with user info", + subjectType: tokenTypeID, + userInfo: true, + }, { + name: "access_token with user info", + subjectType: tokenTypeAccess, + userInfo: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + testServer, err := setupServer(map[string]any{ + "sub": "subvalue", + "name": "namevalue", + }, true) + if err != nil { + t.Fatal("failed to setup test server", err) + } + conn, err := newConnector(Config{ + Issuer: testServer.URL, + Scopes: []string{"openid", "groups"}, + GetUserInfo: tc.userInfo, + }) + if err != nil { + t.Fatal("failed to create new connector", err) + } + + res, err := http.Get(testServer.URL + "/token") + if err != nil { + t.Fatal("failed to get initial token", err) + } + defer res.Body.Close() + var tokenResponse map[string]any + err = json.NewDecoder(res.Body).Decode(&tokenResponse) + if err != nil { + t.Fatal("failed to decode initial token", err) + } + + origToken := tokenResponse[long2short[tc.subjectType]].(string) + identity, err := conn.TokenIdentity(ctx, tc.subjectType, origToken) + if err != nil { + if tc.expectError { + return + } + t.Fatal("failed to get token identity", err) + } + + // assert identity + expectEquals(t, identity.UserID, "subvalue") + expectEquals(t, identity.Username, "namevalue") + }) + } +} + +func TestPromptType(t *testing.T) { + pointer := func(s string) *string { + return &s + } + + tests := []struct { + name string + promptType *string + res string + }{ + {name: "none", promptType: pointer("none"), res: "none"}, + {name: "provided empty string", promptType: pointer(""), res: ""}, + {name: "login", promptType: pointer("login"), res: "login"}, + {name: "consent", promptType: pointer("consent"), res: "consent"}, + {name: "default value", promptType: nil, res: "consent"}, + } + + testServer, err := setupServer(nil, true) + require.NoError(t, err) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + conn, err := newConnector(Config{ + Issuer: testServer.URL, + Scopes: []string{"openid", "groups"}, + PromptType: tc.promptType, + }) + require.NoError(t, err) + + require.Equal(t, tc.res, conn.promptType) + }) + } +} + +func TestProviderOverride(t *testing.T) { + testServer, err := setupServer(map[string]any{ + "sub": "subvalue", + "name": "namevalue", + }, true) + if err != nil { + t.Fatal("failed to setup test server", err) + } + + t.Run("No override", func(t *testing.T) { + conn, err := newConnector(Config{ + Issuer: testServer.URL, + Scopes: []string{"openid", "groups"}, + }) + if err != nil { + t.Fatal("failed to create new connector", err) + } + + expAuth := fmt.Sprintf("%s/authorize", testServer.URL) + if conn.provider.Endpoint().AuthURL != expAuth { + t.Fatalf("unexpected auth URL: %s, expected: %s\n", conn.provider.Endpoint().AuthURL, expAuth) + } + + expToken := fmt.Sprintf("%s/token", testServer.URL) + if conn.provider.Endpoint().TokenURL != expToken { + t.Fatalf("unexpected token URL: %s, expected: %s\n", conn.provider.Endpoint().TokenURL, expToken) + } + }) + + t.Run("Override", func(t *testing.T) { + conn, err := newConnector(Config{ + Issuer: testServer.URL, + Scopes: []string{"openid", "groups"}, + ProviderDiscoveryOverrides: ProviderDiscoveryOverrides{TokenURL: "/test1", AuthURL: "/test2"}, + }) + if err != nil { + t.Fatal("failed to create new connector", err) + } + + expAuth := "/test2" + if conn.provider.Endpoint().AuthURL != expAuth { + t.Fatalf("unexpected auth URL: %s, expected: %s\n", conn.provider.Endpoint().AuthURL, expAuth) + } + + expToken := "/test1" + if conn.provider.Endpoint().TokenURL != expToken { + t.Fatalf("unexpected token URL: %s, expected: %s\n", conn.provider.Endpoint().TokenURL, expToken) + } + }) + + t.Run("Override userinfo and device auth URLs", func(t *testing.T) { + // A second server whose userinfo endpoint returns a distinct subject, + // so we can prove the overridden endpoint (not the discovery default) + // is the one actually used. + overrideServer, err := setupServer(map[string]any{"sub": "override-sub"}, true) + if err != nil { + t.Fatal("failed to setup override server", err) + } + defer overrideServer.Close() + + conn, err := newConnector(Config{ + Issuer: testServer.URL, + Scopes: []string{"openid", "groups"}, + ProviderDiscoveryOverrides: ProviderDiscoveryOverrides{ + DeviceAuthURL: "/test-device", + UserInfoURL: fmt.Sprintf("%s/userinfo", overrideServer.URL), + }, + }) + if err != nil { + t.Fatal("failed to create new connector", err) + } + + expDevice := "/test-device" + if conn.provider.Endpoint().DeviceAuthURL != expDevice { + t.Fatalf("unexpected device auth URL: %s, expected: %s\n", conn.provider.Endpoint().DeviceAuthURL, expDevice) + } + + userInfo, err := conn.provider.UserInfo(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "sometoken"})) + if err != nil { + t.Fatal("failed to call UserInfo", err) + } + if userInfo.Subject != "override-sub" { + t.Fatalf("UserInfo did not use the overridden endpoint: got subject %q, expected %q", userInfo.Subject, "override-sub") + } + }) +} + func setupServer(tok map[string]interface{}, idTokenDesired bool) (*httptest.Server, error) { key, err := rsa.GenerateKey(rand.Reader, 1024) if err != nil { @@ -523,7 +966,7 @@ func newToken(key *jose.JSONWebKey, claims map[string]interface{}) (string, erro } func newConnector(config Config) (*oidcConnector, error) { - logger := logrus.New() + logger := slog.New(slog.DiscardHandler) conn, err := config.Open("id", logger) if err != nil { return nil, fmt.Errorf("unable to open: %v", err) @@ -570,3 +1013,108 @@ func expectEquals(t *testing.T, a interface{}, b interface{}) { t.Errorf("Expected %+v to equal %+v", a, b) } } + +func TestLogoutURL(t *testing.T) { + tests := []struct { + name string + endSessionURL string + postLogoutRedirectURI string + wantURL string + wantEmpty bool + }{ + { + name: "no end_session_endpoint", + endSessionURL: "", + wantEmpty: true, + }, + { + name: "with end_session_endpoint, no redirect", + endSessionURL: "https://provider.example.com/logout", + wantURL: "https://provider.example.com/logout", + }, + { + name: "with end_session_endpoint and redirect", + endSessionURL: "https://provider.example.com/logout", + postLogoutRedirectURI: "https://dex.example.com/logout/callback", + wantURL: "https://provider.example.com/logout?client_id=clientID&post_logout_redirect_uri=https%3A%2F%2Fdex.example.com%2Flogout%2Fcallback", + }, + { + name: "with existing query params", + endSessionURL: "https://provider.example.com/logout?existing=param", + postLogoutRedirectURI: "https://dex.example.com/callback", + wantURL: "https://provider.example.com/logout?client_id=clientID&existing=param&post_logout_redirect_uri=https%3A%2F%2Fdex.example.com%2Fcallback", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + conn := &oidcConnector{ + endSessionURL: tc.endSessionURL, + oauth2Config: &oauth2.Config{ + ClientID: "clientID", + }, + } + + got, err := conn.LogoutURL(context.Background(), tc.postLogoutRedirectURI) + require.NoError(t, err) + + if tc.wantEmpty { + require.Empty(t, got) + return + } + + require.Equal(t, tc.wantURL, got) + }) + } +} + +func TestEndSessionURLDiscovery(t *testing.T) { + // Setup a server that advertises end_session_endpoint in discovery. + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(&map[string]interface{}{ + "keys": []map[string]interface{}{}, + }) + }) + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + url := fmt.Sprintf("http://%s", r.Host) + json.NewEncoder(w).Encode(&map[string]string{ + "issuer": url, + "token_endpoint": fmt.Sprintf("%s/token", url), + "authorization_endpoint": fmt.Sprintf("%s/authorize", url), + "jwks_uri": fmt.Sprintf("%s/keys", url), + "end_session_endpoint": fmt.Sprintf("%s/logout", url), + }) + }) + + ts := httptest.NewServer(mux) + defer ts.Close() + _ = key // We only need the server for discovery. + + conn, err := newConnector(Config{ + Issuer: ts.URL, + Scopes: []string{"openid"}, + }) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("%s/logout", ts.URL), conn.endSessionURL) +} + +func TestEndSessionURLOverride(t *testing.T) { + testServer, err := setupServer(nil, true) + require.NoError(t, err) + defer testServer.Close() + + conn, err := newConnector(Config{ + Issuer: testServer.URL, + Scopes: []string{"openid"}, + ProviderDiscoveryOverrides: ProviderDiscoveryOverrides{ + EndSessionURL: "https://custom.example.com/logout", + }, + }) + require.NoError(t, err) + require.Equal(t, "https://custom.example.com/logout", conn.endSessionURL) +} diff --git a/connector/openshift/openshift.go b/connector/openshift/openshift.go index 81d2b35633..3d4408c585 100644 --- a/connector/openshift/openshift.go +++ b/connector/openshift/openshift.go @@ -2,22 +2,18 @@ package openshift import ( "context" - "crypto/tls" - "crypto/x509" "encoding/json" "fmt" "io" - "net" + "log/slog" "net/http" - "os" "strings" - "time" "golang.org/x/oauth2" "github.com/dexidp/dex/connector" "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" + "github.com/dexidp/dex/pkg/httpclient" "github.com/dexidp/dex/storage/kubernetes/k8sapi" ) @@ -48,7 +44,7 @@ type openshiftConnector struct { clientID string clientSecret string cancel context.CancelFunc - logger log.Logger + logger *slog.Logger httpClient *http.Client oauth2Config *oauth2.Config insecureCA bool @@ -66,8 +62,13 @@ type user struct { // Open returns a connector which can be used to login users through an upstream // OpenShift OAuth2 provider. -func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) { - httpClient, err := newHTTPClient(c.InsecureCA, c.RootCA) +func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, err error) { + var rootCAs []string + if c.RootCA != "" { + rootCAs = append(rootCAs, c.RootCA) + } + + httpClient, err := httpclient.NewHTTPClient(rootCAs, c.InsecureCA) if err != nil { return nil, fmt.Errorf("failed to create HTTP client: %w", err) } @@ -77,13 +78,17 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e // OpenWithHTTPClient returns a connector which can be used to login users through an upstream // OpenShift OAuth2 provider. It provides the ability to inject a http.Client. -func (c *Config) OpenWithHTTPClient(id string, logger log.Logger, +func (c *Config) OpenWithHTTPClient(id string, logger *slog.Logger, httpClient *http.Client, ) (conn connector.Connector, err error) { ctx, cancel := context.WithCancel(context.Background()) + defer cancel() wellKnownURL := strings.TrimSuffix(c.Issuer, "/") + wellKnownURLPath req, err := http.NewRequest(http.MethodGet, wellKnownURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create a request to OpenShift endpoint %w", err) + } openshiftConnector := openshiftConnector{ apiURL: c.Issuer, @@ -91,7 +96,7 @@ func (c *Config) OpenWithHTTPClient(id string, logger log.Logger, clientID: c.ClientID, clientSecret: c.ClientSecret, insecureCA: c.InsecureCA, - logger: logger, + logger: logger.With(slog.Group("connector", "type", "openshift", "id", id)), redirectURI: c.RedirectURI, rootCA: c.RootCA, groups: c.Groups, @@ -105,14 +110,12 @@ func (c *Config) OpenWithHTTPClient(id string, logger log.Logger, resp, err := openshiftConnector.httpClient.Do(req.WithContext(ctx)) if err != nil { - cancel() return nil, fmt.Errorf("failed to query OpenShift endpoint %w", err) } defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { - cancel() return nil, fmt.Errorf("discovery through endpoint %s failed to decode body: %w", wellKnownURL, err) } @@ -135,12 +138,12 @@ func (c *openshiftConnector) Close() error { } // LoginURL returns the URL to redirect the user to login with. -func (c *openshiftConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { +func (c *openshiftConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, []byte, error) { if c.redirectURI != callbackURL { - return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) } - return c.oauth2Config.AuthCodeURL(state), nil + return c.oauth2Config.AuthCodeURL(state), nil, nil } type oauth2Error struct { @@ -157,6 +160,7 @@ func (e *oauth2Error) Error() string { // HandleCallback parses the request and returns the user's identity func (c *openshiftConnector) HandleCallback(s connector.Scopes, + connData []byte, r *http.Request, ) (identity connector.Identity, err error) { q := r.URL.Query() @@ -262,36 +266,3 @@ func validateAllowedGroups(userGroups, allowedGroups []string) bool { return len(matchingGroups) != 0 } - -// newHTTPClient returns a new HTTP client -func newHTTPClient(insecureCA bool, rootCA string) (*http.Client, error) { - tlsConfig := tls.Config{} - if insecureCA { - tlsConfig = tls.Config{InsecureSkipVerify: true} - } else if rootCA != "" { - tlsConfig = tls.Config{RootCAs: x509.NewCertPool()} - rootCABytes, err := os.ReadFile(rootCA) - if err != nil { - return nil, fmt.Errorf("failed to read root-ca: %w", err) - } - if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { - return nil, fmt.Errorf("no certs found in root CA file %q", rootCA) - } - } - - return &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tlsConfig, - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - DualStack: true, - }).DialContext, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - }, nil -} diff --git a/connector/openshift/openshift_test.go b/connector/openshift/openshift_test.go index 6280b831de..bdddfc83be 100644 --- a/connector/openshift/openshift_test.go +++ b/connector/openshift/openshift_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -11,10 +12,10 @@ import ( "testing" "time" - "github.com/sirupsen/logrus" "golang.org/x/oauth2" "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/pkg/httpclient" "github.com/dexidp/dex/storage/kubernetes/k8sapi" ) @@ -36,7 +37,7 @@ func TestOpen(t *testing.T) { InsecureCA: true, } - logger := logrus.New() + logger := slog.New(slog.DiscardHandler) oconfig, err := c.Open("id", logger) @@ -70,7 +71,7 @@ func TestGetUser(t *testing.T) { _, err = http.NewRequest("GET", hostURL.String(), nil) expectNil(t, err) - h, err := newHTTPClient(true, "") + h, err := httpclient.NewHTTPClient(nil, true) expectNil(t, err) @@ -128,7 +129,7 @@ func TestVerifyGroup(t *testing.T) { _, err = http.NewRequest("GET", hostURL.String(), nil) expectNil(t, err) - h, err := newHTTPClient(true, "") + h, err := httpclient.NewHTTPClient(nil, true) expectNil(t, err) @@ -164,7 +165,7 @@ func TestCallbackIdentity(t *testing.T) { req, err := http.NewRequest("GET", hostURL.String(), nil) expectNil(t, err) - h, err := newHTTPClient(true, "") + h, err := httpclient.NewHTTPClient(nil, true) expectNil(t, err) @@ -174,7 +175,7 @@ func TestCallbackIdentity(t *testing.T) { TokenURL: fmt.Sprintf("%s/oauth/token", s.URL), }, }} - identity, err := oc.HandleCallback(connector.Scopes{Groups: true}, req) + identity, err := oc.HandleCallback(connector.Scopes{Groups: true}, nil, req) expectNil(t, err) expectEquals(t, identity.UserID, "12345") @@ -198,7 +199,7 @@ func TestRefreshIdentity(t *testing.T) { }) defer s.Close() - h, err := newHTTPClient(true, "") + h, err := httpclient.NewHTTPClient(nil, true) expectNil(t, err) oc := openshiftConnector{apiURL: s.URL, httpClient: h, oauth2Config: &oauth2.Config{ @@ -237,7 +238,7 @@ func TestRefreshIdentityFailure(t *testing.T) { }) defer s.Close() - h, err := newHTTPClient(true, "") + h, err := httpclient.NewHTTPClient(nil, true) expectNil(t, err) oc := openshiftConnector{apiURL: s.URL, httpClient: h, oauth2Config: &oauth2.Config{ diff --git a/connector/saml/saml.go b/connector/saml/saml.go index 908ec703c9..8ef434b62a 100644 --- a/connector/saml/saml.go +++ b/connector/saml/saml.go @@ -3,11 +3,14 @@ package saml import ( "bytes" + "context" "crypto/x509" "encoding/base64" + "encoding/json" "encoding/pem" "encoding/xml" "fmt" + "log/slog" "os" "strings" "sync" @@ -21,10 +24,8 @@ import ( "github.com/dexidp/dex/connector" "github.com/dexidp/dex/pkg/groups" - "github.com/dexidp/dex/pkg/log" ) -// nolint const ( bindingRedirect = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" bindingPOST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" @@ -120,11 +121,12 @@ func (c certStore) Certificates() (roots []*x509.Certificate, err error) { // Open validates the config and returns a connector. It does not actually // validate connectivity with the provider. -func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { + logger = logger.With(slog.Group("connector", "type", "saml", "id", id)) return c.openConnector(logger) } -func (c *Config) openConnector(logger log.Logger) (*provider, error) { +func (c *Config) openConnector(logger *slog.Logger) (*provider, error) { requiredFields := []struct { name, val string }{ @@ -230,6 +232,11 @@ func (c *Config) openConnector(logger log.Logger) (*provider, error) { return p, nil } +var ( + _ connector.SAMLConnector = (*provider)(nil) + _ connector.RefreshConnector = (*provider)(nil) +) + type provider struct { entityIssuer string ssoIssuer string @@ -252,7 +259,37 @@ type provider struct { nameIDPolicyFormat string - logger log.Logger + logger *slog.Logger +} + +// cachedIdentity stores the identity from SAML assertion for refresh token support. +// Since SAML has no native refresh mechanism, we cache the identity obtained during +// the initial authentication and return it on subsequent refresh requests. +type cachedIdentity struct { + UserID string `json:"userId"` + Username string `json:"username"` + PreferredUsername string `json:"preferredUsername"` + Email string `json:"email"` + EmailVerified bool `json:"emailVerified"` + Groups []string `json:"groups,omitempty"` +} + +// marshalCachedIdentity serializes the identity into ConnectorData for refresh token support. +func marshalCachedIdentity(ident connector.Identity) (connector.Identity, error) { + ci := cachedIdentity{ + UserID: ident.UserID, + Username: ident.Username, + PreferredUsername: ident.PreferredUsername, + Email: ident.Email, + EmailVerified: ident.EmailVerified, + Groups: ident.Groups, + } + connectorData, err := json.Marshal(ci) + if err != nil { + return ident, fmt.Errorf("saml: failed to marshal cached identity: %v", err) + } + ident.ConnectorData = connectorData + return ident, nil } func (p *provider) POSTData(s connector.Scopes, id string) (action, value string, err error) { @@ -292,7 +329,6 @@ func (p *provider) POSTData(s connector.Scopes, id string) (action, value string // * Verify signature on XML document (or verify sig on assertion elements). // * Verify various parts of the Assertion element. Conditions, audience, etc. // * Map the Assertion's attribute elements to user info. -// func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo string) (ident connector.Identity, err error) { rawResp, err := base64.StdEncoding.DecodeString(samlResponse) if err != nil { @@ -390,7 +426,7 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo str // Log the actual attributes we got back from the server. This helps debug // configuration errors on the server side, where the SAML server doesn't // send us the correct attributes. - p.logger.Infof("parsed and verified saml response attributes %s", attributes) + p.logger.Info("parsed and verified saml response attributes", "attributes", attributes) // Grab the email. if ident.Email, _ = attributes.get(p.emailAttr); ident.Email == "" { @@ -406,7 +442,7 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo str if len(p.allowedGroups) == 0 && (!s.Groups || p.groupsAttr == "") { // Groups not requested or not configured. We're done. - return ident, nil + return marshalCachedIdentity(ident) } if len(p.allowedGroups) > 0 && (!s.Groups || p.groupsAttr == "") { @@ -432,7 +468,7 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo str if len(p.allowedGroups) == 0 { // No allowed groups set, just return the ident - return ident, nil + return marshalCachedIdentity(ident) } // Look for membership in one of the allowed groups @@ -448,6 +484,35 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo str } // Otherwise, we're good + return marshalCachedIdentity(ident) +} + +// Refresh implements connector.RefreshConnector. +// Since SAML has no native refresh mechanism, this method returns the cached +// identity from the initial SAML assertion stored in ConnectorData. +func (p *provider) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { + if len(ident.ConnectorData) == 0 { + return ident, fmt.Errorf("saml: no connector data available for refresh") + } + + var ci cachedIdentity + if err := json.Unmarshal(ident.ConnectorData, &ci); err != nil { + return ident, fmt.Errorf("saml: failed to unmarshal cached identity: %v", err) + } + + ident.UserID = ci.UserID + ident.Username = ci.Username + ident.PreferredUsername = ci.PreferredUsername + ident.Email = ci.Email + ident.EmailVerified = ci.EmailVerified + + // Only populate groups if the client requested the groups scope. + if s.Groups { + ident.Groups = ci.Groups + } else { + ident.Groups = nil + } + return ident, nil } @@ -468,7 +533,7 @@ func (p *provider) validateStatus(status *status) error { if statusMessage != nil && statusMessage.Value != "" { errorMessage += " -> " + statusMessage.Value } - return fmt.Errorf(errorMessage) + return errors.New(errorMessage) } return nil } @@ -531,7 +596,7 @@ func (p *provider) validateSubject(subject *subject, inResponseTo string) error return fmt.Errorf("failed to validate subject confirmation: %v", errs) } -// validationConditions ensures that dex is the intended audience +// validateConditions ensures that dex is the intended audience // for the request, and not another service provider. // // See: https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf @@ -598,6 +663,9 @@ func verifyResponseSig(validator *dsig.ValidationContext, data []byte) (signed [ } response := doc.Root() + if response == nil { + return nil, false, fmt.Errorf("parse document: empty root") + } transformedResponse, err := validator.Validate(response) if err == nil { // Root element is verified, return it. @@ -610,7 +678,7 @@ func verifyResponseSig(validator *dsig.ValidationContext, data []byte) (signed [ // // TODO: Only select from child elements of the root. assertion, err := etreeutils.NSSelectOne(response, "urn:oasis:names:tc:SAML:2.0:assertion", "Assertion") - if err != nil { + if err != nil || assertion == nil { return nil, false, fmt.Errorf("response does not contain an Assertion element") } transformedAssertion, err := validator.Validate(assertion) diff --git a/connector/saml/saml_test.go b/connector/saml/saml_test.go index 95d513ed19..3eba5cf878 100644 --- a/connector/saml/saml_test.go +++ b/connector/saml/saml_test.go @@ -1,10 +1,13 @@ package saml import ( + "context" "crypto/x509" "encoding/base64" + "encoding/json" "encoding/pem" "errors" + "log/slog" "os" "sort" "testing" @@ -12,7 +15,6 @@ import ( "github.com/kylelemons/godebug/pretty" dsig "github.com/russellhaering/goxmldsig" - "github.com/sirupsen/logrus" "github.com/dexidp/dex/connector" ) @@ -24,19 +26,18 @@ import ( // To add a new test, define a new, unsigned SAML 2.0 response that exercises some // case, then sign it using the "testdata/gen.sh" script. // -// cp testdata/good-resp.tmpl testdata/( testname ).tmpl -// vim ( testname ).tmpl # Modify your template for your test case. -// vim testdata/gen.sh # Add a xmlsec1 command to the generation script. -// ./testdata/gen.sh # Sign your template. +// cp testdata/good-resp.tmpl testdata/( testname ).tmpl +// vim ( testname ).tmpl # Modify your template for your test case. +// vim testdata/gen.sh # Add a xmlsec1 command to the generation script. +// ./testdata/gen.sh # Sign your template. // // To install xmlsec1 on Fedora run: // -// sudo dnf install xmlsec1 xmlsec1-openssl +// sudo dnf install xmlsec1 xmlsec1-openssl // // On mac: // -// brew install Libxmlsec1 -// +// brew install Libxmlsec1 type responseTest struct { // CA file and XML file of the response. caFile string @@ -421,7 +422,7 @@ func (r responseTest) run(t *testing.T) { t.Fatalf("parse test time: %v", err) } - conn, err := c.openConnector(logrus.New()) + conn, err := c.openConnector(slog.New(slog.DiscardHandler)) if err != nil { t.Fatal(err) } @@ -449,13 +450,31 @@ func (r responseTest) run(t *testing.T) { } sort.Strings(ident.Groups) sort.Strings(r.wantIdent.Groups) + + // Verify ConnectorData contains valid cached identity, then clear it + // for the main identity comparison (ConnectorData is an implementation + // detail of refresh token support). + if len(ident.ConnectorData) > 0 { + var ci cachedIdentity + if err := json.Unmarshal(ident.ConnectorData, &ci); err != nil { + t.Fatalf("failed to unmarshal ConnectorData: %v", err) + } + if ci.UserID != ident.UserID { + t.Errorf("cached identity UserID mismatch: got %q, want %q", ci.UserID, ident.UserID) + } + if ci.Email != ident.Email { + t.Errorf("cached identity Email mismatch: got %q, want %q", ci.Email, ident.Email) + } + } + ident.ConnectorData = nil + if diff := pretty.Compare(ident, r.wantIdent); diff != "" { t.Error(diff) } } func TestConfigCAData(t *testing.T) { - logger := logrus.New() + logger := slog.New(slog.DiscardHandler) validPEM, err := os.ReadFile("testdata/ca.crt") if err != nil { t.Fatal(err) @@ -590,3 +609,310 @@ func TestVerifySignedMessageAndSignedAssertion(t *testing.T) { func TestVerifyUnsignedMessageAndUnsignedAssertion(t *testing.T) { runVerify(t, "testdata/idp-cert.pem", "testdata/idp-resp.xml", false) } + +func TestSAMLRefresh(t *testing.T) { + // Create a provider using the same pattern as existing tests. + c := Config{ + CA: "testdata/ca.crt", + UsernameAttr: "Name", + EmailAttr: "email", + GroupsAttr: "groups", + RedirectURI: "http://127.0.0.1:5556/dex/callback", + SSOURL: "http://foo.bar/", + } + + conn, err := c.openConnector(slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatal(err) + } + + t.Run("SuccessfulRefresh", func(t *testing.T) { + ci := cachedIdentity{ + UserID: "test-user-id", + Username: "testuser", + PreferredUsername: "testuser", + Email: "test@example.com", + EmailVerified: true, + Groups: []string{"group1", "group2"}, + } + connectorData, err := json.Marshal(ci) + if err != nil { + t.Fatal(err) + } + + ident := connector.Identity{ + UserID: "old-id", + Username: "old-name", + ConnectorData: connectorData, + } + + refreshed, err := conn.Refresh(context.Background(), connector.Scopes{Groups: true}, ident) + if err != nil { + t.Fatalf("Refresh failed: %v", err) + } + + if refreshed.UserID != "test-user-id" { + t.Errorf("expected UserID %q, got %q", "test-user-id", refreshed.UserID) + } + if refreshed.Username != "testuser" { + t.Errorf("expected Username %q, got %q", "testuser", refreshed.Username) + } + if refreshed.PreferredUsername != "testuser" { + t.Errorf("expected PreferredUsername %q, got %q", "testuser", refreshed.PreferredUsername) + } + if refreshed.Email != "test@example.com" { + t.Errorf("expected Email %q, got %q", "test@example.com", refreshed.Email) + } + if !refreshed.EmailVerified { + t.Error("expected EmailVerified to be true") + } + if len(refreshed.Groups) != 2 || refreshed.Groups[0] != "group1" || refreshed.Groups[1] != "group2" { + t.Errorf("expected groups [group1, group2], got %v", refreshed.Groups) + } + // ConnectorData should be preserved through refresh + if len(refreshed.ConnectorData) == 0 { + t.Error("expected ConnectorData to be preserved") + } + }) + + t.Run("RefreshPreservesConnectorData", func(t *testing.T) { + ci := cachedIdentity{ + UserID: "user-123", + Username: "alice", + Email: "alice@example.com", + EmailVerified: true, + } + connectorData, err := json.Marshal(ci) + if err != nil { + t.Fatal(err) + } + + ident := connector.Identity{ + UserID: "old-id", + ConnectorData: connectorData, + } + + refreshed, err := conn.Refresh(context.Background(), connector.Scopes{}, ident) + if err != nil { + t.Fatalf("Refresh failed: %v", err) + } + + // Verify the refreshed identity can be refreshed again (round-trip) + var roundTrip cachedIdentity + if err := json.Unmarshal(refreshed.ConnectorData, &roundTrip); err != nil { + t.Fatalf("failed to unmarshal ConnectorData after refresh: %v", err) + } + if roundTrip.UserID != "user-123" { + t.Errorf("round-trip UserID mismatch: got %q, want %q", roundTrip.UserID, "user-123") + } + }) + + t.Run("EmptyConnectorData", func(t *testing.T) { + ident := connector.Identity{ + UserID: "test-id", + ConnectorData: nil, + } + _, err := conn.Refresh(context.Background(), connector.Scopes{}, ident) + if err == nil { + t.Error("expected error for empty ConnectorData") + } + }) + + t.Run("InvalidJSON", func(t *testing.T) { + ident := connector.Identity{ + UserID: "test-id", + ConnectorData: []byte("not-json"), + } + _, err := conn.Refresh(context.Background(), connector.Scopes{}, ident) + if err == nil { + t.Error("expected error for invalid JSON") + } + }) + + t.Run("HandlePOSTThenRefresh", func(t *testing.T) { + // Full integration: HandlePOST โ†’ get ConnectorData โ†’ Refresh โ†’ verify identity + now, err := time.Parse(timeFormat, "2017-04-04T04:34:59.330Z") + if err != nil { + t.Fatal(err) + } + conn.now = func() time.Time { return now } + + resp, err := os.ReadFile("testdata/good-resp.xml") + if err != nil { + t.Fatal(err) + } + samlResp := base64.StdEncoding.EncodeToString(resp) + + scopes := connector.Scopes{ + OfflineAccess: true, + Groups: true, + } + ident, err := conn.HandlePOST(scopes, samlResp, "6zmm5mguyebwvajyf2sdwwcw6m") + if err != nil { + t.Fatalf("HandlePOST failed: %v", err) + } + + if len(ident.ConnectorData) == 0 { + t.Fatal("expected ConnectorData to be set after HandlePOST") + } + + // Now refresh using the ConnectorData from HandlePOST + refreshed, err := conn.Refresh(context.Background(), scopes, ident) + if err != nil { + t.Fatalf("Refresh failed: %v", err) + } + + if refreshed.UserID != ident.UserID { + t.Errorf("UserID mismatch: got %q, want %q", refreshed.UserID, ident.UserID) + } + if refreshed.Username != ident.Username { + t.Errorf("Username mismatch: got %q, want %q", refreshed.Username, ident.Username) + } + if refreshed.Email != ident.Email { + t.Errorf("Email mismatch: got %q, want %q", refreshed.Email, ident.Email) + } + if refreshed.EmailVerified != ident.EmailVerified { + t.Errorf("EmailVerified mismatch: got %v, want %v", refreshed.EmailVerified, ident.EmailVerified) + } + sort.Strings(refreshed.Groups) + sort.Strings(ident.Groups) + if len(refreshed.Groups) != len(ident.Groups) { + t.Errorf("Groups length mismatch: got %d, want %d", len(refreshed.Groups), len(ident.Groups)) + } + for i := range ident.Groups { + if i < len(refreshed.Groups) && refreshed.Groups[i] != ident.Groups[i] { + t.Errorf("Groups[%d] mismatch: got %q, want %q", i, refreshed.Groups[i], ident.Groups[i]) + } + } + }) + + t.Run("HandlePOSTThenDoubleRefresh", func(t *testing.T) { + // Verify that refresh tokens can be chained: HandlePOST โ†’ Refresh โ†’ Refresh + now, err := time.Parse(timeFormat, "2017-04-04T04:34:59.330Z") + if err != nil { + t.Fatal(err) + } + conn.now = func() time.Time { return now } + + resp, err := os.ReadFile("testdata/good-resp.xml") + if err != nil { + t.Fatal(err) + } + samlResp := base64.StdEncoding.EncodeToString(resp) + + scopes := connector.Scopes{OfflineAccess: true, Groups: true} + ident, err := conn.HandlePOST(scopes, samlResp, "6zmm5mguyebwvajyf2sdwwcw6m") + if err != nil { + t.Fatalf("HandlePOST failed: %v", err) + } + + // First refresh + refreshed1, err := conn.Refresh(context.Background(), scopes, ident) + if err != nil { + t.Fatalf("first Refresh failed: %v", err) + } + if len(refreshed1.ConnectorData) == 0 { + t.Fatal("expected ConnectorData after first refresh") + } + + // Second refresh using output of first refresh + refreshed2, err := conn.Refresh(context.Background(), scopes, refreshed1) + if err != nil { + t.Fatalf("second Refresh failed: %v", err) + } + + // All fields should match original + if refreshed2.UserID != ident.UserID { + t.Errorf("UserID mismatch after double refresh: got %q, want %q", refreshed2.UserID, ident.UserID) + } + if refreshed2.Email != ident.Email { + t.Errorf("Email mismatch after double refresh: got %q, want %q", refreshed2.Email, ident.Email) + } + if refreshed2.Username != ident.Username { + t.Errorf("Username mismatch after double refresh: got %q, want %q", refreshed2.Username, ident.Username) + } + }) + + t.Run("HandlePOSTWithAssertionSignedThenRefresh", func(t *testing.T) { + // Test with assertion-signed.xml (signature on assertion, not response) + now, err := time.Parse(timeFormat, "2017-04-04T04:34:59.330Z") + if err != nil { + t.Fatal(err) + } + conn.now = func() time.Time { return now } + + resp, err := os.ReadFile("testdata/assertion-signed.xml") + if err != nil { + t.Fatal(err) + } + samlResp := base64.StdEncoding.EncodeToString(resp) + + scopes := connector.Scopes{OfflineAccess: true, Groups: true} + ident, err := conn.HandlePOST(scopes, samlResp, "6zmm5mguyebwvajyf2sdwwcw6m") + if err != nil { + t.Fatalf("HandlePOST with assertion-signed failed: %v", err) + } + + if len(ident.ConnectorData) == 0 { + t.Fatal("expected ConnectorData after HandlePOST with assertion-signed") + } + + refreshed, err := conn.Refresh(context.Background(), scopes, ident) + if err != nil { + t.Fatalf("Refresh after assertion-signed HandlePOST failed: %v", err) + } + + if refreshed.Email != ident.Email { + t.Errorf("Email mismatch: got %q, want %q", refreshed.Email, ident.Email) + } + if refreshed.Username != ident.Username { + t.Errorf("Username mismatch: got %q, want %q", refreshed.Username, ident.Username) + } + }) + + t.Run("HandlePOSTRefreshWithoutGroupsScope", func(t *testing.T) { + // Verify that groups are NOT returned when groups scope is not requested during refresh + now, err := time.Parse(timeFormat, "2017-04-04T04:34:59.330Z") + if err != nil { + t.Fatal(err) + } + conn.now = func() time.Time { return now } + + resp, err := os.ReadFile("testdata/good-resp.xml") + if err != nil { + t.Fatal(err) + } + samlResp := base64.StdEncoding.EncodeToString(resp) + + // Initial auth WITH groups + scopesWithGroups := connector.Scopes{OfflineAccess: true, Groups: true} + ident, err := conn.HandlePOST(scopesWithGroups, samlResp, "6zmm5mguyebwvajyf2sdwwcw6m") + if err != nil { + t.Fatalf("HandlePOST failed: %v", err) + } + if len(ident.Groups) == 0 { + t.Fatal("expected groups in initial identity") + } + + // Refresh WITHOUT groups scope + scopesNoGroups := connector.Scopes{OfflineAccess: true, Groups: false} + refreshed, err := conn.Refresh(context.Background(), scopesNoGroups, ident) + if err != nil { + t.Fatalf("Refresh failed: %v", err) + } + + if len(refreshed.Groups) != 0 { + t.Errorf("expected no groups when groups scope not requested, got %v", refreshed.Groups) + } + + // Refresh WITH groups scope โ€” groups should be back + refreshedWithGroups, err := conn.Refresh(context.Background(), scopesWithGroups, ident) + if err != nil { + t.Fatalf("Refresh with groups failed: %v", err) + } + + if len(refreshedWithGroups.Groups) == 0 { + t.Error("expected groups when groups scope is requested") + } + }) +} diff --git a/connector/saml/testdata/oam-ca.pem b/connector/saml/testdata/oam-ca.pem index 41645dda75..160ac52b59 100644 --- a/connector/saml/testdata/oam-ca.pem +++ b/connector/saml/testdata/oam-ca.pem @@ -1,13 +1,19 @@ -----BEGIN CERTIFICATE----- -MIIB/jCCAWegAwIBAgIBCjANBgkqhkiG9w0BAQQFADAkMSIwIAYDVQQDExlkZWFv -YW0tZGV2MDIuanBsLm5hc2EuZ292MB4XDTE2MDYzMDA0NTQxNloXDTI2MDYyODA0 -NTQxNlowJDEiMCAGA1UEAxMZZGVhb2FtLWRldjAyLmpwbC5uYXNhLmdvdjCBnzAN -BgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAht1N4lGdwUbl7YRyHwSCrnep6/e2I3+V -eue0pSA/DGn8OuR/udM8UCja5utqlqJdq200ox4b4Mpz0Jg9kMckALtKe+1DgeES -EIx9FpeuBdHlitYQNSbEr30HIG2nmeTOy4Vi5unBO54um3tNazcUTMA0/LJ6KQL8 -LeZSlB/IxwUCAwEAAaNAMD4wDAYDVR0TAQH/BAIwADAPBgNVHQ8BAf8EBQMDB9gA -MB0GA1UdDgQWBBRYo1YjfrNonauLzj6/AsueWFGSszANBgkqhkiG9w0BAQQFAAOB -gQACq7GHK/Zsg0+qC0WWa2ZjmOXE6Dqk/xuooG49QT7ihABs7k9U27Fw3xKF6MkC -7pca1FwT82eZK1N3XKKpZe7Flu1fMKt2o/XSiBkDjWwUcChVnwGsUBe8hJFwFqg7 -olNJn1kaVBJUqZIiXF9kS0d+1H55rStOd0CNXAzp9utr2A== +MIIDATCCAemgAwIBAgIBATANBgkqhkiG9w0BAQsFADAyMQwwCgYDVQQKEwNERVgx +IjAgBgNVBAMTGWRleC1zYW1sLW9hbS10ZXN0LWZpeHR1cmUwIBcNMjYwNzAyMTIw +NDUyWhgPMjEyNTA3MDIxMjA0NTJaMDIxDDAKBgNVBAoTA0RFWDEiMCAGA1UEAxMZ +ZGV4LXNhbWwtb2FtLXRlc3QtZml4dHVyZTCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAMlA5rxidl/KIjQYJNMey55ujzYXBuJvZrBz3m+bsw8VqUkwcOVc +EMZkcmu865mvGuCkE6kdo7c3t6HfzjOpPEv0oM+xB1RqzCzGoT7pMTJ+p77Es/yQ +ML393N19MyDDb2bdUucbw+fAqSyZanUoBB+uobduO3z5cUtZIpF0RDg6wPltbnYL +CX/F9ccIUidjUSA8YwzHRPIe6ozb+y/SMp+9cZvo7EMBSaFqPengCMXuAGLHCOQ+ +nfxcW24YpC4J3mFCulYT/M+dS8sJiD33Uup81lfM6F3/wF8faWryuEIUWpWgZLLA +hs0qN+D/Vnx7ZrqtzFFbD67qEP6N4toas9ECAwEAAaMgMB4wDgYDVR0PAQH/BAQD +AgWgMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggEBAE9n3RkGdpYbb8Tk +AZswdSiBlB47GYuPrTdTfQFSxxaWj7iyaVi4RZL/Llp3HO3ndTLAc6Glr0P6Yx79 +Njv9nlFTBW+gqWD0vaB4zfWEdwKfY+ZBCUg+IXxtKc/V/IGCotMDZAYXIiDblRP7 +i2IBOSZAhuPY1B8pRnCZ5UvvRv+9v5NvUe4kwmSfqAflQ+NbBDRgwHG2sQ+dZqHV +e+BoGlbKYoTEOJTDLBPH6BgNKredTFANp34+rIwPAAzQxCOsrVus2109KKVxKkGp +Q0ycdieQ9FnRrq3320YPNa7NZu0Vqiip2yklZYh5qz/7UD5JAn6AuQZzC3m8kUJO +mz20rDI= -----END CERTIFICATE----- diff --git a/connector/saml/testdata/oam-resp.xml b/connector/saml/testdata/oam-resp.xml index 99d148770c..870b3c9f25 100644 --- a/connector/saml/testdata/oam-resp.xml +++ b/connector/saml/testdata/oam-resp.xml @@ -1 +1 @@ -https://deaoam-dev02.jpl.nasa.gov:14101/oam/fedhttps://deaoam-dev02.jpl.nasa.gov:14101/oam/fedz1HD/59hv6UOd5+jeG+ihaFWLgI=I99oG5kiOfIgbXYa21z/TOmzftTkFnXe9ObhBNSKit9kAhT93apYROqqXv4Ax96P144Ld7ERX1hgJsytK8LC2874Pk7QrSNm4zvW3x0D4GR4lM06CvJK/EhIur3TrCUJDPigvyP7TJitheCyBejwt0x0lqNP/OzR3tMbAIMRoho=pkieuJSAuthurn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport +https://dex.example.com/oam/fedhttps://dex.example.com/oam/feddex-oam-fixture-userhttp://127.0.0.1:5556/callbackdex-oam-fixture@example.comNi0lhZEaolfxURixgYJCa6+h4RwEvIrpA2/WvVtNMOM=QTWGSorT5sQlOxK1Ez1ex3Us22IPC4azEXiI0EuxG0lbMzy+einBPHEFpTsjTguIEuhx5odwInNURSBty8j6BxY4V02qzFOr/yrFu1ULavX8drojYaU0cNQpJgUnYgrTb+5iI4WzVBxn+yM24RE79gLqEG6spQ7S6+kvDSVBgSUnSnVTKChiqGJ0Jm2FJQSwiyf9MMgFFbFvSK9Mqw6D3gpa45uAv8K6GJhE9l12UhrtP+craDCOBiLVcGDFcR0f+TY9MFh9gGm2o3CmiNl39MHGH9EY4RWjLAmieDUR5bdYi97L87xGx0jhrO4shYEb2BwFbOQFsQbzuJPlU6KKTA==MIIDATCCAemgAwIBAgIBATANBgkqhkiG9w0BAQsFADAyMQwwCgYDVQQKEwNERVgxIjAgBgNVBAMTGWRleC1zYW1sLW9hbS10ZXN0LWZpeHR1cmUwIBcNMjYwNzAyMTIwNDUyWhgPMjEyNTA3MDIxMjA0NTJaMDIxDDAKBgNVBAoTA0RFWDEiMCAGA1UEAxMZZGV4LXNhbWwtb2FtLXRlc3QtZml4dHVyZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMlA5rxidl/KIjQYJNMey55ujzYXBuJvZrBz3m+bsw8VqUkwcOVcEMZkcmu865mvGuCkE6kdo7c3t6HfzjOpPEv0oM+xB1RqzCzGoT7pMTJ+p77Es/yQML393N19MyDDb2bdUucbw+fAqSyZanUoBB+uobduO3z5cUtZIpF0RDg6wPltbnYLCX/F9ccIUidjUSA8YwzHRPIe6ozb+y/SMp+9cZvo7EMBSaFqPengCMXuAGLHCOQ+nfxcW24YpC4J3mFCulYT/M+dS8sJiD33Uup81lfM6F3/wF8faWryuEIUWpWgZLLAhs0qN+D/Vnx7ZrqtzFFbD67qEP6N4toas9ECAwEAAaMgMB4wDgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggEBAE9n3RkGdpYbb8TkAZswdSiBlB47GYuPrTdTfQFSxxaWj7iyaVi4RZL/Llp3HO3ndTLAc6Glr0P6Yx79Njv9nlFTBW+gqWD0vaB4zfWEdwKfY+ZBCUg+IXxtKc/V/IGCotMDZAYXIiDblRP7i2IBOSZAhuPY1B8pRnCZ5UvvRv+9v5NvUe4kwmSfqAflQ+NbBDRgwHG2sQ+dZqHVe+BoGlbKYoTEOJTDLBPH6BgNKredTFANp34+rIwPAAzQxCOsrVus2109KKVxKkGpQ0ycdieQ9FnRrq3320YPNa7NZu0Vqiip2yklZYh5qz/7UD5JAn6AuQZzC3m8kUJOmz20rDI= diff --git a/connector/spnego.go b/connector/spnego.go new file mode 100644 index 0000000000..ab89e84069 --- /dev/null +++ b/connector/spnego.go @@ -0,0 +1,22 @@ +package connector + +import ( + "context" + "net/http" +) + +// Handled indicates whether the SPNEGO-aware connector handled the request. +type Handled bool + +// SPNEGOAware is an optional extension for connectors that can authenticate +// users via Kerberos SPNEGO on the initial GET to the password login endpoint. +// +// If handled is true and ident is non-nil, the caller should complete the +// OAuth flow as with a successful password login. If handled is true and +// ident is nil, the implementation has already written an appropriate +// response (e.g., 401 with WWW-Authenticate: Negotiate) and the caller should +// return without rendering the password form. If handled is false, proceed +// with the legacy password form flow. +type SPNEGOAware interface { + TrySPNEGO(ctx context.Context, s Scopes, w http.ResponseWriter, r *http.Request) (*Identity, Handled, error) +} diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000000..a76549c58e --- /dev/null +++ b/devenv.lock @@ -0,0 +1,65 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1782938471, + "narHash": "sha256-m//AHi+NJN+1eTTdEaL3oXcuTHy1XtV6XiyGtsP9PJE=", + "owner": "cachix", + "repo": "devenv", + "rev": "46197d1e4c2ca0cc1f0635927b3bee3d62b51779", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1782924808, + "narHash": "sha256-tn2ahNv3ZNXyVHdPx/uw+N0xa7YSMtXUr6OEvu+s8ng=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "e2c881cb8d5f1cac6cef9d71f0d450a55691b999", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1782636521, + "narHash": "sha256-OG8laCOGtkxlB1JH3XqOnXxnkGJme4mQdXo5hkhCQIY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e1c1b84752fb0897897380a3cae9dc7fcab91ca3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000000..741733ba1f --- /dev/null +++ b/devenv.nix @@ -0,0 +1,24 @@ +{ + pkgs, + ... +}: + +{ + dotenv.enable = true; + + packages = with pkgs; [ + gnumake + + gotestsum + protobuf + protoc-gen-go + protoc-gen-go-grpc + kind + ]; + + languages = { + go = { + enable = true; + }; + }; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000000..68616a49cd --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling diff --git a/docker-compose.override.yaml.dist b/docker-compose.override.yaml.dist index b9eefac533..30591add0f 100644 --- a/docker-compose.override.yaml.dist +++ b/docker-compose.override.yaml.dist @@ -5,6 +5,10 @@ services: ports: - "127.0.0.1:3306:3306" + mysql8: + ports: + - "127.0.0.1:3307:3306" + postgres: ports: - "127.0.0.1:5432:5432" diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 46dfd84c4d..933ff80164 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -11,8 +11,8 @@ services: LDAP_TLS: "true" LDAP_TLS_VERIFY_CLIENT: try ports: - - 389:389 - - 636:636 + - 3890:389 + - 6360:636 volumes: - ./connector/ldap/testdata/certs:/container/service/slapd/assets/certs - ./connector/ldap/testdata/schema.ldif:/container/service/slapd/assets/config/bootstrap/ldif/99-schema.ldif diff --git a/docker-compose.yaml b/docker-compose.yaml index eee32f93e9..6c5a052a70 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,6 +17,15 @@ services: MYSQL_PASSWORD: mysql MYSQL_ROOT_PASSWORD: root + mysql8: + image: mysql:8.0 + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_DATABASE: dex + MYSQL_USER: mysql + MYSQL_PASSWORD: mysql + MYSQL_ROOT_PASSWORD: root + postgres: image: postgres:10.15 environment: @@ -45,3 +54,23 @@ services: volumes: - ./connector/ldap/testdata/certs:/container/service/slapd/assets/certs - ./connector/ldap/testdata/schema.ldif:/container/service/slapd/assets/config/bootstrap/ldif/99-schema.ldif + + vault: + image: hashicorp/vault:1.21 + environment: + VAULT_DEV_ROOT_TOKEN_ID: root-token + VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200" + cap_add: + - IPC_LOCK + ports: + - 8200:8200 + + openbao: + image: quay.io/openbao/openbao:2.5 + environment: + BAO_DEV_ROOT_TOKEN_ID: root-token + BAO_DEV_LISTEN_ADDRESS: "0.0.0.0:8200" + cap_add: + - IPC_LOCK + ports: + - 8210:8200 diff --git a/docs/enhancements/auth-sessions-2026-02-18.md b/docs/enhancements/auth-sessions-2026-02-18.md new file mode 100644 index 0000000000..28f5208ccb --- /dev/null +++ b/docs/enhancements/auth-sessions-2026-02-18.md @@ -0,0 +1,1505 @@ +# Dex Enhancement Proposal (DEP 4560) - 2026-02-18 - Auth Sessions + +## Table of Contents + +- [Summary](#summary) +- [Motivation](#motivation) + - [Goals/Pain](#goalspain) + - [Non-Goals](#non-goals) +- [Proposal](#proposal) + - [User Experience](#user-experience) + - [Implementation Details/Notes/Constraints](#implementation-detailsnotesconstraints) + - [Risks and Mitigations](#risks-and-mitigations) + - [Alternatives](#alternatives) +- [Future Improvements](#future-improvements) + +## Summary + +This DEP introduces **auth sessions** - a persistent authentication state that enables Dex to track logged-in users across browser sessions. Currently, Dex relies entirely on refresh tokens for session management, which prevents proper implementation of OIDC conformance features like `prompt=none`, `prompt=login`, `id_token_hint`, SSO across clients, and proper logout. User Sessions will be stored server-side with a browser cookie reference, enabling these features while maintaining Dex's simplicity and compatibility with all storage backends (SQL, etcd, Kubernetes CRDs). + +## Context + +- [OIDC Core 1.0 - Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) - `prompt` parameter specification +- [OIDC Core 1.0 - ID Token Hint](https://openid.net/specs/openid-connect-core-1_0.html#IDToken) - `id_token_hint` specification +- [OIDC Session Management 1.0](https://openid.net/specs/openid-connect-session-1_0.html) - Session management specification +- [OIDC RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) - Logout specification +- [OIDC Front-Channel Logout 1.0](https://openid.net/specs/openid-connect-frontchannel-1_0.html) - Front-channel logout +- [Keycloak Sessions](https://www.keycloak.org/docs/latest/server_admin/#_sessions) - Reference implementation +- [Ory Hydra Login & Consent Flow](https://www.ory.sh/docs/hydra/concepts/login) - Reference implementation + +Current limitations: +- No support for `prompt=none` (silent authentication) +- No support for `prompt=login` (force re-authentication) +- No support for `max_age` parameter +- No support for `id_token_hint` validation +- No SSO between clients (each client requires separate login) +- No proper logout (only refresh token revocation) +- No consent persistence (user must approve every time if not skipped globally) +- No 2FA enrollment storage +- No "Remember Me" functionality + +## Motivation + +### Goals/Pain + +1. **OIDC Conformance** - Enable proper `prompt=none`, `prompt=login`, `max_age`, and `id_token_hint` support +2. **SSO (Single Sign-On)** - Allow users to authenticate once and access multiple clients without re-login +3. **Remember Me** - Allow users to choose persistent vs session-based authentication +4. **Consent Persistence** - Store user consent decisions per client/scope combination within session +5. **Proper Logout** - Enable session termination with optional front-channel logout +6. **Foundation for 2FA** - Enable future TOTP/WebAuthn enrollment storage + +### Non-Goals + +- **2FA Implementation** - This DEP only provides storage foundation; 2FA flow is a separate DEP +- **Back-Channel Logout** - Server-to-server logout notifications are out of scope +- **Session Clustering/Replication** - Storage backends handle this +- **Admin Session Management UI** - API only, no admin UI +- **Per-connector Session Policies** - Single global session policy initially +- **Identity Refresh During Session** - Deferred to future DEP; initially identity is refreshed only at session termination (like Keycloak) +- **Upstream Connector Logout** - Terminating sessions at upstream IDPs is deferred + +## Proposal + +### User Experience + +#### Configuration + +Sessions are controlled by a feature flag and configuration: + +```yaml +# Feature flag (environment variable) +# DEX_SESSIONS_ENABLED=true + +# config.yaml +sessions: + # Session cookie name (default: "dex_session") + # Other cookie settings (Secure, HttpOnly, SameSite=Lax) are not configurable + # and are set to secure defaults automatically + cookieName: "dex_session" + + # Session lifetime settings (matches refresh token expiry naming) + absoluteLifetime: "24h" # Maximum session lifetime, default: 24h + validIfNotUsedFor: "1h" # Session expires if not used, default: 1h + + # Default SSO sharing policy for clients without explicit ssoSharedWith config + # Options: + # "all" - clients without ssoSharedWith share sessions with all other clients (Keycloak-like) + # "none" - clients without ssoSharedWith don't share sessions (default) + ssoSharedWithDefault: "none" + + # Whether "Remember Me" checkbox is checked by default in login/approval forms + # When true: checkbox is pre-checked, user can uncheck + # When false: checkbox is unchecked, user must check to persist session (default) + rememberMeCheckedByDefault: false +``` + +**ssoSharedWithDefault** controls the default SSO behavior: +- `"none"` (default): Clients without explicit `ssoSharedWith` config don't participate in SSO +- `"all"`: Clients without explicit `ssoSharedWith` config share sessions with all other clients (realm-wide SSO like Keycloak) + +Clients with explicit `ssoSharedWith` configuration always use their configured value. + +**Note**: The `ssoSharedWith` option is separate from the existing `trustedPeers` option. `trustedPeers` controls which clients can issue tokens on behalf of this client (existing behavior), while `ssoSharedWith` controls which clients can reuse this client's authentication session (new behavior). These can be configured independently based on different security requirements. + +**rememberMeCheckedByDefault** controls the initial checkbox state in templates. +This value is passed to templates as `.RememberMeChecked` boolean. + +**SSO via ssoSharedWith**: SSO between clients is controlled by the new `ssoSharedWith` configuration on clients. The `ssoSharedWith` setting defines **which clients can USE this client's session**, not which clients this client can use. + +If client B is listed in client A's `ssoSharedWith`: +1. If user logged in via client A, client B can reuse that session + +This is intentionally separate from `trustedPeers` (which controls token issuance on behalf of another client). Organizations may want different policies for session sharing vs token delegation: +- **ssoSharedWith**: "Can this client's login be reused by another client?" +- **trustedPeers**: "Can another client issue tokens claiming to be this client?" + +**Wildcard Support**: `ssoSharedWith: ["*"]` enables SSO with all clients. This is similar to Keycloak's default behavior where all clients in a realm share sessions. + +**SSO Direction**: SSO sharing is **unidirectional**. Client A sharing with client B does NOT mean client B shares with client A. + +```yaml +staticClients: + # Public app - allows any client to reuse its sessions + - id: public-app + name: Public App + ssoSharedWith: ["*"] + # trustedPeers can be configured separately for token delegation + # ... + + # Admin app - only specific apps can reuse its sessions + - id: admin-app + name: Admin App + ssoSharedWith: ["monitoring-app"] # Only monitoring can SSO from admin sessions + # ... + + # Secret internal service - NO other clients can reuse its sessions + - id: secret-service + name: Secret Service + ssoSharedWith: [] # Empty = no SSO allowed from this client's sessions + # But this client CAN use sessions from other clients that share with it! + # ... + + # Monitoring app - can SSO from admin-app (because admin-app shares with it) + - id: monitoring-app + name: Monitoring App + ssoSharedWith: ["admin-app"] # Bidirectional sharing with admin-app + # ... +``` + +**Example Scenarios:** + +| User logged in via | Accessing | SSO works? | Why | +|-------------------|-----------|------------|-----| +| public-app | admin-app | โœ… Yes | public-app has `ssoSharedWith: ["*"]` | +| admin-app | public-app | โŒ No | admin-app only shares with monitoring-app | +| admin-app | monitoring-app | โœ… Yes | admin-app shares with monitoring-app | +| secret-service | any client | โŒ No | secret-service has `ssoSharedWith: []` | +| public-app | secret-service | โœ… Yes | public-app has `ssoSharedWith: ["*"]` | + +**Key Insight**: A "secret" client that doesn't want others to SSO into it simply doesn't list them in `ssoSharedWith`. But it can still BENEFIT from SSO by being listed in OTHER clients' `ssoSharedWith`. + +**Comparison with Keycloak**: In Keycloak, SSO is realm-wide by default - all clients in a realm share sessions. Dex's approach is more granular: SSO is opt-in per client via `ssoSharedWith`. Use `["*"]` to achieve Keycloak-like behavior. + +**Comparison with trustedPeers**: The `trustedPeers` option continues to control cross-client token issuance (e.g., client B issuing tokens for client A). This is a separate security concern from session sharing. Organizations can configure these independently: +- High SSO sharing, restricted token delegation +- Restricted SSO sharing, high token delegation +- Or any combination based on their security model + +**Cookie Security**: The session cookie is always set with secure defaults: +- `HttpOnly: true` - Not accessible via JavaScript +- `Secure: (issuerURL.Scheme == "https")` - Only sent over HTTPS; for `http` (commonly used on localhost in dev) this is disabled +- `SameSite: Lax` - CSRF protection +- `Path: ` - Derived from issuer URL (e.g., `/dex` for `https://example.com/dex`) + +These settings are not configurable to prevent security misconfigurations. + +#### Authentication Flow with Sessions + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Browser โ”‚ โ”‚ Dex โ”‚ โ”‚ Storage โ”‚ โ”‚ Connector โ”‚ +โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ GET /auth โ”‚ โ”‚ โ”‚ + โ”‚ (no session) โ”‚ โ”‚ โ”‚ + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Check session โ”‚ โ”‚ + โ”‚ โ”‚ cookie โ”‚ โ”‚ + โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ + โ”‚ โ”‚ (not found) โ”‚ โ”‚ + โ”‚ โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ Redirect to โ”‚ โ”‚ โ”‚ + โ”‚ connector โ”‚ โ”‚ โ”‚ + โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ ... connector auth flow ... โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ Callback with โ”‚ โ”‚ โ”‚ + โ”‚ identity โ”‚ โ”‚ โ”‚ + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Create/update โ”‚ โ”‚ + โ”‚ โ”‚ AuthSession โ”‚ โ”‚ + โ”‚ โ”‚ (ALWAYS) โ”‚ โ”‚ + โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ Set-Cookie: โ”‚ โ”‚ โ”‚ + โ”‚ - Session cookie (no MaxAge) โ”‚ โ”‚ + โ”‚ if Remember Me unchecked โ”‚ โ”‚ + โ”‚ - Persistent cookie (with MaxAge) โ”‚ โ”‚ + โ”‚ if Remember Me checked โ”‚ โ”‚ + โ”‚ + redirect to /approval โ”‚ โ”‚ + โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ +``` + +**Key Point**: AuthSession is always created on successful authentication. The "Remember Me" checkbox only controls whether the cookie is a session cookie (deleted on browser close) or a persistent cookie (survives browser restart). This is consistent with Keycloak's behavior. + +#### SSO Flow (Returning User) + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Browser โ”‚ โ”‚ Dex โ”‚ โ”‚ Storage โ”‚ +โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ + โ”‚ GET /auth โ”‚ โ”‚ + โ”‚ (with cookie) โ”‚ โ”‚ + โ”‚ client_id=B โ”‚ โ”‚ + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Get session โ”‚ + โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ + โ”‚ โ”‚ (valid session) โ”‚ + โ”‚ โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Check SSO โ”‚ + โ”‚ โ”‚ policy for โ”‚ + โ”‚ โ”‚ client B โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Check consent โ”‚ + โ”‚ โ”‚ for client B โ”‚ + โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ If consented: โ”‚ โ”‚ + โ”‚ redirect with โ”‚ โ”‚ + โ”‚ code โ”‚ โ”‚ + โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ If not: โ”‚ โ”‚ + โ”‚ show approval โ”‚ โ”‚ + โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ +``` + +#### prompt=none Flow + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Browser โ”‚ โ”‚ Dex โ”‚ โ”‚ Storage โ”‚ +โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ + โ”‚ GET /auth โ”‚ โ”‚ + โ”‚ prompt=none โ”‚ โ”‚ + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Get session โ”‚ + โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ If valid session + consent: โ”‚ + โ”‚ redirect with code โ”‚ + โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ If no session or no consent: โ”‚ + โ”‚ redirect with error=login_requiredโ”‚ + โ”‚ or error=consent_required โ”‚ + โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ +``` + +#### Logout Flow + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Browser โ”‚ โ”‚ Dex โ”‚ โ”‚ Storage โ”‚ +โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ + โ”‚ GET /logout โ”‚ โ”‚ + โ”‚ id_token_hint= โ”‚ โ”‚ + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Validate โ”‚ + โ”‚ โ”‚ id_token_hint โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Get identity โ”‚ + โ”‚ โ”‚ by session ID โ”‚ + โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Deactivate โ”‚ + โ”‚ โ”‚ (Active=false) โ”‚ + โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Revoke refresh โ”‚ + โ”‚ โ”‚ tokens โ”‚ + โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ Clear cookie + โ”‚ โ”‚ + โ”‚ redirect or โ”‚ โ”‚ + โ”‚ show logout โ”‚ โ”‚ + โ”‚ confirmation โ”‚ โ”‚ + โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ +``` + +### Implementation Details/Notes/Constraints + +#### Feature Flag + +```go +// pkg/featureflags/set.go +var ( + // ...existing flags... + + // SessionsEnabled enables user sessions feature + SessionsEnabled = newFlag("sessions_enabled", false) +) +``` + +#### New Storage Entities + + +Two entities are required to properly handle the case where a user might be logged into different clients as different identities in the same browser: + +###### AuthSession + +```go +// storage/storage.go + +// AuthSession represents a browser's authentication state. +// One per browser, referenced by session cookie. +// Key: SessionID (random 32-byte string, stored in cookie) +type AuthSession struct { + // ID is the session identifier stored in cookie + ID string + + // ClientStates maps clientID โ†’ authentication state for that client + // Allows different users/identities per client in same browser + // + // Design note: This map-based approach is consistent with how OfflineSessions + // stores refresh tokens per client (OfflineSessions.Refresh map). Given that + // the number of OAuth clients in a typical deployment is bounded and relatively + // small (tens to hundreds, not thousands), the serialized size of this map + // will not exceed practical storage limits for any supported backend. + ClientStates map[string]*ClientAuthState + + // CreatedAt is when this browser session started + CreatedAt time.Time + + // LastActivity is when any client was last accessed + LastActivity time.Time + + // IPAddress at session creation (for audit) + IPAddress string + + // UserAgent at session creation (for audit) + UserAgent string +} + +// ClientAuthState represents authentication state for a specific client within an auth session. +// Expiration follows OIDC conventions with both absolute and idle timeout: +// - ExpiresAt enforces absolute lifetime (sessions.absoluteLifetime) +// - LastActivity + sessions.validIfNotUsedFor enforces idle timeout +// A client state is considered expired if EITHER condition is met. +type ClientAuthState struct { + // UserID + ConnectorID identify which UserIdentity is authenticated for this client + UserID string + ConnectorID string + + // Active indicates if authentication is active for this client + Active bool + + // ExpiresAt is the absolute expiration time for this client session. + // Set to time.Now() + absoluteLifetime at session creation. + // Cannot be extended - hard upper bound on session duration. + ExpiresAt time.Time + + // LastActivity is when this client session was last used (token issued, SSO check, etc.) + // Used with validIfNotUsedFor to enforce idle timeout. + // Updated on each request that touches this client state. + LastActivity time.Time + + // LastTokenIssuedAt is when a token was last issued for this client. + // Used for logout notifications and audit. + LastTokenIssuedAt time.Time +} +``` + +###### UserIdentity + +```go +// storage/storage.go + +// UserIdentity represents a user's persistent identity data. +// Stores data that persists across sessions: +// - Consent decisions +// - Future: 2FA enrollment +// +// Key: composite of UserID + ConnectorID (one per user per connector) +type UserIdentity struct { + // UserID is the subject identifier from the connector + UserID string + + // ConnectorID is the connector that authenticated the user + ConnectorID string + + // Claims holds the user's identity claims + // Updated on: + // 1. Each login (from connector callback) + // 2. Each refresh token usage (from RefreshConnector.Refresh) + // This ensures claims stay in sync with OfflineSessions and upstream IDP + Claims Claims + + // Consents stores user consent per client: map[clientID][]scopes + // Persists across sessions so user doesn't need to re-consent + Consents map[string][]string + + // CreatedAt is when this identity was first created + CreatedAt time.Time + + // LastLogin is when the user last authenticated (used for auth_time claim) + LastLogin time.Time + + // BlockedUntil is set when user is blocked from logging in + BlockedUntil time.Time + + // Future: 2FA fields + // TOTPSecret string + // WebAuthnCredentials []WebAuthnCredential +} +``` + +**Two-Entity Design Rationale** + +| Entity | Purpose | Lifecycle | Key | +|--------|---------|-----------|-----| +| AuthSession | Browser binding, per-client auth state | Short-lived (session timeout) | SessionID (cookie) | +| UserIdentity | User data, consents, 2FA | Long-lived (persists) | UserID + ConnectorID | + +**How It Works: Different Users in Different Clients** + +``` +Auth Session (cookie: dex_session=abc123) +โ”œโ”€โ”€ ClientStates["client-A"]: +โ”‚ โ””โ”€โ”€ UserID: "alice", ConnectorID: "google", Active: true +โ”œโ”€โ”€ ClientStates["client-B"]: +โ”‚ โ””โ”€โ”€ UserID: "bob", ConnectorID: "ldap", Active: true +โ””โ”€โ”€ ClientStates["client-C"]: + โ””โ”€โ”€ (empty - never authenticated) + +UserIdentity (alice + google): +โ”œโ”€โ”€ Claims: {email: alice@example.com, ...} +โ”œโ”€โ”€ Consents: {"client-A": ["openid", "email"]} +โ””โ”€โ”€ LastLogin: 2024-01-01 + +UserIdentity (bob + ldap): +โ”œโ”€โ”€ Claims: {email: bob@corp.com, ...} +โ”œโ”€โ”€ Consents: {"client-B": ["openid", "groups"]} +โ””โ”€โ”€ LastLogin: 2024-01-02 +``` + +**How SSO Works** + +When user accesses client-B with existing session: + +1. Get `AuthSession` by cookie +2. Check `ClientStates["client-B"]`: + - If exists and active โ†’ user already authenticated for this client +3. If not, check SSO: + - Find any `ClientStates[X]` where client-X has `ssoSharedWith` containing "client-B" + - If found โ†’ SSO! Copy auth state to `ClientStates["client-B"]` + - If not found โ†’ require authentication + +**SSO Session Lookup Algorithm** + +```go +// findSSOSession searches for a valid SSO source session for the target client +func (s *Server) findSSOSession(authSession *AuthSession, targetClientID string) (*ClientAuthState, *UserIdentity) { + targetClient, err := s.storage.GetClient(ctx, targetClientID) + if err != nil { + return nil, nil + } + + // Iterate through all active client states in this browser session + for sourceClientID, state := range authSession.ClientStates { + // Skip inactive or expired states + if !state.Active || time.Now().After(state.ExpiresAt) { + continue + } + + // Get the source client configuration + sourceClient, err := s.storage.GetClient(ctx, sourceClientID) + if err != nil { + continue + } + + // Check if source client shares its session with the target client + // SSO is allowed if: + // 1. Source client has ssoSharedWith: ["*"] (shares with everyone) + // 2. Source client has targetClientID in its ssoSharedWith list + if !s.clientSharesSessionWith(sourceClient, targetClientID) { + continue + } + + // Found a valid SSO source! Get the user identity + identity, err := s.storage.GetUserIdentity(ctx, state.UserID, state.ConnectorID) + if err != nil { + continue + } + + // Check if user is not blocked + if identity.BlockedUntil.After(time.Now()) { + continue + } + + return state, identity + } + + return nil, nil +} + +// clientSharesSessionWith checks if sourceClient shares its session with targetClientID +func (s *Server) clientSharesSessionWith(sourceClient Client, targetClientID string) bool { + for _, peer := range sourceClient.SSOSharedWith { + if peer == "*" || peer == targetClientID { + return true + } + } + return false +} +``` + +**SSO Lookup Flow Diagram** + +``` +User accesses client-B with existing session + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Get AuthSession from cookie โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Check ClientStates["client-B"] โ”‚ +โ”‚ exists and active? โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + Yes No + โ”‚ โ”‚ + โ–ผ โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Use existing โ”‚ โ”‚ For each ClientStates[X]: โ”‚ +โ”‚ session โ”‚ โ”‚ - Is state active? โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - Get client-X config โ”‚ + โ”‚ - Does client-X share with B? โ”‚ + โ”‚ (X.ssoSharedWith has B or *)โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + Found match No match + โ”‚ โ”‚ + โ–ผ โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ SSO! Copy โ”‚ โ”‚ Require โ”‚ + โ”‚ state to B โ”‚ โ”‚ authenticationโ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Example: SSO Flow** + +``` +1. User logs into client-A as alice + AuthSession.ClientStates["client-A"] = {UserID: "alice", Active: true} + +2. User accesses client-B + - client-A.ssoSharedWith includes "client-B" โœ“ + - SSO! Copy: ClientStates["client-B"] = {UserID: "alice", Active: true} + - Issue tokens for alice to client-B +``` + +**Example: No SSO, Different User** + +``` +1. User logged into client-A as alice + AuthSession.ClientStates["client-A"] = {UserID: "alice", Active: true} + +2. User accesses client-B (client-A does NOT share with client-B) + - No SSO available + - Redirect to connector for authentication + +3. User logs in as bob (different account) + AuthSession.ClientStates["client-B"] = {UserID: "bob", Active: true} + +Same browser, two different users, no conflict! +``` + +**Claims Synchronization with Refresh Tokens** + +When a refresh token is used: +1. `RefreshConnector.Refresh()` returns updated claims +2. Update `OfflineSessions.ConnectorData` (existing behavior) +3. **NEW**: Also update `UserIdentity.Claims`: + +```go +// In refresh token handler +func (s *Server) handleRefreshToken(...) { + // ...existing refresh logic... + + newIdentity, err := refreshConn.Refresh(ctx, scopes, oldIdentity) + if err != nil { + // Handle refresh failure + } + + // Update OfflineSessions (existing) + s.storage.UpdateOfflineSessions(...) + + // Update UserIdentity claims (NEW) + if s.sessionsEnabled { + s.storage.UpdateUserIdentity(ctx, newIdentity.UserID, connectorID, + func(u UserIdentity) (UserIdentity, error) { + u.Claims = storage.Claims{ + UserID: newIdentity.UserID, + Username: newIdentity.Username, + Email: newIdentity.Email, + Groups: newIdentity.Groups, + // ... + } + return u, nil + }) + } +} +``` + +This ensures `UserIdentity.Claims` stays synchronized with: +- Connector's current user data +- `OfflineSessions.ConnectorData` +- Actual refresh token claims + +**Why UserIdentity instead of AuthSession?** + +The name `UserIdentity` is chosen because this entity stores more than just session state: +1. **Persistent data**: Consent decisions survive session expiration +2. **Future 2FA**: TOTP secrets and WebAuthn credentials will be stored here +3. **One per user/connector**: Unlike sessions which could be per-browser, this is per-identity + +**Session ID Regeneration** + +The `AuthSession.ID` is regenerated when: +- User logs in from a new browser (new session created) +- Security concern requires new session (e.g., after password change) + +Individual `ClientStates` can be invalidated without changing the auth session ID. + +**Multiple Users in Same Browser** + +With the two-entity design: +- `AuthSession` tracks which user is authenticated for which client +- Different clients can have different users (if no SSO trust) +- Same user can be authenticated for multiple clients (SSO or separate logins) + +**SSO and Different Users** + +With SSO enabled between clients, the same user is used for all sharing clients: +- User logs in to client-A as "alice@example.com" +- User accesses client-B (client-A shares with client-B) โ†’ automatically authenticated as "alice@example.com" +- SSO reuses the identity from the sharing client + +If user needs to login as different identity to a sharing client: +- Use `prompt=login` to force re-authentication +- This creates new ClientState for that client with potentially different user + +Without SSO, user can be different identities in different clients (see examples above). + +#### Storage Interface Extensions + +Two new entities require CRUD operations: + +```go +// storage/storage.go + +type Storage interface { + // ...existing methods... + + // AuthSession management + CreateAuthSession(ctx context.Context, s AuthSession) error + GetAuthSession(ctx context.Context, sessionID string) (AuthSession, error) + UpdateAuthSession(ctx context.Context, sessionID string, updater func(s AuthSession) (AuthSession, error)) error + DeleteAuthSession(ctx context.Context, sessionID string) error + + // UserIdentity management + CreateUserIdentity(ctx context.Context, u UserIdentity) error + GetUserIdentity(ctx context.Context, userID, connectorID string) (UserIdentity, error) + UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u UserIdentity) (UserIdentity, error)) error + DeleteUserIdentity(ctx context.Context, userID, connectorID string) error + + // List for admin API + ListUserIdentities(ctx context.Context) ([]UserIdentity, error) +} +``` + +**Garbage Collection** + +```go +type GCResult struct { + // ...existing fields... + AuthSessions int64 // NEW: expired auth sessions cleaned up +} +``` + +`AuthSession` objects are garbage collected when: +- `LastActivity + validIfNotUsedFor` exceeded (inactivity) +- All `ClientStates` have expired + +`UserIdentity` objects are NOT garbage collected (preserve consents, future 2FA). + +#### Session Expiration + +**AuthSession expiration:** +- Entire session expires when `LastActivity + validIfNotUsedFor` is reached (idle timeout) +- On expiration, `AuthSession` is deleted by GC +- User must re-authenticate for all clients + +**ClientAuthState expiration (per-client within AuthSession):** + +Each client state enforces **both** absolute lifetime and idle timeout, consistent with standard OIDC session semantics: + +```go +func (s *Server) isClientStateValid(state *ClientAuthState) bool { + now := time.Now() + + // 1. Check absolute lifetime - hard upper bound, cannot be extended + if now.After(state.ExpiresAt) { + return false + } + + // 2. Check idle timeout - session unused for too long + if now.After(state.LastActivity.Add(s.sessionsConfig.validIfNotUsedFor)) { + return false + } + + // 3. Check explicit deactivation (admin revoked) + if !state.Active { + return false + } + + return true +} +``` + +When a client state expires: +- Other clients in same auth session remain active +- User must re-authenticate only for the expired client +- On successful re-authentication, a new `ClientAuthState` is created with fresh `ExpiresAt` + +**Admin can force re-authentication:** +- Delete `AuthSession` โ†’ user must re-auth for all clients +- Set `ClientStates[clientID].Active = false` โ†’ user must re-auth for that client only + +#### Deletion Risks + +**Deleting AuthSession:** +- User must re-authenticate for all clients +- No data loss (consents preserved in UserIdentity) +- Safe operation for logout + +**Deleting UserIdentity:** + +| What's Lost | Impact | +|-------------|--------| +| Consent decisions | User must re-approve scopes for all clients | +| Future: 2FA enrollment | User must re-enroll TOTP/WebAuthn | + +**When to delete UserIdentity:** +- User explicitly requests account deletion (GDPR) +- Admin cleanup of stale identities +- User removed from upstream identity provider + +**When NOT to delete (delete AuthSession instead):** +- Regular logout - delete AuthSession or set ClientState.Active = false +- Session expiration - GC handles AuthSession cleanup +- Security concern - delete AuthSession to force re-auth + +#### Session Cookie Format + +The session cookie contains only the session ID (not the session data): + +``` +Cookie: dex_session=; Path=; Secure; HttpOnly; SameSite=Lax +``` + +**Cookie Path**: Derived from the issuer URL path (`issuerURL.Path`). For example: +- Issuer: `https://dex.example.com/` โ†’ `Path=/` +- Issuer: `https://example.com/dex` โ†’ `Path=/dex` + +This is consistent with how Dex already handles routing - all endpoints are prefixed with the issuer path. + +**Session Creation vs Cookie Persistence (Keycloak-like behavior)** + +Unlike some implementations where "Remember Me" controls session creation, we follow Keycloak's approach: + +- **AuthSession is ALWAYS created** on successful authentication +- **"Remember Me" controls cookie persistence**: + - Unchecked: Session cookie (expires when browser closes) + - Checked: Persistent cookie (expires at `absoluteLifetime`) + +This approach is better because: +1. SSO works within a browser session even without "Remember Me" +2. Consent decisions are preserved during the browser session +3. `prompt=none` works correctly within browser session +4. More intuitive: "Remember Me" = "remember me after I close the browser" + +```go +func (s *Server) setSessionCookie(w http.ResponseWriter, sessionID string, rememberMe bool) { + cookie := &http.Cookie{ + Name: s.sessionsConfig.CookieName, + Value: sessionID, + Path: s.issuerURL.Path, + HttpOnly: true, + Secure: s.issuerURL.Scheme == "https", + SameSite: http.SameSiteLaxMode, + } + + if rememberMe { + // Persistent cookie - survives browser restart + cookie.MaxAge = int(s.sessionsConfig.absoluteLifetime.Seconds()) + } + // else: Session cookie - no MaxAge, browser deletes on close + + http.SetCookie(w, cookie) +} +``` + +Session ID generation: +```go +func NewSessionID() string { + return newSecureID(32) // 256-bit random value +} +``` + +#### Client Configuration Extension + +A new client configuration field is introduced for SSO control: + +```go +// storage/storage.go + +type Client struct { + // ...existing fields... + + // TrustedPeers are a list of peers which can issue tokens on this client's behalf. + // This is used for cross-client token issuance (existing behavior). + TrustedPeers []string `json:"trustedPeers" yaml:"trustedPeers"` + + // SSOSharedWith defines which other clients can reuse this client's authentication session. + // When a user is authenticated for this client, clients listed here can skip authentication. + // This is separate from TrustedPeers - organizations may want different policies for + // session sharing vs token delegation. + // Special value "*" means share with all clients (Keycloak-like realm-wide SSO). + // nil means use ssoSharedWithDefault from sessions config. + // Empty slice [] means explicitly share with no one. + SSOSharedWith []string `json:"ssoSharedWith,omitempty" yaml:"ssoSharedWith,omitempty"` +} +``` + +#### Connector Logout (Future) + +Logout URLs should be configured on connectors, not clients. A new connector interface will be added: + +```go +// connector/connector.go + +// LogoutConnector is an optional interface for connectors that support +// terminating upstream sessions on logout. +type LogoutConnector interface { + // Logout terminates the user's session at the upstream identity provider. + // Returns a URL to redirect the user to for upstream logout, or empty string + // if no redirect is needed. + Logout(ctx context.Context, connectorData []byte) (logoutURL string, err error) +} +``` + +Connectors that implement this interface (e.g., OIDC with `end_session_endpoint`, SAML with SLO): +- Are called during Dex logout flow +- Can redirect user to upstream for complete logout +- Implementation details are connector-specific + +This is tracked as a future improvement. + +#### Server Configuration Extension + +```go +// cmd/dex/config.go + +type Sessions struct { + // CookieName is the session cookie name (default: "dex_session") + CookieName string `json:"cookieName"` + + // AbsoluteLifetime is the maximum session lifetime (default: "24h") + AbsoluteLifetime string `json:"absoluteLifetime"` + + // ValidIfNotUsedFor is the inactivity timeout (default: "1h") + ValidIfNotUsedFor string `json:"validIfNotUsedFor"` + + // SSOSharedWithDefault is the default SSO sharing policy + // "all" = share with all clients, "none" = share with no one (default: "none") + SSOSharedWithDefault string `json:"ssoSharedWithDefault"` + + // RememberMeCheckedByDefault controls the initial checkbox state in templates + // true = pre-checked, false = unchecked (default: false) + RememberMeCheckedByDefault bool `json:"rememberMeCheckedByDefault"` +} +``` + +**Using ssoSharedWithDefault in SSO logic:** + +```go +func (s *Server) clientSharesSessionWith(sourceClient Client, targetClientID string) bool { + ssoSharedWith := sourceClient.SSOSharedWith + + // If client has no explicit ssoSharedWith, use default + if ssoSharedWith == nil { + switch s.sessionsConfig.SSOSharedWithDefault { + case "all": + return true // Share with everyone by default + default: // "none" + return false // Share with no one by default + } + } + + // Explicit configuration: empty slice means explicitly share with no one + // This is different from nil (not configured) + if len(ssoSharedWith) == 0 { + return false + } + + // Check explicit sharing list + for _, peer := range ssoSharedWith { + if peer == "*" || peer == targetClientID { + return true + } + } + return false +} +``` + +**Three states for ssoSharedWith:** +1. `nil` (not configured) โ†’ use `ssoSharedWithDefault` +2. `[]` (empty slice) โ†’ explicitly share with no one +3. `["client-a", ...]` or `["*"]` โ†’ explicit sharing list + +#### Prompt Parameter Handling + +Dex will support the following `prompt` values per OIDC Core specification: +- `none` - Silent authentication, no UI displayed +- `login` - Force re-authentication +- `consent` - Force consent screen +- Empty (default) - Normal flow with session reuse + +The `select_account` value is not supported initially (would require account linking feature). + +```go +func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { + // ...existing parsing... + + prompt := r.Form.Get("prompt") + maxAge := r.Form.Get("max_age") + idTokenHint := r.Form.Get("id_token_hint") + clientID := r.Form.Get("client_id") + + // Get auth session from cookie + authSession, err := s.getAuthSessionFromCookie(r) + + // Get client auth state for this specific client + var clientState *ClientAuthState + var userIdentity *UserIdentity + if authSession != nil { + clientState = authSession.ClientStates[clientID] + if clientState != nil && clientState.Active { + userIdentity, _ = s.storage.GetUserIdentity(ctx, clientState.UserID, clientState.ConnectorID) + } + } + + // Handle max_age parameter (OIDC Core 3.1.2.1) + if maxAge != "" && userIdentity != nil { + maxAgeSeconds, err := strconv.Atoi(maxAge) + if err == nil && maxAgeSeconds >= 0 { + authAge := time.Since(userIdentity.LastLogin) + if authAge > time.Duration(maxAgeSeconds)*time.Second { + // Session is too old, force re-authentication + clientState = nil + userIdentity = nil + } + } + } + + switch prompt { + case "none": + // Silent authentication - must have valid session and consent + if clientState == nil || userIdentity == nil { + s.authErr(w, r, redirectURI, "login_required", state) + return + } + // Check consent in identity + consentedScopes, hasConsent := userIdentity.Consents[clientID] + if !hasConsent || !s.scopesCovered(consentedScopes, requestedScopes) { + s.authErr(w, r, redirectURI, "consent_required", state) + return + } + // Issue tokens without UI + + case "login": + // Force re-authentication - ignore existing session for this client + clientState = nil + userIdentity = nil + // Continue to connector login + + case "consent": + // Force consent screen even if previously consented + // Continue but don't check consent + + default: // "" - normal flow + // Check for SSO from trusted clients if no direct session + if clientState == nil && authSession != nil { + clientState, userIdentity = s.findSSOSession(authSession, clientID) + } + } + + // Validate id_token_hint if provided + if idTokenHint != "" { + claims, err := s.validateIDTokenHint(idTokenHint) + if err != nil { + s.authErr(w, r, redirectURI, "invalid_request", state) + return + } + if userIdentity != nil && userIdentity.UserID != claims.Subject { + // Identity user doesn't match hint + if prompt == "none" { + s.authErr(w, r, redirectURI, "login_required", state) + return + } + // Force re-login for different user + clientState = nil + userIdentity = nil + } + } + + // ...continue with flow... +} + +// findSSOSession looks for a valid SSO session from a sharing client +func (s *Server) findSSOSession(authSession *AuthSession, targetClientID string) (*ClientAuthState, *UserIdentity) { + for sourceClientID, state := range authSession.ClientStates { + if !state.Active { + continue + } + sourceClient, _ := s.storage.GetClient(ctx, sourceClientID) + if sourceClient == nil { + continue + } + // Check if source client shares its session with target client + if s.clientSharesSessionWith(sourceClient, targetClientID) { + identity, _ := s.storage.GetUserIdentity(ctx, state.UserID, state.ConnectorID) + if identity != nil { + return state, identity + } + } + } + return nil, nil +} +``` + +**max_age Parameter** + +The `max_age` parameter is supported per OIDC Core specification: +- Specifies the maximum authentication age in seconds +- If the identity's last authentication time (`LastLogin`) exceeds `max_age`, force re-authentication +- When `max_age` is used, the `auth_time` claim MUST be included in the ID token + +#### New Endpoints + +``` +POST /logout +GET /logout +``` + +Logout endpoint following the OpenID RP-Initiated Logout specification ([OpenID spec](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)): + +```go +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + idTokenHint := r.FormValue("id_token_hint") + postLogoutRedirectURI := r.FormValue("post_logout_redirect_uri") + state := r.FormValue("state") + clientID := r.FormValue("client_id") // Optional: logout from specific client + + // Get auth session from cookie + authSession, _ := s.getAuthSessionFromCookie(r) + + // Validate id_token_hint if provided + var hintUserID, hintConnectorID string + if idTokenHint != "" { + claims, err := s.validateIDTokenHint(idTokenHint) + if err == nil { + hintUserID = claims.Subject + // Extract connector from token if possible + } + } + + if authSession != nil { + if clientID != "" { + // Logout from specific client only + delete(authSession.ClientStates, clientID) + s.storage.UpdateAuthSession(ctx, authSession.ID, ...) + } else { + // Logout from all clients - delete entire auth session + s.storage.DeleteAuthSession(ctx, authSession.ID) + } + + // Revoke refresh tokens for logged-out clients + // ... + } + + // Clear cookie and redirect + s.clearSessionCookie(w) + + // Show logout confirmation or redirect + if postLogoutRedirectURI != "" && s.isValidPostLogoutURI(postLogoutRedirectURI, idTokenHint) { + u, _ := url.Parse(postLogoutRedirectURI) + if state != "" { + q := u.Query() + q.Set("state", state) + u.RawQuery = q.Encode() + } + http.Redirect(w, r, u.String(), http.StatusFound) + return + } + + // Show logout confirmation page + s.templates.logout(w, r) +} +``` + +**Future: Upstream Connector Logout** + +For CallbackConnectors (OIDC, OAuth, SAML), the upstream identity provider may also have an active session. Future work should include: +- Implement `LogoutConnector` interface (see above) +- OIDC connectors use `end_session_endpoint` from discovery +- SAML connectors use Single Logout (SLO) +- Redirect user to upstream after Dex logout + +This is tracked as a future improvement. + +#### Discovery Updates + +```go +func (s *Server) constructDiscovery(ctx context.Context) discovery { + d := discovery{ + // ...existing fields... + } + + if s.sessionsEnabled { + d.EndSessionEndpoint = s.absURL("/logout") + } + + return d +} +``` + +#### Login Template Updates + +When sessions are enabled, add "Remember Me" checkbox to authentication flow. + +**Template Data** + +The server passes these values to templates: + +```go +type templateData struct { + // ...existing fields... + + // SessionsEnabled indicates if sessions feature is active + SessionsEnabled bool + + // RememberMeChecked is the default checkbox state + // Set from config: sessions.rememberMeCheckedByDefault + RememberMeChecked bool +} +``` + +**For PasswordConnector (login form exists in Dex):** + +```html + +
+ + + {{ if .SessionsEnabled }} +
+ + +
+ {{ end }} + + +
+``` + +**For CallbackConnector (no login form in Dex):** + +For OAuth/OIDC/SAML connectors, the user is redirected to upstream IDP and there's no Dex login form. + +**Show on Approval Page** (recommended): Add "Remember Me" checkbox to the approval/consent page. User sees it after returning from upstream IDP, before granting consent. + +```html + +
+ + + {{ if .SessionsEnabled }} +
+ + +
+ {{ end }} + + +
+``` + +**When skipApprovalScreen is true**: If approval screen is skipped, the `rememberMeCheckedByDefault` config determines cookie persistence: +- `false` (default): Session cookie (deleted on browser close) +- `true`: Persistent cookie (survives browser restart) + +**Remember Me Behavior** (Keycloak-like): +- **AuthSession is ALWAYS created** on successful authentication regardless of checkbox +- **Checkbox controls cookie persistence only**: + - **Unchecked**: Session cookie - expires when browser closes. SSO works within browser session. + - **Checked**: Persistent cookie - survives browser restart until `absoluteLifetime` expires. + +#### Connector Type Considerations + +**CallbackConnector** (OIDC, OAuth, SAML, GitHub, etc.): +- Session created after successful callback +- Upstream tokens stored in refresh token's ConnectorData (not in session) +- Identity refresh via RefreshConnector when refresh token is used + +**PasswordConnector** (LDAP, local passwords): +- Session created after successful password verification +- No upstream tokens +- Identity refresh re-validates against password backend when refresh token is used + +Both types work the same way with sessions - the connector type only affects: +1. Initial authentication flow (redirect vs password form) +2. How identity refresh works (via refresh tokens, not sessions) + +#### Connector Configuration Changes + +Sessions reference a `ConnectorID`, but connector configuration may change after session creation (e.g., OIDC issuer URL changes, LDAP server replaced, connector removed entirely). + +**Behavior**: Dex does NOT automatically invalidate sessions when connector configuration changes. This is by design - Dex has no mechanism to detect configuration changes at runtime, and connectors are typically reconfigured during planned maintenance. + +**Administrator responsibility**: When connector configuration changes in a way that invalidates existing user identities (e.g., connector removed, upstream IdP replaced), administrators should: +1. Terminate affected sessions via gRPC admin API (future: `DexSessions.TerminateByConnector(connectorID)`) +2. Or wait for sessions to expire naturally +3. Or restart Dex with `DEX_SESSIONS_ENABLED=false` temporarily to force re-authentication + +If a session references a connector that no longer exists, the session will fail gracefully at the next use: `GetConnector()` will return an error, and the user will be redirected to authenticate again. + +### Risks and Mitigations + +#### Security Risks + +| Risk | Mitigation | +|------|------------| +| Session hijacking | Secure cookie flags (HttpOnly, Secure, SameSite), short idle timeout | +| Session fixation | Generate new session ID after authentication (see below) | +| CSRF on logout | GET shows confirmation page, POST performs logout | +| Cookie theft | Bind session to fingerprint (IP range, partial user agent) - optional | +| Storage exposure | Session IDs are random 256-bit values, no sensitive data in cookie | + +**Session Fixation Protection** + +Session fixation attacks occur when an attacker sets a known session ID in a victim's browser before authentication, then hijacks the session after the victim logs in. + +References: +- [OWASP Session Fixation](https://owasp.org/www-community/attacks/Session_fixation) +- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) + +**Mitigations implemented:** + +1. **Regenerate session ID on authentication**: When a user successfully authenticates, ALWAYS generate a new `AuthSession.ID` even if a session already exists. Never reuse a pre-authentication session ID. + +```go +// This is not the real method signature, but the implementation example of a specific behavior. +func (s *Server) onSuccessfulAuthentication(w http.ResponseWriter, userID, connectorID, clientID string, rememberMe bool) { + // ALWAYS generate new session ID - prevents session fixation + newSessionID := NewSessionID() + + // Create or update AuthSession with NEW ID + authSession := &AuthSession{ + ID: newSessionID, // Always new, never reuse + ClientStates: make(map[string]*ClientAuthState), + CreatedAt: time.Now(), + // ... + } + + // Set cookie with new session ID + s.setSessionCookie(w, newSessionID, rememberMe) +} +``` + +2. **Don't accept session IDs from URL parameters**: Session IDs are ONLY accepted from cookies, never from query parameters or POST data. + +3. **Strict cookie settings**: `HttpOnly`, `Secure`, `SameSite=Lax` prevent common session theft vectors. + +4. **Session binding (optional future enhancement)**: Bind session to client characteristics (IP range, user agent) to detect stolen cookies. + +**Handling existing sessions during authentication:** + +When a user authenticates and an existing `AuthSession` is found: +1. Generate a completely new session ID +2. Copy relevant state from old session to new session (if any) +3. Delete the old `AuthSession` from storage +4. Set cookie with new session ID + +This ensures that even if an attacker set a session cookie before authentication, they cannot use it after the victim logs in. + +#### Operational Risks + +| Risk | Mitigation | +|------|------------| +| Storage growth | AuthSessions are GC'd on inactivity; UserIdentities are per-user like OfflineSessions; admin API allows cleanup | +| Storage performance | Additional read per request to resolve session cookie. Impact depends on backend โ€” see note below | +| Migration complexity | Feature flag allows gradual rollout, no breaking changes | + +**Storage Performance Note** + +Enabling sessions introduces an additional storage read on each authorization request (to resolve the session cookie to an `AuthSession`). The actual performance impact depends on the storage backend: + +- **SQL (Postgres, MySQL, SQLite)**: Session lookup by primary key is a single indexed read โ€” negligible overhead +- **etcd**: Single key-value lookup โ€” negligible overhead +- **Kubernetes CRDs**: GET by resource name โ€” slightly higher latency than SQL/etcd but still within acceptable bounds (may require [priority&fairness](https://kubernetes.io/docs/concepts/cluster-administration/flow-control/) tuning) +- **Memory**: In-process map lookup โ€” no overhead + +At this stage, we do not have production metrics to quantify the exact impact. The storage access pattern is identical to existing `OfflineSessions` lookups (single record by key), which are already proven in production. It is recommended to monitor storage latency after enabling sessions and adjusting `validIfNotUsedFor` if the GC frequency needs tuning. + +#### Breaking Changes + +**None** - Sessions are opt-in via feature flag and configuration. Existing deployments continue to work without changes. + +#### Rollback Plan + +Sessions are fully controlled by the `DEX_SESSIONS_ENABLED` feature flag. Rollback is straightforward: + +1. **Disable feature flag**: Set `DEX_SESSIONS_ENABLED=false` (or remove it) +2. **Immediate effect**: Dex stops creating, reading, and validating sessions. All authorization requests proceed as before sessions were introduced โ€” connector authentication on every request, no SSO, no session cookies +3. **Cookie cleanup**: Existing session cookies in browsers become inert โ€” Dex ignores them when sessions are disabled. They expire naturally per their MaxAge or when the browser is closed +4. **Storage cleanup**: `AuthSession` and `UserIdentity` records remain in storage but are unused. They can be cleaned up manually or left to accumulate no further growth +5. **No downtime required**: Feature flag can be toggled without restart if environment variable reload is supported; otherwise, a rolling restart is sufficient + +**Key guarantee**: Disabling the feature flag returns Dex to its pre-sessions behavior with zero side effects. No existing functionality (refresh tokens, connector authentication, token issuance) depends on sessions. Additional tables in the database cost nothing when the feature flag is disabled: they remain unused schema objects and can be deleted later if desired. + +#### Migration Path + +1. Deploy new Dex version - storage migrations create `AuthSession` and `UserIdentity` tables/resources automatically (no feature flag needed for schema) +2. Enable feature flag `DEX_SESSIONS_ENABLED=true` when ready to use sessions +3. Add `sessions:` configuration block +4. Sessions start being created for all new logins; "Remember Me" controls cookie persistence (session vs persistent cookie) +5. Existing refresh tokens continue to work + +**Note**: Storage schema changes (new tables/CRDs) are applied on startup regardless of feature flag. The feature flag only controls whether sessions are actually created and used. This simplifies deployment - you can deploy the new version, then enable sessions later without another deployment. + +### Alternatives + +#### 1. Stateless Sessions (JWT in Cookie) + +**Approach**: Store session data directly in a signed/encrypted JWT cookie. + +**Pros**: +- No server-side storage required +- Scales horizontally without shared state + +**Cons**: +- Cannot revoke sessions without blocklist +- Cookie size limits (~4KB) +- Cannot store consent history or client tracking for logout +- No server-side session list for logout + +**Decision**: Rejected. Server-side sessions are required for proper logout and SSO. + +#### 2. Extend OfflineSessions + +**Approach**: Add session data to existing OfflineSessions entity. + +**Pros**: +- Reuses existing storage +- Simpler migration + +**Cons**: +- OfflineSessions are per-connector, not per-browser +- Different lifecycle (refresh token vs browser session) +- Would complicate existing OfflineSessions logic + +**Decision**: Rejected. Clean separation is better for maintainability. + +#### 3. External Session Store (Redis) + +**Approach**: Use Redis for session storage instead of existing backends. + +**Pros**: +- Built-in TTL support +- Fast reads/writes +- Proven session store + +**Cons**: +- Adds infrastructure dependency +- Against Dex's simplicity philosophy +- Doesn't work with Kubernetes CRD backend + +**Decision**: Rejected. Must work with existing storage backends. + +#### 4. Do Nothing + +**Approach**: Keep using refresh tokens as implicit sessions. + +**Cons**: +- Cannot implement OIDC conformance features +- No proper SSO +- No proper logout +- Blocks future features (2FA, etc.) + +**Decision**: Rejected. These features are essential for enterprise adoption. + +## Future Improvements + +1. **Identity Refresh for Long-Lived Sessions** + - Periodic refresh of user identity from connector during active session + - Configurable refresh interval + - Refresh on token request option + - Handle connector revocation (terminate session) + +2. **Upstream Connector Logout** + - Redirect to upstream IDP logout endpoint after Dex logout + - Support RP-Initiated Logout towards upstream OIDC providers + - SAML Single Logout (SLO) support + - Configurable per-connector logout URLs + +3. **Session Introspection Endpoint** + - Implement session check endpoint similar to [RFC 7662 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662) + - Could enable replacing OAuth2 Proxy in some deployments + - Endpoint: `GET /session/introspect` or similar + - Returns session validity and user claims + - Useful for reverse proxies to validate session cookies directly + +4. **Front-Channel Logout** + - Implement [OIDC Front-Channel Logout 1.0](https://openid.net/specs/openid-connect-frontchannel-1_0.html) + - Notify client applications when user logs out via iframes + - Requires client `logoutURL` configuration + +5. **2FA/MFA Support** + - Store TOTP secrets in user profile + - Add MFA enrollment flow + - Step-up authentication for sensitive operations + - WebAuthn/Passkey support + +6. **Session Management API** + - List active sessions via gRPC API + - Revoke sessions via gRPC API + - Session activity audit log + +7. **Back-Channel Logout** + - Implement [OIDC Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) + - Server-to-server logout notifications + +8. **Account Linking** + - Link multiple connector identities to single user + - Switch between linked identities + +9. **Device/Session Fingerprinting** + - Optional session binding to client characteristics + - Anomaly detection for session theft + +10. **Per-Connector Session Policies** + - Different session lifetimes per connector + - Different SSO policies per connector + +11. **Session Impersonation for Admin** + - Admin can impersonate user sessions for debugging + - Audit logging for impersonation + +12. **Consent Management UI** + - User-facing page to view/revoke consents + - GDPR compliance features + diff --git a/docs/enhancements/cel-expressions-2026-02-28.md b/docs/enhancements/cel-expressions-2026-02-28.md new file mode 100644 index 0000000000..efd2831f7e --- /dev/null +++ b/docs/enhancements/cel-expressions-2026-02-28.md @@ -0,0 +1,732 @@ +# Dex Enhancement Proposal (DEP) - 2026-02-28 - CEL (Common Expression Language) Integration + +## Table of Contents + +- [Summary](#summary) +- [Context](#context) +- [Motivation](#motivation) + - [Goals/Pain](#goalspain) + - [Non-Goals](#non-goals) +- [Proposal](#proposal) + - [User Experience](#user-experience) + - [Implementation Details/Notes/Constraints](#implementation-detailsnotesconstraints) + - [Phase 1: pkg/cel - Core CEL Library](#phase-1-pkgcel---core-cel-library) + - [Phase 2: Authentication Policies](#phase-2-authentication-policies) + - [Phase 3: Token Policies](#phase-3-token-policies) + - [Phase 4: OIDC Connector Claim Mapping](#phase-4-oidc-connector-claim-mapping) + - [Policy Application Flow](#policy-application-flow) + - [Risks and Mitigations](#risks-and-mitigations) + - [Alternatives](#alternatives) +- [Future Improvements](#future-improvements) + +## Summary + +This DEP proposes integrating [CEL (Common Expression Language)][cel-spec] into Dex as a first-class +expression engine for policy evaluation, claim mapping, and token customization. A new reusable +`pkg/cel` package will provide a safe, sandboxed CEL environment with Kubernetes-grade compatibility +guarantees, cost budgets, and a curated set of extension libraries. Subsequent phases will leverage +this package to implement authentication policies, token policies, advanced claim mapping in +connectors, and per-client/global access rules โ€” replacing the need for ad-hoc configuration fields +and external policy engines. + +[cel-spec]: https://github.com/google/cel-spec + +## Context + +- [#1583 Add allowedGroups option for clients config][#1583] โ€” a long-standing request for a + configuration option to allow a client to specify a list of allowed groups. +- [#1635 Connector Middleware][#1635] โ€” long-standing request for a policy/middleware layer between + connectors and the server for claim transformations and access control. +- [#1052 Allow restricting connectors per client][#1052] โ€” frequently requested feature to restrict + which connectors are available to specific OAuth2 clients. +- [#2178 Custom claims in ID tokens][#2178] โ€” requests for including additional payload in issued tokens. +- [#2812 Token Exchange DEP][dep-token-exchange] โ€” mentions CEL/Rego as future improvement for + policy-based assertions on exchanged tokens. +- The OIDC connector already has a growing set of ad-hoc claim mutation options + (`ClaimMapping`, `ClaimMutations.NewGroupFromClaims`, `FilterGroupClaims`, `ModifyGroupNames`) + that would benefit from a unified expression language. +- Previous community discussions explored OPA/Rego and JMESPath, but CEL offers a better fit + (see [Alternatives](#alternatives)). + +[#1583]: https://github.com/dexidp/dex/pull/1583 +[#1635]: https://github.com/dexidp/dex/issues/1635 +[#1052]: https://github.com/dexidp/dex/issues/1052 +[#2178]: https://github.com/dexidp/dex/issues/2178 +[dep-token-exchange]: /docs/enhancements/token-exchange-2023-02-03-%232812.md + +## Motivation + +### Goals/Pain + +1. **Complex query/filter capabilities** โ€” Dex needs a way to express complex validations and + mutations in multiple places (authentication flow, token issuance, claim mapping). Today each + feature requires new Go code, new config fields, and a new release cycle. CEL allows operators + to express these rules declaratively without code changes. + +2. **Authentication policies** โ€” Operators want to control _who_ can log in based on rich + conditions: restrict specific connectors to specific clients, require group membership for + certain clients, deny login based on email domain, enforce MFA claims, etc. Currently there is + no unified mechanism; users rely on downstream applications or external proxies. + +3. **Token policies** โ€” Operators want to customize issued tokens: add extra claims to ID tokens, + restrict scopes per client, modify `aud` claims, include upstream connector metadata, etc. + Today this requires forking Dex or using a reverse proxy. + +4. **Claim mapping in OIDC connector** โ€” The OIDC connector has accumulated multiple ad-hoc config + options for claim mapping and group mutations (`ClaimMapping`, `NewGroupFromClaims`, + `FilterGroupClaims`, `ModifyGroupNames`). A single CEL expression field would replace all of + these with a more powerful and composable approach. + +5. **Per-client and global policies** โ€” One of the most frequent requests is allowing different + connectors for different clients and restricting group-based access per client. CEL policies at + the global and per-client level address this cleanly. + +6. **CNCF ecosystem alignment** โ€” CEL has massive adoption across the CNCF ecosystem: + + | Project | CEL Usage | Evidence | + |---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|----------| + | **Kubernetes** | ValidatingAdmissionPolicy, CRD validation rules (`x-kubernetes-validations`), AuthorizationPolicy, field selectors, CEL-based match conditions in webhooks | [KEP-3488][k8s-cel-kep], [CRD Validation Rules][k8s-crd-cel], [AuthorizationPolicy KEP-3221][k8s-authz-cel] | + | **Kyverno** | CEL expressions in validation/mutation policies (v1.12+), preconditions | [Kyverno CEL docs][kyverno-cel] | + | **OPA Gatekeeper** | Partially added support for CEL in constraint templates | [Gatekeeper CEL][gatekeeper-cel] | + | **Istio** | AuthorizationPolicy conditions, request routing, telemetry | [Istio CEL docs][istio-cel] | + | **Envoy / Envoy Gateway** | RBAC filter, ext_authz, rate limiting, route matching, access logging | [Envoy CEL docs][envoy-cel] | + | **Tekton** | Pipeline when expressions, CEL custom tasks | [Tekton CEL Interceptor][tekton-cel] | + | **Knative** | Trigger filters using CEL expressions | [Knative CEL filters][knative-cel] | + | **Google Cloud** | IAM Conditions, Cloud Deploy, Security Command Center | [Google IAM CEL][gcp-cel] | + | **Cert-Manager** | CertificateRequestPolicy approval using CEL | [cert-manager approver-policy CEL][cert-manager-cel] | + | **Cilium** | Hubble CEL filter logic | [Cilium CEL docs][cilium-cel] | + | **Crossplane** | Composition functions with CEL-based patch transforms | [Crossplane CEL transforms][crossplane-cel] | + | **Kube-OVN** | Network policy extensions using CEL | [Kube-OVN CEL][kube-ovn-cel] | + + [k8s-cel-kep]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3488-cel-admission-control + [k8s-crd-cel]: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules + [k8s-authz-cel]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/3221-structured-authorization-configuration + [kyverno-cel]: https://kyverno.io/docs/writing-policies/cel/ + [gatekeeper-cel]: https://open-policy-agent.github.io/gatekeeper/website/docs/validating-admission-policy/#policy-updates-to-add-vap-cel + [istio-cel]: https://istio.io/latest/docs/reference/config/security/conditions/ + [envoy-cel]: https://www.envoyproxy.io/docs/envoy/latest/xds/type/v3/cel.proto + [tekton-cel]: https://tekton.dev/docs/triggers/cel_expressions/ + [knative-cel]: https://github.com/knative/eventing/blob/main/docs/broker/filtering.md#add-cel-expression-filter + [gcp-cel]: https://cloud.google.com/iam/docs/conditions-overview + [cert-manager-cel]: https://cert-manager.io/docs/policy/approval/approver-policy/#validations + [cilium-cel]: https://docs.cilium.io/en/stable/_api/v1/flow/README/#flowfilter-experimental + [crossplane-cel]: https://github.com/crossplane-contrib/function-cel-filter + [kube-ovn-cel]: https://kubeovn.github.io/docs/stable/en/advance/cel-expression/ + + By choosing CEL, Dex operators who already use Kubernetes or other CNCF tools can reuse their + existing knowledge of the expression language. + +### Non-Goals + +- **Full policy engine** โ€” This DEP does not aim to replace dedicated external policy engines + (OPA, Kyverno). CEL in Dex is scoped to identity and token operations. +- **Breaking changes to existing configuration** โ€” All existing config fields (`ClaimMapping`, + `ClaimMutations`, etc.) will continue to work. CEL expressions are additive/opt-in. +- **Authorization (beyond Dex scope)** โ€” Dex is an identity provider; downstream authorization + decisions remain the responsibility of relying parties. CEL policies in Dex are limited to + authentication and token issuance concerns. +- **Multi-phase CEL in a single DEP** โ€” Only Phase 1 (`pkg/cel` package) is targeted for + immediate implementation. Phases 2-4 are included here for design context and will have their + own implementation PRs. +- **Multi-step logic** โ€” CEL in Dex is scoped to single-expression evaluation. Each expression + is a standalone, stateless computation with no intermediate variables, chaining, or + multi-step transformations. If a use case requires sequential logic or conditionally chained + expressions, it belongs outside Dex (e.g. in an external policy engine or middleware). + This boundary protects the design from scope creep that pushes CEL beyond what it's good at. + +## Proposal + +### User Experience + +#### Authentication Policy (Phase 2) + +Operators can define global and per-client authentication policies in the Dex config: + +```yaml +# Global authentication policy โ€” each expression evaluates to bool. +# If true โ€” the request is denied. Evaluated in order; first match wins. +authPolicy: + - expression: "!identity.email.endsWith('@example.com')" + message: "'Login restricted to example.com domain'" + - expression: "!identity.email_verified" + message: "'Email must be verified'" + +staticClients: + - id: admin-app + name: Admin Application + secret: ... + redirectURIs: [...] + # Per-client policy โ€” same structure as global + authPolicy: + - expression: "!(request.connector_id in ['okta', 'ldap'])" + message: "'This application requires Okta or LDAP login'" + - expression: "!('admin' in identity.groups)" + message: "'Admin group membership required'" +``` + +#### Token Policy (Phase 3) + +Operators can add extra claims or mutate token contents: + +```yaml +tokenPolicy: + # Global mutations applied to all ID tokens + claims: + # Add a custom claim based on group membership + - key: "'role'" + value: "identity.groups.exists(g, g == 'admin') ? 'admin' : 'user'" + # Include connector ID as a claim + - key: "'idp'" + value: "request.connector_id" + # Add department from upstream claims (only if present) + - key: "'department'" + value: "identity.extra['department']" + condition: "'department' in identity.extra" + +staticClients: + - id: internal-api + name: Internal API + secret: ... + redirectURIs: [...] + tokenPolicy: + claims: + - key: "'custom-claim.company.com/team'" + value: "identity.extra['team'].orValue('engineering')" + # Only add on-call claim for ops group members + - key: "'on_call'" + value: "true" + condition: "identity.groups.exists(g, g == 'ops')" + # Restrict scopes + filter: + expression: "request.scopes.all(s, s in ['openid', 'email', 'profile'])" + message: "'Unsupported scope requested'" +``` + +#### OIDC Connector Claim Mapping (Phase 4) + +Replace ad-hoc claim mapping with CEL: + +```yaml +connectors: + - type: oidc + id: corporate-idp + name: Corporate IdP + config: + issuer: https://idp.example.com + clientID: dex-client + clientSecret: ... + # CEL-based claim mapping โ€” replaces claimMapping and claimModifications + claimMappingExpressions: + username: "claims.preferred_username.orValue(claims.email)" + email: "claims.email" + groups: > + claims.groups + .filter(g, g.startsWith('dex:')) + .map(g, g.trimPrefix('dex:')) + emailVerified: "claims.email_verified.orValue(true)" + # Extra claims to pass through to token policies + extra: + department: "claims.department.orValue('unknown')" + cost_center: "claims.cost_center.orValue('')" +``` + +### Implementation Details/Notes/Constraints + +### Phase 1: `pkg/cel` โ€” Core CEL Library + +This is the foundation that all subsequent phases build upon. The package provides a safe, +reusable CEL environment with Kubernetes-grade guarantees. + +#### Package Structure + +``` +pkg/ + cel/ + cel.go # Core Environment, compilation, evaluation + types.go # CEL type declarations (Identity, Request, etc.) + cost.go # Cost estimation and budgeting + doc.go # Package documentation + library/ + email.go # Email-related CEL functions + groups.go # Group-related CEL functions +``` + +#### Dependencies + +``` +github.com/google/cel-go v0.27.0 +``` + +The `cel-go` library is the canonical Go implementation maintained by Google, used by Kubernetes +and all major CNCF projects. It follows semantic versioning and provides strong backward +compatibility guarantees. + +#### Core API Design + +**Public types:** + +```go +// CompilationResult holds a compiled CEL program ready for evaluation. +type CompilationResult struct { + Program cel.Program + OutputType *cel.Type + Expression string +} + +// Compiler compiles CEL expressions against a specific environment. +type Compiler struct { /* ... */ } + +// CompilerOption configures a Compiler. +type CompilerOption func(*compilerConfig) +``` + +**Compilation pipeline:** + +Each `Compile*` call performs these steps sequentially: +1. Reject expressions exceeding `MaxExpressionLength` (10,240 chars). +2. Compile and type-check the expression via `cel-go`. +3. Validate output type matches the expected type (for typed variants). +4. Estimate cost using `defaultCostEstimator` with size hints โ€” reject if estimated max cost + exceeds the cost budget. +5. Create an optimized `cel.Program` with runtime cost limit. + +Presence tests (`has(field)`, `'key' in map`) have zero cost, matching Kubernetes CEL behavior. + +#### Variable Declarations + +Variables are declared via `VariableDeclaration{Name, Type}` and registered with `NewCompiler`. +Helper constructors provide pre-defined variable sets: + +**`IdentityVariables()`** โ€” the `identity` variable (from `connector.Identity`), +typed as `cel.ObjectType`: + +| Field | CEL Type | Source | +|-------|----------|--------| +| `identity.user_id` | `string` | `connector.Identity.UserID` | +| `identity.username` | `string` | `connector.Identity.Username` | +| `identity.preferred_username` | `string` | `connector.Identity.PreferredUsername` | +| `identity.email` | `string` | `connector.Identity.Email` | +| `identity.email_verified` | `bool` | `connector.Identity.EmailVerified` | +| `identity.groups` | `list(string)` | `connector.Identity.Groups` | + +**`RequestVariables()`** โ€” the `request` variable (from `RequestContext`), +typed as `cel.ObjectType`: + +| Field | CEL Type | +|-------|----------| +| `request.client_id` | `string` | +| `request.connector_id` | `string` | +| `request.scopes` | `list(string)` | +| `request.redirect_uri` | `string` | + +**`ClaimsVariable()`** โ€” the `claims` variable for raw upstream claims as `map(string, dyn)`. + +**Typing strategy:** + +`identity` and `request` use `cel.ObjectType` with explicitly declared fields. This gives +compile-time type checking: a typo like `identity.emial` is rejected at config load time +rather than silently evaluating to null in production โ€” critical for an auth system where a +misconfigured policy could lock users out. + +`claims` remains `map(string, dyn)` because its shape is genuinely unknown โ€” it carries +arbitrary upstream IdP data. + +#### Compatibility Guarantees + +Following the Kubernetes CEL compatibility model +([KEP-3488: CEL for Admission Control][kep-3488], [Kubernetes CEL Migration Guide][k8s-cel-compat]): + +1. **Environment versioning** โ€” The CEL environment is versioned. When new functions or variables + are added, they are introduced under a new environment version. Existing expressions compiled + against an older version continue to work. + + ```go + // EnvironmentVersion represents the version of the CEL environment. + // New variables, functions, or libraries are introduced in new versions. + type EnvironmentVersion uint32 + + const ( + // EnvironmentV1 is the initial CEL environment. + EnvironmentV1 EnvironmentVersion = 1 + ) + + // WithVersion sets the target environment version for the compiler. + func WithVersion(v EnvironmentVersion) CompilerOption + ``` + + This is directly modeled on `k8s.io/apiserver/pkg/cel/environment`. + +2. **Library stability** โ€” Custom functions in the `pkg/cel/library` subpackage follow these rules: + - Functions MUST NOT be removed once released. + - Function signatures MUST NOT change once released. + - New functions MUST be added under a new `EnvironmentVersion`. + - If a function needs to be replaced, the old one is deprecated but kept forever. + +3. **Type stability** โ€” CEL types (`Identity`, `Request`, `Claims`) follow the same rules: + - Fields MUST NOT be removed. + - Field types MUST NOT change. + - New fields are added in a new `EnvironmentVersion`. + +4. **Semantic versioning of `cel-go`** โ€” The `cel-go` dependency follows semver. Dex pins to a + minor version range and updates are tested for behavioral changes. This is exactly the approach + Kubernetes takes: `k8s.io/apiextensions-apiserver` pins `cel-go` and gates new features behind + environment versions. + +5. **Feature gates** โ€” New CEL-powered features are gated behind Dex feature flags (using the + existing `pkg/featureflags` mechanism) during their alpha phase. + +[kep-3488]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3488-cel-admission-control +[k8s-cel-compat]: https://kubernetes.io/docs/reference/using-api/cel/ + +#### Cost Estimation and Budgets + +Like Kubernetes, Dex CEL expressions must be bounded to prevent denial-of-service. + +**Constants:** + +| Constant | Value | Description | +|----------|-------|-------------| +| `DefaultCostBudget` | `10_000_000` | Max cost units per evaluation (aligned with Kubernetes) | +| `MaxExpressionLength` | `10_240` | Max expression string length in characters | +| `DefaultStringMaxLength` | `256` | Estimated max string size for cost estimation | +| `DefaultListMaxLength` | `100` | Estimated max list size for cost estimation | + +**How it works:** + +A `defaultCostEstimator` (implementing `checker.CostEstimator`) provides size hints for known +variables (`identity`, `request`, `claims`) so the `cel-go` cost estimator doesn't assume +unbounded sizes. It also provides call cost estimates for custom Dex functions +(`dex.emailDomain`, `dex.emailLocalPart`, `dex.groupMatches`, `dex.groupFilter`). + +Expressions are validated at three levels: +1. **Length check** โ€” reject expressions exceeding `MaxExpressionLength`. +2. **Compile-time cost estimation** โ€” reject expressions whose estimated max cost exceeds + the cost budget. +3. **Runtime cost limit** โ€” abort evaluation if actual cost exceeds the budget. + +#### Extension Libraries + +The `pkg/cel` environment includes these cel-go standard extensions (same set as Kubernetes): + +| Library | Description | Examples | +|---------|-------------|---------| +| `ext.Strings()` | Extended string functions | `"hello".upperAscii()`, `"foo:bar".split(':')`, `s.trim()`, `s.replace('a','b')` | +| `ext.Encoders()` | Base64 encoding/decoding | `base64.encode(bytes)`, `base64.decode(str)` | +| `ext.Lists()` | Extended list functions | `list.slice(1, 3)`, `list.flatten()` | +| `ext.Sets()` | Set operations on lists | `sets.contains(a, b)`, `sets.intersects(a, b)`, `sets.equivalent(a, b)` | +| `ext.Math()` | Math functions | `math.greatest(a, b)`, `math.least(a, b)` | + +Plus custom Dex libraries in the `pkg/cel/library` subpackage, each implementing the +`cel.Library` interface: + +**`library.Email`** โ€” email-related helpers: + +| Function | Signature | Description | +|----------|-----------|-------------| +| `dex.emailDomain` | `(string) -> string` | Returns the domain portion of an email address. `dex.emailDomain("user@example.com") == "example.com"` | +| `dex.emailLocalPart` | `(string) -> string` | Returns the local part of an email address. `dex.emailLocalPart("user@example.com") == "user"` | + +**`library.Groups`** โ€” group-related helpers: + +| Function | Signature | Description | +|----------|-----------|-------------| +| `dex.groupMatches` | `(list(string), string) -> list(string)` | Returns groups matching a glob pattern. `dex.groupMatches(identity.groups, "team:*")` | +| `dex.groupFilter` | `(list(string), list(string)) -> list(string)` | Returns only groups present in the allowed list. `dex.groupFilter(identity.groups, ["admin", "ops"])` | + +#### Example: Compile and Evaluate + +```go +// 1. Create a compiler with identity and request variables +compiler, _ := cel.NewCompiler( + append(cel.IdentityVariables(), cel.RequestVariables()...), +) + +// 2. Compile a policy expression (type-checked, cost-estimated) +prog, _ := compiler.CompileBool( + `identity.email.endsWith('@example.com') && 'admin' in identity.groups`, +) + +// 3. Evaluate against real data +result, _ := cel.EvalBool(ctx, prog, map[string]any{ + "identity": cel.IdentityFromConnector(connectorIdentity), + "request": cel.RequestFromContext(cel.RequestContext{...}), +}) +// result == true +``` + +### Phase 2: Authentication Policies + +**Config Model:** + +```go +// AuthPolicy is a list of deny expressions evaluated after a user +// authenticates with a connector. Each expression evaluates to bool. +// If true โ€” the request is denied. Evaluated in order; first match wins. +type AuthPolicy []PolicyExpression + +// PolicyExpression is a CEL expression with an optional human-readable message. +type PolicyExpression struct { + // Expression is a CEL expression that evaluates to bool. + Expression string `json:"expression"` + // Message is a CEL expression that evaluates to string (displayed to the user on deny). + // If empty, a generic message is shown. + Message string `json:"message,omitempty"` +} +``` + +**Evaluation point:** After `connector.CallbackConnector.HandleCallback()` or +`connector.PasswordConnector.Login()` returns an identity, and before the auth request is +finalized. Implemented in `server/handlers.go` at `handleConnectorCallback`. + +**Available CEL variables:** `identity` (from connector), `request` (client_id, connector_id, +scopes, redirect_uri). + +**Compilation:** All policy expressions are compiled once at config load time (in +`cmd/dex/serve.go`) and stored in the `Server` struct. This ensures: +- Syntax/type errors are caught at startup, not at runtime. +- No compilation overhead per request. +- Cost estimation can warn operators about expensive expressions at startup. + +**Evaluation flow:** + +``` +User authenticates via connector + โ”‚ + v +connector.HandleCallback() returns Identity + โ”‚ + v +Evaluate global authPolicy (in order) + - For each expression: evaluate โ†’ bool + - If true โ†’ deny with message, HTTP 403 + โ”‚ + v +Evaluate per-client authPolicy (in order) + - Same logic as global + โ”‚ + v +Continue normal flow (approval screen or redirect) +``` + +### Phase 3: Token Policies + +**Config Model:** + +```go +// TokenPolicy defines policies for token issuance. +type TokenPolicy struct { + // Claims adds or overrides claims in the issued ID token. + Claims []ClaimExpression `json:"claims,omitempty"` + // Filter validates the token request. If expression evaluates to false, + // the request is denied. + Filter *PolicyExpression `json:"filter,omitempty"` +} + +type ClaimExpression struct { + // Key is a CEL expression evaluating to string โ€” the claim name. + Key string `json:"key"` + // Value is a CEL expression evaluating to dyn โ€” the claim value. + Value string `json:"value"` + // Condition is an optional CEL expression evaluating to bool. + // When set, the claim is only included in the token if the condition + // evaluates to true. If omitted, the claim is always included. + Condition string `json:"condition,omitempty"` +} +``` + +**Evaluation point:** In `server/oauth2.go` during ID token construction, after standard +claims are built but before JWT signing. + +**Available CEL variables:** `identity`, `request`, `existing_claims` (the standard claims already +computed as `map(string, dyn)`). + +**Claim merge order:** +1. Standard Dex claims (sub, iss, aud, email, groups, etc.) +2. Global `tokenPolicy.claims` evaluated and merged +3. Per-client `tokenPolicy.claims` evaluated and merged (overrides global) + +**Reserved (forbidden) claim names:** + +Certain claim names are reserved and MUST NOT be set or overridden by CEL token policy +expressions. Attempting to use a reserved claim key will result in a config validation error at +startup. This prevents operators from accidentally breaking the OIDC/OAuth2 contract or +undermining Dex's security guarantees. + +```go +// ReservedClaimNames is the set of claim names that CEL token policy +// expressions are forbidden from setting. These are core OIDC/OAuth2 claims +// managed exclusively by Dex. +var ReservedClaimNames = map[string]struct{}{ + "iss": {}, // Issuer โ€” always set by Dex to its own issuer URL + "sub": {}, // Subject โ€” derived from connector identity, must not be spoofed + "aud": {}, // Audience โ€” determined by the OAuth2 client, not policy + "exp": {}, // Expiration โ€” controlled by Dex token TTL configuration + "iat": {}, // Issued At โ€” set by Dex at signing time + "nbf": {}, // Not Before โ€” set by Dex at signing time + "jti": {}, // JWT ID โ€” generated by Dex for token revocation/uniqueness + "auth_time": {}, // Authentication Time โ€” set by Dex from the auth session + "nonce": {}, // Nonce โ€” echoed from the client's authorization request + "at_hash": {}, // Access Token Hash โ€” computed by Dex from the access token + "c_hash": {}, // Code Hash โ€” computed by Dex from the authorization code +} +``` + +The reserved list is enforced in two places: +1. **Config load time** โ€” When compiling token policy `ClaimExpression` entries, Dex statically + evaluates the `Key` expression (which must be a string literal or constant-foldable) and rejects + it if the result is in `ReservedClaimNames`. +2. **Runtime (defense in depth)** โ€” Before merging evaluated claims into the ID token, Dex checks + each key against `ReservedClaimNames` and logs a warning + skips the claim if it matches. This + guards against dynamic key expressions that couldn't be statically checked. + +### Phase 4: OIDC Connector Claim Mapping + +**Config Model:** + +In `connector/oidc/oidc.go`: + +```go +type Config struct { + // ... existing fields ... + + // ClaimMappingExpressions provides CEL-based claim mapping. + // When set, these take precedence over ClaimMapping and ClaimMutations. + ClaimMappingExpressions *ClaimMappingExpression `json:"claimMappingExpressions,omitempty"` +} + +type ClaimMappingExpression struct { + // Username is a CEL expression evaluating to string. + // Available variable: 'claims' (map of upstream claims). + Username string `json:"username,omitempty"` + // Email is a CEL expression evaluating to string. + Email string `json:"email,omitempty"` + // Groups is a CEL expression evaluating to list(string). + Groups string `json:"groups,omitempty"` + // EmailVerified is a CEL expression evaluating to bool. + EmailVerified string `json:"emailVerified,omitempty"` + // Extra is a map of claim names to CEL expressions evaluating to dyn. + // These are carried through to token policies. + Extra map[string]string `json:"extra,omitempty"` +} +``` + +**Available CEL variable:** `claims` โ€” `map(string, dyn)` containing all raw upstream claims from +the ID token and/or UserInfo endpoint. + +This replaces the need for `ClaimMapping`, `NewGroupFromClaims`, `FilterGroupClaims`, and +`ModifyGroupNames` with a single, more powerful mechanism. + +**Backward compatibility:** When `claimMappingExpressions` is nil, the existing `ClaimMapping` and +`ClaimMutations` logic is used unchanged. When `claimMappingExpressions` is set, a startup warning is +logged if legacy mapping fields are also configured. + +### Policy Application Flow + +The following diagram shows the order in which CEL policies are applied. +Each step is optional โ€” if not configured, it is skipped. + +``` +Connector Authentication + โ”‚ + โ”‚ upstream claims โ†’ connector.Identity + โ”‚ + v +Authentication Policies + โ”‚ + โ”‚ Global authPolicy + โ”‚ Per-client authPolicy + โ”‚ + v +Token Issuance + โ”‚ + โ”‚ Global tokenPolicy.filter + โ”‚ Per-client tokenPolicy.filter + โ”‚ + โ”‚ Global tokenPolicy.claims + โ”‚ Per-client tokenPolicy.claims + โ”‚ + โ”‚ Sign JWT + โ”‚ + v +Token Response +``` + +| Step | Policy | Scope | Action on match | +|------|--------|-------|-----------------| +| 2 | `authPolicy` (global) | Global | Expression โ†’ `true` = DENY login | +| 3 | `authPolicy` (per-client) | Per-client | Expression โ†’ `true` = DENY login | +| 4 | `tokenPolicy.filter` (global) | Global | Expression โ†’ `false` = DENY token | +| 5 | `tokenPolicy.filter` (per-client) | Per-client | Expression โ†’ `false` = DENY token | +| 6 | `tokenPolicy.claims` (global) | Global | Adds/overrides claims (with optional condition) | +| 7 | `tokenPolicy.claims` (per-client) | Per-client | Adds/overrides claims (overrides global) | + +### Risks and Mitigations + +| Risk | Mitigation | +|------|------------| +| **CEL expression complexity / DoS** | Cost budgets with configurable limits (default aligned with Kubernetes). Expressions are validated at config load time. Runtime evaluation is aborted if cost exceeds budget. | +| **Learning curve for operators** | CEL has excellent documentation, playground ([cel.dev](https://cel.dev)), and massive CNCF adoption. Dex docs will include a dedicated CEL guide with examples. Most operators already know CEL from Kubernetes. | +| **`cel-go` dependency size** | `cel-go` adds ~5MB to binary. This is acceptable for the functionality provided. Kubernetes, Istio, Envoy all accept this trade-off. | +| **Breaking changes in `cel-go`** | Pin to semver minor range. Environment versioning ensures existing expressions continue to work across upgrades. | +| **Security: CEL expression injection** | CEL expressions are defined by operators in the server config, not by end users. No CEL expression is ever constructed from user input at runtime. | +| **Config migration** | Old config fields (`ClaimMapping`, `ClaimMutations`) continue to work. CEL expressions are opt-in. If both are specified, CEL takes precedence with a config-time warning. | +| **Error messages exposing internals** | CEL deny `message` expressions are controlled by the operator. Default messages are generic. Evaluation errors are logged server-side, not exposed to end users. | +| **Performance** | Expressions are compiled once at startup. Evaluation is sub-millisecond for typical identity operations. Cost budgets prevent pathological cases. Benchmarks will be included in `pkg/cel` tests. | + +### Alternatives + +#### OPA/Rego + +OPA was previously considered ([#1635], token exchange DEP). While powerful, it has significant +drawbacks for Dex: + +- **Separate daemon** โ€” OPA typically runs as a sidecar or daemon; adds operational complexity. + Even the embedded Go library (`github.com/open-policy-agent/opa/rego`) is significantly + heavier than `cel-go`. +- **Rego learning curve** โ€” Rego is a Datalog-derived language unfamiliar to most developers. + CEL syntax is closer to C/Java/Go and is immediately readable. +- **Overkill** โ€” Dex needs simple expression evaluation, not a full policy engine with data + loading, bundles, and partial evaluation. +- **No inline expressions** โ€” Rego policies are typically separate files, not inline config + expressions. This makes the config harder to understand and deploy. +- **Smaller CNCF footprint for embedding** โ€” While OPA is a graduated CNCF project, CEL has + broader adoption as an _embedded_ language (Kubernetes, Istio, Envoy, Kyverno, etc.). + +#### JMESPath + +JMESPath was proposed for claim mapping. Drawbacks: + +- **Query-only** โ€” JMESPath is a JSON query language. It cannot express boolean conditions, + mutations, or string operations naturally. +- **Limited type system** โ€” No type checking at compile time. Errors are only caught at runtime. +- **Small ecosystem** โ€” Limited adoption compared to CEL. No CNCF projects use JMESPath for + policy evaluation. +- **No cost estimation** โ€” No way to bound execution time. + +#### Hardcoded Go Logic + +The current approach: each feature requires new Go structs, config fields, and code. This is +unsustainable: +- `ClaimMapping`, `NewGroupFromClaims`, `FilterGroupClaims`, `ModifyGroupNames` are each separate + features that could be one CEL expression. +- Every new policy need requires a Dex code change and release. +- Combinatorial explosion of config options. + +#### No Change + +Without CEL or an equivalent: +- Operators continue to request per-client connector restrictions, custom claims, claim + transformations, and access policies โ€” issues remain open indefinitely. +- Dex accumulates more ad-hoc config fields, increasing maintenance burden. +- Complex use cases require external reverse proxies, forking Dex, or middleware. + +## Future Improvements + +- **CEL in other connectors** โ€” Extend CEL claim mapping beyond OIDC to LDAP (attribute mapping), + SAML (assertion mapping), and other connectors with complex attribute mapping needs. +- **Policy testing framework** โ€” Unit test framework for operators to validate their CEL + expressions against fixture data before deployment. +- **Connector selection via CEL** โ€” Replace the static connector-per-client mapping with a CEL + expression that dynamically determines which connectors to show based on request attributes. + + diff --git a/docs/enhancements/id-jag-2026-03-02#4600.md b/docs/enhancements/id-jag-2026-03-02#4600.md new file mode 100644 index 0000000000..231921c883 --- /dev/null +++ b/docs/enhancements/id-jag-2026-03-02#4600.md @@ -0,0 +1,283 @@ +# Dex Enhancement Proposal (DEP) 4600 - 2026-03-02 - Identity Assertion JWT Authorization Grant (ID-JAG) + +## Table of Contents + +- [Dex Enhancement Proposal (DEP) 4600 - 2026-03-02 - Identity Assertion JWT Authorization Grant (ID-JAG)](#dex-enhancement-proposal-dep-4600---2026-03-02---identity-assertion-jwt-authorization-grant-id-jag) + - [Table of Contents](#table-of-contents) + - [Summary](#summary) + - [Context](#context) + - [Motivation](#motivation) + - [Goals/Pain](#goalspain) + - [Non-goals](#non-goals) + - [Proposal](#proposal) + - [User Experience](#user-experience) + - [Implementation Details/Notes/Constraints](#implementation-detailsnotesconstraints) + - [Observability](#observability) + - [Risks and Mitigations](#risks-and-mitigations) + - [Alternatives](#alternatives) + - [Future Improvements](#future-improvements) + +## Summary + +[draft-ietf-oauth-identity-assertion-authz-grant-02] specifies a mechanism +for an application to use an identity assertion to obtain an access token +for a third-party API by coordinating through a common enterprise identity +provider using Token Exchange [RFC 8693] and JWT Profile for OAuth 2.0 +Authorization Grants [RFC 7523]. + +This DEP proposes to extend Dex's existing Token Exchange implementation +to support issuing Identity Assertion JWT Authorization Grants (ID-JAGs), +enabling cross-domain access managed by the enterprise IdP. + +[draft-ietf-oauth-identity-assertion-authz-grant-02]: https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/ + +## Context + +- [#2812 DEP for RFC 8693 OAuth 2 Token Exchange] + established the Token Exchange foundation that ID-JAG builds upon. +- [draft-ietf-oauth-identity-assertion-authz-grant-02] + is the IETF Standards Track specification this DEP implements. +- [draft-ietf-oauth-identity-chaining] + is the broader identity chaining specification that ID-JAG profiles. + +The specification is authored by A. Parecki (Okta), K. McGuinness, and +B. Campbell (Ping Identity). It is actively being developed within the +IETF OAuth Working Group. + +Use cases: + +- LLM agents accessing enterprise APIs on behalf of users (Appendix A.3 of the spec) +- Enterprise applications embedding content from third-party apps +- Email/calendaring applications accessing cross-domain resources + +Real-world adoption: + +- [Okta Cross App Access] is GA, implementing ID-JAG for SaaS-to-SaaS and + AI agent scenarios with a developer tutorial available. +- [Okta AI Agent Token Exchange] (Early Access) uses ID-JAG for AI agents + accessing enterprise APIs on behalf of authenticated users. +- [Keycloak #43971] tracks ID-JAG support as a feature request. +- The upcoming MCP (Model Context Protocol) specification references ID-JAG + for AI agent authorization flows. + +[#2812 DEP for RFC 8693 OAuth 2 Token Exchange]: https://github.com/dexidp/dex/pull/2812 +[draft-ietf-oauth-identity-chaining]: https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/ +[Okta Cross App Access]: https://developer.okta.com/blog/2026/02/10/xaa-client +[Okta AI Agent Token Exchange]: https://developer.okta.com/docs/guides/ai-agent-token-exchange/authserver/main/ +[Keycloak #43971]: https://github.com/keycloak/keycloak/issues/43971 + +## Motivation + +### Goals/Pain + +In enterprise environments, applications are configured for SSO through +a common IdP. When one application needs to access a user's data at another +application, the current approach requires either: + +1. A direct OAuth flow between apps (bypassing the IdP's visibility and policy) +2. Static API keys or service accounts (security risk) + +ID-JAG solves this by letting the IdP broker cross-domain access, maintaining +visibility and policy control. + +**Specific goals:** + +- Issue ID-JAG tokens via Token Exchange (`requested_token_type=urn:ietf:params:oauth:token-type:id-jag`) +- Support `audience` and `resource` parameters per the specification +- Validate subject token audience against requesting client +- Support configurable policy evaluation for token exchange requests + +### Non-goals + +- Implementing the Resource Authorization Server role (JWT Bearer Grant / RFC 7523) + is out of scope for this initial DEP. It may be addressed in a follow-up. +- SAML assertion support as `subject_token_type` is deferred. +- Step-up authentication flow is deferred. + +## Proposal + +### User Experience + +End-to-end flow: + +```mermaid +sequenceDiagram + participant C as Client (Wiki App) + participant Dex as Dex (IdP AS) + participant RAS as Resource AS (Chat AS) + participant RS as Resource Server (Chat API) + + C->>Dex: 1. OIDC Authentication (authorization_code flow) + Dex-->>C: ID Token + optional Refresh Token + + C->>Dex: 2. Token Exchange (grant_type=token-exchange)
subject_token=ID Token
requested_token_type=id-jag
audience=https://acme.chat.example/
connector_id=google + Note over Dex: Validate subject_token aud == client_id
Evaluate policy (clientID โ†’ allowed audiences)
Issue ID-JAG JWT (typ: oauth-id-jag+jwt) + Dex-->>C: ID-JAG (access_token, token_type=N_A, expires_in=300) + + C->>RAS: 3. JWT Bearer Grant (RFC 7523)
grant_type=jwt-bearer, assertion=ID-JAG + Note over RAS: Validate ID-JAG signature (Dex JWKS)
Validate aud == RAS issuer
Issue access token + RAS-->>C: Access Token + + C->>RS: 4. API Request with Access Token + RS-->>C: Protected Resource +``` + +Clients can request ID-JAG tokens from Dex's `/token` endpoint by specifying +`requested_token_type=urn:ietf:params:oauth:token-type:id-jag` in a +Token Exchange request. ID-JAG support is enabled by adding +`urn:ietf:params:oauth:token-type:id-jag` to `oauth2.tokenExchange.tokenTypes`. +When not listed, requests with this `requested_token_type` are rejected, +ensuring no change in behavior for existing deployments. + +The request parameters (extending existing Token Exchange): + +- `grant_type`: REQUIRED - `urn:ietf:params:oauth:grant-type:token-exchange` +- `subject_token`: REQUIRED - the identity assertion (OpenID Connect ID Token) +- `subject_token_type`: REQUIRED - `urn:ietf:params:oauth:token-type:id_token`. + SAML 2.0 (`urn:ietf:params:oauth:token-type:saml2`) is deferred (see Non-goals). +- `requested_token_type`: REQUIRED - `urn:ietf:params:oauth:token-type:id-jag` +- `audience`: REQUIRED - the Issuer URL of the Resource Authorization Server. + **Note**: The existing Token Exchange implementation uses a Dex-specific `connector_id` + parameter (not part of RFC 8693) for connector selection. The `audience` parameter was + not used in the current implementation despite DEP #2812 originally proposing it for + connector identification. ID-JAG introduces `audience` with its standard RFC 8693 + meaning (target Resource AS). This is purely additive and does not affect existing + Token Exchange requests. +- `connector_id`: REQUIRED (Dex extension) - the ID of the Dex connector to verify the + subject token against. The connector validates the token (issuer, signature, etc.), + so a mismatched token is rejected. This parameter already exists in the current + Token Exchange implementation and is reused as-is. +- `resource`: OPTIONAL - the Resource Identifier of the Resource Server +- `scope`: OPTIONAL - the requested scopes at the Resource Server + +The response: + +- `access_token`: the ID-JAG JWT (named `access_token` for RFC 8693 compatibility) +- `issued_token_type`: `urn:ietf:params:oauth:token-type:id-jag` +- `token_type`: `N_A` (this is not an OAuth access token) +- `expires_in`: lifetime in seconds (default: 300, configurable independently of ID token + lifetime via `expiry.idJAGTokens`) +- `scope`: OPTIONAL if the issued scope is identical to the requested scope; REQUIRED + otherwise. Per Section 4.3.2 of the specification, policy evaluation at the IdP may + result in different scopes being issued than were requested. + +Complete configuration example: + +```yaml +oauth2: + grantTypes: + - authorization_code + - urn:ietf:params:oauth:grant-type:token-exchange + tokenExchange: + # List of token types enabled for exchange. Adding id-jag enables ID-JAG support. + # Omitting it (default) disables ID-JAG without affecting other token exchange flows. + # SAML2 (urn:ietf:params:oauth:token-type:saml2) may be added in a future release. + tokenTypes: + - urn:ietf:params:oauth:token-type:id_token + - urn:ietf:params:oauth:token-type:id-jag + +expiry: + idTokens: "24h" + idJAGTokens: "5m" # default: 5m; independent of idTokens + +staticClients: + - id: wiki-app + name: "Wiki Application" + secret: "wiki-secret" + redirectURIs: + - "https://wiki.example/callback" + # Per-client ID-JAG policy. Clients without this section cannot obtain ID-JAG tokens + # (default-deny). Only audiences and scopes listed here may be requested. + idJAGPolicies: + allowedAudiences: + - "https://chat.example/" + - "https://calendar.example/" + allowedScopes: + - "chat.read" + - "calendar.read" + + - id: supermarket-app + name: "Supermarket Application" + secret: "supermarket-secret" + redirectURIs: + - "https://supermarket.example/callback" + idJAGPolicies: + allowedAudiences: + - "https://grocery.store.1/" + - "https://grocery.store.2/" + allowedScopes: + - "eat.bananas" + - "eat.apples" +``` + +### Implementation Details/Notes/Constraints + +- A new `id-jag` branch is added to the existing Token Exchange flow, issuing a signed JWT + per Section 3 of the specification (header `typ: "oauth-id-jag+jwt"`, claims including + `iss`, `sub`, `aud`, `client_id`, `jti`, `exp`, `iat`). + +- Per-client `idJAGPolicies` in `staticClients` control which audiences and scopes a + given client may request in an ID-JAG. Clients without `idJAGPolicies` are denied + by default. Dynamically registered clients are currently unsupported for ID-JAG policies; + support via CEL expressions (building on the CEL infrastructure from #4601) is future work. + +- OIDC discovery is extended with `identity_chaining_requested_token_types_supported` per + Section 7 of the specification. When ID-JAG is enabled, Dex includes + `urn:ietf:params:oauth:token-type:id-jag` in this metadata property. + +- ID-JAG support is enabled by listing `urn:ietf:params:oauth:token-type:id-jag` in + `oauth2.tokenExchange.tokenTypes`. When not listed (default), requests are rejected, + ensuring no change in behavior for existing deployments. + +### Observability + +- Every ID-JAG token exchange request (issued or rejected) emits a structured log entry + with `client_id`, `connector_id`, `audience`, `resource` (if present), requested and + granted `scope` (these may differ after policy evaluation), `sub`, `jti` (if issued), + and the policy decision (`approved`/`denied` with reason like `audience_not_allowed` + or `client_has_no_policy`). + +- The following Prometheus counters are exposed: + - `dex_id_jag_requests_total` (labels: `result`) โ€” issued vs rejected + - `dex_id_jag_policy_rejections_total` (labels: `reason`) โ€” + breakdown by denial reason, useful for spotting misconfigurations or abuse + - `dex_id_jag_scope_modifications_total` โ€” cases where policy reduced the requested scopes + +### Risks and Mitigations + +- **Lateral movement risk**: Same as existing Token Exchange. Mitigated by + not issuing refresh tokens, short expiry (5 min recommended), and + policy-based audience restrictions. +- **Token confusion**: The `typ: "oauth-id-jag+jwt"` header and distinct + `issued_token_type` prevent confusion with ID Tokens or access tokens. +- **Replay attack risk**: Server-side `jti` tracking is deferred, so a stolen ID-JAG + can be replayed within its 5-minute lifetime. Short `expires_in` is the only Dex-side + mitigation; Resource Authorization Servers should implement `jti` caching independently. +- **Public client misuse**: Per Section 8.1 of the specification, ID-JAG SHOULD only be + used by confidential clients. Public clients should use the standard authorization code + flow with interactive user consent at the Resource Authorization Server. Dex will enforce + this by rejecting ID-JAG requests from public clients (clients without a secret). +- **Breaking changes**: None. This is purely additive to the existing + Token Exchange implementation. The `audience` parameter is newly introduced + (not previously used in the implementation despite DEP #2812's original proposal), + and `connector_id` already exists. + +### Alternatives + +- **Wait for spec finalization**: The draft is Standards Track and stable enough + to implement. Okta and Ping Identity (the spec authors) already ship implementations, + and the spec has been adopted by the IETF OAuth WG. +- **External policy engine (OPA/CEL)**: Config-based policies are sufficient for now. + The CEL infrastructure (#4601) is merged; ID-JAG policy evaluation via CEL is future work. + +## Future Improvements + +- Resource Authorization Server role (JWT Bearer Grant / RFC 7523) + accepting ID-JAGs from external IdPs +- SAML 2.0 assertion support as `subject_token_type` + (`urn:ietf:params:oauth:token-type:saml2`) +- CEL-based ID-JAG policy evaluation (building on #4601) enabling dynamic policies for + DB-managed clients, including runtime policy changes without restart +- Step-up authentication when authentication context is insufficient +- `actor_token` support for delegation scenarios +- Server-side `jti` tracking to prevent ID-JAG replay attacks diff --git a/docs/enhancements/token-exchange-2023-02-03-#2812.md b/docs/enhancements/token-exchange-2023-02-03-#2812.md new file mode 100644 index 0000000000..f9f556d26e --- /dev/null +++ b/docs/enhancements/token-exchange-2023-02-03-#2812.md @@ -0,0 +1,175 @@ +# Dex Enhancement Proposal (DEP) 2812 - 2023-02-03 - Token Exchange + +## Table of Contents + +- [Summary](#summary) +- [Motivation](#motivation) + - [Goals/Pain](#goals) + - [Non-Goals](#non-goals) +- [Proposal](#proposal) + - [User Experience](#user-experience) + - [Implementation Details/Notes/Constraints](#implementation-detailsnotesconstraints) + - [Risks and Mitigations](#risks-and-mitigations) + - [Alternatives](#alternatives) +- [Future Improvements](#future-improvements) + +## Summary + +[RFC 8693] specifies a new OAuth2 `grant_type` of `urn:ietf:params:oauth:grant-type:token-exchange`. +Using this grant type, when clients start an authentication flow with Dex, +in lieu of being redirected to their upstream IDP for authentication on demand, +clients can present an independently obtained, valid token from their IDP to Dex. +This is primarily useful in fully automated environments with job/machine identities, +where there is no human in the loop to handle browser-based login flows. +This DEP proposes to implement the new grant type for Dex. + +[RFC 8693]: https://www.rfc-editor.org/rfc/rfc8693.html + +## Context + +- [#1668 Question: non-web based clients?] + was closed with no real resolution +- [#1484 Token exchange for external tokens] + mentions that Keycloak has a similar capability +- [#2657 Get OIDC token issued by Dex using a token issued by one of the connectors] + is similar to the previous issue, but this time links to the new (January 2020) [RFC 8693]. + +I believe the context for all of these are similar: +a downstream project using Dex as its only IDP wants to grant access to programmatic clients +without issuing long lived API tokens. + +Examples of downstream issues: + +- [argoproj/argo-cd#11632 ArgoCD SSO login via Azure AD Auth using OIDC not work for cli sso login] + +Other related Dex issues: + +- [#2450 Non-OIDC JWT Connector] is a functionally similar request, but expanded to arbitrary JWTs +- [#1225 GitHub Non-Web application flow support] also asks for an exchange, but for an opaque GitHub PAT + +More broadly, this fits into recent movements to issue machine identities: + +- [GCP Service Identity](https://cloud.google.com/run/docs/securing/service-identity) +- [AWS Execution Role](https://docs.aws.amazon.com/lambda/latest/dg/lambda-intro-execution-role.html) +- [GitHub Actions OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) +- [CircleCI OIDC](https://circleci.com/docs/openid-connect-tokens/) +- [Kubernetes Service Accounts](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/) +- [SPIFFE](https://spiffe.io/) + +and granting access to resources based on trusting federated identities: + +- [GCP Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation) +- [AWS STS AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html) + +[#1484 Token exchange for external tokens]: https://github.com/dexidp/dex/issues/1484 +[#1668 Question: non-web based clients?]: https://github.com/dexidp/dex/issues/1668 +[#2657 Get OIDC token issued by Dex using a token issued by one of the connectors]: https://github.com/dexidp/dex/issues/2657 +[argoproj/argo-cd#11632 ArgoCD SSO login via Azure AD Auth using OIDC not work for cli sso login]: https://github.com/argoproj/argo-cd/issues/11632 +[#2450 Non-OIDC JWT Connector]: https://github.com/dexidp/dex/issues/2450 +[#1225 GitHub Non-Web application flow support]: https://github.com/dexidp/dex/issues/1225 + +An initial attempt is at [#2806](https://github.com/dexidp/dex/pull/2806) + +## Motivation + +### Goals/Pain + +The goal is to allow programmatic access to Dex-protected resources +without the use of static/long-lived secret tokens (API keys, username/password) +or web-based redirect flows. +Such scenarios are common in CI/CD workflows, +and in general automation of common tasks. + +### Non-goals + +- Work will be scoped to just the OIDC connector +- [RFC 8693 Section 2.1.1. Relationship between Resource, Audience, and Scope] + details more complex authorization checks based on targeted resources. + This is considered out of scope. + +[RFC 8693 Section 2.1.1. Relationship between Resource, Audience, and Scope]: https://www.rfc-editor.org/rfc/rfc8693.html#name-relationship-between-resour + +## Proposal + +### User Experience + +Clients can make `POST` requests with `application/x-www-form-urlencoded` +parameters as specified by [RFC 8693] to Dex's `/token` endpoint. +If successful, an access token will be returned, +allowing direct authentication with Dex. +No refresh tokens will be issued, +perform a new exchange (possibly with refreshed upstream tokens) to obtain a new access token. + +The request parameters from [RFC 8693 Section 2.1](https://www.rfc-editor.org/rfc/rfc8693.html#name-request): + +- `grant_type`: REQUIRED - `urn:ietf:params:oauth:grant-type:token-exchange` +- `resource`: OPTIONAL - the `audience` in the issued Dex token +- `audience`: REQUIRED (RFC OPTIONAL) - the connector to verify the provided token against +- `scope`: OPTIONAL - the `scope` in the issued Dex token +- `requested_token_type`: OPTIONAL - one of `urn:ietf:params:oauth:token-type:access_token` or `urn:ietf:params:oauth:token-type:id_token`, defaulting to access token +- `subject_token`: REQUIRED - the token issued by the upstream IDP +- `subject_token_type`: REQUIRED - `urn:ietf:params:oauth:token-type:id_token` or `urn:ietf:params:oauth:token-type:access_token` if `getUserInfo` is `true`. +- `actor_token`: OPTIONAL - unused +- `actor_token_type`: OPTIONAL - unused + +The response parameters from [RFC 8693 Section 2.2](https://www.rfc-editor.org/rfc/rfc8693.html#name-response): + +- `access_token`: the issued token, the field is called `access_token` for legacy reasons +- `issued_token_type`: the actual type of the issued token +- `token_type`: the value `Bearer` +- `expires_in`: validity lifetime in seconds +- `scope`: the requested scope +- `refresh_token`: unused + +The connector only needs to be configured with an issuer, +no client ID / client secrets are necessary + +```yaml +connectors: +- type: oidc + id: my-platform + name: My Platform + config: + issuer: https://oidc.my-platform.example/ +``` + +We expose a global and connector setting, +`allowedGrantTypes: []string` defaulting to all implemented types. + +### Implementation Details/Notes/Constraints + +- Connectors expose a new interface `TokenIdentity` that will verify the given token and return the associated identity. + A Dex access/id token is then minted for the given identity. + +- `actor_token` and `actor_token_type` are "MUST ... if the actor token is present, + also perform the appropriate validation procedures for its indicated token type". + We will ignore these fields for the initial implementation. + + +### Risks and Mitigations + +With token exchanges (sometimes known as identity impersonation), +is they allow for easier lateral movement if an attacker gains access to an upstream token. +We limit the potential impact by not issuing refresh tokens, preventing persistent access. +Combined with short token lifetimes, it should limit the period of time between authentication to upstream IDPs. +Additionally, a new `allowedGrantTypes` would allow for disabling exchanges if the functionality isn't needed. + +### Alternatives + +- Continue to use static keys - + this is a secret management nightmare + and quite painful when client storage of keys is [breached](https://circleci.com/blog/january-4-2023-security-alert/) + +## Future Improvements + +- Other connectors may wish to implement the same capability under Oauth +- The password connector could be switch to support this new endpoint, submitting passwords as access tokens, + allowing for multiple password connectors to be configured +- The `audience` field could be made optional if there is a single connector or the id token is inspected for issuer url +- The `actor_token` and `actor_token_type` can be checked / validated if a suitable use case is determined. +- A policy language like [cel] or [rego] as mentioned on [#1635 Connector Middleware] + would allow for stronger assertions of the provided identity against requested resource access. + +[cel]: https://github.com/google/cel-go +[rego]: https://www.openpolicyagent.org/docs/latest/policy-language/ +[#1635 Connector Middleware]: https://github.com/dexidp/dex/issues/1635 diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index bf11570a4e..cc576261a8 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -52,12 +52,25 @@ web: # https: 127.0.0.1:5554 # tlsCert: /etc/dex/tls.crt # tlsKey: /etc/dex/tls.key + # headers: + # X-Frame-Options: "DENY" + # X-Content-Type-Options: "nosniff" + # X-XSS-Protection: "1; mode=block" + # Content-Security-Policy: "default-src 'self'" + # Strict-Transport-Security: "max-age=31536000; includeSubDomains" + # The header is read only for requests arriving from one of trustedProxies; + # without them any client could spoof it, so it is ignored. + # clientRemoteIP: + # header: X-Forwarded-For + # trustedProxies: + # - 10.0.0.0/8 # Configuration for dex appearance # frontend: # issuer: dex # logoURL: theme/logo.png # dir: web/ +# Allowed values: light, dark # theme: light # Configuration for telemetry @@ -67,8 +80,8 @@ telemetry: # Uncomment this block to enable the gRPC API. This values MUST be different # from the HTTP endpoints. -# grpc: -# addr: 127.0.0.1:5557 +grpc: + addr: 127.0.0.1:5557 # tlsCert: examples/grpc-client/server.crt # tlsKey: examples/grpc-client/server.key # tlsClientCA: examples/grpc-client/ca.crt @@ -77,31 +90,110 @@ telemetry: # Is possible to specify units using only s, m and h suffixes. # expiry: # deviceRequests: "5m" -# signingKeys: "6h" +# signingKeys: "6h" # deprecated, use signer.config.keysRotationPeriod # idTokens: "24h" # refreshTokens: # reuseInterval: "3s" # validIfNotUsedFor: "2160h" # 90 days # absoluteLifetime: "3960h" # 165 days +# Authentication sessions configuration. +# Requires DEX_SESSIONS_ENABLED=true feature flag. +# +# Enabled here so that single sign-on works out of the box: without it every +# authorization is a fresh login, the home page has no session to describe, and +# prompt=none requests โ€” how a client asks whether you are still signed in โ€” +# always answer no. +sessions: + cookieName: "dex_session" + absoluteLifetime: "24h" + validIfNotUsedFor: "1h" +# rememberMeCheckedByDefault: false +# # Default SSO sharing policy for clients without explicit ssoSharedWith. +# # "all" = share with all clients (Keycloak-like), "none" = no sharing (default). +# ssoSharedWithDefault: "none" + # Options for controlling the logger. # logger: # level: "debug" # format: "text" # can also be "json" +# Enabled so the example app can exercise the grants dex implements. The +# password grant needs passwordConnector to name the connector that checks it; +# without that it is refused however the grant types are listed. +oauth2: + # Listed explicitly so none of them depends on a default: the example app + # draws a flow only when the provider advertises the grant behind it. + grantTypes: + - "authorization_code" + - "refresh_token" + - "client_credentials" + - "urn:ietf:params:oauth:grant-type:device_code" + - "urn:ietf:params:oauth:grant-type:token-exchange" + - "password" + # The password grant is refused without this: dex needs to know which + # connector checks the password. + passwordConnector: local + # Default values shown below # oauth2: - # use ["code", "token", "id_token"] to enable implicit flow for web-only clients +# # grantTypes determines the allowed set of authorization flows. +# grantTypes: +# - "authorization_code" +# - "client_credentials" +# - "refresh_token" +# - "implicit" +# - "password" +# - "urn:ietf:params:oauth:grant-type:device_code" +# - "urn:ietf:params:oauth:grant-type:token-exchange" +# # responseTypes determines the allowed response contents of a successful authorization flow. +# # use ["code", "token", "id_token"] to enable implicit flow for web-only clients. # responseTypes: [ "code" ] # also allowed are "token" and "id_token" - # By default, Dex will ask for approval to share data with application - # (approval for sharing data from connected IdP to Dex is separate process on IdP) +# # By default, Dex will ask for approval to share data with application +# # (approval for sharing data from connected IdP to Dex is separate process on IdP) # skipApprovalScreen: false - # If only one authentication method is enabled, the default behavior is to - # go directly to it. For connected IdPs, this redirects the browser away - # from application to upstream provider such as the Google login page +# # If only one authentication method is enabled, the default behavior is to +# # go directly to it. For connected IdPs, this redirects the browser away +# # from application to upstream provider such as the Google login page # alwaysShowLoginScreen: false - # Uncomment the passwordConnector to use a specific connector for password grants +# # Uncomment the passwordConnector to use a specific connector for password grants # passwordConnector: local +# # PKCE (Proof Key for Code Exchange) configuration +# pkce: +# # If true, PKCE is required for all authorization code flows (OAuth 2.1). +# enforce: false +# # Supported code challenge methods. Defaults to ["S256", "plain"]. +# codeChallengeMethodsSupported: ["S256", "plain"] + +# Multi-factor authentication configuration. +# Requires DEX_SESSIONS_ENABLED=true feature flag. +# mfa: +# authenticators: +# - id: totp-1 +# type: TOTP +# config: +# issuer: "dex-1" +# # Optional: limit this authenticator to specific connector types (e.g., ldap, oidc, saml). +# # If omitted or empty, applies to all connector types. +# # It is recommended to use this option to prevent MFA from being used for connectors +# # with their own MFA mechanisms, e.g., OIDC, Google, etc. (but technically, it is possible). +# connectorTypes: +# - mockCallback +# - id: webauthn-1 +# type: WebAuthn +# config: +# rpDisplayName: "Dex Dev" +# # rpID defaults to the hostname of the issuer URL. +# # rpID: "127.0.0.1" +# # rpOrigins defaults to the issuer URL. +# # rpOrigins: +# # - "http://127.0.0.1:5556" +# attestationPreference: "indirect" # none, indirect, or direct +# userVerification: "preferred" # required, preferred, or discouraged +# # authenticatorAttachment: "" # platform, cross-platform, or empty (any) +# timeout: "60s" +# defaultMFAChain: +# - totp-1 # Instead of reading from an external storage, use this list of clients. # @@ -110,17 +202,59 @@ staticClients: - id: example-app redirectURIs: - 'http://127.0.0.1:5555/callback' + - '/dex/device/callback' + postLogoutRedirectURIs: + - 'http://127.0.0.1:5555/' + # Where dex POSTs a logout token when this user's session ends, per OIDC + # Back-Channel Logout 1.0. Needs sessions enabled (see the sessions block). + backchannelLogoutURI: 'http://127.0.0.1:5555/backchannel-logout' name: 'Example App' secret: ZXhhbXBsZS1hcHAtc2VjcmV0 + # Optional: restrict which connectors this client can use for authentication. + # If omitted or empty, all connectors are allowed. + # allowedConnectors: + # - mock + # Optional: ordered list of MFA authenticator IDs the user must complete during login. + # References authenticator IDs from mfa.authenticators. + # If omitted, mfa.defaultMFAChain is used. + # mfaChain: + # - totp-1 + # Optional: which other clients can reuse this client's authentication session (SSO). + # ["*"] = share with all clients, [] = share with no one. + # If omitted, ssoSharedWithDefault from sessions config is used. + # ssoSharedWith: + # - "*" + +# Example using environment variables +# Set DEX_CLIENT_ID and DEX_SECURE_CLIENT_SECRET before starting Dex +# - idEnv: DEX_CLIENT_ID +# secretEnv: DEX_CLIENT_SECRET +# redirectURIs: +# - 'http://127.0.0.1:5556/callback' +# name: 'Secure Example App' + # - id: example-device-client # redirectURIs: # - /device/callback # name: 'Static Client for Device Flow' # public: true + connectors: - type: mockCallback id: mock name: Example + # grantTypes restricts which grant types can use this connector. + # If not specified, all grant types are allowed. + # Supported values: + # - "authorization_code" + # - "implicit" + # - "refresh_token" + # - "password" + # - "urn:ietf:params:oauth:grant-type:device_code" + # - "urn:ietf:params:oauth:grant-type:token-exchange" +# grantTypes: +# - "authorization_code" +# - "refresh_token" # - type: google # id: google # name: Google @@ -145,4 +279,25 @@ staticPasswords: # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: "admin" + name: "Admin User" + emailVerified: true + preferredUsername: "admin" + groups: + - "team-a" + - "team-a/admins" userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" + +# Configuration for signing JWT tokens. +# - "local": use local keys (supports RS256 (default) and ES256) +# - "vault": use Vault Transit backend (supports RSA, ECDSA, and Ed25519) +signer: + type: local + config: + keysRotationPeriod: "6h" + algorithm: "RS256" # changes apply on the next key rotation +# signer +# type: vault +# config: +# addr: http://127.0.0.1:8200 +# token: root +# keyName: dex-key diff --git a/examples/example-app/README.md b/examples/example-app/README.md new file mode 100644 index 0000000000..0b5a42ff3f --- /dev/null +++ b/examples/example-app/README.md @@ -0,0 +1,117 @@ +# Example app + +An OpenID Connect client for trying dex out. It runs the flows dex implements, +shows what comes back, and โ€” given `--grpc-addr` โ€” talks to dex's management API. + +``` +go run ./examples/example-app +``` + +It listens on `http://127.0.0.1:5555` and expects dex at `http://127.0.0.1:5556/dex`. + +## What it does + +**Browser flows.** Authorization code with PKCE, and device code. Each +authorization gets its own `state`, `nonce` and PKCE verifier, kept against the +browser's session โ€” that is what the callback is checked against. + +**Direct grants.** Refresh, client credentials, password and token exchange, +each from a form on the front page. + +**Token tools.** Introspection, local signature verification, and UserInfo. +The first two answer different questions: introspection asks dex whether it is +still honouring a token, verification checks the signature and lifetime the way +a resource server would. A revoked token passes the second and fails the first. + +**Sessions.** The app keeps a session per browser and, by default, re-checks +every 30s with a `prompt=none` request that dex still has one. Sign out of dex +in another tab and this app stops showing you as signed in. + +## Configuring dex for it + +`examples/config-dev.yaml` is set up for all of this already โ€” sessions, the +device callback, the password connector and the gRPC API. What each part is for, +if you are writing your own config: + +```yaml +oauth2: + # Grants are refused unless listed. The default list is authorization_code + # and refresh_token. + grantTypes: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:device_code + - client_credentials + - password + - urn:ietf:params:oauth:grant-type:token-exchange + # Without this the password grant is refused: dex needs to know which + # connector verifies the password. + passwordConnector: local + +# Sessions are what make single sign-on, the home page and prompt=none session +# checks work. Also needs DEX_SESSIONS_ENABLED=true. +sessions: + cookieName: dex_session + +staticClients: + - id: example-app + secret: ZXhhbXBsZS1hcHAtc2VjcmV0 + name: Example App + redirectURIs: + - http://127.0.0.1:5555/callback + # The device flow redirects through dex itself, so this has to be + # registered too โ€” with the issuer's path prefix. + - /dex/device/callback + +# Only needed for the gRPC API page. +grpc: + addr: 127.0.0.1:5557 +``` + +Token exchange also needs a connector that can verify the token you bring, which +in dex means one implementing `TokenIdentityConnector`. + +## The gRPC API page + +``` +--grpc-addr 127.0.0.1:5557 +``` + +The page is in sections: clients, local passwords, connectors, the identities +dex has recorded, and a user's sessions and refresh tokens. Without +`--grpc-addr` the page still exists and says what to pass to connect it. + +The last two sections call methods behind a feature flag, so dex has to be +started with it: + +``` +DEX_API_SESSIONS_IDENTITIES_CRUD=true +``` + +The connection is plaintext by default, which is how dex's example config +exposes the API locally. For anything else, pass certificates โ€” see +`examples/grpc-client/cert-gen` for generating a set: + +``` +--grpc-addr 127.0.0.1:5557 +--grpc-ca examples/grpc-client/ca.crt +--grpc-client-cert examples/grpc-client/client.crt +--grpc-client-key examples/grpc-client/client.key +``` + +Revoking a refresh token is worth trying against your own session: revoke it, +then let the app refresh, and the sign-in ends. + +## Flags + +Run `--help` for the full list. The ones worth knowing: + +| Flag | Default | | +|---|---|---| +| `--issuer` | `http://127.0.0.1:5556/dex` | Where dex is. | +| `--listen` | `http://127.0.0.1:5555` | Where this app serves. | +| `--redirect-uri` | `http://127.0.0.1:5555/callback` | Also decides the path the callback is served on. | +| `--pkce` | `true` | Send a PKCE challenge. | +| `--session-check-interval` | `30s` | How stale the app lets its idea of dex's session get. `0` stops checking, and then it will show a user who has signed out. | +| `--grpc-addr` | โ€” | Enables the API page. | +| `--debug` | `false` | Log every request to and from dex. | diff --git a/examples/example-app/main.go b/examples/example-app/main.go index 451bea5b46..da891b4ff1 100644 --- a/examples/example-app/main.go +++ b/examples/example-app/main.go @@ -1,205 +1,75 @@ package main import ( - "bytes" - "context" - "crypto/tls" - "crypto/x509" - "encoding/json" "errors" "fmt" - "log" - "net" - "net/http" - "net/http/httputil" - "net/url" "os" - "strings" "time" - "github.com/coreos/go-oidc/v3/oidc" "github.com/spf13/cobra" - "golang.org/x/oauth2" -) - -const exampleAppState = "I wish to wash my irish wristwatch" - -type app struct { - clientID string - clientSecret string - redirectURI string - - verifier *oidc.IDTokenVerifier - provider *oidc.Provider - - // Does the provider use "offline_access" scope to request a refresh token - // or does it use "access_type=offline" (e.g. Google)? - offlineAsScope bool - client *http.Client -} - -// return an HTTP client which trusts the provided root CAs. -func httpClientForRootCAs(rootCAs string) (*http.Client, error) { - tlsConfig := tls.Config{RootCAs: x509.NewCertPool()} - rootCABytes, err := os.ReadFile(rootCAs) - if err != nil { - return nil, fmt.Errorf("failed to read root-ca: %v", err) - } - if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { - return nil, fmt.Errorf("no certs found in root CA file %q", rootCAs) - } - return &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tlsConfig, - Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).Dial, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - }, nil -} - -type debugTransport struct { - t http.RoundTripper -} - -func (d debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { - reqDump, err := httputil.DumpRequest(req, true) - if err != nil { - return nil, err - } - log.Printf("%s", reqDump) + "github.com/dexidp/dex/examples/example-app/server" +) - resp, err := d.t.RoundTrip(req) - if err != nil { - return nil, err - } - - respDump, err := httputil.DumpResponse(resp, true) - if err != nil { - resp.Body.Close() - return nil, err - } - log.Printf("%s", respDump) - return resp, nil -} +// defaultSessionCheck is short enough that signing out of dex in another tab +// shows up here while you are still looking at the page. +const defaultSessionCheck = 30 * time.Second func cmd() *cobra.Command { var ( - a app - issuerURL string - listen string - tlsCert string - tlsKey string - rootCAs string - debug bool + opts server.Options + listen string + tlsCert string + tlsKey string ) + c := cobra.Command{ Use: "example-app", Short: "An example OpenID Connect client", - Long: "", + Long: `An example OpenID Connect client for dex. + +It runs the flows dex implements โ€” authorization code, device code, refresh, +client credentials, password and token exchange โ€” and shows what comes back. +Given --grpc-addr it also talks to dex's management API.`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) != 0 { return errors.New("surplus arguments provided") } - u, err := url.Parse(a.redirectURI) - if err != nil { - return fmt.Errorf("parse redirect-uri: %v", err) - } - listenURL, err := url.Parse(listen) + s, err := server.New(opts) if err != nil { - return fmt.Errorf("parse listen address: %v", err) - } - - if rootCAs != "" { - client, err := httpClientForRootCAs(rootCAs) - if err != nil { - return err - } - a.client = client - } - - if debug { - if a.client == nil { - a.client = &http.Client{ - Transport: debugTransport{http.DefaultTransport}, - } - } else { - a.client.Transport = debugTransport{a.client.Transport} - } + return err } + return s.Run(listen, tlsCert, tlsKey) + }, + } - if a.client == nil { - a.client = http.DefaultClient - } + flags := c.Flags() - // TODO(ericchiang): Retry with backoff - ctx := oidc.ClientContext(context.Background(), a.client) - provider, err := oidc.NewProvider(ctx, issuerURL) - if err != nil { - return fmt.Errorf("failed to query provider %q: %v", issuerURL, err) - } + // This client. + flags.StringVar(&opts.ClientID, "client-id", "example-app", "OAuth2 client ID of this application.") + flags.StringVar(&opts.ClientSecret, "client-secret", "ZXhhbXBsZS1hcHAtc2VjcmV0", "OAuth2 client secret of this application.") + flags.StringVar(&opts.RedirectURI, "redirect-uri", "http://127.0.0.1:5555/callback", "Callback URL for OAuth2 responses. Its path is where this app serves the callback.") + flags.BoolVar(&opts.PKCE, "pkce", true, "Send a PKCE challenge with authorization requests.") - var s struct { - // What scopes does a provider support? - // - // See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - ScopesSupported []string `json:"scopes_supported"` - } - if err := provider.Claims(&s); err != nil { - return fmt.Errorf("failed to parse provider scopes_supported: %v", err) - } - - if len(s.ScopesSupported) == 0 { - // scopes_supported is a "RECOMMENDED" discovery claim, not a required - // one. If missing, assume that the provider follows the spec and has - // an "offline_access" scope. - a.offlineAsScope = true - } else { - // See if scopes_supported has the "offline_access" scope. - a.offlineAsScope = func() bool { - for _, scope := range s.ScopesSupported { - if scope == oidc.ScopeOfflineAccess { - return true - } - } - return false - }() - } + // The provider. + flags.StringVar(&opts.IssuerURL, "issuer", "http://127.0.0.1:5556/dex", "URL of the OpenID Connect issuer.") + flags.StringVar(&opts.RootCAs, "issuer-root-ca", "", "Root certificate authorities for the issuer. Defaults to host certs.") + flags.DurationVar(&opts.SessionCheckInterval, "session-check-interval", defaultSessionCheck, + "How often to confirm with the provider, using prompt=none, that its session still exists. Zero stops checking, which lets this app show a user who has signed out of the provider.") - a.provider = provider - a.verifier = provider.Verifier(&oidc.Config{ClientID: a.clientID}) + // The management API. + flags.StringVar(&opts.GRPCAddr, "grpc-addr", "", "Address of dex's gRPC API, e.g. 127.0.0.1:5557. Enables the API page; empty leaves it out.") + flags.StringVar(&opts.GRPCCA, "grpc-ca", "", "CA certificate for the gRPC API. Without it the connection is plaintext.") + flags.StringVar(&opts.GRPCClientCert, "grpc-client-cert", "", "Client certificate for the gRPC API.") + flags.StringVar(&opts.GRPCClientKey, "grpc-client-key", "", "Client key for the gRPC API.") - http.HandleFunc("/", a.handleIndex) - http.HandleFunc("/login", a.handleLogin) - http.HandleFunc(u.Path, a.handleCallback) + // This process. + flags.StringVar(&listen, "listen", "http://127.0.0.1:5555", "HTTP(S) address to listen at.") + flags.StringVar(&tlsCert, "tls-cert", "", "X509 cert file to present when serving HTTPS.") + flags.StringVar(&tlsKey, "tls-key", "", "Private key for the HTTPS cert.") + flags.BoolVar(&opts.Debug, "debug", false, "Print all requests and responses to and from the issuer.") - switch listenURL.Scheme { - case "http": - log.Printf("listening on %s", listen) - return http.ListenAndServe(listenURL.Host, nil) - case "https": - log.Printf("listening on %s", listen) - return http.ListenAndServeTLS(listenURL.Host, tlsCert, tlsKey, nil) - default: - return fmt.Errorf("listen address %q is not using http or https", listen) - } - }, - } - c.Flags().StringVar(&a.clientID, "client-id", "example-app", "OAuth2 client ID of this application.") - c.Flags().StringVar(&a.clientSecret, "client-secret", "ZXhhbXBsZS1hcHAtc2VjcmV0", "OAuth2 client secret of this application.") - c.Flags().StringVar(&a.redirectURI, "redirect-uri", "http://127.0.0.1:5555/callback", "Callback URL for OAuth2 responses.") - c.Flags().StringVar(&issuerURL, "issuer", "http://127.0.0.1:5556/dex", "URL of the OpenID Connect issuer.") - c.Flags().StringVar(&listen, "listen", "http://127.0.0.1:5555", "HTTP(S) address to listen at.") - c.Flags().StringVar(&tlsCert, "tls-cert", "", "X509 cert file to present when serving HTTPS.") - c.Flags().StringVar(&tlsKey, "tls-key", "", "Private key for the HTTPS cert.") - c.Flags().StringVar(&rootCAs, "issuer-root-ca", "", "Root certificate authorities for the issuer. Defaults to host certs.") - c.Flags().BoolVar(&debug, "debug", false, "Print all request and responses from the OpenID Connect issuer.") return &c } @@ -209,131 +79,3 @@ func main() { os.Exit(2) } } - -func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) { - renderIndex(w) -} - -func (a *app) oauth2Config(scopes []string) *oauth2.Config { - return &oauth2.Config{ - ClientID: a.clientID, - ClientSecret: a.clientSecret, - Endpoint: a.provider.Endpoint(), - Scopes: scopes, - RedirectURL: a.redirectURI, - } -} - -func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) { - var scopes []string - if extraScopes := r.FormValue("extra_scopes"); extraScopes != "" { - scopes = strings.Split(extraScopes, " ") - } - var clients []string - if crossClients := r.FormValue("cross_client"); crossClients != "" { - clients = strings.Split(crossClients, " ") - } - for _, client := range clients { - scopes = append(scopes, "audience:server:client_id:"+client) - } - connectorID := "" - if id := r.FormValue("connector_id"); id != "" { - connectorID = id - } - - authCodeURL := "" - scopes = append(scopes, "openid", "profile", "email") - if r.FormValue("offline_access") != "yes" { - authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState) - } else if a.offlineAsScope { - scopes = append(scopes, "offline_access") - authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState) - } else { - authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState, oauth2.AccessTypeOffline) - } - if connectorID != "" { - authCodeURL = authCodeURL + "&connector_id=" + connectorID - } - - http.Redirect(w, r, authCodeURL, http.StatusSeeOther) -} - -func (a *app) handleCallback(w http.ResponseWriter, r *http.Request) { - var ( - err error - token *oauth2.Token - ) - - ctx := oidc.ClientContext(r.Context(), a.client) - oauth2Config := a.oauth2Config(nil) - switch r.Method { - case http.MethodGet: - // Authorization redirect callback from OAuth2 auth flow. - if errMsg := r.FormValue("error"); errMsg != "" { - http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest) - return - } - code := r.FormValue("code") - if code == "" { - http.Error(w, fmt.Sprintf("no code in request: %q", r.Form), http.StatusBadRequest) - return - } - if state := r.FormValue("state"); state != exampleAppState { - http.Error(w, fmt.Sprintf("expected state %q got %q", exampleAppState, state), http.StatusBadRequest) - return - } - token, err = oauth2Config.Exchange(ctx, code) - case http.MethodPost: - // Form request from frontend to refresh a token. - refresh := r.FormValue("refresh_token") - if refresh == "" { - http.Error(w, fmt.Sprintf("no refresh_token in request: %q", r.Form), http.StatusBadRequest) - return - } - t := &oauth2.Token{ - RefreshToken: refresh, - Expiry: time.Now().Add(-time.Hour), - } - token, err = oauth2Config.TokenSource(ctx, t).Token() - default: - http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) - return - } - - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - http.Error(w, "no id_token in token response", http.StatusInternalServerError) - return - } - - idToken, err := a.verifier.Verify(r.Context(), rawIDToken) - if err != nil { - http.Error(w, fmt.Sprintf("failed to verify ID token: %v", err), http.StatusInternalServerError) - return - } - - accessToken, ok := token.Extra("access_token").(string) - if !ok { - http.Error(w, "no access_token in token response", http.StatusInternalServerError) - return - } - - var claims json.RawMessage - if err := idToken.Claims(&claims); err != nil { - http.Error(w, fmt.Sprintf("error decoding ID token claims: %v", err), http.StatusInternalServerError) - return - } - - buff := new(bytes.Buffer) - if err := json.Indent(buff, []byte(claims), "", " "); err != nil { - http.Error(w, fmt.Sprintf("error indenting ID token claims: %v", err), http.StatusInternalServerError) - return - } - - renderToken(w, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buff.String()) -} diff --git a/examples/example-app/server/admin.go b/examples/example-app/server/admin.go new file mode 100644 index 0000000000..d79e376b3f --- /dev/null +++ b/examples/example-app/server/admin.go @@ -0,0 +1,790 @@ +package server + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "net/url" + "os" + "time" + + "golang.org/x/crypto/bcrypt" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + api "github.com/dexidp/dex/api/v2" +) + +// adminClient talks to dex's gRPC API โ€” the interface that manages the +// provider itself rather than authenticating against it. +type adminClient struct { + conn *grpc.ClientConn + api api.DexClient +} + +// newAdminClient dials the gRPC API. Plaintext is allowed because that is how +// dex's own example config exposes it locally; anything reachable off the host +// should be given the certificates instead. +func newAdminClient(opts Options) (*adminClient, error) { + creds := insecure.NewCredentials() + + if opts.GRPCCA != "" { + pool := x509.NewCertPool() + caCert, err := os.ReadFile(opts.GRPCCA) + if err != nil { + return nil, fmt.Errorf("read gRPC CA: %v", err) + } + if !pool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("no certificates found in %q", opts.GRPCCA) + } + + cfg := &tls.Config{RootCAs: pool} + if opts.GRPCClientCert != "" || opts.GRPCClientKey != "" { + cert, err := tls.LoadX509KeyPair(opts.GRPCClientCert, opts.GRPCClientKey) + if err != nil { + return nil, fmt.Errorf("load gRPC client key pair: %v", err) + } + cfg.Certificates = []tls.Certificate{cert} + } + creds = credentials.NewTLS(cfg) + } + + conn, err := grpc.NewClient(opts.GRPCAddr, grpc.WithTransportCredentials(creds)) + if err != nil { + return nil, fmt.Errorf("dial gRPC API at %q: %v", opts.GRPCAddr, err) + } + + return &adminClient{conn: conn, api: api.NewDexClient(conn)}, nil +} + +func (a *adminClient) close() { + if a.conn != nil { + a.conn.Close() + } +} + +// adminSections are the tabs of the API page. dex's API has more than twenty +// methods; putting every list and every form on one page is what made the last +// version unreadable. +// connectorTypes are the types dex's config parser knows. The list is short, +// fixed, and impossible to guess the spelling of, so the form offers it rather +// than asking you to type one. +var connectorTypes = []string{ + "atlassian-crowd", "authproxy", "bitbucket-cloud", "gitea", "github", + "gitlab", "google", "keystone", "ldap", "linkedin", "microsoft", + "mockCallback", "mockPassword", "oauth", "oidc", "openshift", "saml", +} + +// connectorGrantTypes pairs each grant with a readable name: the two URNs are +// forty characters of boilerplate and four of meaning. +func connectorGrantTypes() []Option { + return []Option{ + {Value: grantAuthorizationCode, Label: "authorization_code"}, + {Value: grantRefreshToken, Label: "refresh_token"}, + {Value: grantDeviceCode, Label: "device_code"}, + {Value: grantTokenExchange, Label: "token_exchange"}, + {Value: grantClientCredentials, Label: "client_credentials"}, + {Value: grantPassword, Label: "password"}, + } +} + +var adminSections = []AdminSection{ + {ID: "clients", Label: "Clients"}, + {ID: "passwords", Label: "Passwords"}, + {ID: "connectors", Label: "Connectors"}, + {ID: "identities", Label: "Users"}, + {ID: "sessions", Label: "Sessions"}, + {ID: "discovery", Label: "Discovery"}, +} + +// handleAdmin renders one section of the API. +func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { + section := r.URL.Query().Get("section") + if section == "" { + section = "clients" + } + + data := AdminPageData{ + LogoURI: dexLogoDataURI, + AdminEnabled: true, + Configured: s.admin != nil, + Section: section, + Notice: r.URL.Query().Get("notice"), + Error: r.URL.Query().Get("error"), + UserID: r.URL.Query().Get("user_id"), + ConnectorID: r.URL.Query().Get("connector_id"), + Mode: r.URL.Query().Get("mode"), + + ConnectorTypes: connectorTypes, + ConnectorGrantTypes: connectorGrantTypes(), + } + for _, sec := range adminSections { + sec.Current = sec.ID == section + data.Sections = append(data.Sections, sec) + } + + // Without --grpc-addr the page explains itself rather than 404ing, which is + // how you find out the feature exists at all. + if s.admin == nil { + s.renderer.RenderAdminPage(w, data) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + fail := func(err error) { + if data.Error == "" { + data.Error = err.Error() + } + } + + if version, err := s.admin.api.GetVersion(ctx, &api.VersionReq{}); err == nil { + data.Version = fmt.Sprintf("%s (API %d)", version.Server, version.Api) + } else { + fail(err) + } + if disco, err := s.admin.api.GetDiscovery(ctx, &api.DiscoveryReq{}); err == nil { + data.Issuer = disco.Issuer + } + + switch section { + case "clients": + if resp, err := s.admin.api.ListClients(ctx, &api.ListClientReq{}); err == nil { + for _, c := range resp.Clients { + data.Clients = append(data.Clients, AdminClient{ + ID: c.Id, + Name: c.Name, + RedirectURIs: c.RedirectUris, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + LogoURL: c.LogoUrl, + AllowedConnectors: c.AllowedConnectors, + SSOSharedWith: c.SsoSharedWith, + BackchannelLogoutURI: c.BackchannelLogoutUri, + PostLogoutRedirectURIs: c.PostLogoutRedirectUris, + RefreshTokenLifetime: c.RefreshTokenLifetime, + }) + } + } else { + fail(err) + } + + case "passwords": + if resp, err := s.admin.api.ListPasswords(ctx, &api.ListPasswordReq{}); err == nil { + for _, p := range resp.Passwords { + data.Passwords = append(data.Passwords, AdminPassword{ + Email: p.Email, + Username: p.Username, + UserID: p.UserId, + }) + } + } else { + fail(err) + } + + case "connectors": + if resp, err := s.admin.api.ListConnectors(ctx, &api.ListConnectorReq{}); err == nil { + for _, c := range resp.Connectors { + data.Connectors = append(data.Connectors, AdminConnector{ + ID: c.Id, Type: c.Type, Name: c.Name, GrantTypes: c.GrantTypes, + }) + } + } else { + fail(err) + } + + case "identities": + if resp, err := s.admin.api.ListUserIdentities(ctx, &api.ListUserIdentitiesReq{}); err == nil { + for _, u := range resp.Identities { + identity := AdminIdentity{ + UserID: u.UserId, + ConnectorID: u.ConnectorId, + Email: u.Email, + EmailVerified: u.EmailVerified, + Username: u.Username, + Groups: u.Groups, + } + for _, d := range u.MfaDevices { + identity.MFADevices = append(identity.MFADevices, AdminMFADevice{AuthenticatorID: d.AuthenticatorId}) + } + data.Identities = append(data.Identities, identity) + } + } else { + fail(err) + } + + case "discovery": + endpoints, capabilities, err := s.discoveryEntries(ctx) + if err != nil { + fail(err) + } + data.Endpoints, data.Capabilities = endpoints, capabilities + + case "sessions": + // The two listings are keyed differently, which is a property of the API + // rather than a choice here: sessions are stored under the ID the + // connector gave the user, refresh tokens under the encoded sub claim + // that ends up in tokens. + if data.UserID != "" { + req := &api.ListAuthSessionsReq{UserId: data.UserID, ConnectorId: data.ConnectorID} + if resp, err := s.admin.api.ListAuthSessions(ctx, req); err == nil { + for _, sess := range resp.Sessions { + data.Sessions = append(data.Sessions, AdminSession{ + ID: sess.Id, + UserID: sess.UserId, + ConnectorID: sess.ConnectorId, + IPAddress: sess.IpAddress, + UserAgent: sess.UserAgent, + Created: epochText(sess.CreatedAt), + Expires: epochText(sess.AbsoluteExpiry), + }) + } + } else { + fail(err) + } + + } + + // Refresh tokens are keyed by the sub claim, which is the user and the + // connector encoded together. + if data.UserID != "" && data.ConnectorID != "" { + subject := idTokenSubject(data.UserID, data.ConnectorID) + if resp, err := s.admin.api.ListRefresh(ctx, &api.ListRefreshReq{UserId: subject}); err == nil { + for _, t := range resp.RefreshTokens { + data.RefreshTokens = append(data.RefreshTokens, AdminRefreshToken{ + ID: t.Id, + ClientID: t.ClientId, + Created: epochText(t.CreatedAt), + LastUsed: epochText(t.LastUsed), + }) + } + } else { + fail(err) + } + } + } + + // An edit starts from a row, so the form comes back filled in. + if id := r.URL.Query().Get("edit"); id != "" && section == "clients" { + if resp, err := s.admin.api.GetClient(ctx, &api.GetClientReq{Id: id}); err == nil && resp.Client != nil { + c := resp.Client + data.EditClient = &AdminClient{ + ID: c.Id, + Name: c.Name, + RedirectURIs: c.RedirectUris, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + LogoURL: c.LogoUrl, + AllowedConnectors: c.AllowedConnectors, + SSOSharedWith: c.SsoSharedWith, + BackchannelLogoutURI: c.BackchannelLogoutUri, + PostLogoutRedirectURIs: c.PostLogoutRedirectUris, + RefreshTokenLifetime: c.RefreshTokenLifetime, + } + } else if err != nil { + fail(err) + } + } + if email := r.URL.Query().Get("edit"); email != "" && section == "passwords" { + for _, p := range data.Passwords { + if p.Email == email { + entry := p + data.EditPassword = &entry + break + } + } + } + + s.renderer.RenderAdminPage(w, data) +} + +// epochText renders one of the API's Unix timestamps, which are zero when the +// field was never set. +func epochText(epoch int64) string { + if epoch == 0 { + return "" + } + return time.Unix(epoch, 0).UTC().Format("2 Jan 2006, 15:04 UTC") +} + +// handleAdminCreateClient registers an OAuth2 client. +func (s *Server) handleAdminCreateClient(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + req := &api.CreateClientReq{ + Client: &api.Client{ + Id: r.FormValue("id"), + Name: r.FormValue("name"), + Secret: r.FormValue("secret"), + LogoUrl: r.FormValue("logo_url"), + RedirectUris: r.Form["redirect_uris"], + TrustedPeers: r.Form["trusted_peers"], + AllowedConnectors: r.Form["allowed_connectors"], + SsoSharedWith: r.Form["sso_shared_with"], + BackchannelLogoutUri: r.FormValue("backchannel_logout_uri"), + PostLogoutRedirectUris: r.Form["post_logout_redirect_uris"], + RefreshTokenLifetime: r.FormValue("refresh_token_lifetime"), + Public: r.FormValue("public") != "", + }, + } + + resp, err := s.admin.api.CreateClient(ctx, req) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.AlreadyExists: + s.adminRedirect(w, r, "", fmt.Sprintf("client %q already exists", req.Client.Id)) + default: + s.adminRedirect(w, r, fmt.Sprintf("created client %q", req.Client.Id), "") + } +} + +// handleAdminDeleteClient removes an OAuth2 client. +func (s *Server) handleAdminDeleteClient(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := r.FormValue("id") + resp, err := s.admin.api.DeleteClient(ctx, &api.DeleteClientReq{Id: id}) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("client %q not found", id)) + default: + s.adminRedirect(w, r, fmt.Sprintf("deleted client %q", id), "") + } +} + +// handleAdminCreatePassword adds a user to dex's local password database. The +// API takes a bcrypt hash rather than a password, so the hashing happens here. +func (s *Server) handleAdminCreatePassword(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + password := r.FormValue("password") + if password == "" { + s.adminRedirect(w, r, "", "password is required") + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + email := r.FormValue("email") + req := &api.CreatePasswordReq{ + Password: &api.Password{ + Email: email, + Username: r.FormValue("username"), + UserId: r.FormValue("user_id"), + Hash: hash, + }, + } + + resp, err := s.admin.api.CreatePassword(ctx, req) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.AlreadyExists: + s.adminRedirect(w, r, "", fmt.Sprintf("password for %q already exists", email)) + default: + s.adminRedirect(w, r, fmt.Sprintf("created password for %q", email), "") + } +} + +// handleAdminDeletePassword removes a user from the local password database. +func (s *Server) handleAdminDeletePassword(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + email := r.FormValue("email") + resp, err := s.admin.api.DeletePassword(ctx, &api.DeletePasswordReq{Email: email}) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("password for %q not found", email)) + default: + s.adminRedirect(w, r, fmt.Sprintf("deleted password for %q", email), "") + } +} + +// handleAdminRevokeRefresh revokes a user's refresh token for one client. This +// is the other half of the session story: it is what makes a sign-in stop +// working before the token expires on its own. +func (s *Server) handleAdminRevokeRefresh(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // The API keys refresh tokens by the sub claim, not by the user id the + // connector gave โ€” the same encoding the listing above uses. + userID, clientID := r.FormValue("user_id"), r.FormValue("client_id") + resp, err := s.admin.api.RevokeRefresh(ctx, &api.RevokeRefreshReq{ + UserId: idTokenSubject(userID, r.FormValue("connector_id")), + ClientId: clientID, + }) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("no refresh token for user %q and client %q", userID, clientID)) + default: + s.adminRedirect(w, r, fmt.Sprintf("revoked refresh token for user %q", userID), "") + } +} + +// adminRedirect returns to the admin page carrying the outcome, so a reload +// does not repeat the action. +func (s *Server) adminRedirect(w http.ResponseWriter, r *http.Request, notice, errMsg string) { + q := url.Values{} + if section := r.FormValue("section"); section != "" { + q.Set("section", section) + } + if userID := r.FormValue("list_user_id"); userID != "" { + q.Set("user_id", userID) + } + if connectorID := r.FormValue("list_connector_id"); connectorID != "" { + q.Set("connector_id", connectorID) + } + switch { + case errMsg != "": + q.Set("error", errMsg) + case notice != "": + q.Set("notice", notice) + } + + u := "/admin" + if len(q) > 0 { + u += "?" + q.Encode() + } + http.Redirect(w, r, u, http.StatusSeeOther) +} + +// handleAdminUpdateClient changes a client. Empty fields are left alone, since +// the API's update takes only what it should overwrite. +func (s *Server) handleAdminUpdateClient(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := r.FormValue("id") + + // The form always submits the field, so an emptied box has to reach the API as + // a present-but-empty value โ€” that is what clears the URI. Sending nothing + // would leave the old one in place, and the box would fill itself back in on + // the next load. + backchannelLogoutURI := r.FormValue("backchannel_logout_uri") + refreshTokenLifetime := r.FormValue("refresh_token_lifetime") + + resp, err := s.admin.api.UpdateClient(ctx, &api.UpdateClientReq{ + Id: id, + Name: r.FormValue("name"), + LogoUrl: r.FormValue("logo_url"), + RedirectUris: r.Form["redirect_uris"], + TrustedPeers: r.Form["trusted_peers"], + AllowedConnectors: r.Form["allowed_connectors"], + SsoSharedWith: r.Form["sso_shared_with"], + BackchannelLogoutUri: &backchannelLogoutURI, + PostLogoutRedirectUris: r.Form["post_logout_redirect_uris"], + RefreshTokenLifetime: &refreshTokenLifetime, + }) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("client %q not found", id)) + default: + s.adminRedirect(w, r, fmt.Sprintf("updated client %q", id), "") + } +} + +// handleAdminUpdatePassword changes a user's password or username. +func (s *Server) handleAdminUpdatePassword(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + req := &api.UpdatePasswordReq{ + Email: r.FormValue("email"), + NewUsername: r.FormValue("username"), + } + if password := r.FormValue("password"); password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + req.NewHash = hash + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + resp, err := s.admin.api.UpdatePassword(ctx, req) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("password for %q not found", req.Email)) + default: + s.adminRedirect(w, r, fmt.Sprintf("updated password for %q", req.Email), "") + } +} + +// handleAdminVerifyPassword checks a password without issuing anything, which +// is how a service that owns its own login screen would use dex's user store. +func (s *Server) handleAdminVerifyPassword(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + email := r.FormValue("email") + resp, err := s.admin.api.VerifyPassword(ctx, &api.VerifyPasswordReq{ + Email: email, + Password: r.FormValue("password"), + }) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("no password for %q", email)) + case !resp.Verified: + s.adminRedirect(w, r, "", fmt.Sprintf("password for %q does not match", email)) + default: + s.adminRedirect(w, r, fmt.Sprintf("password for %q verified", email), "") + } +} + +// handleAdminCreateConnector adds a connector at runtime. +func (s *Server) handleAdminCreateConnector(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := r.FormValue("id") + resp, err := s.admin.api.CreateConnector(ctx, &api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: id, + Type: r.FormValue("type"), + Name: r.FormValue("name"), + Config: []byte(r.FormValue("config")), + GrantTypes: r.Form["grant_types"], + }, + }) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.AlreadyExists: + s.adminRedirect(w, r, "", fmt.Sprintf("connector %q already exists", id)) + default: + s.adminRedirect(w, r, fmt.Sprintf("created connector %q", id), "") + } +} + +// handleAdminDeleteConnector removes a connector. +func (s *Server) handleAdminDeleteConnector(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := r.FormValue("id") + resp, err := s.admin.api.DeleteConnector(ctx, &api.DeleteConnectorReq{Id: id}) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("connector %q not found", id)) + default: + s.adminRedirect(w, r, fmt.Sprintf("deleted connector %q", id), "") + } +} + +// handleAdminDeleteIdentity forgets what dex recorded about a user from one +// connector. The next sign-in records it again. +func (s *Server) handleAdminDeleteIdentity(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + userID := r.FormValue("user_id") + resp, err := s.admin.api.DeleteUserIdentity(ctx, &api.DeleteUserIdentityReq{ + UserId: userID, + ConnectorId: r.FormValue("connector_id"), + }) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("no identity for user %q", userID)) + default: + s.adminRedirect(w, r, fmt.Sprintf("deleted identity for user %q", userID), "") + } +} + +// handleAdminDeleteSession ends one of dex's sessions. +func (s *Server) handleAdminDeleteSession(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // One session is one signed-in browser, so this ends that device and no other. + sessionID := r.FormValue("session_id") + resp, err := s.admin.api.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{Id: sessionID}) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("no session %q", sessionID)) + default: + s.adminRedirect(w, r, fmt.Sprintf("deleted session %q", sessionID), "") + } +} + +// handleAdminTerminateSessions ends every session a user has. +func (s *Server) handleAdminTerminateSessions(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + userID := r.FormValue("user_id") + resp, err := s.admin.api.TerminateSessionsByUser(ctx, &api.TerminateSessionsByUserReq{UserId: userID}) + if err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + s.adminRedirect(w, r, fmt.Sprintf("terminated %d session(s) for user %q", resp.SessionsTerminated, userID), "") +} + +// handleAdminResetMFA clears a user's second factors so they enrol again. +func (s *Server) handleAdminResetMFA(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + userID := r.FormValue("user_id") + resp, err := s.admin.api.ResetMFA(ctx, &api.ResetMFAReq{ + UserId: userID, + ConnectorId: r.FormValue("connector_id"), + }) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("no MFA enrolment for user %q", userID)) + default: + s.adminRedirect(w, r, fmt.Sprintf("reset MFA for user %q", userID), "") + } +} + +// handleAdminDeleteMFASecret removes one authenticator's secret, leaving the +// user's other factors alone. +func (s *Server) handleAdminDeleteMFASecret(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + userID, authenticator := r.FormValue("user_id"), r.FormValue("authenticator_id") + resp, err := s.admin.api.DeleteMFASecret(ctx, &api.DeleteMFASecretReq{ + UserId: userID, + ConnectorId: r.FormValue("connector_id"), + AuthenticatorId: authenticator, + }) + switch { + case err != nil: + s.adminRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.adminRedirect(w, r, "", fmt.Sprintf("no %q secret for user %q", authenticator, userID)) + default: + s.adminRedirect(w, r, fmt.Sprintf("deleted %q secret for user %q", authenticator, userID), "") + } +} + +// handleAdminTerminateByConnector ends every session that came through one +// connector โ€” what you reach for when a connector is compromised or retired. +func (s *Server) handleAdminTerminateByConnector(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + connectorID := r.FormValue("connector_id") + resp, err := s.admin.api.TerminateSessionsByConnector(ctx, &api.TerminateSessionsByConnectorReq{ConnectorId: connectorID}) + if err != nil { + s.adminRedirect(w, r, "", err.Error()) + return + } + s.detailRedirect(w, r, fmt.Sprintf("terminated %d session(s) from connector %q", resp.SessionsTerminated, connectorID), "") +} diff --git a/examples/example-app/server/admindetail.go b/examples/example-app/server/admindetail.go new file mode 100644 index 0000000000..4d8b890ba2 --- /dev/null +++ b/examples/example-app/server/admindetail.go @@ -0,0 +1,357 @@ +package server + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + api "github.com/dexidp/dex/api/v2" +) + +// The API page lists objects; these pages open one. A list can only show the +// fields that fit, and for a connector the interesting part โ€” its config โ€” never +// does. + +// handleAdminClientDetail shows one client in full. +func (s *Server) handleAdminClientDetail(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := r.PathValue("id") + data := AdminDetailPageData{ + LogoURI: dexLogoDataURI, + AdminEnabled: true, + Kind: "client", + Title: id, + BackURL: "/admin?section=clients", + } + + resp, err := s.admin.api.GetClient(ctx, &api.GetClientReq{Id: id}) + switch { + case err != nil: + data.Error = err.Error() + case resp.Client == nil: + data.Error = fmt.Sprintf("client %q not found", id) + default: + c := resp.Client + data.Client = &AdminClient{ + ID: c.Id, + Name: c.Name, + Secret: c.Secret, + RedirectURIs: c.RedirectUris, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + LogoURL: c.LogoUrl, + AllowedConnectors: c.AllowedConnectors, + SSOSharedWith: c.SsoSharedWith, + BackchannelLogoutURI: c.BackchannelLogoutUri, + PostLogoutRedirectURIs: c.PostLogoutRedirectUris, + RefreshTokenLifetime: c.RefreshTokenLifetime, + } + } + + s.renderer.RenderAdminDetailPage(w, data) +} + +// handleAdminConnectorDetail shows one connector, including the configuration +// it was created with. +func (s *Server) handleAdminConnectorDetail(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := r.PathValue("id") + data := AdminDetailPageData{ + LogoURI: dexLogoDataURI, + AdminEnabled: true, + Kind: "connector", + Title: id, + BackURL: "/admin?section=connectors", + + ConnectorGrantTypes: connectorGrantTypes(), + } + + // The API lists connectors but has no getter, so the list is the lookup. + resp, err := s.admin.api.ListConnectors(ctx, &api.ListConnectorReq{}) + if err != nil { + data.Error = err.Error() + s.renderer.RenderAdminDetailPage(w, data) + return + } + + for _, c := range resp.Connectors { + if c.Id != id { + continue + } + data.Connector = &AdminConnector{ + ID: c.Id, + Type: c.Type, + Name: c.Name, + GrantTypes: c.GrantTypes, + Config: indentJSON(c.Config), + } + break + } + if data.Connector == nil { + data.Error = fmt.Sprintf("connector %q not found โ€” connectors from the config file are not stored", id) + } + + s.renderer.RenderAdminDetailPage(w, data) +} + +// handleAdminUserDetail shows one identity: its claims, what it has consented +// to, and what it has enrolled for MFA. +func (s *Server) handleAdminUserDetail(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + userID, connectorID := r.PathValue("user"), r.PathValue("connector") + data := AdminDetailPageData{ + LogoURI: dexLogoDataURI, + AdminEnabled: true, + Kind: "user", + Title: userID, + BackURL: "/admin?section=identities", + } + + resp, err := s.admin.api.GetUserIdentity(ctx, &api.GetUserIdentityReq{ + UserId: userID, + ConnectorId: connectorID, + }) + switch { + case err != nil: + data.Error = err.Error() + case resp.Identity == nil: + data.Error = fmt.Sprintf("no identity for user %q on connector %q", userID, connectorID) + default: + data.Identity = adminIdentity(resp.Identity) + data.Title = data.Identity.Email + if data.Title == "" { + data.Title = userID + } + } + + s.renderer.RenderAdminDetailPage(w, data) +} + +// adminIdentity converts an identity, keeping everything the API returned. +func adminIdentity(u *api.UserIdentity) *AdminIdentity { + identity := &AdminIdentity{ + UserID: u.UserId, + ConnectorID: u.ConnectorId, + Email: u.Email, + EmailVerified: u.EmailVerified, + Username: u.Username, + Groups: u.Groups, + Created: epochText(u.CreatedAt), + LastLogin: epochText(u.LastLogin), + } + + for _, c := range u.Consents { + identity.Consents = append(identity.Consents, AdminConsent{ClientID: c.ClientId, Scopes: c.Scopes}) + } + + for _, d := range u.MfaDevices { + device := AdminMFADevice{ + AuthenticatorID: d.AuthenticatorId, + HasSecret: d.MfaSecret != nil, + } + for _, cred := range d.WebauthnCredentials { + device.Credentials = append(device.Credentials, AdminWebAuthnCredential{ + ID: base64.RawURLEncoding.EncodeToString(cred.CredentialId), + DisplayName: cred.DisplayName, + Transport: cred.Transport, + SignCount: cred.SignCount, + Created: epochText(cred.CreatedAt), + }) + } + identity.MFADevices = append(identity.MFADevices, device) + } + + return identity +} + +// handleAdminRevokeConsent withdraws a user's approval for one client, which +// puts the consent screen back in front of them next time. +func (s *Server) handleAdminRevokeConsent(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + userID, connectorID, clientID := r.FormValue("user_id"), r.FormValue("connector_id"), r.FormValue("client_id") + resp, err := s.admin.api.RevokeConsent(ctx, &api.RevokeConsentReq{ + UserId: userID, + ConnectorId: connectorID, + ClientId: clientID, + }) + switch { + case err != nil: + s.detailRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.detailRedirect(w, r, "", fmt.Sprintf("no consent for client %q", clientID)) + default: + s.detailRedirect(w, r, fmt.Sprintf("revoked consent for client %q", clientID), "") + } +} + +// handleAdminDeleteWebAuthn removes one registered key. +func (s *Server) handleAdminDeleteWebAuthn(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + credentialID, err := base64.RawURLEncoding.DecodeString(r.FormValue("credential_id")) + if err != nil { + s.detailRedirect(w, r, "", "credential id is not base64: "+err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + resp, err := s.admin.api.DeleteWebAuthnCredential(ctx, &api.DeleteWebAuthnCredentialReq{ + UserId: r.FormValue("user_id"), + ConnectorId: r.FormValue("connector_id"), + CredentialId: credentialID, + }) + switch { + case err != nil: + s.detailRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.detailRedirect(w, r, "", "credential not found") + default: + s.detailRedirect(w, r, "deleted the credential", "") + } +} + +// localPath reports whether a redirect target stays inside this app. +// +// A leading slash is not enough: a browser reads "//host" as another host, and +// so does it read "/\host", because it turns backslashes into slashes before +// resolving. Normalising first and then insisting the parsed URL carries +// neither scheme nor host covers both spellings. +func localPath(target string) bool { + if !strings.HasPrefix(target, "/") { + return false + } + + u, err := url.Parse(strings.ReplaceAll(target, `\`, "/")) + if err != nil { + return false + } + + return u.Scheme == "" && u.Host == "" && strings.HasPrefix(u.Path, "/") && !strings.HasPrefix(u.Path, "//") +} + +// detailRedirect returns to the detail page an action was started from. +// +// The target comes from the form, so it is only honoured when it points back +// into this app. Anything else would make these endpoints a way to bounce +// someone off to a site of the sender's choosing. +func (s *Server) detailRedirect(w http.ResponseWriter, r *http.Request, notice, errMsg string) { + back := r.FormValue("back") + if !localPath(back) { + back = "/admin" + } + + sep := "?" + if strings.Contains(back, "?") { + sep = "&" + } + switch { + case errMsg != "": + back += sep + "error=" + url.QueryEscape(errMsg) + case notice != "": + back += sep + "notice=" + url.QueryEscape(notice) + } + + http.Redirect(w, r, back, http.StatusSeeOther) +} + +// handleAdminUpdateConnector changes a connector in place. Its type, name, +// config and grant types are all updatable; its id is what looks it up. +func (s *Server) handleAdminUpdateConnector(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.detailRedirect(w, r, "", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := r.FormValue("id") + req := &api.UpdateConnectorReq{ + Id: id, + NewType: r.FormValue("type"), + NewName: r.FormValue("name"), + } + if config := r.FormValue("config"); config != "" { + req.NewConfig = []byte(config) + } + // The field is a tri-state: absent leaves the restriction alone, an empty + // list lifts it, a list replaces it. + switch r.FormValue("grant_mode") { + case "any": + req.NewGrantTypes = &api.GrantTypes{} + case "restrict": + req.NewGrantTypes = &api.GrantTypes{GrantTypes: r.Form["grant_types"]} + } + + resp, err := s.admin.api.UpdateConnector(ctx, req) + switch { + case err != nil: + s.detailRedirect(w, r, "", err.Error()) + case resp.NotFound: + s.detailRedirect(w, r, "", fmt.Sprintf("connector %q not found", id)) + default: + s.detailRedirect(w, r, fmt.Sprintf("updated connector %q", id), "") + } +} + +// discoveryEntries asks dex what it says about itself, over the API rather than +// by reading the document it publishes for clients. +func (s *Server) discoveryEntries(ctx context.Context) (endpoints, capabilities []DiscoveryEntry, err error) { + resp, err := s.admin.api.GetDiscovery(ctx, &api.DiscoveryReq{}) + if err != nil { + return nil, nil, err + } + + for _, e := range []DiscoveryEntry{ + {Name: "issuer", Value: resp.Issuer}, + {Name: "authorization_endpoint", Value: resp.AuthorizationEndpoint}, + {Name: "token_endpoint", Value: resp.TokenEndpoint}, + {Name: "userinfo_endpoint", Value: resp.UserinfoEndpoint}, + {Name: "jwks_uri", Value: resp.JwksUri}, + {Name: "device_authorization_endpoint", Value: resp.DeviceAuthorizationEndpoint}, + {Name: "introspection_endpoint", Value: resp.IntrospectionEndpoint}, + } { + if e.Value != "" { + endpoints = append(endpoints, e) + } + } + + for _, e := range []DiscoveryEntry{ + {Name: "scopes_supported", Values: resp.ScopesSupported}, + {Name: "grant_types_supported", Values: resp.GrantTypesSupported}, + {Name: "response_types_supported", Values: resp.ResponseTypesSupported}, + {Name: "claims_supported", Values: resp.ClaimsSupported}, + {Name: "subject_types_supported", Values: resp.SubjectTypesSupported}, + {Name: "id_token_signing_alg_values_supported", Values: resp.IdTokenSigningAlgValuesSupported}, + {Name: "code_challenge_methods_supported", Values: resp.CodeChallengeMethodsSupported}, + {Name: "token_endpoint_auth_methods_supported", Values: resp.TokenEndpointAuthMethodsSupported}, + } { + if len(e.Values) > 0 { + capabilities = append(capabilities, e) + } + } + + return endpoints, capabilities, nil +} diff --git a/examples/example-app/server/admindetail_test.go b/examples/example-app/server/admindetail_test.go new file mode 100644 index 0000000000..bdd39a81a8 --- /dev/null +++ b/examples/example-app/server/admindetail_test.go @@ -0,0 +1,44 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// The redirect target comes from a form field, so it has to stay inside the +// app: an endpoint that bounces a browser to whatever a form says is an open +// redirect, whatever the app is for. +func TestDetailRedirectStaysLocal(t *testing.T) { + tests := []struct { + back string + want string + }{ + {"/admin/client/example-app", "/admin/client/example-app?notice=done"}, + {"/admin?section=clients", "/admin?section=clients¬ice=done"}, + {"", "/admin?notice=done"}, + {"https://example.com/phish", "/admin?notice=done"}, + {"//example.com/phish", "/admin?notice=done"}, + // A browser turns the backslash into a slash, so these read as a host too. + {`/\example.com/phish`, "/admin?notice=done"}, + {`/\/example.com/phish`, "/admin?notice=done"}, + {"http://127.0.0.1:5599/", "/admin?notice=done"}, + {"javascript:alert(1)", "/admin?notice=done"}, + } + + s := &Server{} + for _, tc := range tests { + r := httptest.NewRequest(http.MethodPost, "/admin/consent/revoke", + strings.NewReader(url.Values{"back": {tc.back}}.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + s.detailRedirect(w, r, "done", "") + + if got := w.Header().Get("Location"); got != tc.want { + t.Errorf("back=%q: redirected to %q, want %q", tc.back, got, tc.want) + } + } +} diff --git a/examples/example-app/server/authcode.go b/examples/example-app/server/authcode.go new file mode 100644 index 0000000000..d48c8fe05b --- /dev/null +++ b/examples/example-app/server/authcode.go @@ -0,0 +1,335 @@ +package server + +import ( + "fmt" + "log" + "net/http" + "net/url" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +// settleTime is how long after a check the app takes its own answer for +// granted. It only has to outlast the redirect the check itself causes. +const settleTime = 2 * time.Second + +// handleIndex renders the app's own view of who is signed in. +// +// Before rendering it makes sure that view is still true. An access token that +// has expired is refreshed; if the refresh fails the sign-in is over. And every +// so often the app asks the provider directly, with prompt=none, whether the +// session there still exists โ€” otherwise signing out of dex in another tab +// would leave this app showing the user indefinitely. +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + sess := s.session(w, r) + + if sess.SignedIn() { + s.refreshIfExpired(r, sess) + } + + if s.shouldCheckProvider(sess) { + s.startAuth(w, r, sess, nil, "", true) + return + } + + data := IndexPageData{ + ScopesSupported: s.scopesSupported, + LogoURI: dexLogoDataURI, + AdminEnabled: s.admin != nil, + PKCE: s.pkce, + SessionCheck: s.sessionCheckInterval, + + DeviceSupported: s.deviceAuthURL != "" && s.supportsGrant(grantDeviceCode), + RefreshSupported: s.supportsGrant(grantRefreshToken), + ClientCredentialsSupported: s.supportsGrant(grantClientCredentials), + PasswordSupported: s.supportsGrant(grantPassword), + TokenExchangeSupported: s.supportsGrant(grantTokenExchange), + } + if sess.SignedIn() { + data.User = sess.Claims + data.Token = tokenSummary(sess.Token, sess.IDToken) + } + + s.renderer.RenderIndexPage(w, data) +} + +// shouldCheckProvider decides whether to spend a redirect confirming the +// session with the provider: for a signed-in browser once the app's answer has +// gone stale, and for a browser that has not asked at all, which is how the app +// picks up a session someone already has with dex. +func (s *Server) shouldCheckProvider(sess *session.Session) bool { + if s.sessionCheckInterval <= 0 { + return false + } + if sess.LastProviderCheck.IsZero() { + return true + } + + // A check redirects out and back, and the page it comes back to is this one. + // Without a floor it would ask again on that render, and again on the next: + // a browser bouncing through the provider forever. + if time.Since(sess.LastProviderCheck) < settleTime { + return false + } + + // Past that, a signed-in browser is confirmed on every load. The interval + // only paces the check for a browser that is not signed in, where it is + // looking for a session rather than confirming one โ€” a session dex has been + // told to end should not outlive the page it is shown on. + if sess.SignedIn() { + return true + } + return time.Since(sess.LastProviderCheck) > s.sessionCheckInterval +} + +// refreshIfExpired renews an expired access token, and ends the app's session +// if the provider will not renew it โ€” a refresh token dex has revoked is as +// good an answer as a failed session check. +func (s *Server) refreshIfExpired(r *http.Request, sess *session.Session) { + if sess.Token == nil || sess.Token.Valid() { + return + } + if sess.Token.RefreshToken == "" { + s.sessions.SignOut(sess) + return + } + + ctx := oidc.ClientContext(r.Context(), s.client) + token, err := s.oauth2Config(nil).TokenSource(ctx, sess.Token).Token() + if err != nil { + log.Printf("refreshing expired token: %v", err) + s.sessions.SignOut(sess) + return + } + + claims, rawIDToken, err := s.claimsFromToken(r, token) + if err != nil { + log.Printf("verifying refreshed token: %v", err) + s.sessions.SignOut(sess) + return + } + s.sessions.SignIn(sess, claims, token, rawIDToken) +} + +// handleLogin starts the authorization code flow. +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest) + return + } + + sess := s.session(w, r) + scopes := buildScopes(r.Form["extra_scopes"], r.Form["cross_client"]) + s.startAuth(w, r, sess, scopes, r.FormValue("connector_id"), false) +} + +// startAuth sends the browser to the provider. Each call gets its own state, +// nonce and PKCE verifier, kept on this browser's session: they are what ties +// the callback back to the request that started it, and reusing them across +// requests would leave nothing to check. +func (s *Server) startAuth(w http.ResponseWriter, r *http.Request, sess *session.Session, scopes []string, connectorID string, silent bool) { + if silent { + // A silent check needs only enough scope to identify the user. + scopes = []string{oidc.ScopeOpenID, "profile", "email"} + } + + pending := s.sessions.StartAuth(sess, silent) + + opts := []oauth2.AuthCodeOption{oidc.Nonce(pending.Nonce)} + if s.pkce { + opts = append(opts, + oauth2.SetAuthURLParam("code_challenge", oauth2.S256ChallengeFromVerifier(pending.CodeVerifier)), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + } + if silent { + opts = append(opts, oauth2.SetAuthURLParam("prompt", "none")) + } + if connectorID != "" { + opts = append(opts, oauth2.SetAuthURLParam("connector_id", connectorID)) + } + + // Providers that do not take offline_access as a scope want a parameter. + if !s.offlineAsScope && containsScope(scopes, oidc.ScopeOfflineAccess) { + opts = append(opts, oauth2.AccessTypeOffline) + scopes = withoutScope(scopes, oidc.ScopeOfflineAccess) + } + + http.Redirect(w, r, s.oauth2Config(scopes).AuthCodeURL(pending.State, opts...), http.StatusSeeOther) +} + +// handleAuthCallback finishes an authorization this browser started. +func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { + sess := s.session(w, r) + + state := r.FormValue("state") + pending, ok := s.sessions.TakeAuth(sess, state) + if !ok { + // A state is good for one callback, so coming back to this URL โ€” the + // browser's Back button, a reload, a bookmark โ€” finds nothing pending. + // For a browser that already holds what the callback produced that is + // not an error, it is the page it is looking for. + if sess.LastTokens != nil { + http.Redirect(w, r, "/tokens", http.StatusSeeOther) + return + } + http.Error(w, "no authorization in progress for this state โ€” a callback is good once, so start the flow again", http.StatusBadRequest) + return + } + + // A silent check answers a question, so its failure is a result: the + // provider has no session for this browser, and neither should the app. + if pending.Silent { + s.sessions.MarkChecked(sess) + + if r.FormValue("error") != "" { + // The provider has no session for this browser. Whether it never had + // one or has just been told to end it, the app has no business + // showing a signed-in user either way. + s.sessions.SignOut(sess) + http.Redirect(w, r, "/", http.StatusFound) + return + } + + if err := s.completeAuth(w, r, sess, pending); err != nil { + log.Printf("silent session check: %v", err) + s.sessions.SignOut(sess) + } + http.Redirect(w, r, "/", http.StatusFound) + return + } + + if errMsg := r.FormValue("error"); errMsg != "" { + http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest) + return + } + + if err := s.completeAuth(w, r, sess, pending); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // The result lives at its own address rather than at the callback: a page + // rendered on a URL that carries a one-use code is a page that cannot be + // reloaded or come back to. + s.sessions.RememberTokens(sess, "Authorization code", sess.Token, sess.IDToken) + http.Redirect(w, r, "/tokens", http.StatusSeeOther) +} + +// completeAuth exchanges the code and records the sign-in. +func (s *Server) completeAuth(w http.ResponseWriter, r *http.Request, sess *session.Session, pending *session.PendingAuth) error { + code := r.FormValue("code") + if code == "" { + return fmt.Errorf("no code in callback: %q", r.URL.RawQuery) + } + + ctx := oidc.ClientContext(r.Context(), s.client) + + var opts []oauth2.AuthCodeOption + if s.pkce { + opts = append(opts, oauth2.VerifierOption(pending.CodeVerifier)) + } + + token, err := s.oauth2Config(nil).Exchange(ctx, code, opts...) + if err != nil { + return fmt.Errorf("failed to get token: %v", err) + } + + claims, rawIDToken, err := s.claimsFromToken(r, token) + if err != nil { + return err + } + + // The nonce ties the ID token to the authorization this browser started. + idToken, err := s.verifier.Verify(r.Context(), rawIDToken) + if err == nil && idToken.Nonce != pending.Nonce { + return fmt.Errorf("id token nonce does not match the authorization request") + } + + // A silent check confirms who is signed in; it does not re-issue the + // sign-in. Its token set is narrower than the one the flow asked for โ€” no + // offline_access, so no refresh token โ€” and storing it would quietly cost + // the app the refresh token it already had. + if pending.Silent && sess.Token != nil { + s.sessions.Confirm(sess, claims) + return nil + } + + s.sessions.SignIn(sess, claims, token, rawIDToken) + return nil +} + +// claimsFromToken verifies the ID token in a token response and returns its +// claims. A response without one is not an error for every grant, so callers +// that can live without it check the returned raw token instead. +func (s *Server) claimsFromToken(r *http.Request, token *oauth2.Token) (*session.UserClaims, string, error) { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, "", fmt.Errorf("no id_token in token response") + } + + idToken, err := s.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + return nil, "", fmt.Errorf("failed to verify ID token: %v", err) + } + + var claims session.UserClaims + if err := idToken.Claims(&claims); err != nil { + return nil, "", fmt.Errorf("failed to decode ID token claims: %v", err) + } + return &claims, rawIDToken, nil +} + +// handleLogout ends the app's session and, when the provider supports it, the +// provider's session too. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + sess := s.session(w, r) + idToken := s.sessions.SignOut(sess) + + if s.endSessionEndpoint == "" { + http.Redirect(w, r, "/", http.StatusFound) + return + } + + logoutURL, err := url.Parse(s.endSessionEndpoint) + if err != nil { + http.Redirect(w, r, "/", http.StatusFound) + return + } + + q := logoutURL.Query() + if idToken != "" { + q.Set("id_token_hint", idToken) + } + if appURL, err := url.Parse(s.redirectURI); err == nil { + appURL.Path = "/" + appURL.RawQuery = "" + q.Set("post_logout_redirect_uri", appURL.String()) + } + logoutURL.RawQuery = q.Encode() + + http.Redirect(w, r, logoutURL.String(), http.StatusFound) +} + +func containsScope(scopes []string, want string) bool { + for _, s := range scopes { + if s == want { + return true + } + } + return false +} + +func withoutScope(scopes []string, drop string) []string { + out := make([]string, 0, len(scopes)) + for _, s := range scopes { + if s != drop { + out = append(out, s) + } + } + return out +} diff --git a/examples/example-app/server/backchannel.go b/examples/example-app/server/backchannel.go new file mode 100644 index 0000000000..8558551a6a --- /dev/null +++ b/examples/example-app/server/backchannel.go @@ -0,0 +1,167 @@ +package server + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + + "github.com/dexidp/dex/examples/example-app/session" +) + +// backchannelLogoutEvent is the member a logout token's "events" claim must +// carry, per OIDC Back-Channel Logout 1.0 ยง2.4. Its presence is what separates a +// logout token from an ID token that happens to be POSTed here. +const backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout" + +// logoutTokenClaims are the parts of a logout token this app reads. +type logoutTokenClaims struct { + Events map[string]json.RawMessage `json:"events"` + SessionID string `json:"sid"` + Nonce string `json:"nonce"` +} + +// handleBackchannelLogout receives a logout token from the provider and ends the +// sessions it names. +// +// This is the half of single logout that does not involve the browser: dex POSTs +// here directly, so a user who signs out somewhere else stops being signed in +// here even if they never load a page. The app also runs a prompt=none check on +// page load, which catches the same thing eventually โ€” the difference is that +// this arrives immediately, and works for a tab nobody is looking at. +// +// Validation follows Back-Channel Logout 1.0 ยง2.6. The signature, issuer, +// audience and expiry checks come from the same verifier the app uses for ID +// tokens; the rest is checked here. +func (s *Server) handleBackchannelLogout(w http.ResponseWriter, r *http.Request) { + // ยง2.7: the response must not be cached, whatever it says. + w.Header().Set("Cache-Control", "no-cache, no-store") + + if err := r.ParseForm(); err != nil { + backchannelError(w, "invalid_request", "could not parse form") + return + } + + raw := r.PostFormValue("logout_token") + if raw == "" { + backchannelError(w, "invalid_request", "no logout_token in request") + return + } + + ctx := oidc.ClientContext(r.Context(), s.client) + + // Signature, iss, aud and exp. A logout token is signed and audienced exactly + // like an ID token, so the same verifier applies โ€” which is also why the + // checks below matter: without them an ID token would pass for one. + token, err := s.verifier.Verify(ctx, raw) + if err != nil { + backchannelError(w, "invalid_request", "logout token did not verify: "+err.Error()) + return + } + + var claims logoutTokenClaims + if err := token.Claims(&claims); err != nil { + backchannelError(w, "invalid_request", "could not decode logout token claims") + return + } + + if _, ok := claims.Events[backchannelLogoutEvent]; !ok { + backchannelError(w, "invalid_request", "logout token has no backchannel-logout event") + return + } + + // ยง2.4 forbids a nonce, precisely so that an ID token replayed to this + // endpoint cannot be mistaken for a logout token. + if claims.Nonce != "" { + backchannelError(w, "invalid_request", "logout token must not contain a nonce") + return + } + + if token.Subject == "" && claims.SessionID == "" { + backchannelError(w, "invalid_request", "logout token has neither sub nor sid") + return + } + + // Replay detection on jti is left out. It is optional in ยง2.6, and a repeated + // logout token can only sign out a session that is already signed out. A real + // relying party with side effects on logout should keep recently seen jti + // values until their tokens expire. + + // The result is shown on the home page rather than logged: what makes this + // worth seeing is when the token arrived relative to the next page load, and + // that comparison only means anything on the page itself. + s.sessions.SignOutByBackchannel(claims.SessionID, token.Subject) + + w.WriteHeader(http.StatusOK) +} + +// backchannelError writes the JSON error body ยง2.7 asks for. +func backchannelError(w http.ResponseWriter, code, description string) { + log.Printf("back-channel logout rejected: %s: %s", code, description) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": code, + "error_description": description, + }) +} + +// handleEvents streams this browser's session events over SSE. +// +// The stream is what makes a back channel observable. Without it the earliest a +// page could learn its session had ended is the next request it makes โ€” and a +// request is exactly when the app's own prompt=none check would have found out +// anyway, so a message delivered then demonstrates nothing. +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + // Watch an existing browser, never mint one. This endpoint is opened by a page + // that already has a session; handing a cookie to anything that connects would + // let a crawler fill the store with sessions nobody is behind. + cookie, err := r.Cookie(session.CookieName) + if err != nil || cookie.Value == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + notices, stop := s.sessions.Watch(cookie.Value) + defer stop() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + // Idle streams are kept alive with comments, which proxies and browsers both + // leave alone. + ping := time.NewTicker(25 * time.Second) + defer ping.Stop() + + for { + select { + case <-r.Context().Done(): + return + + case <-ping.C: + fmt.Fprint(w, ": ping\n\n") + flusher.Flush() + + case notice := <-notices: + payload, err := json.Marshal(notice) + if err != nil { + return + } + fmt.Fprintf(w, "event: backchannel-logout\ndata: %s\n\n", payload) + flusher.Flush() + } + } +} diff --git a/examples/example-app/server/devicecode.go b/examples/example-app/server/devicecode.go new file mode 100644 index 0000000000..6307c84d21 --- /dev/null +++ b/examples/example-app/server/devicecode.go @@ -0,0 +1,210 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +// handleDeviceStart initiates the Device Code Flow by requesting a device code from the IdP. +func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) { + var reqBody struct { + Scopes []string `json:"scopes"` + CrossClients []string `json:"cross_clients"` + ConnectorID string `json:"connector_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, fmt.Sprintf("failed to parse request body: %v", err), http.StatusBadRequest) + return + } + + scopes := buildScopes(reqBody.Scopes, reqBody.CrossClients) + + data := url.Values{} + data.Set("client_id", s.clientID) + data.Set("client_secret", s.clientSecret) + data.Set("scope", strings.Join(scopes, " ")) + if reqBody.ConnectorID != "" { + data.Set("connector_id", reqBody.ConnectorID) + } + + resp, err := s.client.PostForm(s.deviceAuthURL, data) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body := new(bytes.Buffer) + body.ReadFrom(resp.Body) + http.Error(w, fmt.Sprintf("Device code request failed: %s", body.String()), resp.StatusCode) + return + } + + var deviceResp struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + } + + if err := json.NewDecoder(resp.Body).Decode(&deviceResp); err != nil { + http.Error(w, fmt.Sprintf("Failed to decode device response: %v", err), http.StatusInternalServerError) + return + } + + pollInterval := deviceResp.Interval + if pollInterval == 0 { + pollInterval = 5 + } + + s.sessions.StartDevice(s.session(w, r), &session.Device{ + DeviceCode: deviceResp.DeviceCode, + UserCode: deviceResp.UserCode, + VerificationURI: deviceResp.VerificationURI, + PollInterval: pollInterval, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) +} + +// handleDeviceStatus renders the device flow pending page with verification URL and user code. +func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) { + device := s.session(w, r).Device + if device == nil { + http.Error(w, "No device flow in progress", http.StatusBadRequest) + return + } + + s.renderer.RenderDevicePage(w, DevicePageData{ + AdminEnabled: s.admin != nil, + DeviceCode: device.DeviceCode, + UserCode: device.UserCode, + VerificationURI: device.VerificationURI, + PollInterval: device.PollInterval, + LogoURI: dexLogoDataURI, + }) +} + +// handleDevicePoll polls the token endpoint on behalf of the device. +func (s *Server) handleDevicePoll(w http.ResponseWriter, r *http.Request) { + sess := s.session(w, r) + device := sess.Device + if device == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusGone) + json.NewEncoder(w).Encode(map[string]any{ + "error": "no_device_flow", + "error_description": "This browser has no device flow in progress", + }) + return + } + + // If we already have a token, return success. + if device.Token != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "complete"}) + return + } + + // Poll the token endpoint. + data := url.Values{} + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + data.Set("device_code", device.DeviceCode) + data.Set("client_id", s.clientID) + data.Set("client_secret", s.clientSecret) + + tokenResp, err := s.client.PostForm(s.provider.Endpoint().TokenURL, data) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) + return + } + defer tokenResp.Body.Close() + + if tokenResp.StatusCode == http.StatusOK { + var tokenData struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + } + + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenData); err != nil { + http.Error(w, "Failed to decode token", http.StatusInternalServerError) + return + } + + token := (&oauth2.Token{ + AccessToken: tokenData.AccessToken, + TokenType: tokenData.TokenType, + RefreshToken: tokenData.RefreshToken, + }).WithExtra(map[string]any{ + "id_token": tokenData.IDToken, + }) + + s.sessions.SetDeviceToken(sess, token) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "complete"}) + return + } + + // Check for OAuth2 error response. + var errorResp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + if err := json.NewDecoder(tokenResp.Body).Decode(&errorResp); err == nil { + if errorResp.Error == "authorization_pending" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tokenResp.StatusCode) + json.NewEncoder(w).Encode(map[string]any{ + "error": errorResp.Error, + "error_description": errorResp.ErrorDescription, + }) + return + } + + // Unknown response โ€” treat as pending. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) +} + +// handleDeviceComplete displays the token obtained via the Device Code Flow and +// signs this browser in with it, the same as any other completed flow. +func (s *Server) handleDeviceComplete(w http.ResponseWriter, r *http.Request) { + sess := s.session(w, r) + if sess.Device == nil || sess.Device.Token == nil { + http.Error(w, "No token available", http.StatusBadRequest) + return + } + + token := sess.Device.Token + rawIDToken, _ := token.Extra("id_token").(string) + if claims, raw, err := s.claimsFromToken(r, token); err == nil { + s.sessions.SignIn(sess, claims, token, raw) + rawIDToken = raw + } + + s.sessions.RememberTokens(sess, "Device code", token, rawIDToken) + http.Redirect(w, r, "/tokens", http.StatusSeeOther) +} diff --git a/examples/example-app/server/forms.go b/examples/example-app/server/forms.go new file mode 100644 index 0000000000..3778d6a780 --- /dev/null +++ b/examples/example-app/server/forms.go @@ -0,0 +1,228 @@ +package server + +import "net/http" + +// A form page is one flow's parameters and nothing else. The front page lists +// flows; choosing one opens its form. That split is the whole point: parameters +// for six grants on one screen is a wall of inputs with no way to tell which +// belong together, and every one of them is irrelevant to whatever you came to +// do. +type Field struct { + Name string + Label string + Hint string + Type string // text, password, textarea, select, scopes + Placeholder string + Value string + Options []Option + Required bool + Wide bool // full-width control, for tokens rather than identifiers +} + +// scopeField offers the scopes the provider advertises, with the usual ones +// ticked โ€” the same control the browser flows use, rather than a text box you +// have to know the spelling for. +func (s *Server) scopeField(defaults ...string) Field { + chosen := make(map[string]bool, len(defaults)) + for _, d := range defaults { + chosen[d] = true + } + + options := make([]Option, 0, len(s.scopesSupported)) + for _, scope := range s.scopesSupported { + options = append(options, Option{Value: scope, Label: scope, Selected: chosen[scope]}) + } + + return Field{ + Name: "scopes", + Label: "Scopes", + Type: "scopes", + Wide: true, + Options: options, + Hint: "What the token should be good for. Add any the provider does not advertise.", + } +} + +// Option is one choice in a select or scope field. +type Option struct { + Value string + Label string + Selected bool +} + +// FormPageData is a page that asks for parameters and posts them somewhere. +type FormPageData struct { + LogoURI string + AdminEnabled bool + Title string + Description string + Note string + Action string + Submit string + Fields []Field +} + +// handleGrantForm serves the parameters for one direct grant. +func (s *Server) handleGrantForm(w http.ResponseWriter, r *http.Request) { + sess := s.session(w, r) + + var data FormPageData + switch r.PathValue("grant") { + case "client-credentials": + data = FormPageData{ + Title: "Client credentials", + Description: "A token for this application itself. There is no user in this flow, and so no ID token โ€” what comes back says only which client it belongs to.", + Action: "/grant/client-credentials", + Submit: "Request token", + Fields: []Field{s.scopeField("openid")}, + } + case "password": + data = FormPageData{ + Title: "Password", + Description: "Exchanges a username and password for tokens directly.", + Note: "Deprecated. The password passes through this application instead of staying at the provider, which is the thing OAuth exists to avoid. dex allows it only when oauth2.passwordConnector names a connector.", + Action: "/grant/password", + Submit: "Request token", + Fields: []Field{ + {Name: "username", Label: "Username", Type: "text", Placeholder: "user@example.com", Required: true}, + {Name: "password", Label: "Password", Type: "password", Required: true}, + s.scopeField("openid", "profile", "email", "offline_access"), + }, + } + case "token-exchange": + data = FormPageData{ + Title: "Token exchange", + Description: "RFC 8693. Trades a token issued elsewhere for one issued by dex.", + Note: "dex requires connector_id, which is an extension to the RFC: the connector is what verifies the subject token, so it must be one that can.", + Action: "/grant/token-exchange", + Submit: "Exchange", + Fields: []Field{ + {Name: "subject_token", Label: "Subject token", Type: "textarea", Required: true, Wide: true, Hint: "The token you are trading in."}, + {Name: "connector_id", Label: "Connector", Type: "text", Placeholder: "mock", Required: true, Hint: "Which connector verifies the subject token."}, + { + Name: "subject_token_type", Label: "Subject token type", Type: "select", + Options: []Option{ + {Value: tokenTypeAccess, Label: "Access token"}, + {Value: tokenTypeID, Label: "ID token"}, + }, + }, + { + Name: "requested_token_type", Label: "Requested token type", Type: "select", + Options: []Option{ + {Value: "", Label: "Default (access token)"}, + {Value: tokenTypeID, Label: "ID token"}, + {Value: tokenTypeAccess, Label: "Access token"}, + }, + }, + s.scopeField("openid"), + }, + } + case "refresh": + value := "" + if sess.LastTokens != nil { + value = sess.LastTokens.RefreshToken + } + if value == "" && sess.Token != nil { + value = sess.Token.RefreshToken + } + data = FormPageData{ + Title: "Refresh", + Description: "Redeems a refresh token for a new set of tokens. dex rotates the refresh token, so the one that comes back replaces the one you sent.", + Action: "/grant/refresh", + Submit: "Redeem", + Fields: []Field{ + {Name: "refresh_token", Label: "Refresh token", Type: "textarea", Required: true, Wide: true, Value: value}, + }, + } + default: + http.NotFound(w, r) + return + } + + data.LogoURI = dexLogoDataURI + data.AdminEnabled = s.admin != nil + s.renderer.RenderFormPage(w, data) +} + +// handleToolForm serves the parameters for one token tool. +func (s *Server) handleToolForm(w http.ResponseWriter, r *http.Request) { + sess := s.session(w, r) + + // Whatever this browser last got back is the likeliest thing to want to look + // at, so it is filled in already โ€” including from flows that sign nobody in. + var accessToken, idToken, refreshToken string + if sess.LastTokens != nil { + accessToken = sess.LastTokens.AccessToken + refreshToken = sess.LastTokens.RefreshToken + idToken = sess.LastIDToken + } else if sess.Token != nil { + accessToken = sess.Token.AccessToken + refreshToken = sess.Token.RefreshToken + idToken = sess.IDToken + } + + // Introspection takes any of the three, so the page offers whichever this + // browser holds rather than filling in one and leaving the rest to a paste. + held := []Option{} + if accessToken != "" { + held = append(held, Option{Value: accessToken, Label: "access token"}) + } + if idToken != "" { + held = append(held, Option{Value: idToken, Label: "ID token"}) + } + if refreshToken != "" { + held = append(held, Option{Value: refreshToken, Label: "refresh token"}) + } + + var data FormPageData + switch r.PathValue("tool") { + case "introspect": + data = FormPageData{ + Title: "Introspection", + Description: "Asks dex what it makes of a token โ€” access, ID or refresh. This is the only way to learn that one has been revoked: a revoked token still has a valid signature.", + Action: "/tools/introspect", + Submit: "Introspect", + Fields: []Field{ + {Name: "token", Label: "Token", Type: "textarea", Required: true, Wide: true, Value: accessToken, Options: held, + Hint: "Any token dex issued."}, + { + Name: "token_type_hint", Label: "Type hint", Type: "select", + Hint: "Optional โ€” dex works the type out from the token itself and only logs a mismatch.", + Options: []Option{ + {Value: "", Label: "no hint"}, + {Value: "access_token", Label: "access_token"}, + {Value: "id_token", Label: "id_token"}, + {Value: "refresh_token", Label: "refresh_token"}, + }, + }, + }, + } + case "verify": + data = FormPageData{ + Title: "Local verification", + Description: "Checks the signature against the issuer's published keys and reads the claims, the way a resource server would. It says nothing about revocation.", + Action: "/tools/verify", + Submit: "Verify", + Fields: []Field{ + {Name: "token", Label: "JWT", Type: "textarea", Required: true, Wide: true, Value: idToken}, + }, + } + case "userinfo": + data = FormPageData{ + Title: "UserInfo", + Description: "Calls the provider's UserInfo endpoint with an access token, which is how a client gets claims it did not ask to have in the ID token.", + Action: "/userinfo", + Submit: "Call UserInfo", + Fields: []Field{ + {Name: "access_token", Label: "Access token", Type: "textarea", Required: true, Wide: true, Value: accessToken}, + }, + } + default: + http.NotFound(w, r) + return + } + + data.LogoURI = dexLogoDataURI + data.AdminEnabled = s.admin != nil + s.renderer.RenderFormPage(w, data) +} diff --git a/examples/example-app/server/grants.go b/examples/example-app/server/grants.go new file mode 100644 index 0000000000..5d33540320 --- /dev/null +++ b/examples/example-app/server/grants.go @@ -0,0 +1,207 @@ +package server + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +// Token type URNs from RFC 8693. +const ( + tokenTypeAccess = "urn:ietf:params:oauth:token-type:access_token" + tokenTypeID = "urn:ietf:params:oauth:token-type:id_token" +) + +// Grant type identifiers, as they appear in the provider's metadata. +const ( + grantAuthorizationCode = "authorization_code" + grantRefreshToken = "refresh_token" + grantDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" + grantTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" + grantClientCredentials = "client_credentials" + grantPassword = "password" +) + +// handleRefreshGrant redeems a refresh token for a new token set. The token +// comes from the form rather than the session so that a refresh token from +// anywhere โ€” another flow, a copy-paste โ€” can be tried here. +func (s *Server) handleRefreshGrant(w http.ResponseWriter, r *http.Request) { + refresh := r.FormValue("refresh_token") + if refresh == "" { + http.Error(w, "refresh_token is required", http.StatusBadRequest) + return + } + + ctx := oidc.ClientContext(r.Context(), s.client) + + // An expiry in the past is what tells the token source to redeem it now. + stale := &oauth2.Token{RefreshToken: refresh, Expiry: time.Now().Add(-time.Hour)} + token, err := s.oauth2Config(nil).TokenSource(ctx, stale).Token() + if err != nil { + http.Error(w, fmt.Sprintf("refresh failed: %v", err), http.StatusBadRequest) + return + } + + // A refresh renews this browser's sign-in as well, when it produced one. + sess := s.session(w, r) + if claims, rawIDToken, err := s.claimsFromToken(r, token); err == nil { + s.sessions.SignIn(sess, claims, token, rawIDToken) + } + + s.renderToken(w, r, "Refresh token", token) +} + +// handleClientCredentialsGrant gets a token for the application itself. There +// is no user in this flow, so there is no ID token either โ€” what comes back +// says only which client it belongs to. +func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest) + return + } + + cfg := clientcredentials.Config{ + ClientID: s.clientID, + ClientSecret: s.clientSecret, + TokenURL: s.provider.Endpoint().TokenURL, + Scopes: r.Form["scopes"], + } + + ctx := oidc.ClientContext(r.Context(), s.client) + token, err := cfg.Token(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("client credentials grant failed: %v", err), http.StatusBadRequest) + return + } + + s.renderToken(w, r, "Client credentials", token) +} + +// handlePasswordGrant exchanges a username and password for tokens. +// +// The flow is deprecated โ€” it puts the user's password through the application +// rather than keeping it at the provider, which is the thing OAuth exists to +// avoid โ€” and dex only allows it for clients and connectors configured for it. +// It is here because dex still implements it and an example app is where you +// find out whether your configuration works. +func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest) + return + } + + username, password := r.FormValue("username"), r.FormValue("password") + if username == "" || password == "" { + http.Error(w, "username and password are required", http.StatusBadRequest) + return + } + + ctx := oidc.ClientContext(r.Context(), s.client) + cfg := s.oauth2Config(r.Form["scopes"]) + token, err := cfg.PasswordCredentialsToken(ctx, username, password) + if err != nil { + http.Error(w, fmt.Sprintf("password grant failed: %v", err), http.StatusBadRequest) + return + } + + s.renderToken(w, r, "Password", token) +} + +// handleTokenExchangeGrant trades a token from a connector for one issued by +// dex (RFC 8693). +// +// The response carries a single token and an issued_token_type rather than the +// usual set, so the app reads it directly instead of through the oauth2 +// library. dex also requires connector_id, which is an extension to the RFC: +// the connector is what verifies the subject token. +func (s *Server) handleTokenExchangeGrant(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest) + return + } + + subjectToken := r.FormValue("subject_token") + connectorID := r.FormValue("connector_id") + if subjectToken == "" || connectorID == "" { + http.Error(w, "subject_token and connector_id are required", http.StatusBadRequest) + return + } + + subjectTokenType := r.FormValue("subject_token_type") + if subjectTokenType == "" { + subjectTokenType = tokenTypeAccess + } + + form := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"}, + "subject_token": {subjectToken}, + "subject_token_type": {subjectTokenType}, + "connector_id": {connectorID}, + } + if v := r.FormValue("requested_token_type"); v != "" { + form.Set("requested_token_type", v) + } + if scopes := r.Form["scopes"]; len(scopes) > 0 { + form.Set("scope", strings.Join(scopes, " ")) + } + + body, err := s.postToTokenEndpoint(r, form) + if err != nil { + http.Error(w, fmt.Sprintf("token exchange failed: %v", err), http.StatusBadRequest) + return + } + + s.renderRawToken(w, r, "Token exchange", body) +} + +// postToTokenEndpoint posts a form to the token endpoint with client +// authentication and returns the response body. +func (s *Server) postToTokenEndpoint(r *http.Request, form url.Values) ([]byte, error) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, s.provider.Endpoint().TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(url.QueryEscape(s.clientID), url.QueryEscape(s.clientSecret)) + + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(body))) + } + return body, nil +} + +// buildScopes assembles the scope list for an authorization request. +func buildScopes(extraScopes, crossClients []string) []string { + scopes := []string{oidc.ScopeOpenID} + seen := map[string]bool{oidc.ScopeOpenID: true} + + for _, scope := range extraScopes { + if scope != "" && !seen[scope] { + scopes = append(scopes, scope) + seen[scope] = true + } + } + for _, client := range crossClients { + if client != "" { + scopes = append(scopes, "audience:server:client_id:"+client) + } + } + return scopes +} diff --git a/examples/example-app/server/render.go b/examples/example-app/server/render.go new file mode 100644 index 0000000000..f1e5269d52 --- /dev/null +++ b/examples/example-app/server/render.go @@ -0,0 +1,397 @@ +package server + +import ( + "embed" + "html/template" + "io/fs" + "log" + "net/http" + "time" + + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +const dexLogoDataURI = "/static/dex-glyph-color.svg" + +// staticHandler serves embedded static assets. +var staticHandler http.Handler + +func init() { + staticSubFS, err := fs.Sub(staticFS, "static") + if err != nil { + log.Fatalf("failed to create static sub filesystem: %v", err) + } + staticHandler = http.FileServer(http.FS(staticSubFS)) +} + +// TokenSummary is what the index page says about the tokens this browser holds. +type TokenSummary struct { + AccessToken string + IDToken string + RefreshToken bool + Expiry string + Expired bool +} + +// tokenSummary describes a token set without printing it in full. +func tokenSummary(token *oauth2.Token, rawIDToken string) *TokenSummary { + if token == nil { + return nil + } + s := &TokenSummary{ + AccessToken: abbreviate(token.AccessToken), + IDToken: abbreviate(rawIDToken), + RefreshToken: token.RefreshToken != "", + } + if !token.Expiry.IsZero() { + s.Expiry = token.Expiry.Format(time.RFC3339) + s.Expired = time.Now().After(token.Expiry) + } + return s +} + +func abbreviate(token string) string { + const keep = 12 + if len(token) <= keep*2+1 { + return token + } + return token[:keep] + "โ€ฆ" + token[len(token)-keep:] +} + +// IndexPageData holds data for the index page template. +type IndexPageData struct { + ScopesSupported []string + LogoURI string + AdminEnabled bool + PKCE bool + SessionCheck time.Duration + + // Which flows the provider says it supports. A button for a grant dex will + // refuse is a button that only teaches you it does not work. + DeviceSupported bool + RefreshSupported bool + ClientCredentialsSupported bool + PasswordSupported bool + TokenExchangeSupported bool + + User *session.UserClaims + Token *TokenSummary +} + +// TokenPageData holds data for the token display template. +type TokenPageData struct { + LogoURI string + AdminEnabled bool + Grant string + IDToken string + IDTokenJWTLink string + AccessToken string + AccessTokenJWTLink string + RefreshToken string + IssuedTokenType string + ExpiresIn string + RedirectURL string + Claims string + RawResponse string + PublicKeyPEM string +} + +// DevicePageData holds data for the device flow template. +type DevicePageData struct { + AdminEnabled bool + DeviceCode string + UserCode string + VerificationURI string + PollInterval int + LogoURI string +} + +// ToolsPageData holds data for the token tools page. +type ToolsPageData struct { + LogoURI string + AdminEnabled bool +} + +// ResultPageData holds the output of a tool. +type ResultPageData struct { + LogoURI string + AdminEnabled bool + Title string + Verdict string + Body string + // LastGrant names the flow whose tokens are still on the session, so a tool + // result is not a dead end with the tokens left behind on a page you can no + // longer reach. + LastGrant string +} + +// AdminConnector is one connector as the API reports it. +type AdminConnector struct { + ID string + Type string + Name string + GrantTypes []string + Config string +} + +// AdminIdentity is one user identity as the API reports it. +type AdminIdentity struct { + UserID string + ConnectorID string + Email string + EmailVerified bool + Username string + Groups []string + MFADevices []AdminMFADevice + Consents []AdminConsent + Created string + LastLogin string +} + +// AdminConsent is one client a user has approved, and for what. +type AdminConsent struct { + ClientID string + Scopes []string +} + +// AdminMFADevice is one enrolled authenticator. +type AdminMFADevice struct { + AuthenticatorID string + HasSecret bool + Credentials []AdminWebAuthnCredential +} + +// AdminWebAuthnCredential is one registered key. +type AdminWebAuthnCredential struct { + ID string + DisplayName string + Transport []string + SignCount uint32 + Created string +} + +// AdminDetailPageData is one object with everything the API knows about it. +// The lists say what a thing is; this says what it holds. +type AdminDetailPageData struct { + LogoURI string + AdminEnabled bool + Kind string // client, connector, user + Title string + BackURL string + Error string + + Client *AdminClient + Connector *AdminConnector + Identity *AdminIdentity + + // ConnectorGrantTypes are the grants a connector can be restricted to. + ConnectorGrantTypes []Option +} + +// DiscoveryEntry is one metadata field worth reading without scrolling. +type DiscoveryEntry struct { + Name string + Value string + Values []string +} + +// AdminSession is one of dex's own sessions as the API reports it. +type AdminSession struct { + ID string + UserID string + ConnectorID string + IPAddress string + UserAgent string + Created string + Expires string +} + +// AdminRefreshToken is one refresh token as the API reports it. +type AdminRefreshToken struct { + ID string + ClientID string + Created string + LastUsed string +} + +// AdminClient is one OAuth2 client as the API reports it โ€” every field it has, +// since a list that shows three of nine invites you to guess the rest. +type AdminClient struct { + ID string + Name string + Secret string + RedirectURIs []string + TrustedPeers []string + Public bool + LogoURL string + AllowedConnectors []string + SSOSharedWith []string + BackchannelLogoutURI string + PostLogoutRedirectURIs []string + RefreshTokenLifetime string +} + +// AdminPassword is one local password entry as the API reports it. +type AdminPassword struct { + Email string + Username string + UserID string +} + +// AdminPageData holds data for the gRPC API page. Configured is false when the +// app was started without --grpc-addr: the page still exists and says what to +// pass, because a feature that is simply absent reads as a feature that is +// missing. +type AdminPageData struct { + LogoURI string + AdminEnabled bool + Configured bool + Version string + Issuer string + Notice string + Error string + + // Section is which part of the API the page is showing. The API has more + // than twenty methods; one page of all of them is the stack this replaced. + Section string + Sections []AdminSection + + Clients []AdminClient + Passwords []AdminPassword + Connectors []AdminConnector + Identities []AdminIdentity + Sessions []AdminSession + RefreshTokens []AdminRefreshToken + // UserID and ConnectorID are who the sessions section is about. The refresh + // listing needs the sub claim, which the app derives from the pair rather + // than asking for a value nobody has to hand. + UserID string + ConnectorID string + + // Mode is which form the section is showing, if any: create, edit, verify. + // One at a time โ€” three stacked forms is a page you have to read to find + // the one you wanted. + Mode string + + // ConnectorTypes are the types dex accepts, and ConnectorGrantTypes the + // grants a connector can be restricted to, both for the create form. + ConnectorTypes []string + ConnectorGrantTypes []Option + + // Endpoints and Capabilities are what GetDiscovery reports. + Endpoints []DiscoveryEntry + Capabilities []DiscoveryEntry + + // EditClient and EditPassword are prefilled from the row an edit was + // started from, so updating is something you do to a thing you can see. + EditClient *AdminClient + EditPassword *AdminPassword +} + +// AdminSection is one tab of the API page. +type AdminSection struct { + ID string + Label string + Current bool +} + +// Renderer renders HTML pages for the application. +type Renderer interface { + RenderIndexPage(w http.ResponseWriter, data IndexPageData) + RenderTokenPage(w http.ResponseWriter, data TokenPageData) + RenderDevicePage(w http.ResponseWriter, data DevicePageData) + RenderToolsPage(w http.ResponseWriter, data ToolsPageData) + RenderFormPage(w http.ResponseWriter, data FormPageData) + RenderResultPage(w http.ResponseWriter, data ResultPageData) + RenderAdminPage(w http.ResponseWriter, data AdminPageData) + RenderAdminDetailPage(w http.ResponseWriter, data AdminDetailPageData) +} + +// templateRenderer implements Renderer using Go html/template. +type templateRenderer struct { + index *template.Template + token *template.Template + device *template.Template + tools *template.Template + form *template.Template + result *template.Template + admin *template.Template + detail *template.Template +} + +// newTemplateRenderer parses embedded templates and returns a Renderer. +func newTemplateRenderer() Renderer { + parse := func(name string) *template.Template { + t, err := template.ParseFS(templatesFS, "templates/layout.html", "templates/"+name) + if err != nil { + log.Fatalf("failed to parse template %s: %v", name, err) + } + return t + } + + return &templateRenderer{ + index: parse("index.html"), + token: parse("token.html"), + device: parse("device.html"), + tools: parse("tools.html"), + form: parse("form.html"), + result: parse("result.html"), + admin: parse("admin.html"), + detail: parse("detail.html"), + } +} + +func (r *templateRenderer) RenderIndexPage(w http.ResponseWriter, data IndexPageData) { + renderTemplate(w, r.index, data) +} + +func (r *templateRenderer) RenderTokenPage(w http.ResponseWriter, data TokenPageData) { + renderTemplate(w, r.token, data) +} + +func (r *templateRenderer) RenderDevicePage(w http.ResponseWriter, data DevicePageData) { + renderTemplate(w, r.device, data) +} + +func (r *templateRenderer) RenderToolsPage(w http.ResponseWriter, data ToolsPageData) { + renderTemplate(w, r.tools, data) +} + +func (r *templateRenderer) RenderFormPage(w http.ResponseWriter, data FormPageData) { + renderTemplate(w, r.form, data) +} + +func (r *templateRenderer) RenderResultPage(w http.ResponseWriter, data ResultPageData) { + renderTemplate(w, r.result, data) +} + +func (r *templateRenderer) RenderAdminPage(w http.ResponseWriter, data AdminPageData) { + renderTemplate(w, r.admin, data) +} + +func (r *templateRenderer) RenderAdminDetailPage(w http.ResponseWriter, data AdminDetailPageData) { + renderTemplate(w, r.detail, data) +} + +func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data any) { + err := tmpl.ExecuteTemplate(w, "page", data) + if err == nil { + return + } + + switch err := err.(type) { + case *template.Error: + log.Printf("Error rendering template %s: %s", tmpl.Name(), err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + default: + // An error with the underlying writer (e.g. connection dropped). Ignore. + } +} diff --git a/examples/example-app/server/server.go b/examples/example-app/server/server.go new file mode 100644 index 0000000000..c4869dcbae --- /dev/null +++ b/examples/example-app/server/server.go @@ -0,0 +1,323 @@ +package server + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "slices" + "strings" + "syscall" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +// Options configures the Server. +type Options struct { + ClientID string + ClientSecret string + RedirectURI string + IssuerURL string + PKCE bool + RootCAs string + Debug bool + + // SessionCheckInterval is how stale the app lets its idea of the provider's + // session get before confirming it with a prompt=none request. Zero turns + // the check off, which means the app will keep showing a user who has since + // signed out of the provider. + SessionCheckInterval time.Duration + + // gRPC API. Empty GRPCAddr leaves the admin page out of the app entirely. + GRPCAddr string + GRPCCA string + GRPCClientCert string + GRPCClientKey string +} + +// Server is the HTTP server for the example OIDC client application. +type Server struct { + clientID string + clientSecret string + redirectURI string + pkce bool + + sessionCheckInterval time.Duration + + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + scopesSupported []string + grantsSupported []string + offlineAsScope bool + + // Discovered endpoint URLs. + deviceAuthURL string + userInfoURL string + jwksURL string + introspectURL string + endSessionEndpoint string + + client *http.Client + renderer Renderer + sessions *session.Store + admin *adminClient +} + +// New creates a Server by performing OIDC discovery and initializing dependencies. +func New(opts Options) (*Server, error) { + client, err := newHTTPClient(opts.RootCAs, opts.Debug) + if err != nil { + return nil, err + } + + ctx := oidc.ClientContext(context.Background(), client) + provider, err := oidc.NewProvider(ctx, opts.IssuerURL) + if err != nil { + return nil, fmt.Errorf("failed to query provider %q: %v", opts.IssuerURL, err) + } + + // Extract discovery metadata: scopes and endpoint URLs. + var discovery struct { + ScopesSupported []string `json:"scopes_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + JWKSURI string `json:"jwks_uri"` + IntrospectionEndpoint string `json:"introspection_endpoint"` + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err := provider.Claims(&discovery); err != nil { + return nil, fmt.Errorf("failed to parse provider discovery claims: %v", err) + } + + // Determine offline access strategy. + offlineAsScope := true + if len(discovery.ScopesSupported) > 0 { + offlineAsScope = slices.Contains(discovery.ScopesSupported, oidc.ScopeOfflineAccess) + } + + introspectURL := discovery.IntrospectionEndpoint + if introspectURL == "" { + // Dex serves it but has not always advertised it. + introspectURL = strings.TrimSuffix(opts.IssuerURL, "/") + "/token/introspect" + } + + s := &Server{ + clientID: opts.ClientID, + clientSecret: opts.ClientSecret, + redirectURI: opts.RedirectURI, + pkce: opts.PKCE, + + sessionCheckInterval: opts.SessionCheckInterval, + + provider: provider, + verifier: provider.Verifier(&oidc.Config{ClientID: opts.ClientID}), + scopesSupported: discovery.ScopesSupported, + grantsSupported: discovery.GrantTypesSupported, + offlineAsScope: offlineAsScope, + + deviceAuthURL: discovery.DeviceAuthorizationEndpoint, + userInfoURL: discovery.UserInfoEndpoint, + jwksURL: discovery.JWKSURI, + introspectURL: introspectURL, + endSessionEndpoint: discovery.EndSessionEndpoint, + + client: client, + renderer: newTemplateRenderer(), + sessions: session.NewStore(), + } + + if opts.GRPCAddr != "" { + admin, err := newAdminClient(opts) + if err != nil { + return nil, err + } + s.admin = admin + } + + return s, nil +} + +// supportsGrant reports whether the provider advertises a grant. A provider +// that does not is not going to start honouring it because this app offers a +// button for it, so the button is not drawn. +func (s *Server) supportsGrant(grant string) bool { + if len(s.grantsSupported) == 0 { + // Nothing advertised: offer everything rather than nothing, since the + // metadata field is optional. + return true + } + return slices.Contains(s.grantsSupported, grant) +} + +// oauth2Config returns an oauth2.Config for the given scopes. +func (s *Server) oauth2Config(scopes []string) *oauth2.Config { + return &oauth2.Config{ + ClientID: s.clientID, + ClientSecret: s.clientSecret, + Endpoint: s.provider.Endpoint(), + Scopes: scopes, + RedirectURL: s.redirectURI, + } +} + +// sessionKey marks the session already resolved for a request. +type sessionKey struct{} + +// session returns this browser's session, creating one if needed. +// +// The lookup is remembered on the request: handlers ask for the session more +// than once, and on the very first request โ€” the one that has no cookie yet โ€” +// asking twice used to mint two sessions and set two cookies. Whatever the +// second one recorded then belonged to a session the browser never came back +// with, which looked like tokens appearing from nowhere and sign-ins that did +// not survive the redirect they arrived on. +func (s *Server) session(w http.ResponseWriter, r *http.Request) *session.Session { + if sess, ok := r.Context().Value(sessionKey{}).(*session.Session); ok { + return sess + } + + sess := s.sessions.FromRequest(w, r, strings.HasPrefix(s.redirectURI, "https://")) + *r = *r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess)) + return sess +} + +// routes builds the HTTP handler with all application routes. +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + + mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler)) + + mux.HandleFunc("GET /{$}", s.handleIndex) + mux.HandleFunc("POST /login", s.handleLogin) + mux.HandleFunc("GET /logout", s.handleLogout) + + // Back-channel logout. The provider POSTs here directly, so it takes no + // session cookie and belongs to no browser. The event stream is the other + // half: it is how an open page finds out, without waiting to be reloaded. + mux.HandleFunc("POST /backchannel-logout", s.handleBackchannelLogout) + mux.HandleFunc("GET /events", s.handleEvents) + + // Parse redirect URI to register callback on the correct path. + callbackPath := "/callback" + if u, err := url.Parse(s.redirectURI); err == nil && u.Path != "" { + callbackPath = u.Path + } + mux.HandleFunc("GET "+callbackPath, s.handleAuthCallback) + + mux.HandleFunc("POST /device/login", s.handleDeviceStart) + mux.HandleFunc("GET /device", s.handleDeviceStatus) + mux.HandleFunc("POST /device/poll", s.handleDevicePoll) + mux.HandleFunc("GET /device/result", s.handleDeviceComplete) + + // Grants that need no browser redirect: a page for the parameters, and the + // endpoint the page posts to. + mux.HandleFunc("GET /grant/{grant}", s.handleGrantForm) + mux.HandleFunc("POST /grant/refresh", s.handleRefreshGrant) + mux.HandleFunc("POST /grant/client-credentials", s.handleClientCredentialsGrant) + mux.HandleFunc("POST /grant/password", s.handlePasswordGrant) + mux.HandleFunc("POST /grant/token-exchange", s.handleTokenExchangeGrant) + + mux.HandleFunc("GET /tokens", s.handleTokens) + + // Tools for looking at a token you already hold. + mux.HandleFunc("GET /tools", s.handleTools) + mux.HandleFunc("GET /tools/{tool}", s.handleToolForm) + mux.HandleFunc("POST /tools/introspect", s.handleIntrospect) + mux.HandleFunc("POST /tools/verify", s.handleVerify) + mux.HandleFunc("POST /userinfo", s.handleUserInfo) + + // The page is always served; without a gRPC address it says what to pass to + // get the rest of it. + mux.HandleFunc("GET /admin", s.handleAdmin) + if s.admin != nil { + mux.HandleFunc("POST /admin/client/create", s.handleAdminCreateClient) + mux.HandleFunc("POST /admin/client/update", s.handleAdminUpdateClient) + mux.HandleFunc("POST /admin/client/delete", s.handleAdminDeleteClient) + mux.HandleFunc("POST /admin/password/create", s.handleAdminCreatePassword) + mux.HandleFunc("POST /admin/password/update", s.handleAdminUpdatePassword) + mux.HandleFunc("POST /admin/password/verify", s.handleAdminVerifyPassword) + mux.HandleFunc("POST /admin/password/delete", s.handleAdminDeletePassword) + mux.HandleFunc("POST /admin/connector/create", s.handleAdminCreateConnector) + mux.HandleFunc("POST /admin/connector/delete", s.handleAdminDeleteConnector) + mux.HandleFunc("POST /admin/identity/delete", s.handleAdminDeleteIdentity) + mux.HandleFunc("POST /admin/session/delete", s.handleAdminDeleteSession) + mux.HandleFunc("POST /admin/session/terminate", s.handleAdminTerminateSessions) + mux.HandleFunc("POST /admin/session/terminate-connector", s.handleAdminTerminateByConnector) + mux.HandleFunc("GET /admin/client/{id}", s.handleAdminClientDetail) + mux.HandleFunc("GET /admin/connector/{id}", s.handleAdminConnectorDetail) + mux.HandleFunc("GET /admin/user/{connector}/{user}", s.handleAdminUserDetail) + mux.HandleFunc("POST /admin/connector/update", s.handleAdminUpdateConnector) + mux.HandleFunc("POST /admin/consent/revoke", s.handleAdminRevokeConsent) + mux.HandleFunc("POST /admin/webauthn/delete", s.handleAdminDeleteWebAuthn) + mux.HandleFunc("POST /admin/mfa/reset", s.handleAdminResetMFA) + mux.HandleFunc("POST /admin/mfa/secret/delete", s.handleAdminDeleteMFASecret) + mux.HandleFunc("POST /admin/refresh/revoke", s.handleAdminRevokeRefresh) + } + + return mux +} + +// Run starts the HTTP(S) server with graceful shutdown on SIGINT/SIGTERM. +func (s *Server) Run(listenAddr, tlsCert, tlsKey string) error { + u, err := url.Parse(listenAddr) + if err != nil { + return fmt.Errorf("parse listen address: %v", err) + } + + // Shutdown waits for handlers to return and does not cancel their contexts, so + // a response that never ends on its own โ€” the event stream โ€” would hold it open + // forever. Deriving every request from a context cancelled below is what lets + // those handlers notice and finish. + baseCtx, cancelRequests := context.WithCancel(context.Background()) + defer cancelRequests() + + srv := &http.Server{ + Addr: u.Host, + Handler: s.routes(), + BaseContext: func(net.Listener) context.Context { return baseCtx }, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + log.Printf("listening on %s", listenAddr) + switch u.Scheme { + case "http": + errCh <- srv.ListenAndServe() + case "https": + errCh <- srv.ListenAndServeTLS(tlsCert, tlsKey) + default: + errCh <- fmt.Errorf("listen address %q is not using http or https", listenAddr) + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + log.Println("shutting down...") + if s.admin != nil { + s.admin.close() + } + + // Cut the long-lived handlers loose, then give the rest a moment to drain. + // The deadline matters: a second interrupt will not help, because + // NotifyContext has already taken the signal away from the default handler. + cancelRequests() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) + } +} diff --git a/examples/example-app/server/static/app.js b/examples/example-app/server/static/app.js new file mode 100644 index 0000000000..53d3898aab --- /dev/null +++ b/examples/example-app/server/static/app.js @@ -0,0 +1,377 @@ +(function() { + const crossClientInput = document.getElementById("cross_client_input"); + const crossClientList = document.getElementById("cross-client-list"); + const addClientBtn = document.getElementById("add-cross-client"); + const scopesList = document.getElementById("scopes-list"); + const customScopeInput = document.getElementById("custom_scope_input"); + const addCustomScopeBtn = document.getElementById("add-custom-scope"); + + // Default scopes that should be checked by default + const defaultScopes = ["openid", "profile", "email", "offline_access"]; + + // The script is loaded on every page; only the index has these controls. + if (scopesList) { + scopesList.querySelectorAll('input[type="checkbox"]').forEach(cb => { + if (defaultScopes.includes(cb.value)) { + cb.checked = true; + } + }); + } + + function addCrossClient(value) { + const trimmed = value.trim(); + if (!trimmed) return; + + const chip = document.createElement("div"); + chip.className = "chip"; + + const text = document.createElement("span"); + text.textContent = trimmed; + + const hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.name = "cross_client"; + hidden.value = trimmed; + + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "ร—"; + remove.onclick = () => crossClientList.removeChild(chip); + + chip.append(text, hidden, remove); + crossClientList.appendChild(chip); + } + + function addCustomScope(scope) { + const trimmed = scope.trim(); + if (!trimmed || !scopesList) return; + + // Check if scope already exists + const existingCheckboxes = scopesList.querySelectorAll('input[type="checkbox"]'); + for (const cb of existingCheckboxes) { + if (cb.value === trimmed) { + cb.checked = true; + return; + } + } + + // Add new scope checkbox + const scopeItem = document.createElement("div"); + scopeItem.className = "scope-item"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.name = "extra_scopes"; + checkbox.value = trimmed; + checkbox.id = "scope_custom_" + trimmed; + checkbox.checked = true; + + const label = document.createElement("label"); + label.htmlFor = checkbox.id; + label.textContent = trimmed; + + scopeItem.append(checkbox, label); + scopesList.appendChild(scopeItem); + } + + addClientBtn?.addEventListener("click", () => { + addCrossClient(crossClientInput.value); + crossClientInput.value = ""; + crossClientInput.focus(); + }); + + crossClientInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + addCrossClient(crossClientInput.value); + crossClientInput.value = ""; + } + }); + + addCustomScopeBtn?.addEventListener("click", () => { + addCustomScope(customScopeInput.value); + customScopeInput.value = ""; + customScopeInput.focus(); + }); + + customScopeInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + addCustomScope(customScopeInput.value); + customScopeInput.value = ""; + } + }); + + // Scope pickers on the flow forms take custom scopes too. + document.querySelectorAll(".add-custom-scope").forEach(function (btn) { + var wrap = btn.closest(".form-control"); + var input = wrap && wrap.querySelector(".custom-scope-input"); + var list = wrap && wrap.querySelector(".scopes-list"); + if (!input || !list) return; + + var add = function () { + var value = input.value.trim(); + if (!value) return; + + var existing = list.querySelector('input[value="' + value + '"]'); + if (existing) { + existing.checked = true; + } else { + var item = document.createElement("div"); + item.className = "scope-item"; + var box = document.createElement("input"); + box.type = "checkbox"; + box.name = "scopes"; + box.value = value; + box.checked = true; + box.id = "scope_custom_" + value; + var label = document.createElement("label"); + label.htmlFor = box.id; + label.textContent = value; + item.append(box, label); + list.appendChild(item); + } + input.value = ""; + input.focus(); + }; + + btn.addEventListener("click", add); + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }); + }); + + // Device Grant Login Handler + const deviceGrantBtn = document.getElementById("device-grant-btn"); + deviceGrantBtn?.addEventListener("click", async () => { + deviceGrantBtn.disabled = true; + deviceGrantBtn.textContent = "Loading..."; + + try { + // Collect form data similar to regular login + const form = document.getElementById("login-form"); + const formData = new FormData(form); + + // Get selected scopes + const scopes = formData.getAll("extra_scopes"); + + // Get cross-client values + const crossClients = formData.getAll("cross_client"); + + // Get connector_id if specified + const connectorId = formData.get("connector_id") || ""; + + // Initiate device flow with options + const response = await fetch('/device/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + scopes: scopes, + cross_clients: crossClients, + connector_id: connectorId + }) + }); + + if (response.ok) { + // Redirect to device flow page + window.location.href = '/device'; + } else { + const errorText = await response.text(); + alert('Failed to start device flow: ' + errorText); + } + } catch (error) { + alert('Error starting device flow: ' + error.message); + } finally { + deviceGrantBtn.disabled = false; + deviceGrantBtn.textContent = "Device code"; + } + }); +})(); + + +// JSON syntax highlighting. Claims and API responses are read, not skimmed: +// telling a key from a value from a number is most of what makes a blob of +// JSON legible at a glance. +(function () { + function escapeHTML(text) { + return text.replace(/&/g, "&").replace(//g, ">"); + } + + function highlight(text) { + return escapeHTML(text).replace( + /("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\b\d+(\.\d+)?([eE][+-]?\d+)?\b)/g, + function (match) { + var cls = "json-number"; + if (/^"/.test(match)) { + cls = /:$/.test(match) ? "json-key" : "json-string"; + } else if (/true|false/.test(match)) { + cls = "json-boolean"; + } else if (/null/.test(match)) { + cls = "json-null"; + } + return '' + match + ""; + }, + ); + } + + document.querySelectorAll("pre.json").forEach(function (el) { + var text = el.textContent; + try { + // Reject anything that is not JSON rather than colouring it wrongly: + // these blocks also carry error text from the provider. + JSON.parse(text); + } catch (e) { + return; + } + el.innerHTML = highlight(text); + }); + + // A JWT is three base64 segments; colouring the dots apart is enough to see + // where the header ends and the signature begins. + document.querySelectorAll("pre.token[data-jwt]").forEach(function (el) { + var parts = el.textContent.trim().split("."); + if (parts.length !== 3) return; + el.innerHTML = + '' + escapeHTML(parts[0]) + "." + + '' + escapeHTML(parts[1]) + "." + + '' + escapeHTML(parts[2]) + ""; + }); +})(); + +// "Use my access token" and friends fill the field they sit under, so trying a +// tool against a different one of your tokens is a click rather than a paste. +document.querySelectorAll(".fill-token").forEach(function (btn) { + btn.addEventListener("click", function () { + var control = btn.closest(".form-control"); + var field = control && control.querySelector("textarea"); + if (!field) return; + field.value = btn.getAttribute("data-token"); + field.focus(); + }); +}); + +// List fields โ€” redirect URIs, trusted peers, allowed connectors โ€” are lists in +// the API, so the form collects them as one value each rather than as lines of +// text somebody has to split. +document.querySelectorAll(".chip-add").forEach(function (btn) { + var control = btn.closest(".form-control"); + var list = control && control.querySelector(".chips"); + var input = control && control.querySelector(".chip-input"); + if (!list || !input) return; + + var add = function () { + var value = input.value.trim(); + if (!value) return; + + var chip = document.createElement("span"); + chip.className = "chip"; + + var text = document.createElement("span"); + text.textContent = value; + + var hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.name = list.getAttribute("data-name"); + hidden.value = value; + + var remove = document.createElement("button"); + remove.type = "button"; + remove.className = "chip-remove"; + remove.textContent = "ร—"; + remove.addEventListener("click", function () { chip.remove(); }); + + chip.append(text, hidden, remove); + list.appendChild(chip); + input.value = ""; + input.focus(); + }; + + btn.addEventListener("click", add); + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }); +}); + +document.querySelectorAll(".chip-remove").forEach(function (btn) { + btn.addEventListener("click", function () { + var chip = btn.closest(".chip"); + if (chip) chip.remove(); + }); +}); + +// Back-channel logout arrives over an event stream rather than on the next page +// load, because when it arrives is the whole point: a push that only surfaces +// when you reload is indistinguishable from the prompt=none check this app +// already runs on every load. +(function () { + if (!window.EventSource) return; + + // Only where the notice has something to say. A stream is a connection held + // open for as long as the page lives, and a browser allows six of them per + // host, so opening one from every page โ€” the admin screens included โ€” is how + // you starve the rest of the site of connections. + if (!document.getElementById("signed-in-card")) return; + + const stream = new EventSource("/events"); + + // Release the connection as the page goes away rather than waiting for the + // browser to notice, so a reload does not briefly hold two. + window.addEventListener("pagehide", function () { + stream.close(); + }); + + stream.addEventListener("backchannel-logout", function (event) { + let notice = {}; + try { + notice = JSON.parse(event.data); + } catch (e) { + return; + } + + const container = document.querySelector(".container"); + if (!container || document.getElementById("backchannel-banner")) return; + + // The session is over, so the page must stop showing one. Leaving the + // signed-in card up next to a notice saying you are signed out is the + // page contradicting itself. + document.getElementById("signed-in-card")?.remove(); + document.getElementById("signed-out-card")?.removeAttribute("hidden"); + + const card = document.createElement("div"); + card.className = "card"; + card.id = "backchannel-banner"; + + const title = document.createElement("div"); + title.className = "card-title"; + title.textContent = "Signed out by back-channel logout"; + + const hint = document.createElement("p"); + hint.className = "hint"; + const at = notice.at ? new Date(notice.at).toLocaleTimeString() : "just now"; + hint.textContent = + "dex pushed a logout token to this app at " + at + + ". Nothing was loaded here to find that out" + + (notice.sid ? " โ€” session " + notice.sid : "") + "."; + + const actions = document.createElement("div"); + actions.className = "form-actions"; + + const dismiss = document.createElement("button"); + dismiss.type = "button"; + dismiss.className = "button button-secondary"; + dismiss.textContent = "Dismiss"; + dismiss.addEventListener("click", function () { card.remove(); }); + + actions.append(dismiss); + card.append(title, hint, actions); + container.insertBefore(card, container.querySelector(".card")); + }); +})(); diff --git a/examples/example-app/server/static/device.js b/examples/example-app/server/static/device.js new file mode 100644 index 0000000000..5091c763f1 --- /dev/null +++ b/examples/example-app/server/static/device.js @@ -0,0 +1,84 @@ +(function() { + const deviceCode = document.getElementById("device-code")?.value; + const pollInterval = parseInt(document.getElementById("poll-interval")?.value || "5", 10); + const verificationURL = document.getElementById("verification-url")?.textContent; + const userCode = document.getElementById("user-code")?.textContent; + const statusText = document.getElementById("status-text"); + const errorMessage = document.getElementById("error-message"); + const openAuthBtn = document.getElementById("open-auth-btn"); + + let pollTimer = null; + + openAuthBtn?.addEventListener("click", () => { + if (verificationURL && userCode) { + const url = verificationURL + "?user_code=" + encodeURIComponent(userCode); + window.open(url, "_blank", "width=600,height=800"); + } + }); + + async function pollForToken() { + try { + const response = await fetch('/device/poll', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({device_code: deviceCode}) + }); + + const data = await response.json(); + + if (response.ok && data.status === 'complete') { + statusText.textContent = "Authentication successful! Redirecting..."; + stopPolling(); + window.location.href = '/device/result'; + } else if (response.ok && data.status === 'pending') { + statusText.textContent = "Waiting for authentication..."; + } else { + const errorText = data.error_description || data.error || 'Unknown error'; + + if (data.error === 'no_device_flow') { + showError('This browser has no device flow in progress. Start one from the home page.'); + stopPolling(); + } else if (data.error === 'expired_token' || data.error === 'access_denied') { + showError(data.error === 'expired_token' ? + 'The device code has expired. Please start over.' : + 'Authentication was denied.'); + stopPolling(); + } + } + } catch (error) { + console.error('Polling error:', error); + } + } + + function showError(message) { + errorMessage.textContent = message; + errorMessage.style.display = 'block'; + + // Hide the status indicator (contains spinner and status text) + const statusIndicator = document.querySelector('.status-indicator'); + if (statusIndicator) { + statusIndicator.style.display = 'none'; + } + } + + function startPolling() { + pollForToken(); + pollTimer = setInterval(pollForToken, pollInterval * 1000); + } + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + if (deviceCode) { + startPolling(); + } + + window.addEventListener('beforeunload', stopPolling); +})(); + diff --git a/examples/example-app/server/static/dex-glyph-color.svg b/examples/example-app/server/static/dex-glyph-color.svg new file mode 100644 index 0000000000..5852a5f348 --- /dev/null +++ b/examples/example-app/server/static/dex-glyph-color.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/examples/example-app/server/static/style.css b/examples/example-app/server/static/style.css new file mode 100644 index 0000000000..ce3be3a2b9 --- /dev/null +++ b/examples/example-app/server/static/style.css @@ -0,0 +1,664 @@ +:root { + --fg: #1a1a1a; + --muted: #6b7280; + --border: #e1e4e8; + --accent: #2f6fb0; + --bad: #b3261e; + --surface: #fff; + --page: #f4f5f7; +} + +* { + box-sizing: border-box; +} + +body { + background-color: var(--page); + color: var(--fg); + font-family: -apple-system, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.5; + margin: 0; + padding: 32px 16px 64px; +} + +.container { + margin: 0 auto; + max-width: 640px; +} + +.logo-link { + display: block; + text-align: center; +} + +.logo { + height: auto; + width: 140px; +} + +.nav { + display: flex; + gap: 20px; + justify-content: center; + margin: 16px 0 24px; +} + +.nav a { + color: var(--muted); + font-size: 14px; + text-decoration: none; +} + +.nav a:hover { + color: var(--accent); + text-decoration: underline; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 16px; + padding: 20px; +} + +.card-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 12px; +} + +.hint { + color: var(--muted); + font-size: 13px; + margin: 6px 0; +} + +.verdict { + background: #f4f5f7; + border-left: 3px solid var(--accent); + font-size: 13px; + margin: 12px 0; + padding: 8px 12px; +} + +.verdict-bad { + border-left-color: var(--bad); + color: var(--bad); +} + +/* Label/value pairs. */ +.kv { + display: grid; + font-size: 13px; + gap: 6px 16px; + grid-template-columns: 140px minmax(0, 1fr); +} + +.kv .k { + color: var(--muted); +} + +.kv .v { + overflow-wrap: anywhere; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12.5px; +} + +.small { + font-size: 12px; +} + +.user-code code { + font-size: 20px; + letter-spacing: 0.08em; +} + +pre.token, +pre.json { + background: #f7f8fa; + border: 1px solid var(--border); + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + margin: 8px 0; + max-height: 320px; + overflow: auto; + overflow-wrap: anywhere; + padding: 10px; + white-space: pre-wrap; +} + +.row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.stack { + margin-top: 12px; +} + +.button { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--fg); + cursor: pointer; + display: inline-block; + font: inherit; + font-size: 14px; + font-weight: 600; + padding: 8px 14px; + text-decoration: none; +} + +.button:hover { + border-color: #b0b5bd; +} + +.button-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.button-primary:hover { + filter: brightness(0.94); +} + +.button-secondary { + color: var(--muted); +} + +.button-small { + font-size: 13px; + padding: 6px 10px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.field > label { + font-size: 14px; + font-weight: 600; +} + +textarea, +select, +input[type="text"], +input[type="email"], +input[type="password"] { + border: 1px solid var(--border); + border-radius: 6px; + font: inherit; + font-size: 14px; + padding: 8px 10px; + width: 100%; +} + +textarea { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + resize: vertical; +} + +.checkbox { + align-items: center; + display: flex; + font-size: 14px; + font-weight: 400; + gap: 8px; +} + +.checkbox input { + width: auto; +} + +.advanced { + border: 1px solid var(--border); + border-radius: 8px; + margin-top: 12px; + padding: 12px; +} + +.advanced summary { + cursor: pointer; + font-size: 14px; + font-weight: 600; +} + +.advanced .field { + margin-top: 12px; +} + +.scopes-list { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; +} + +.scope-item { + align-items: center; + display: flex; + font-size: 13px; + gap: 6px; +} + +.scope-item input { + width: auto; +} + +.inline-input { + display: flex; + gap: 8px; +} + +.chip { + align-items: center; + background: #f4f5f7; + border: 1px solid var(--border); + border-radius: 6px; + display: inline-flex; + font-size: 13px; + gap: 6px; + margin: 4px 4px 0 0; + padding: 3px 8px; +} + +.chip button { + background: none; + border: 0; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} + +.table { + border-collapse: collapse; + font-size: 13px; + margin-bottom: 12px; + width: 100%; +} + +.table th { + border-bottom: 1px solid var(--border); + color: var(--muted); + font-weight: 600; + padding: 6px 8px 6px 0; + text-align: left; +} + +.table td { + border-bottom: 1px solid var(--border); + padding: 8px 8px 8px 0; + vertical-align: top; +} + +/* The last row's rule and the following section's rule would otherwise sit one + pixel apart and read as a double line. */ +.table tr:last-child td { + border-bottom: 0; +} + +.row-actions { + display: flex; + gap: 6px; +} + +.row-actions form { + margin: 0; +} + +.mfa-device { + white-space: nowrap; +} + +.tag { + background: #f4f5f7; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 11px; + padding: 1px 5px; +} + +@media (max-width: 480px) { + .kv { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Flow lists. The front page offers flows, not their parameters: six grants' + worth of inputs on one screen is a wall with no way to tell which fields + belong together, and most of them are irrelevant to what you came to do. */ +.flows { + list-style: none; + margin: 0; + padding: 0; +} + +.flow { + align-items: center; + border-top: 1px solid var(--border); + display: flex; + gap: 16px; + justify-content: space-between; + padding: 12px 0; +} + +.flow:first-child { + border-top: 0; + padding-top: 4px; +} + +.flow-text { + min-width: 0; +} + +.flow-name { + font-size: 14px; + font-weight: 600; +} + +.flow-desc { + color: var(--muted); + font-size: 13px; +} + +.flow form { + margin: 0; +} + +/* Forms. One label column, so every field on a page lines up whatever its + control is. */ +.form { + margin-top: 16px; +} + +.form-row { + display: grid; + gap: 4px 16px; + grid-template-columns: 150px minmax(0, 1fr); + margin-bottom: 14px; +} + +.form-row > label { + font-size: 13px; + font-weight: 600; + padding-top: 8px; +} + +/* Tokens and lists get the full width; identifiers do not need it. */ +.form-row--wide { + grid-template-columns: minmax(0, 1fr); +} + +.form-row--wide > label { + padding-top: 0; +} + +.form-control { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 420px; +} + +.form-row--wide .form-control { + max-width: none; +} + +.form-control .hint { + margin: 0; +} + +.form-actions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.req { + color: var(--muted); + font-weight: 400; +} + +.subhead { + border-top: 1px solid var(--border); + font-size: 14px; + font-weight: 600; + margin-top: 16px; + padding-top: 16px; +} + +.note { + background: #fff8e6; + border-left: 3px solid #d9a441; + font-size: 13px; + margin: 12px 0; + padding: 8px 12px; +} + +@media (max-width: 560px) { + .form-row { + grid-template-columns: minmax(0, 1fr); + } + + .form-row > label { + padding-top: 0; + } + + .flow { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } +} + +/* Syntax colours. Enough contrast to separate the parts, not so much that the + block becomes a Christmas tree. */ +.json-key { + color: #2f6fb0; +} + +.json-string { + color: #227a4b; +} + +.json-number { + color: #a2540d; +} + +.json-boolean, +.json-null { + color: #8250df; +} + +.jwt-header { + color: #b3261e; +} + +.jwt-payload { + color: #2f6fb0; +} + +.jwt-signature { + color: #227a4b; +} + +pre.code { + background: #f7f8fa; + border: 1px solid var(--border); + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + margin: 4px 0; + padding: 10px; + white-space: pre-wrap; +} + +/* Section tabs on the API page. */ +.tabs { + border-bottom: 1px solid var(--border); + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 16px; +} + +.tab { + border-bottom: 2px solid transparent; + color: var(--muted); + font-size: 14px; + font-weight: 600; + padding: 6px 10px; + text-decoration: none; +} + +.tab:hover { + color: var(--fg); +} + +.tab--current { + border-bottom-color: var(--accent); + color: var(--fg); +} + +/* Row actions sit in a column of their own so the buttons line up down the + table instead of wherever the text before them happens to end. */ +.table .actions-col { + text-align: right; + white-space: nowrap; + width: 1%; +} + +.table .row-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.table .button-small { + line-height: 1.2; +} + +/* The flow list starts far enough below the heading to read as a list under it + rather than a first row of it. */ +.card-title + .flows, +.card-title + .hint + .flows { + margin-top: 16px; +} + +.card .table { + display: block; + overflow-x: auto; +} + +/* An object's header: what it is on the left, the way back on the right. */ +.detail-head { + align-items: flex-start; + display: flex; + gap: 16px; + justify-content: space-between; +} + +.detail-head .card-title { + margin-bottom: 2px; + overflow-wrap: anywhere; +} + +.detail-head .hint { + margin: 0; +} + +.table a { + color: var(--fg); +} + +.table a:hover { + color: var(--accent); +} + +/* A label column sized for "Redirect URIs" cannot hold + "device_authorization_endpoint" โ€” the metadata page names its own fields, so + it gets a column wide enough for them and labels that wrap rather than run + into their values. */ +.kv .k { + overflow-wrap: anywhere; +} + +.kv--metadata { + grid-template-columns: minmax(0, 280px) minmax(0, 1fr); +} + +@media (max-width: 620px) { + .kv--metadata { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Grant types are URNs; side by side they wrap to one per line, so they get a + grid with cells wide enough to hold one. */ +.scopes-list--grid { + display: grid; + gap: 6px 16px; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.chips:empty { + display: none; +} + +.chip-remove { + background: none; + border: 0; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} + +/* One authenticator per block, so its keys and its actions read as belonging + to it rather than to the section. */ +.mfa-device { + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 12px; + padding: 12px; +} + +.mfa-device .subhead { + border-top: 0; + margin-top: 0; + padding-top: 0; +} + +.mfa-reset { + border-top: 1px solid var(--border); + margin-top: 16px; + padding-top: 16px; +} + +/* Metadata names are long and fixed; values are lists. A column for the name + and pills for the values beats a two-column grid that has to fit both. */ +.meta-name { + color: var(--muted); + white-space: nowrap; + width: 1%; +} + diff --git a/examples/example-app/server/subject.go b/examples/example-app/server/subject.go new file mode 100644 index 0000000000..4c72120359 --- /dev/null +++ b/examples/example-app/server/subject.go @@ -0,0 +1,37 @@ +package server + +import "encoding/base64" + +// idTokenSubject builds the sub claim dex issues for a user. +// +// dex's API is inconsistent about which identifier it takes: sessions are keyed +// by the ID the connector gave the user, refresh tokens by the sub claim. Rather +// than ask for a value nobody has to hand, the app derives one from the other. +// +// The sub is base64url of a two-field protobuf message โ€” user_id then conn_id, +// both length-delimited strings โ€” which is small enough to encode here and +// avoids the example depending on dex's internals for it. +func idTokenSubject(userID, connectorID string) string { + var buf []byte + buf = appendProtoString(buf, 1, userID) + buf = appendProtoString(buf, 2, connectorID) + return base64.RawURLEncoding.EncodeToString(buf) +} + +// appendProtoString writes one length-delimited protobuf string field. +func appendProtoString(buf []byte, field int, value string) []byte { + if value == "" { + return buf + } + buf = appendVarint(buf, uint64(field)<<3|2) // wire type 2: length-delimited + buf = appendVarint(buf, uint64(len(value))) + return append(buf, value...) +} + +func appendVarint(buf []byte, v uint64) []byte { + for v >= 0x80 { + buf = append(buf, byte(v)|0x80) + v >>= 7 + } + return append(buf, byte(v)) +} diff --git a/examples/example-app/server/subject_test.go b/examples/example-app/server/subject_test.go new file mode 100644 index 0000000000..6db15e8bce --- /dev/null +++ b/examples/example-app/server/subject_test.go @@ -0,0 +1,23 @@ +package server + +import "testing" + +// The subject the app derives has to be byte for byte what dex puts in a token, +// or the refresh listing it feeds asks about a user who does not exist. These +// are subs taken from tokens dex issued. +func TestIDTokenSubject(t *testing.T) { + tests := []struct { + userID string + connectorID string + want string + }{ + {"0-385-28089-0", "mock", "Cg0wLTM4NS0yODA4OS0wEgRtb2Nr"}, + {"08a8684b-db88-4b73-90a9-3cd1661f5466", "local", "CiQwOGE4Njg0Yi1kYjg4LTRiNzMtOTBhOS0zY2QxNjYxZjU0NjYSBWxvY2Fs"}, + } + + for _, tc := range tests { + if got := idTokenSubject(tc.userID, tc.connectorID); got != tc.want { + t.Errorf("idTokenSubject(%q, %q) = %q, want %q", tc.userID, tc.connectorID, got, tc.want) + } + } +} diff --git a/examples/example-app/server/templates/admin.html b/examples/example-app/server/templates/admin.html new file mode 100644 index 0000000000..84bdd7c2ba --- /dev/null +++ b/examples/example-app/server/templates/admin.html @@ -0,0 +1,673 @@ +{{define "content"}} +
+
dex gRPC API
+

+ The API that manages dex itself. It is a separate endpoint from the OpenID + Connect one, and tokens from that one do not authenticate against it. +

+
+ {{if .Version}}
Server
{{.Version}}
{{end}} + {{if .Issuer}}
Issuer
{{.Issuer}}
{{end}} +
+ {{if .Notice}}

{{.Notice}}

{{end}} + {{if .Error}}

{{.Error}}

{{end}} +
+ +{{if not .Configured}} +
+
Not connected
+

This app was started without a gRPC address, so it has nothing to talk to. Two things are needed.

+ +
+ +
+
grpc:
+  addr: 127.0.0.1:5557
+ Plaintext is fine on a loopback address; add tlsCert, tlsKey and tlsClientCA for anything else. +
+
+ +
+ +
+
--grpc-addr 127.0.0.1:5557
+ With certificates: --grpc-ca, --grpc-client-cert and --grpc-client-key. +
+
+
+{{else}} + +
+ + + {{if eq .Section "clients"}} + {{if .Clients}} + + + {{range .Clients}} + + + + + + + {{end}} +
IDNameRedirects
{{.ID}}{{if .Public}} public{{end}}{{.Name}}{{len .RedirectURIs}} +
+ Open +
+ + + +
+
+
+ {{else}} +

ListClients returns nothing. Clients from a static config are not stored, and so are not listed.

+ {{end}} + + {{if not .Mode}} + + {{end}} + + {{if eq .Mode "create"}} +
CreateClient
+
+ +
+ +
+
+
+ +
+
+
+ +
+ + Leave empty for a public client. +
+
+
+ +
+
+
+ +
+
+
+
+ + +
+ Where dex may send the browser back to. +
+
+
+ +
+
+
+
+ + +
+ Clients allowed to ask for tokens with this one as the audience. +
+
+
+ +
+
+
+
+ + +
+ Empty means every connector. +
+
+
+ +
+
+
+
+ + +
+ Which clients may reuse this one's session. * for all. +
+
+
+ +
+
+
+
+ + +
+ Where the browser may be sent after logging out. Anything not listed is refused. +
+
+
+ +
+ + Where dex POSTs a logout token when a session ends. Empty means this client is never told. +
+
+
+ +
+ + standalone tokens outlive the browser session, which is what keeps a CLI signed in. session tokens stop refreshing the moment that session ends. +
+
+
+ +
+ +
+
+
+ + Cancel +
+
+ {{end}} + + {{with .EditClient}} +
UpdateClient โ€” {{.ID}}
+
+ + +
+ +
+ + Fields left empty stay as they are. +
+
+
+ +
+
+
+ +
+
+ {{range .RedirectURIs}}{{.}}{{end}} +
+
+ + +
+ Where dex may send the browser back to. +
+
+
+ +
+
+ {{range .TrustedPeers}}{{.}}{{end}} +
+
+ + +
+ Clients allowed to ask for tokens with this one as the audience. +
+
+
+ +
+
+ {{range .AllowedConnectors}}{{.}}{{end}} +
+
+ + +
+ Empty means every connector. +
+
+
+ +
+
+ {{range .SSOSharedWith}}{{.}}{{end}} +
+
+ + +
+ Which clients may reuse this one's session. * for all. +
+
+
+ +
+
+ {{range .PostLogoutRedirectURIs}}{{.}}{{end}} +
+
+ + +
+ Where the browser may be sent after logging out. Anything not listed is refused. +
+
+
+ +
+ + Clearing the box removes it, and dex stops sending this client logout tokens. +
+
+
+ +
+ + Switching to session ends this client's refresh tokens with the browser session they came from. +
+
+

+ UpdateClient carries no secret and no public flag, so neither can be + changed: dex expects a client that needs a new secret to be replaced. +

+
+ + Cancel +
+
+ {{end}} + {{end}} + + {{if eq .Section "passwords"}} + {{if .Passwords}} + + + {{range .Passwords}} + + + + + + + {{end}} +
EmailUsernameUser ID
{{.Email}}{{.Username}}{{.UserID}} +
+ Edit +
+ + + +
+
+
+ {{else}} +

ListPasswords returns nothing. Users from staticPasswords are not stored, and so are not listed.

+ {{end}} + + {{if not .Mode}} + + {{end}} + + {{if eq .Mode "create"}} +
CreatePassword
+
+ +
+ +
+
+
+ +
+
+
+ +
+ + What the connector calls this user; the sub claim is built from it. +
+
+
+ +
+ + The API takes a bcrypt hash; this page hashes what you type. +
+
+
+ + Cancel +
+
+ {{end}} + + {{with .EditPassword}} +
UpdatePassword โ€” {{.Email}}
+
+ + +
+ +
+
+
+ +
+ + Leave empty to change only the username. +
+
+

+ A password entry holds four things and no more: email, username, user + ID and the hash. UpdatePassword carries only the username and the hash, + and looks the entry up by its email โ€” so the email and the user ID are + fixed once created. Claims like groups are not stored here at all; they + come from the connector. +

+
+ + Cancel +
+
+ {{end}} + + {{if eq .Mode "verify"}} +
VerifyPassword
+
+ +
+ +
+
+
+ +
+ + Checks a password without issuing anything. +
+
+
+ + Cancel +
+
+ {{end}} + {{end}} + + {{if eq .Section "connectors"}} + {{if .Connectors}} + + + {{range .Connectors}} + + + + + + + {{end}} +
IDTypeName
{{.ID}}{{.Type}}{{.Name}} +
+ Open +
+ + + +
+
+
+ {{else}} +

ListConnectors returns nothing. Connectors from the config file are not stored, and so are not listed.

+ {{end}} + + {{if not .Mode}} + + {{end}} + + {{if eq .Mode "terminate"}} +
TerminateSessionsByConnector
+
+ + +
+ +
+ + Ends every session that came through it, for every user โ€” for a connector being retired or one you no longer trust. +
+
+
+ + Cancel +
+
+ {{end}} + + {{if eq .Mode "create"}} +
CreateConnector
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+ +
+ + The connector's own JSON configuration โ€” the same keys the config file takes for this type. +
+
+
+ +
+
+ {{range $.ConnectorGrantTypes}} +
+ + +
+ {{end}} +
+ Nothing ticked leaves the connector unrestricted. +
+
+
+ + Cancel +
+
+ {{end}} + {{end}} + + {{if eq .Section "identities"}} + {{if .Identities}} + + + {{range .Identities}} + + + + + + + + {{end}} +
UserConnectorGroupsMFA
+ {{if .Email}}{{.Email}}{{else}}{{.UserID}}{{end}} +
{{.UserID}}
+
{{.ConnectorID}}{{len .Groups}}{{len .MFADevices}} +
+ Open +
+
+

+ Counts rather than contents: a user with forty groups would otherwise be the + whole table. Everything about one โ€” claims, consents, factors โ€” is on its page. +

+ {{else}} +

+ ListUserIdentities returns nothing. An identity appears once someone signs + in through a connector โ€” and the method itself needs dex started with + DEX_API_SESSIONS_IDENTITIES_CRUD=true. +

+ {{end}} + {{end}} + + {{if eq .Section "discovery"}} +

What dex reports about itself through GetDiscovery.

+ + {{range .Endpoints}} + + {{end}} + {{range .Capabilities}} + + + + + {{end}} +
{{.Name}}{{.Value}}
{{.Name}}{{range .Values}}{{.}}
{{end}}
+ {{end}} + + {{if eq .Section "sessions"}} +

+ Pick a user from the Users tab, or name one here. Sessions are keyed by the + ID the connector gave the user; refresh tokens by the sub claim, which the + app derives from that ID and the connector. Listing sessions needs dex + started with DEX_API_SESSIONS_IDENTITIES_CRUD=true. +

+
+ +
+ +
+
+
+ +
+ + Needed for the refresh tokens. +
+
+
+
+ + {{if .UserID}} +
Sessions
+ {{if .Sessions}} + + + {{range .Sessions}} + + + + + + + + + + {{end}} +
SessionConnectorFromDeviceStartedExpires
{{.ID}}{{.ConnectorID}}{{.IPAddress}}{{.UserAgent}}{{.Created}}{{.Expires}} +
+
+ + + + + +
+
+
+ {{else}} +

No sessions for this user.

+ {{end}} + {{end}} + + {{if and .UserID .ConnectorID}} +
Refresh tokens
+ {{if .RefreshTokens}} + + + {{range .RefreshTokens}} + + + + + + + {{end}} +
ClientCreatedLast used
{{.ClientID}}{{.Created}}{{.LastUsed}} +
+
+ + + + + + + +
+
+
+

Revoke one, then let this app refresh: the sign-in ends. That is the pair worth trying together.

+ {{else}} +

No refresh tokens for this user. They are issued only when offline_access is requested.

+ {{end}} + {{end}} + + {{if .UserID}} +
TerminateSessionsByUser
+
+ + + + +

Ends every session this user has, whatever connector they came through.

+
+
+ {{end}} + + {{end}} +
+{{end}} +{{end}} diff --git a/examples/example-app/server/templates/detail.html b/examples/example-app/server/templates/detail.html new file mode 100644 index 0000000000..a14ff9fe27 --- /dev/null +++ b/examples/example-app/server/templates/detail.html @@ -0,0 +1,233 @@ +{{define "content"}} +
+
+
+
{{.Title}}
+
{{.Kind}}
+
+ Back +
+ {{if .Error}}

{{.Error}}

{{end}} +
+ +{{with .Client}} +
+
Client
+
+
ID
{{.ID}}
+
Name
{{if .Name}}{{.Name}}{{else}}not set{{end}}
+
Type
{{if .Public}}public โ€” no secret{{else}}confidential{{end}}
+ {{if .Secret}}
Secret
{{.Secret}}
{{end}} +
Logo URL
{{if .LogoURL}}{{.LogoURL}}{{else}}not set{{end}}
+
Redirect URIs
+
{{range .RedirectURIs}}{{.}}
{{else}}none{{end}}
+
Trusted peers
+
{{range .TrustedPeers}}{{.}}
{{else}}none{{end}}
+
Allowed connectors
+
{{range .AllowedConnectors}}{{.}}
{{else}}all{{end}}
+
SSO shared with
+
{{range .SSOSharedWith}}{{.}}
{{else}}the sessions default{{end}}
+
Post-logout redirect URIs
+
{{range .PostLogoutRedirectURIs}}{{.}}
{{else}}none โ€” logout cannot redirect back{{end}}
+
Back-channel logout URI
+
{{if .BackchannelLogoutURI}}{{.BackchannelLogoutURI}}{{else}}not set โ€” this client is not notified when a session ends{{end}}
+
Refresh token lifetime
+
{{if eq .RefreshTokenLifetime "session"}}session โ€” refreshing stops when the browser session ends{{else}}standalone โ€” refresh tokens outlive the browser session{{end}}
+
+
+ Edit +
+

+ The API's UpdateClient does not carry a secret or the public flag, so + neither can be changed here: dex expects a client that needs a new secret + to be deleted and created again. +

+
+{{end}} + +{{with .Connector}} +
+
Connector
+
+
ID
{{.ID}}
+
Type
{{.Type}}
+
Name
{{.Name}}
+
Grant types
+
{{range .GrantTypes}}{{.}}
{{else}}unrestricted{{end}}
+
+
+ +
+
Configuration
+ {{if .Config}}
{{.Config}}
{{else}}

No configuration stored.

{{end}} +
+ +
+
UpdateConnector
+
+ + +
+ +
+
+
+ +
+
+
+ +
+ + Left empty, the stored configuration is kept. +
+
+
+ +
+ +
+ {{range $g := $.ConnectorGrantTypes}} +
+ + +
+ {{end}} +
+
+
+
+ + Cancel +
+
+
+{{end}} + +{{with .Identity}} +
+
Identity
+
+
User ID
{{.UserID}}
+
Connector
{{.ConnectorID}}
+
Email
{{.Email}}{{if .EmailVerified}} (verified){{else}} (not verified){{end}}
+
Username
{{if .Username}}{{.Username}}{{else}}not set{{end}}
+
Groups
+
{{range .Groups}}{{.}}
{{else}}none{{end}}
+ {{if .Created}}
First seen
{{.Created}}
{{end}} + {{if .LastLogin}}
Last sign-in
{{.LastLogin}}
{{end}} +
+

+ None of this is editable: it is what the connector said about the user at + their last sign-in, and dex re-records it at the next one. For the local + connector, the entry behind it is on the Passwords tab. +

+
+ +
+
Consents
+ {{if .Consents}} + + + {{range .Consents}} + + + + + + {{end}} +
ClientScopes
{{.ClientID}}{{range .Scopes}}{{.}} {{end}} +
+
+ + + + + +
+
+
+

Revoking one puts the consent screen back in front of the user for that client.

+ {{else}} +

Nothing approved yet, or the client skips the consent screen.

+ {{end}} +
+ +
+
Multi-factor
+ {{if .MFADevices}} + {{range .MFADevices}} +
+
{{.AuthenticatorID}}
+
+
Secret
{{if .HasSecret}}enrolled{{else}}none{{end}}
+
Keys
{{len .Credentials}}
+
+ + {{if .Credentials}} + + + {{range .Credentials}} + + + + + + + + {{end}} +
KeyTransportUsesRegistered
+ {{if .DisplayName}}{{.DisplayName}}{{else}}unnamed{{end}} +
{{.ID}}
+
{{range .Transport}}{{.}} {{else}}unknown{{end}}{{.SignCount}}{{.Created}} +
+
+ + + + + +
+
+
+ {{end}} + + {{if .HasSecret}} +
+ + + + +
+
+ {{end}} +
+ {{end}} + +
+
+ + + +

Clears every factor above at once; the user enrols again on their next sign-in.

+
+
+
+ {{else}} +

Nothing enrolled.

+ {{end}} +
+ +
+
Sessions
+

Sessions and refresh tokens for this user are on the Sessions tab.

+
+ Open them +
+
+{{end}} +{{end}} diff --git a/examples/example-app/server/templates/device.html b/examples/example-app/server/templates/device.html new file mode 100644 index 0000000000..98181835b3 --- /dev/null +++ b/examples/example-app/server/templates/device.html @@ -0,0 +1,25 @@ +{{define "content"}} +
+
Device code flow
+

+ Enter the code at the verification URL. This page polls the token endpoint + every {{.PollInterval}}s until the provider says the code has been approved. +

+
+
Verification URL
+
{{.VerificationURI}}
+
User code
+
{{.UserCode}}
+
+
+ + Cancel +
+

Waiting for authorizationโ€ฆ

+ + + + +
+ +{{end}} diff --git a/examples/example-app/server/templates/form.html b/examples/example-app/server/templates/form.html new file mode 100644 index 0000000000..fdfa83bfb6 --- /dev/null +++ b/examples/example-app/server/templates/form.html @@ -0,0 +1,52 @@ +{{define "content"}} +
+
{{.Title}}
+ {{if .Description}}

{{.Description}}

{{end}} + {{if .Note}}

{{.Note}}

{{end}} + +
+ {{range .Fields}} +
+ +
+ {{if eq .Type "scopes"}} +
+ {{range .Options}} +
+ + +
+ {{end}} +
+
+ + +
+ {{else if eq .Type "textarea"}} + + {{if .Options}} +
+ {{range .Options}} + + {{end}} +
+ {{end}} + {{else if eq .Type "select"}} + + {{else}} + + {{end}} + {{if .Hint}}{{.Hint}}{{end}} +
+
+ {{end}} + +
+ + Cancel +
+
+
+{{end}} diff --git a/examples/example-app/server/templates/index.html b/examples/example-app/server/templates/index.html new file mode 100644 index 0000000000..f20abfae19 --- /dev/null +++ b/examples/example-app/server/templates/index.html @@ -0,0 +1,149 @@ +{{define "content"}} +{{if .User}} +
+
Signed in
+
+ {{if .User.Name}}
Name
{{.User.Name}}
{{end}} + {{if .User.Email}}
Email
{{.User.Email}}
{{end}} +
Subject
{{.User.Subject}}
+ {{if .User.SessionID}}
Session (sid)
{{.User.SessionID}}
{{end}} + {{with .Token}} +
Access token
{{.AccessToken}}
+ {{if .Expiry}}
Expires
{{.Expiry}}{{if .Expired}} โ€” expired, refreshing on next load{{end}}
{{end}} +
Refresh token
{{if .RefreshToken}}held{{else}}none โ€” request offline_access to get one{{end}}
+ {{end}} +
+ + {{if .SessionCheck}} +

Re-checked with the provider every {{.SessionCheck}} using prompt=none, so signing out of dex elsewhere ends this session too.

+ {{end}} +
+{{end}} +{{/* Rendered whichever way round, so a back-channel logout can swap the two + without the page having to build markup the template already owns. */}} +
+
Not signed in
+

+ The example client for dex. Pick a flow to run it and see what comes back. + Configuration for each one is in the + README. +

+
+ +
+
Flows through the browser
+
    +
  • +
    +
    Authorization code{{if .PKCE}} with PKCE{{end}}
    +
    The one web applications use. Redirects to dex and comes back with a code.
    +
    +
    + +
    +
  • + {{if .DeviceSupported}} +
  • +
    +
    Device code
    +
    For input-constrained devices: shows a code to type in elsewhere while this page polls.
    +
    + +
  • + {{end}} +
+ +
+ Authorization request options +

These apply to both flows above.

+
+ +
+
+ {{range .ScopesSupported}} +
+ + +
+ {{end}} +
+ {{if eq (len .ScopesSupported) 0}} + The provider advertises no scopes โ€” add them by hand. + {{end}} +
+ + +
+ openid is always requested. offline_access is what gets you a refresh token. +
+
+
+ +
+
+
+ + +
+ Sent as audience:server:client_id:<id>, which asks dex for a token another client will accept. +
+
+
+ +
+ + Skips dex's connector selection screen. +
+
+
+
+ +
+
Flows without a browser
+

These call the token endpoint directly. Each one asks for its own parameters.

+
    + {{if .ClientCredentialsSupported}} +
  • +
    +
    Client credentials
    +
    A token for this application itself, with no user involved.
    +
    + Open +
  • + {{end}} + {{if .RefreshSupported}} +
  • +
    +
    Refresh
    +
    Redeems a refresh token for a new set.
    +
    + Open +
  • + {{end}} + {{if .TokenExchangeSupported}} +
  • +
    +
    Token exchange
    +
    RFC 8693. Trades a token from a connector for one issued by dex.
    +
    + Open +
  • + {{end}} + {{if .PasswordSupported}} +
  • +
    +
    Password deprecated
    +
    Sends a username and password to the token endpoint.
    +
    + Open +
  • + {{end}} +
+ {{if not (or .ClientCredentialsSupported .RefreshSupported .TokenExchangeSupported .PasswordSupported)}} +

The provider advertises none of these grants. oauth2.grantTypes in dex's config decides.

+ {{end}} +
+{{end}} diff --git a/examples/example-app/server/templates/layout.html b/examples/example-app/server/templates/layout.html new file mode 100644 index 0000000000..d48bab4b1e --- /dev/null +++ b/examples/example-app/server/templates/layout.html @@ -0,0 +1,23 @@ +{{define "page"}} + + + + + + Example App + + + +
+ + + {{template "content" .}} +
+ + + +{{end}} diff --git a/examples/example-app/server/templates/result.html b/examples/example-app/server/templates/result.html new file mode 100644 index 0000000000..423d82940c --- /dev/null +++ b/examples/example-app/server/templates/result.html @@ -0,0 +1,12 @@ +{{define "content"}} +
+
{{.Title}}
+ {{if .Verdict}}

{{.Verdict}}

{{end}} +
{{.Body}}
+
+ {{if .LastGrant}}Back to the {{.LastGrant}} tokens{{end}} + Token tools + Home +
+
+{{end}} diff --git a/examples/example-app/server/templates/token.html b/examples/example-app/server/templates/token.html new file mode 100644 index 0000000000..f33b4f5b4a --- /dev/null +++ b/examples/example-app/server/templates/token.html @@ -0,0 +1,74 @@ +{{define "content"}} +
+
{{.Grant}} grant
+
+ {{if .ExpiresIn}}
Expires in
{{.ExpiresIn}}
{{end}} + {{if .IssuedTokenType}}
Issued token type
{{.IssuedTokenType}}
{{end}} +
+ +
+ +{{if .IDToken}} +
+
ID token
+
{{.IDToken}}
+ Open in jwt.io +
+{{end}} + +{{if .AccessToken}} +
+
Access token
+
{{.AccessToken}}
+ Open in jwt.io +
+
+ + +
+
+ + + +
+
+
+{{end}} + +{{if .RefreshToken}} +
+
Refresh token
+
{{.RefreshToken}}
+
+ + +
+
+{{end}} + +{{if .Claims}} +
+
Claims
+
{{.Claims}}
+
+{{end}} + +{{if .RawResponse}} +
+
Token endpoint response
+
{{.RawResponse}}
+
+{{end}} + +{{if .PublicKeyPEM}} +
+
+ Issuer public key +
{{.PublicKeyPEM}}
+
+
+{{end}} +{{end}} diff --git a/examples/example-app/server/templates/tools.html b/examples/example-app/server/templates/tools.html new file mode 100644 index 0000000000..99e2933ab0 --- /dev/null +++ b/examples/example-app/server/templates/tools.html @@ -0,0 +1,41 @@ +{{define "content"}} +
+
Token tools
+

+ Two of these answer questions that sound alike and are not: + introspection asks dex whether it still honours a token, verification checks + the signature and lifetime for yourself. A revoked token passes verification + and fails introspection. +

+
    +
  • +
    +
    Introspection
    +
    Ask dex whether a token is still active.
    +
    + Open +
  • +
  • +
    +
    Local verification
    +
    Check a JWT's signature and claims against the issuer's keys.
    +
    + Open +
  • +
  • +
    +
    UserInfo
    +
    Fetch claims with an access token.
    +
    + Open +
  • +
  • +
    +
    Refresh
    +
    Redeem a refresh token for a new set.
    +
    + Open +
  • +
+
+{{end}} diff --git a/examples/example-app/server/token.go b/examples/example-app/server/token.go new file mode 100644 index 0000000000..6aee20dfb8 --- /dev/null +++ b/examples/example-app/server/token.go @@ -0,0 +1,196 @@ +package server + +import ( + "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// renderToken shows the result of a grant that produced a token through the +// oauth2 library. +func (s *Server) renderToken(w http.ResponseWriter, r *http.Request, grant string, token *oauth2.Token) { + rawID, _ := token.Extra("id_token").(string) + s.sessions.RememberTokens(s.session(w, r), grant, token, rawID) + + data := TokenPageData{ + LogoURI: dexLogoDataURI, + AdminEnabled: s.admin != nil, + Grant: grant, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + RedirectURL: s.redirectURI, + PublicKeyPEM: s.fetchPublicKeyPEM(), + } + + if rawIDToken, ok := token.Extra("id_token").(string); ok { + data.IDToken = rawIDToken + data.IDTokenJWTLink = jwtIOLink(rawIDToken) + data.Claims, _ = decodeJWTClaims(rawIDToken) + } + if data.AccessToken != "" { + data.AccessTokenJWTLink = jwtIOLink(data.AccessToken) + if data.Claims == "" { + // A grant without an ID token โ€” client credentials, token exchange + // โ€” still says who the token is for, in the access token itself. + data.Claims, _ = decodeJWTClaims(data.AccessToken) + } + } + if !token.Expiry.IsZero() { + data.ExpiresIn = time.Until(token.Expiry).Round(time.Second).String() + } + + s.renderer.RenderTokenPage(w, data) +} + +// renderRawToken shows the result of a grant whose response the app reads +// directly, because it does not have the shape the oauth2 library expects. +func (s *Server) renderRawToken(w http.ResponseWriter, r *http.Request, grant string, body []byte) { + var resp struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + IssuedTokenType string `json:"issued_token_type"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &resp); err != nil { + http.Error(w, fmt.Sprintf("token response is not JSON: %v", err), http.StatusInternalServerError) + return + } + + data := TokenPageData{ + LogoURI: dexLogoDataURI, + AdminEnabled: s.admin != nil, + Grant: grant, + AccessToken: resp.AccessToken, + IDToken: resp.IDToken, + RefreshToken: resp.RefreshToken, + IssuedTokenType: resp.IssuedTokenType, + RedirectURL: s.redirectURI, + PublicKeyPEM: s.fetchPublicKeyPEM(), + RawResponse: indentJSON(body), + } + if resp.ExpiresIn > 0 { + data.ExpiresIn = (time.Duration(resp.ExpiresIn) * time.Second).String() + } + + remembered := (&oauth2.Token{AccessToken: resp.AccessToken, RefreshToken: resp.RefreshToken}). + WithExtra(map[string]any{"id_token": resp.IDToken}) + s.sessions.RememberTokens(s.session(w, r), grant, remembered, resp.IDToken) + if data.IDToken != "" { + data.IDTokenJWTLink = jwtIOLink(data.IDToken) + data.Claims, _ = decodeJWTClaims(data.IDToken) + } + if data.AccessToken != "" { + data.AccessTokenJWTLink = jwtIOLink(data.AccessToken) + if data.Claims == "" { + data.Claims, _ = decodeJWTClaims(data.AccessToken) + } + } + + s.renderer.RenderTokenPage(w, data) +} + +// decodeJWTClaims pretty-prints a JWT payload without verifying it: this is for +// looking at a token, and the verify tool is what says whether to believe it. +func decodeJWTClaims(token string) (string, bool) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", false + } + return indentJSON(payload), true +} + +func indentJSON(raw []byte) string { + buf := new(bytes.Buffer) + if err := json.Indent(buf, raw, "", " "); err != nil { + return string(raw) + } + return buf.String() +} + +// jwtIOLink creates a jwt.io debugger URL for the given token. +func jwtIOLink(token string) string { + return "https://jwt.io/#debugger-io?token=" + url.QueryEscape(token) +} + +// fetchPublicKeyPEM fetches the provider's JWKS and returns the first RSA public key as PEM. +func (s *Server) fetchPublicKeyPEM() string { + if s.jwksURL == "" { + return "" + } + + resp, err := s.client.Get(s.jwksURL) + if err != nil { + return "" + } + defer resp.Body.Close() + + var jwks struct { + Keys []json.RawMessage `json:"keys"` + } + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil || len(jwks.Keys) == 0 { + return "" + } + + var key struct { + N string `json:"n"` + E string `json:"e"` + Kty string `json:"kty"` + } + if err := json.Unmarshal(jwks.Keys[0], &key); err != nil || key.Kty != "RSA" { + return "" + } + + nBytes, err1 := base64.RawURLEncoding.DecodeString(key.N) + eBytes, err2 := base64.RawURLEncoding.DecodeString(key.E) + if err1 != nil || err2 != nil { + return "" + } + + var eInt int + for _, b := range eBytes { + eInt = eInt<<8 | int(b) + } + + pubKey := &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: eInt, + } + + pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + return "" + } + + return string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubKeyBytes, + })) +} + +// handleTokens re-renders the last tokens this browser was given. Without it a +// tool result is a dead end: the page holding the tokens you were working with +// is gone, and nothing in the app can bring it back. +func (s *Server) handleTokens(w http.ResponseWriter, r *http.Request) { + sess := s.session(w, r) + if sess.LastTokens == nil { + http.Redirect(w, r, "/", http.StatusFound) + return + } + s.renderToken(w, r, sess.LastGrant, sess.LastTokens) +} diff --git a/examples/example-app/server/tools.go b/examples/example-app/server/tools.go new file mode 100644 index 0000000000..6e91ead6d7 --- /dev/null +++ b/examples/example-app/server/tools.go @@ -0,0 +1,171 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// handleTools renders the page for looking at tokens you already hold. +func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) { + s.renderer.RenderToolsPage(w, ToolsPageData{ + LogoURI: dexLogoDataURI, + AdminEnabled: s.admin != nil, + }) +} + +// handleIntrospect asks the provider what it thinks of a token. +// +// This is the answer the app cannot work out for itself: an access token can +// look perfectly valid and still have been revoked, and only the issuer knows. +func (s *Server) handleIntrospect(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest) + return + } + + token := strings.TrimSpace(r.FormValue("token")) + if token == "" { + http.Error(w, "token is required", http.StatusBadRequest) + return + } + + form := url.Values{"token": {token}} + if hint := r.FormValue("token_type_hint"); hint != "" { + form.Set("token_type_hint", hint) + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, s.introspectURL, strings.NewReader(form.Encode())) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(url.QueryEscape(s.clientID), url.QueryEscape(s.clientSecret)) + + resp, err := s.client.Do(req) + if err != nil { + http.Error(w, fmt.Sprintf("introspection request failed: %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if resp.StatusCode != http.StatusOK { + s.renderResult(w, r, "Introspection", fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), "") + return + } + + var result struct { + Active bool `json:"active"` + } + _ = json.Unmarshal(body, &result) + + verdict := "The provider says this token is active." + if !result.Active { + verdict = "The provider says this token is not active โ€” expired, revoked, or never issued by it." + } + + s.renderResult(w, r, "Introspection", indentJSON(body), verdict) +} + +// handleVerify checks a token's signature and claims locally, the way a +// resource server would: fetch the issuer's keys, verify, then look at what the +// token says. It is deliberately separate from introspection โ€” this answers +// "was this signed by the issuer and is it still within its lifetime", not "is +// the issuer still honouring it". +func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest) + return + } + + raw := strings.TrimSpace(r.FormValue("token")) + if raw == "" { + http.Error(w, "token is required", http.StatusBadRequest) + return + } + + ctx := oidc.ClientContext(r.Context(), s.client) + + // Skipping the audience check keeps the tool useful for tokens issued to + // another client โ€” the claims below still show who the audience is. + verifier := s.provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) + idToken, err := verifier.Verify(ctx, raw) + if err != nil { + claims, _ := decodeJWTClaims(raw) + s.renderResult(w, r, "Local verification", claims, "Signature or claims rejected: "+err.Error()) + return + } + + claims, _ := decodeJWTClaims(raw) + verdict := fmt.Sprintf("Signature valid. Issued by %s for %v, expires %s.", + idToken.Issuer, idToken.Audience, idToken.Expiry.Format(time.RFC3339)) + if idToken.Audience != nil && !containsScope(idToken.Audience, s.clientID) { + verdict += " Note: this application is not in the audience." + } + + s.renderResult(w, r, "Local verification", claims, verdict) +} + +// handleUserInfo calls the provider's UserInfo endpoint with an access token. +func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest) + return + } + + accessToken := strings.TrimSpace(r.FormValue("access_token")) + if accessToken == "" { + http.Error(w, "access_token is required", http.StatusBadRequest) + return + } + if s.userInfoURL == "" { + http.Error(w, "the provider does not advertise a userinfo endpoint", http.StatusBadRequest) + return + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.userInfoURL, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := s.client.Do(req) + if err != nil { + http.Error(w, fmt.Sprintf("userinfo request failed: %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + s.renderResult(w, r, "UserInfo", fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), "") + return + } + + s.renderResult(w, r, "UserInfo", indentJSON(body), "") +} + +// renderResult shows the output of a tool. +func (s *Server) renderResult(w http.ResponseWriter, r *http.Request, title, body, verdict string) { + s.renderer.RenderResultPage(w, ResultPageData{ + LogoURI: dexLogoDataURI, + AdminEnabled: s.admin != nil, + Title: title, + Verdict: verdict, + Body: body, + LastGrant: s.session(w, r).LastGrant, + }) +} diff --git a/examples/example-app/server/transport.go b/examples/example-app/server/transport.go new file mode 100644 index 0000000000..a262667fbf --- /dev/null +++ b/examples/example-app/server/transport.go @@ -0,0 +1,83 @@ +package server + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "os" + "time" +) + +// newHTTPClient creates an *http.Client with optional custom root CAs and debug logging. +func newHTTPClient(rootCAs string, debug bool) (*http.Client, error) { + var client *http.Client + + if rootCAs != "" { + tlsConfig := &tls.Config{RootCAs: x509.NewCertPool()} + rootCABytes, err := os.ReadFile(rootCAs) + if err != nil { + return nil, fmt.Errorf("failed to read root-ca: %v", err) + } + if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { + return nil, fmt.Errorf("no certs found in root CA file %q", rootCAs) + } + client = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + } + } + + if debug { + if client == nil { + client = &http.Client{ + Transport: debugTransport{http.DefaultTransport}, + } + } else { + client.Transport = debugTransport{client.Transport} + } + } + + if client == nil { + client = http.DefaultClient + } + + return client, nil +} + +// debugTransport wraps an http.RoundTripper and logs full request/response details. +type debugTransport struct { + t http.RoundTripper +} + +func (d debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { + reqDump, err := httputil.DumpRequest(req, true) + if err != nil { + return nil, err + } + log.Printf("%s", reqDump) + + resp, err := d.t.RoundTrip(req) + if err != nil { + return nil, err + } + + respDump, err := httputil.DumpResponse(resp, true) + if err != nil { + resp.Body.Close() + return nil, err + } + log.Printf("%s", respDump) + return resp, nil +} diff --git a/examples/example-app/session/session.go b/examples/example-app/session/session.go new file mode 100644 index 0000000000..7d02cb78a6 --- /dev/null +++ b/examples/example-app/session/session.go @@ -0,0 +1,385 @@ +// Package session keeps the example app's own state: who is signed in to this +// application, and what it is waiting for from the provider. +package session + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + "sync" + "time" + + "golang.org/x/oauth2" +) + +// CookieName is the app's session cookie. It identifies the browser, not the +// user: a session exists from the first request, before anyone signs in, so +// that a login can be tied to the browser that started it. +const CookieName = "example_app_session" + +// ttl is how long an idle session is kept. The app is a demo; the number only +// has to be long enough that nobody loses a session mid-experiment. +const ttl = 12 * time.Hour + +// UserClaims holds basic user identity claims from an ID token. +type UserClaims struct { + Subject string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + + // SessionID is the "sid" claim: the provider's session this token was issued + // under. The app keeps it so a back-channel logout token, which carries the + // same value, can be matched to the browser it is about. + SessionID string `json:"sid"` +} + +// PendingAuth is one authorization the app has started and not yet finished. +// Everything in it belongs to a single authorization request: reusing any of it +// across requests is what the state and PKCE parameters exist to prevent. +type PendingAuth struct { + State string + Nonce string + CodeVerifier string + // Silent marks a prompt=none request, whose failure is an answer ("no + // session at the provider") rather than an error to show the user. + Silent bool + Created time.Time +} + +// Device is a device authorization this browser started. It lives on the +// session for the same reason everything else here does: two people trying the +// device flow at once should not share one user code. +type Device struct { + DeviceCode string + UserCode string + VerificationURI string + PollInterval int + Token *oauth2.Token +} + +// Session is one browser's state. +type Session struct { + ID string + + // Claims and Token are the result of the last completed sign-in. + Claims *UserClaims + Token *oauth2.Token + IDToken string + + // Device is the device authorization in progress, if any. + Device *Device + + // LastTokens is whatever the last flow produced, sign-in or not. Client + // credentials and token exchange do not sign anyone in, and a tool that + // forgets the tokens you just fetched is a tool you have to run twice. + LastTokens *oauth2.Token + LastIDToken string + LastGrant string + + // LastProviderCheck is when the app last confirmed with the provider that + // the session there still exists. Without it the app would keep showing a + // user who signed out of the provider in another tab. + LastProviderCheck time.Time + + // watchers are open SSE streams for this session, notified when a logout + // token arrives for it. + watchers []chan Notice + + pending map[string]*PendingAuth + expires time.Time +} + +// SignedIn reports whether this browser has completed a sign-in. +func (s *Session) SignedIn() bool { return s.Claims != nil } + +// Store keeps sessions for the process. A demo app has no reason to persist +// them, but it does have a reason to keep them apart: one global session would +// mean every browser hitting this app shares one identity. +type Store struct { + mu sync.Mutex + sessions map[string]*Session +} + +// NewStore returns an empty in-memory store. +func NewStore() *Store { + return &Store{sessions: make(map[string]*Session)} +} + +// FromRequest returns the session for this browser, creating one and setting +// the cookie if the request carries none. The returned session is the store's +// own: the methods below change it in place. +func (st *Store) FromRequest(w http.ResponseWriter, r *http.Request, secure bool) *Session { + st.mu.Lock() + defer st.mu.Unlock() + + st.sweepLocked() + + if c, err := r.Cookie(CookieName); err == nil { + if s, ok := st.sessions[c.Value]; ok { + s.expires = time.Now().Add(ttl) + return s + } + } + + s := &Session{ + ID: randomString(), + pending: make(map[string]*PendingAuth), + expires: time.Now().Add(ttl), + } + st.sessions[s.ID] = s + + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: s.ID, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(ttl.Seconds()), + }) + + return s +} + +// SignIn records a completed sign-in. +func (st *Store) SignIn(s *Session, claims *UserClaims, token *oauth2.Token, rawIDToken string) { + st.mu.Lock() + defer st.mu.Unlock() + + s.Claims = claims + s.Token = token + if rawIDToken != "" { + s.IDToken = rawIDToken + } + s.LastProviderCheck = time.Now() + s.expires = time.Now().Add(ttl) +} + +// Confirm records that the provider still knows this user, without touching the +// tokens the app is holding. A silent check asks for the scopes it needs to +// identify someone, not the ones the sign-in asked for, so the tokens it comes +// back with are narrower โ€” overwriting with them loses the refresh token the +// original flow was given. +func (st *Store) Confirm(s *Session, claims *UserClaims) { + st.mu.Lock() + defer st.mu.Unlock() + + if claims != nil { + s.Claims = claims + } + s.LastProviderCheck = time.Now() + s.expires = time.Now().Add(ttl) +} + +// SignOut drops what the app knows about the user and returns the last ID +// token, which RP-initiated logout sends back to the provider as a hint. +func (st *Store) SignOut(s *Session) string { + st.mu.Lock() + defer st.mu.Unlock() + + return signOutLocked(s) +} + +// Notice is what a browser watching its session is told when a logout token +// ends it. It travels to the page over SSE, which is the only way an application +// can show a back channel doing its job: a message that waits for the next page +// load proves nothing, since a page load is exactly when the app's own +// prompt=none check would have noticed anyway. +type Notice struct { + At time.Time `json:"at"` + SessionID string `json:"sid,omitempty"` +} + +// Watch returns a channel carrying notices for one session, and a function that +// stops watching. Every open page gets its own. +func (st *Store) Watch(sessionID string) (<-chan Notice, func()) { + st.mu.Lock() + defer st.mu.Unlock() + + ch := make(chan Notice, 1) + if s, ok := st.sessions[sessionID]; ok { + s.watchers = append(s.watchers, ch) + } + + return ch, func() { + st.mu.Lock() + defer st.mu.Unlock() + + s, ok := st.sessions[sessionID] + if !ok { + return + } + for i, w := range s.watchers { + if w == ch { + s.watchers = append(s.watchers[:i], s.watchers[i+1:]...) + break + } + } + } +} + +// SignOutByBackchannel ends every session the provider's logout token is about +// and returns how many it ended. A token carrying a sid names one session, which +// is the precise case; matching on the subject alone is the fallback for a +// provider that sends only sub, and ends every session that user has here. +// +// Any page watching an ended session is told at once. +func (st *Store) SignOutByBackchannel(sid, subject string) int { + st.mu.Lock() + defer st.mu.Unlock() + + now := time.Now() + matched := 0 + for _, s := range st.sessions { + if s.Claims == nil { + continue + } + + switch { + case sid != "" && s.Claims.SessionID != "": + if s.Claims.SessionID != sid { + continue + } + case subject != "": + if s.Claims.Subject != subject { + continue + } + default: + continue + } + + // Read the sid before signing out, which drops the claims it lives on. + notice := Notice{At: now, SessionID: s.Claims.SessionID} + signOutLocked(s) + matched++ + + // Non-blocking: a page that has not drained its last notice is already + // being told, and a wedged reader must not stall the logout. + for _, w := range s.watchers { + select { + case w <- notice: + default: + } + } + } + return matched +} + +// signOutLocked clears the user from a session. Callers hold the store's lock. +func signOutLocked(s *Session) string { + idToken := s.IDToken + s.Claims = nil + s.Token = nil + s.IDToken = "" + // The tools prefill from the last flow's result. Keeping it past a sign-out + // would put a signed-out user's access token back on screen, which reads as + // the app inventing tokens. + s.LastTokens = nil + s.LastIDToken = "" + s.LastGrant = "" + s.Device = nil + // LastProviderCheck deliberately survives: it records when the provider was + // last asked, and signing out here does not make that answer any older. + // Clearing it would send the next page load straight back into a check, + // which is a redirect loop when the answer is "no session". + return idToken +} + +// StartAuth records an authorization the app is about to send the browser off +// to complete, and returns it. Each one carries its own state and PKCE +// verifier. +func (st *Store) StartAuth(s *Session, silent bool) *PendingAuth { + st.mu.Lock() + defer st.mu.Unlock() + + // Anything still pending from a much earlier request was abandoned. + for state, p := range s.pending { + if time.Since(p.Created) > 10*time.Minute { + delete(s.pending, state) + } + } + + p := &PendingAuth{ + State: randomString(), + Nonce: randomString(), + CodeVerifier: oauth2.GenerateVerifier(), + Silent: silent, + Created: time.Now(), + } + s.pending[p.State] = p + return p +} + +// TakeAuth returns the pending authorization matching a callback's state +// parameter and forgets it, so a state cannot be replayed. A miss means the +// callback did not come from a request this browser started. +func (st *Store) TakeAuth(s *Session, state string) (*PendingAuth, bool) { + st.mu.Lock() + defer st.mu.Unlock() + + p, ok := s.pending[state] + if ok { + delete(s.pending, state) + } + return p, ok +} + +// RememberTokens keeps the result of the last flow so the tools can offer it +// and the token page can be reached again. +func (st *Store) RememberTokens(s *Session, grant string, token *oauth2.Token, rawIDToken string) { + st.mu.Lock() + defer st.mu.Unlock() + + s.LastTokens = token + s.LastIDToken = rawIDToken + s.LastGrant = grant + s.expires = time.Now().Add(ttl) +} + +// StartDevice records a device authorization for this browser. +func (st *Store) StartDevice(s *Session, d *Device) { + st.mu.Lock() + defer st.mu.Unlock() + + s.Device = d + s.expires = time.Now().Add(ttl) +} + +// SetDeviceToken attaches the token a device authorization ended with. +func (st *Store) SetDeviceToken(s *Session, token *oauth2.Token) { + st.mu.Lock() + defer st.mu.Unlock() + + if s.Device != nil { + s.Device.Token = token + } +} + +// MarkChecked records that the provider was just asked about the session, +// whatever the answer, so a failing check cannot spin. +func (st *Store) MarkChecked(s *Session) { + st.mu.Lock() + defer st.mu.Unlock() + + s.LastProviderCheck = time.Now() +} + +// sweepLocked drops expired sessions. Called on the way in, which is often +// enough for a process that only serves a handful of browsers. +func (st *Store) sweepLocked() { + now := time.Now() + for id, s := range st.sessions { + if now.After(s.expires) { + delete(st.sessions, id) + } + } +} + +func randomString() string { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + panic("example-app: out of randomness: " + err.Error()) + } + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/examples/example-app/templates.go b/examples/example-app/templates.go deleted file mode 100644 index a9425ead27..0000000000 --- a/examples/example-app/templates.go +++ /dev/null @@ -1,110 +0,0 @@ -package main - -import ( - "html/template" - "log" - "net/http" -) - -var indexTmpl = template.Must(template.New("index.html").Parse(` - - - - -
-

- - -

-

- - -

-

- - -

-

- - -

-

- -

-
- -`)) - -func renderIndex(w http.ResponseWriter) { - renderTemplate(w, indexTmpl, nil) -} - -type tokenTmplData struct { - IDToken string - AccessToken string - RefreshToken string - RedirectURL string - Claims string -} - -var tokenTmpl = template.Must(template.New("token.html").Parse(` - - - - -

ID Token:

{{ .IDToken }}

-

Access Token:

{{ .AccessToken }}

-

Claims:

{{ .Claims }}

- {{ if .RefreshToken }} -

Refresh Token:

{{ .RefreshToken }}

-
- - -
- {{ end }} - - -`)) - -func renderToken(w http.ResponseWriter, redirectURL, idToken, accessToken, refreshToken, claims string) { - renderTemplate(w, tokenTmpl, tokenTmplData{ - IDToken: idToken, - AccessToken: accessToken, - RefreshToken: refreshToken, - RedirectURL: redirectURL, - Claims: claims, - }) -} - -func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data interface{}) { - err := tmpl.Execute(w, data) - if err == nil { - return - } - - switch err := err.(type) { - case *template.Error: - // An ExecError guarantees that Execute has not written to the underlying reader. - log.Printf("Error rendering template %s: %s", tmpl.Name(), err) - - // TODO(ericchiang): replace with better internal server error. - http.Error(w, "Internal server error", http.StatusInternalServerError) - default: - // An error with the underlying write, such as the connection being - // dropped. Ignore for now. - } -} diff --git a/examples/go.mod b/examples/go.mod index d66c118a7f..3cd54c4828 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,25 +1,27 @@ module github.com/dexidp/dex/examples -go 1.17 +go 1.25.0 require ( - github.com/coreos/go-oidc/v3 v3.1.0 - github.com/dexidp/dex/api/v2 v2.0.0 - github.com/spf13/cobra v1.3.0 - golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 - google.golang.org/grpc v1.43.0 + github.com/coreos/go-oidc/v3 v3.20.0 + github.com/dexidp/dex/api/v2 v2.4.0 + github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.54.0 + golang.org/x/oauth2 v0.36.0 + google.golang.org/grpc v1.83.0 ) require ( - github.com/golang/protobuf v1.5.2 // indirect - github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect - golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d // indirect - golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect - golang.org/x/text v0.3.7 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0 // indirect - google.golang.org/protobuf v1.27.1 // indirect - gopkg.in/square/go-jose.v2 v2.6.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.11 // indirect ) + +// The example lives in this repository, so it demonstrates this repository's +// API rather than the last published version of it. +replace github.com/dexidp/dex/api/v2 => ../api/v2 diff --git a/examples/go.sum b/examples/go.sum index 7907afde92..80bac09741 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,788 +1,56 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= -cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= -cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= -cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= -cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= -cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= -cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= -cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= -cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/coreos/go-oidc/v3 v3.1.0 h1:6avEvcdvTa1qYsOZ6I5PRkSYHzpTNWgKYmaJfaYbrRw= -github.com/coreos/go-oidc/v3 v3.1.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dexidp/dex/api/v2 v2.0.0 h1:bvge1sRmzVzWPWp4WlMzS04lcNQA+jFzHqKV3066bRw= -github.com/dexidp/dex/api/v2 v2.0.0/go.mod h1:k5arBJT1QYvpsEY3sEd0NXJp3hKWKuUUfzJ3BlcqPdM= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= -github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= -github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= -github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= -github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= -github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= -github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0= -github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce h1:Roh6XWxHFKrPgC/EQhVubSAGQ6Ozk6IdxHSzt1mR0EI= -golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d h1:1n1fc535VhN8SYtD4cDUyNlfpAF2ROMM9+11equK3hs= -golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= -google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= -google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= -google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= -google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= -google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= -google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= -google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= -google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= -google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= -google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0 h1:aCsSLXylHWFno0r4S3joLpiaWayvqd2Mn4iSvx4WZZc= -google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.43.0 h1:Eeu7bZtDZ2DpRCsLhUlcrLnvYaMK1Gz86a+hMVvELmM= -google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= -gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/examples/grpc-client/README.md b/examples/grpc-client/README.md index 59629e0590..6a78df9199 100644 --- a/examples/grpc-client/README.md +++ b/examples/grpc-client/README.md @@ -50,6 +50,9 @@ Running the gRPC client will cause the following API calls to be made to the ser 2. ListPasswords 3. VerifyPassword 4. DeletePassword +5. CreateClient +6. ListClients +7. DeleteClient ## Cleaning up diff --git a/examples/grpc-client/client.go b/examples/grpc-client/client.go index fb8d4aaf06..7bbbac6494 100644 --- a/examples/grpc-client/client.go +++ b/examples/grpc-client/client.go @@ -58,7 +58,7 @@ func createPassword(cli api.DexClient) error { // Create password. if resp, err := cli.CreatePassword(context.TODO(), createReq); err != nil || resp.AlreadyExists { - if resp != nil && resp.AlreadyExists { + if resp != nil && resp.AlreadyExists { return fmt.Errorf("Password %s already exists", createReq.Password.Email) } return fmt.Errorf("failed to create password: %v", err) @@ -125,6 +125,57 @@ func createPassword(cli api.DexClient) error { return nil } +func createAndListClients(cli api.DexClient) error { + client := &api.Client{ + Id: "example-client", + Secret: "example-secret", + RedirectUris: []string{"http://localhost:8080/callback"}, + TrustedPeers: []string{}, + Public: false, + Name: "Example Client", + LogoUrl: "http://example.com/logo.png", + } + + createReq := &api.CreateClientReq{ + Client: client, + } + + if resp, err := cli.CreateClient(context.TODO(), createReq); err != nil || resp.AlreadyExists { + if resp != nil && resp.AlreadyExists { + log.Printf("Client %s already exists", createReq.Client.Id) + } else { + return fmt.Errorf("failed to create client: %v", err) + } + } else { + log.Printf("Created client with ID %s", createReq.Client.Id) + } + + listResp, err := cli.ListClients(context.TODO(), &api.ListClientReq{}) + if err != nil { + return fmt.Errorf("failed to list clients: %v", err) + } + + log.Print("Listing Clients:\n") + for _, client := range listResp.Clients { + log.Printf("ID: %s, Name: %s, Public: %t, RedirectURIs: %v", + client.Id, client.Name, client.Public, client.RedirectUris) + } + + deleteReq := &api.DeleteClientReq{ + Id: client.Id, + } + + if resp, err := cli.DeleteClient(context.TODO(), deleteReq); err != nil || resp.NotFound { + if resp != nil && resp.NotFound { + return fmt.Errorf("Client %s not found", deleteReq.Id) + } + return fmt.Errorf("failed to delete client: %v", err) + } + log.Printf("Deleted client with ID %s", deleteReq.Id) + + return nil +} + func main() { caCrt := flag.String("ca-crt", "", "CA certificate") clientCrt := flag.String("client-crt", "", "Client certificate") @@ -143,4 +194,8 @@ func main() { if err := createPassword(client); err != nil { log.Fatalf("testPassword failed: %v", err) } + + if err := createAndListClients(client); err != nil { + log.Fatalf("testClients failed: %v", err) + } } diff --git a/examples/k8s/dex.yaml b/examples/k8s/dex.yaml index 89ac40b223..c20d268774 100644 --- a/examples/k8s/dex.yaml +++ b/examples/k8s/dex.yaml @@ -23,7 +23,7 @@ spec: spec: serviceAccountName: dex # This is created below containers: - - image: ghcr.io/dexidp/dex:v2.30.0 + - image: ghcr.io/dexidp/dex:v2.32.0 name: dex command: ["/usr/local/bin/dex", "serve", "/etc/dex/cfg/config.yaml"] @@ -106,6 +106,12 @@ data: # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: "admin" + name: "Admin User" + emailVerified: true + preferredUsername: "admin" + groups: + - "team-a" + - "team-a/admins" userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" --- apiVersion: v1 diff --git a/examples/ldap/config-ldap.yaml b/examples/ldap/config-ldap.yaml index 05d1661826..49a7e25fff 100644 --- a/examples/ldap/config-ldap.yaml +++ b/examples/ldap/config-ldap.yaml @@ -59,6 +59,19 @@ connectors: # The group name should be the "cn" value. nameAttr: cn + # Optional Kerberos (SPNEGO) SSO. When enabled, Dex will challenge with + # WWW-Authenticate: Negotiate on GET and skip the password form on success. + #kerberos: + # enabled: true + # keytabPath: /etc/dex/krb5.keytab + # expectedRealm: EXAMPLE.COM + # usernameFromPrincipal: sAMAccountName # or userPrincipalName or localpart + # fallbackToPassword: false + # # Optional gokrb5 service settings โ€” leave empty to use library defaults. + # spn: HTTP/dex.example.com # service principal name expected in tickets + # keytabPrincipal: HTTP/dex.example.com@EXAMPLE.COM # explicit principal to load from the keytab + # maxClockSkew: 300 # tolerated clock skew, seconds (default 300) + staticClients: - id: example-app redirectURIs: diff --git a/examples/oidc-conformance/config.yaml.tmpl b/examples/oidc-conformance/config.yaml.tmpl new file mode 100644 index 0000000000..1f89afc64b --- /dev/null +++ b/examples/oidc-conformance/config.yaml.tmpl @@ -0,0 +1,36 @@ +# Dex configuration for OIDC Conformance Testing. +# See https://dexidp.io/docs/development/oidc-certification/ +# +# This template is processed by run.sh which replaces ISSUER_URL and ALIAS +# with actual values before starting Dex. + +issuer: ISSUER_URL/dex + +storage: + type: sqlite3 + config: + file: examples/oidc-conformance/dex.db + +web: + http: 0.0.0.0:5556 + +enablePasswordDB: true + +staticPasswords: +- email: "admin@example.com" + # bcrypt hash of the string "password" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "admin" + +staticClients: + - id: first_client + secret: 89d6205220381728e85c4cf5 + redirectURIs: + - https://www.certification.openid.net/test/a/ALIAS/callback + name: First client + + - id: second_client + secret: 51c612288018fd384b05d6ad + redirectURIs: + - https://www.certification.openid.net/test/a/ALIAS/callback + name: Second client diff --git a/examples/oidc-conformance/run.sh b/examples/oidc-conformance/run.sh new file mode 100755 index 0000000000..702928bd18 --- /dev/null +++ b/examples/oidc-conformance/run.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# OIDC Conformance Test Runner +# +# Starts Dex with a test configuration and exposes it via a public tunnel +# for use with https://www.certification.openid.net/ +# +# Usage: +# ./run.sh # uses cloudflared (default) +# ./run.sh --tunnel ngrok # uses ngrok +# ./run.sh --url https://my.url # uses a pre-existing public URL (no tunnel) +# ./run.sh --alias my-dex # custom alias for the test plan (default: dex) +# +# Prerequisites: +# - Dex binary in PATH or ../../bin/dex +# - ngrok or cloudflared installed (unless --url is provided) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +DEX_PORT=5556 +TUNNEL_TYPE="cloudflared" +PUBLIC_URL="" +ALIAS="dex" + +while [[ $# -gt 0 ]]; do + case $1 in + --tunnel) TUNNEL_TYPE="$2"; shift 2 ;; + --url) PUBLIC_URL="$2"; shift 2 ;; + --alias) ALIAS="$2"; shift 2 ;; + -h|--help) + sed -n '2,/^$/p' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# Find dex binary. +DEX_BIN="" +for candidate in "dex" "$ROOT_DIR/bin/dex"; do + if command -v "$candidate" &>/dev/null || [[ -x "$candidate" ]]; then + DEX_BIN="$candidate" + break + fi +done +if [[ -z "$DEX_BIN" ]]; then + echo "Error: dex binary not found. Run 'make build' first or install dex." + exit 1 +fi + +cleanup() { + echo "" + echo "Shutting down..." + kill "${TUNNEL_PID:-}" "${DEX_PID:-}" 2>/dev/null || true + rm -f "${CONFIG_FILE:-}" + wait 2>/dev/null +} +trap cleanup EXIT + +# Start tunnel if no URL provided. +TUNNEL_PID="" +if [[ -z "$PUBLIC_URL" ]]; then + case "$TUNNEL_TYPE" in + ngrok) + if ! command -v ngrok &>/dev/null; then + echo "Error: ngrok not found. Install it from https://ngrok.com/ or use --url." + exit 1 + fi + ngrok http "$DEX_PORT" --log=stdout --log-level=warn &>/dev/null & + TUNNEL_PID=$! + echo "Waiting for ngrok tunnel..." + sleep 3 + PUBLIC_URL=$(curl -s http://localhost:4040/api/tunnels | grep -o '"public_url":"https://[^"]*' | head -1 | cut -d'"' -f4) + if [[ -z "$PUBLIC_URL" ]]; then + echo "Error: failed to get ngrok public URL. Is ngrok running?" + exit 1 + fi + ;; + cloudflared) + if ! command -v cloudflared &>/dev/null; then + echo "Error: cloudflared not found. Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" + exit 1 + fi + CLOUDFLARED_LOG=$(mktemp) + cloudflared tunnel --url "http://localhost:$DEX_PORT" --no-autoupdate 2>"$CLOUDFLARED_LOG" & + TUNNEL_PID=$! + echo "Waiting for cloudflared tunnel..." + for _ in $(seq 1 30); do + PUBLIC_URL=$(grep -o 'https://[^ ]*\.trycloudflare\.com' "$CLOUDFLARED_LOG" | head -1) && break + sleep 1 + done + rm -f "$CLOUDFLARED_LOG" + if [[ -z "$PUBLIC_URL" ]]; then + echo "Error: failed to get cloudflared URL." + exit 1 + fi + ;; + *) + echo "Error: unknown tunnel type '$TUNNEL_TYPE'. Use 'ngrok' or 'cloudflared'." + exit 1 + ;; + esac +fi + +PUBLIC_URL="${PUBLIC_URL%/}" +echo "Public URL: $PUBLIC_URL" + +# Generate config from template. +CONFIG_FILE=$(mktemp) +sed -e "s|ISSUER_URL|$PUBLIC_URL|g" -e "s|ALIAS|$ALIAS|g" "$SCRIPT_DIR/config.yaml.tmpl" > "$CONFIG_FILE" + +echo "Starting Dex on port $DEX_PORT..." +"$DEX_BIN" serve "$CONFIG_FILE" & +DEX_PID=$! +sleep 2 + +DISCOVERY_URL="$PUBLIC_URL/dex/.well-known/openid-configuration" + +echo "" +echo "============================================================" +echo " OIDC Conformance Test Setup Ready" +echo "============================================================" +echo "" +echo " Discovery URL: $DISCOVERY_URL" +echo " Alias: $ALIAS" +echo "" +echo " Client 1: id=first_client secret=89d6205220381728e85c4cf5" +echo " Client 2: id=second_client secret=51c612288018fd384b05d6ad" +echo "" +echo " Steps:" +echo " 1. Open https://www.certification.openid.net/" +echo " 2. Log in with Google or GitLab" +echo " 3. Create a new test plan:" +echo " - Plan: OpenID Connect Core: Basic Certification Profile" +echo " - Server metadata: discovery" +echo " - Client registration: static_client" +echo " - Alias: $ALIAS" +echo " - Discovery URL: $DISCOVERY_URL" +echo " - Enter both client credentials above" +echo " 4. Run tests and follow instructions" +echo "" +echo " Press Ctrl+C to stop." +echo "============================================================" + +wait "$DEX_PID" diff --git a/flake.lock b/flake.lock deleted file mode 100644 index b67b61d98b..0000000000 --- a/flake.lock +++ /dev/null @@ -1,42 +0,0 @@ -{ - "nodes": { - "flake-utils": { - "locked": { - "lastModified": 1659877975, - "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1662019588, - "narHash": "sha256-oPEjHKGGVbBXqwwL+UjsveJzghWiWV0n9ogo1X6l4cw=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "2da64a81275b68fdad38af669afeda43d401e94b", - "type": "github" - }, - "original": { - "id": "nixpkgs", - "ref": "nixos-unstable", - "type": "indirect" - } - }, - "root": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 155ebf99e3..0000000000 --- a/flake.nix +++ /dev/null @@ -1,27 +0,0 @@ -{ - description = "OpenID Connect (OIDC) identity and OAuth 2.0 provider with pluggable connectors"; - - inputs = { - nixpkgs.url = "nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - }; - - outputs = { self, nixpkgs, flake-utils, ... }: - flake-utils.lib.eachDefaultSystem ( - system: - let - pkgs = nixpkgs.legacyPackages.${system}; - buildDeps = with pkgs; [ git go_1_19 gnumake ]; - devDeps = with pkgs; - buildDeps ++ [ - golangci-lint - gotestsum - protobuf - protoc-gen-go - protoc-gen-go-grpc - kind - ]; - in - { devShell = pkgs.mkShell { buildInputs = devDeps; }; } - ); -} diff --git a/go.mod b/go.mod index d15823aa1b..d791224c2e 100644 --- a/go.mod +++ b/go.mod @@ -1,95 +1,158 @@ module github.com/dexidp/dex -go 1.19 +go 1.25.8 require ( - entgo.io/ent v0.11.2 - github.com/AppsFlyer/go-sundheit v0.5.0 + cloud.google.com/go/compute/metadata v0.9.0 + entgo.io/ent v0.14.6 + github.com/AppsFlyer/go-sundheit v0.6.0 github.com/Masterminds/semver v1.5.0 - github.com/Masterminds/sprig/v3 v3.2.2 - github.com/beevik/etree v1.1.0 - github.com/coreos/go-oidc/v3 v3.3.0 - github.com/dexidp/dex/api/v2 v2.1.0 - github.com/felixge/httpsnoop v1.0.3 + github.com/Masterminds/sprig/v3 v3.3.0 + github.com/beevik/etree v1.7.0 + github.com/coreos/go-oidc/v3 v3.19.0 + github.com/dexidp/dex/api/v2 v2.4.0 + github.com/fsnotify/fsnotify v1.10.1 github.com/ghodss/yaml v1.0.0 - github.com/go-ldap/ldap/v3 v3.4.4 - github.com/go-sql-driver/mysql v1.6.0 - github.com/gorilla/handlers v1.5.1 - github.com/gorilla/mux v1.8.0 + github.com/go-jose/go-jose/v4 v4.1.4 + github.com/go-ldap/ldap/v3 v3.4.14 + github.com/go-sql-driver/mysql v1.10.0 + github.com/go-webauthn/webauthn v0.17.4 + github.com/google/cel-go v0.30.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/handlers v1.5.2 + github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 + github.com/jcmturner/goidentity/v6 v6.0.1 + github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/kylelemons/godebug v1.1.0 - github.com/lib/pq v1.10.5 + github.com/lib/pq v1.12.3 github.com/mattermost/xml-roundtrip-validator v0.1.0 - github.com/mattn/go-sqlite3 v1.14.15 - github.com/oklog/run v1.1.0 + github.com/mattn/go-sqlite3 v1.14.48 + github.com/oklog/run v1.2.0 + github.com/openbao/openbao/api/v2 v2.6.0 github.com/pkg/errors v0.9.1 - github.com/prometheus/client_golang v1.13.0 - github.com/russellhaering/goxmldsig v1.2.0 - github.com/sirupsen/logrus v1.9.0 - github.com/spf13/cobra v1.5.0 - github.com/stretchr/testify v1.8.0 - go.etcd.io/etcd/client/pkg/v3 v3.5.4 - go.etcd.io/etcd/client/v3 v3.5.4 - golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d - golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b - golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 - google.golang.org/api v0.94.0 - google.golang.org/grpc v1.49.0 - google.golang.org/protobuf v1.28.1 - gopkg.in/square/go-jose.v2 v2.6.0 + github.com/pquerna/otp v1.5.0 + github.com/prometheus/client_golang v1.24.1 + github.com/russellhaering/goxmldsig v1.6.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + go.etcd.io/etcd/client/pkg/v3 v3.6.13 + go.etcd.io/etcd/client/v3 v3.6.13 + golang.org/x/crypto v0.54.0 + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 + golang.org/x/net v0.57.0 + golang.org/x/oauth2 v0.36.0 + google.golang.org/api v0.291.0 + google.golang.org/grpc v1.82.1 + google.golang.org/protobuf v1.36.11 ) require ( - ariga.io/atlas v0.5.1-0.20220717122844-8593d7eb1a8e // indirect - cloud.google.com/go/compute v1.7.0 // indirect - github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect + ariga.io/atlas v0.36.2-0.20250730182955-2c6300d0a3e1 // indirect + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go/auth v0.22.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + dario.cat/mergo v1.0.1 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.1.1 // indirect - github.com/agext/levenshtein v1.2.1 // indirect - github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.1.2 // indirect - github.com/coreos/go-semver v0.3.0 // indirect - github.com/coreos/go-systemd/v22 v22.3.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect + github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/displaywidth v0.6.2 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/inflect v0.19.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.6 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.2 // indirect - github.com/google/go-cmp v0.5.8 // indirect - github.com/google/uuid v1.3.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect - github.com/googleapis/gax-go/v2 v2.4.0 // indirect - github.com/hashicorp/hcl/v2 v2.10.0 // indirect - github.com/huandu/xstrings v1.3.1 // indirect - github.com/imdario/mergo v0.3.11 // indirect - github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/jonboulle/clockwork v0.2.2 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect - github.com/mitchellh/copystructure v1.0.0 // indirect - github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect - github.com/mitchellh/reflectwalk v1.0.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.37.0 // indirect - github.com/prometheus/procfs v0.8.0 // indirect - github.com/shopspring/decimal v1.2.0 // indirect - github.com/spf13/cast v1.4.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/zclconf/go-cty v1.8.0 // indirect - go.etcd.io/etcd/api/v3 v3.5.4 // indirect - go.opencensus.io v0.23.0 // indirect - go.uber.org/atomic v1.7.0 // indirect - go.uber.org/multierr v1.6.0 // indirect - go.uber.org/zap v1.17.0 // indirect - golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect - golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect - golang.org/x/text v0.3.7 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/hcl/v2 v2.18.1 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect + github.com/olekukonko/tablewriter v1.1.3 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zclconf/go-cty v1.14.4 // indirect + github.com/zclconf/go-cty-yaml v1.1.0 // indirect + go.etcd.io/etcd/api/v3 v3.6.13 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/dexidp/dex/api/v2 => ./api/v2 + +tool entgo.io/ent/cmd/ent diff --git a/go.sum b/go.sum index 27fdbb8eea..ea24ba1d10 100644 --- a/go.sum +++ b/go.sum @@ -1,923 +1,415 @@ -ariga.io/atlas v0.5.1-0.20220717122844-8593d7eb1a8e h1:/r1xGMwmLg4LZ2V3/wWui9TtM3+STh1fp5ExSVRNFZo= -ariga.io/atlas v0.5.1-0.20220717122844-8593d7eb1a8e/go.mod h1:ofVetkJqlaWle3mvYmaS2uyFGFcc7dSq436tmxa/Mzk= -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= -cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= -cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= -cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= -cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= -cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= -cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= -cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= -cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= -cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= -cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= -cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= -cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= -cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= -cloud.google.com/go/compute v1.7.0 h1:v/k9Eueb8aAJ0vZuxKMrgm6kPhCLZU9HxFU+AFDs9Uk= -cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -entgo.io/ent v0.11.2 h1:UM2/BUhF2FfsxPHRxLjQbhqJNaDdVlOwNIAMLs2jyto= -entgo.io/ent v0.11.2/go.mod h1:YGHEQnmmIUgtD5b1ICD5vg74dS3npkNnmC5K+0J+IHU= -github.com/AppsFlyer/go-sundheit v0.5.0 h1:/VxpyigCfJrq1r97mn9HPiAB2qrhcTFHwNIIDr15CZM= -github.com/AppsFlyer/go-sundheit v0.5.0/go.mod h1:2ZM0BnfqT/mljBQO224VbL5XH06TgWuQ6Cn+cTtCpTY= -github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU= -github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +ariga.io/atlas v0.36.2-0.20250730182955-2c6300d0a3e1 h1:NPPfBaVZgz4LKBCIc0FbMogCjvXN+yGf7CZwotOwJo8= +ariga.io/atlas v0.36.2-0.20250730182955-2c6300d0a3e1/go.mod h1:Ex5l1xHsnWQUc3wYnrJ9gD7RUEzG76P7ZRQp8wNr0wc= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= +cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +entgo.io/ent v0.14.6 h1:/f2696BpwuWAEEG6PVGWflg6+Inrpq4pRWuNlWz/Skk= +entgo.io/ent v0.14.6/go.mod h1:z46QBUdGC+BATwsedbDuREfSS0oSCV+csdEYlL4p73s= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk= +github.com/AppsFlyer/go-sundheit v0.6.0/go.mod h1:LDdBHD6tQBtmHsdW+i1GwdTt6Wqc0qazf5ZEJVTbTME= +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= -github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= -github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= -github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= -github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= -github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= -github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= -github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/beevik/etree v1.7.0 h1:xjBk9O4p4x7D1YajePjfLzdaFC4/uYUENA7P0pv6gXA= +github.com/beevik/etree v1.7.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/coreos/go-oidc/v3 v3.3.0 h1:Y1LV3mP+QT3MEycATZpAiwfyN+uxZLqVbAHJUuOJEe4= -github.com/coreos/go-oidc/v3 v3.3.0/go.mod h1:eHUXhZtXPQLgEaDrOVTgwbgmz1xGOkJNye6h3zkD2Pw= -github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= +github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= +github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= +github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= -github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= -github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs= -github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= +github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= +github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= -github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= -github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw= -github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= -github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= -github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= -github.com/googleapis/gax-go/v2 v2.4.0 h1:dS9eYAjhrE2RjmzYw2XAPvcXfmcQLtFEQWn0CR82awk= -github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= -github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= -github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= -github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo= +github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4= +github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl/v2 v2.10.0 h1:1S1UnuhDGlv3gRFV4+0EdwB+znNP5HmcGbIqwnSCByg= -github.com/hashicorp/hcl/v2 v2.10.0/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= -github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs= -github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= -github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo= +github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ= -github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= -github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= -github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= -github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= -github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= +github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= +github.com/openbao/openbao/api/v2 v2.6.0 h1:KvfspAaL9bab9hI8jFYkV2cgtSrwWtaG+k9AUTHWU4M= +github.com/openbao/openbao/api/v2 v2.6.0/go.mod h1:H4IWiH+2rgF/TbrsUbsfrMyGoqojkLqxPCRLENSMnSo= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_golang v1.13.0 h1:b71QUfeo5M8gq2+evJdTPfZhYMAU0uKPkyPJ7TPsloU= -github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE= -github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= -github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/russellhaering/goxmldsig v1.2.0 h1:Y6GTTc9Un5hCxSzVz4UIWQ/zuVwDvzJk80guqzwx6Vg= -github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= +github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= -github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= -github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= -github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= -github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= -github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= -github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= -github.com/zclconf/go-cty v1.8.0 h1:s4AvqaeQzJIu3ndv4gVIhplVD0krU+bgrcLSVUnaWuA= -github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= -github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= -go.etcd.io/etcd/api/v3 v3.5.4 h1:OHVyt3TopwtUQ2GKdd5wu3PmmipR4FTwCqoEjSyRdIc= -go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= -go.etcd.io/etcd/client/pkg/v3 v3.5.4 h1:lrneYvz923dvC14R54XcA7FXoZ3mlGZAgmwhfm7HqOg= -go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v3 v3.5.4 h1:p83BUL3tAYS0OT/r0qglgc3M1JjhM0diV8DSWAhVXv4= -go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= +github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= +github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= +go.etcd.io/etcd/api/v3 v3.6.13 h1:AvHPZv15LYEe7tZDyFglv7xnbiuF6GMZpZqKpIzXTt0= +go.etcd.io/etcd/api/v3 v3.6.13/go.mod h1:X9+3gaKwzjlOxzo6TZ2u3b7HcHBcAL+Ph7EBPjI/VWk= +go.etcd.io/etcd/client/pkg/v3 v3.6.13 h1:7QeMOisYByx8dBA7/CKcwCaPWfjb5C0xpmrIov/8WyY= +go.etcd.io/etcd/client/pkg/v3 v3.6.13/go.mod h1:Dn2zUBOCu/6xYcd6iAjB7LgoY16OTQjDZfWHLwvuQj4= +go.etcd.io/etcd/client/v3 v3.6.13 h1:0E+9ZYGpMsi9KlOJVoCdONh9PUDawKDTy5mSNY8wOEI= +go.etcd.io/etcd/client/v3 v3.6.13/go.mod h1:rtVI3vwobljb8xlTGcp1Yhz7hBIuBWULXwB848kqJGw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY= -golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 h1:2o1E+E8TpNLklK9nHiPiK1uzIYrIHt+cQx3ynCwq9V8= -golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= +golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0= -golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= -google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= -google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= -google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= -google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= -google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= -google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= -google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= -google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= -google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= -google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= -google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= -google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= -google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= -google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= -google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= -google.golang.org/api v0.94.0 h1:KtKM9ru3nzQioV1HLlUf1cR7vMYJIpgls5VhAYQXIwA= -google.golang.org/api v0.94.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= -google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= -google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= -google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f h1:hJ/Y5SqPXbarffmAsApliUlcvMU+wScNGfyop4bZm8o= -google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= -google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw= -google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.291.0 h1:wfPbbY+mr9c7wZLqqzrHJLft/q8iFKREd6IgTBUene0= +google.golang.org/api v0.291.0/go.mod h1:at7kwWbuonglBFEBoeMDAV1bguHqL3qf0BHFsv3coa0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df h1:O3ig1i5WDDzsVzRp+cCdgelT9vXnlnOFdlEeFtL4HCc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= -gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/pkg/cel/cel.go b/pkg/cel/cel.go new file mode 100644 index 0000000000..8dd686ba72 --- /dev/null +++ b/pkg/cel/cel.go @@ -0,0 +1,232 @@ +package cel + +import ( + "context" + "fmt" + "reflect" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/checker" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/ext" + + "github.com/dexidp/dex/pkg/cel/library" +) + +// EnvironmentVersion represents the version of the CEL environment. +// New variables, functions, or libraries are introduced in new versions. +type EnvironmentVersion uint32 + +const ( + // EnvironmentV1 is the initial CEL environment. + EnvironmentV1 EnvironmentVersion = 1 +) + +// CompilationResult holds a compiled CEL program ready for evaluation. +type CompilationResult struct { + Program cel.Program + OutputType *cel.Type + Expression string + + ast *cel.Ast +} + +// CompilerOption configures a Compiler. +type CompilerOption func(*compilerConfig) + +type compilerConfig struct { + costBudget uint64 + version EnvironmentVersion +} + +func defaultCompilerConfig() *compilerConfig { + return &compilerConfig{ + costBudget: DefaultCostBudget, + version: EnvironmentV1, + } +} + +// WithCostBudget sets a custom cost budget for expression evaluation. +func WithCostBudget(budget uint64) CompilerOption { + return func(cfg *compilerConfig) { + cfg.costBudget = budget + } +} + +// WithVersion sets the target environment version for the compiler. +// Defaults to the latest version. Specifying an older version ensures +// that only functions/types available at that version are used. +func WithVersion(v EnvironmentVersion) CompilerOption { + return func(cfg *compilerConfig) { + cfg.version = v + } +} + +// Compiler compiles CEL expressions against a specific environment. +type Compiler struct { + env *cel.Env + cfg *compilerConfig +} + +// NewCompiler creates a new CEL compiler with the specified variable +// declarations and options. +// +// All custom Dex libraries are automatically included. +// The environment is configured with cost limits and safe defaults. +func NewCompiler(variables []VariableDeclaration, opts ...CompilerOption) (*Compiler, error) { + cfg := defaultCompilerConfig() + for _, opt := range opts { + opt(cfg) + } + + envOpts := make([]cel.EnvOption, 0, 8+len(variables)) + envOpts = append(envOpts, + cel.DefaultUTCTimeZone(true), + + // Standard extension libraries (same set as Kubernetes) + ext.Strings(), + ext.Encoders(), + ext.Lists(), + ext.Sets(), + ext.Math(), + + // Native Go types for typed variable access. + // This gives compile-time field checking: identity.emial โ†’ error at config load. + ext.NativeTypes( + ext.ParseStructTags(true), + reflect.TypeOf(IdentityVal{}), + reflect.TypeOf(RequestVal{}), + ), + + // Custom Dex libraries + cel.Lib(&library.Email{}), + cel.Lib(&library.Groups{}), + + // Presence tests like has(field) and 'key' in map are O(1) hash + // lookups on map(string, dyn) variables, so they should not count + // toward the cost budget. Without this, expressions with multiple + // 'in' checks (e.g. "'admin' in identity.groups") would accumulate + // inflated cost estimates. This matches Kubernetes CEL behavior + // where presence tests are free for CRD validation rules. + cel.CostEstimatorOptions( + checker.PresenceTestHasCost(false), + ), + ) + + for _, v := range variables { + envOpts = append(envOpts, cel.Variable(v.Name, v.Type)) + } + + env, err := cel.NewEnv(envOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create CEL environment: %w", err) + } + + return &Compiler{env: env, cfg: cfg}, nil +} + +// CompileBool compiles a CEL expression that must evaluate to bool. +func (c *Compiler) CompileBool(expression string) (*CompilationResult, error) { + return c.compile(expression, cel.BoolType) +} + +// CompileString compiles a CEL expression that must evaluate to string. +func (c *Compiler) CompileString(expression string) (*CompilationResult, error) { + return c.compile(expression, cel.StringType) +} + +// CompileStringList compiles a CEL expression that must evaluate to list(string). +func (c *Compiler) CompileStringList(expression string) (*CompilationResult, error) { + return c.compile(expression, cel.ListType(cel.StringType)) +} + +// Compile compiles a CEL expression with any output type. +func (c *Compiler) Compile(expression string) (*CompilationResult, error) { + return c.compile(expression, nil) +} + +func (c *Compiler) compile(expression string, expectedType *cel.Type) (*CompilationResult, error) { + if len(expression) > MaxExpressionLength { + return nil, fmt.Errorf("expression exceeds maximum length of %d characters", MaxExpressionLength) + } + + ast, issues := c.env.Compile(expression) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("CEL compilation failed: %w", issues.Err()) + } + + if expectedType != nil && !ast.OutputType().IsEquivalentType(expectedType) { + return nil, fmt.Errorf( + "expected expression output type %s, got %s", + expectedType, ast.OutputType(), + ) + } + + // Estimate cost at compile time and reject expressions that are too expensive. + costEst, err := c.env.EstimateCost(ast, &defaultCostEstimator{}) + if err != nil { + return nil, fmt.Errorf("CEL cost estimation failed: %w", err) + } + + if costEst.Max > c.cfg.costBudget { + return nil, fmt.Errorf( + "CEL expression estimated cost %d exceeds budget %d", + costEst.Max, c.cfg.costBudget, + ) + } + + prog, err := c.env.Program(ast, + cel.EvalOptions(cel.OptOptimize), + cel.CostLimit(c.cfg.costBudget), + ) + if err != nil { + return nil, fmt.Errorf("CEL program creation failed: %w", err) + } + + return &CompilationResult{ + Program: prog, + OutputType: ast.OutputType(), + Expression: expression, + ast: ast, + }, nil +} + +// Eval evaluates a compiled program against the given variables. +func Eval(ctx context.Context, result *CompilationResult, variables map[string]any) (ref.Val, error) { + out, _, err := result.Program.ContextEval(ctx, variables) + if err != nil { + return nil, fmt.Errorf("CEL evaluation failed: %w", err) + } + + return out, nil +} + +// EvalBool is a convenience function that evaluates and asserts bool output. +func EvalBool(ctx context.Context, result *CompilationResult, variables map[string]any) (bool, error) { + out, err := Eval(ctx, result, variables) + if err != nil { + return false, err + } + + v, ok := out.Value().(bool) + if !ok { + return false, fmt.Errorf("expected bool result, got %T", out.Value()) + } + + return v, nil +} + +// EvalString is a convenience function that evaluates and asserts string output. +func EvalString(ctx context.Context, result *CompilationResult, variables map[string]any) (string, error) { + out, err := Eval(ctx, result, variables) + if err != nil { + return "", err + } + + v, ok := out.Value().(string) + if !ok { + return "", fmt.Errorf("expected string result, got %T", out.Value()) + } + + return v, nil +} diff --git a/pkg/cel/cel_test.go b/pkg/cel/cel_test.go new file mode 100644 index 0000000000..b211f344b4 --- /dev/null +++ b/pkg/cel/cel_test.go @@ -0,0 +1,280 @@ +package cel_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/connector" + dexcel "github.com/dexidp/dex/pkg/cel" +) + +func TestCompileBool(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + tests := map[string]struct { + expr string + wantErr bool + }{ + "true literal": { + expr: "true", + }, + "comparison": { + expr: "1 == 1", + }, + "string type mismatch": { + expr: "'hello'", + wantErr: true, + }, + "int type mismatch": { + expr: "42", + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := compiler.CompileBool(tc.expr) + if tc.wantErr { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + } + }) + } +} + +func TestCompileString(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + tests := map[string]struct { + expr string + wantErr bool + }{ + "string literal": { + expr: "'hello'", + }, + "string concatenation": { + expr: "'hello' + ' ' + 'world'", + }, + "bool type mismatch": { + expr: "true", + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := compiler.CompileString(tc.expr) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + } + }) + } +} + +func TestCompileStringList(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + result, err := compiler.CompileStringList("['a', 'b', 'c']") + assert.NoError(t, err) + assert.NotNil(t, result) + + _, err = compiler.CompileStringList("'not a list'") + assert.Error(t, err) +} + +func TestCompile(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + // Compile accepts any type + result, err := compiler.Compile("true") + assert.NoError(t, err) + assert.NotNil(t, result) + + result, err = compiler.Compile("'hello'") + assert.NoError(t, err) + assert.NotNil(t, result) + + result, err = compiler.Compile("42") + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestCompileErrors(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + tests := map[string]struct { + expr string + }{ + "syntax error": { + expr: "1 +", + }, + "undefined variable": { + expr: "undefined_var", + }, + "undefined function": { + expr: "undefinedFunc()", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + _, err := compiler.Compile(tc.expr) + assert.Error(t, err) + }) + } +} + +func TestCompileRejectsUnknownFields(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + // Typo in field name: should fail at compile time with ObjectType + _, err = compiler.CompileBool("identity.emial == 'test@example.com'") + assert.Error(t, err) + assert.Contains(t, err.Error(), "compilation failed") + + // Type mismatch: comparing string field to int should fail at compile time + _, err = compiler.CompileBool("identity.email == 123") + assert.Error(t, err) + assert.Contains(t, err.Error(), "compilation failed") + + // Valid field: should compile fine + _, err = compiler.CompileBool("identity.email == 'test@example.com'") + assert.NoError(t, err) +} + +func TestMaxExpressionLength(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + longExpr := "'" + strings.Repeat("a", dexcel.MaxExpressionLength) + "'" + _, err = compiler.Compile(longExpr) + assert.Error(t, err) + assert.Contains(t, err.Error(), "maximum length") +} + +func TestEvalBool(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + tests := map[string]struct { + expr string + identity dexcel.IdentityVal + want bool + }{ + "email endsWith": { + expr: "identity.email.endsWith('@example.com')", + identity: dexcel.IdentityVal{Email: "user@example.com"}, + want: true, + }, + "email endsWith false": { + expr: "identity.email.endsWith('@example.com')", + identity: dexcel.IdentityVal{Email: "user@other.com"}, + want: false, + }, + "email_verified": { + expr: "identity.email_verified == true", + identity: dexcel.IdentityVal{EmailVerified: true}, + want: true, + }, + "group membership": { + expr: "identity.groups.exists(g, g == 'admin')", + identity: dexcel.IdentityVal{Groups: []string{"admin", "dev"}}, + want: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + prog, err := compiler.CompileBool(tc.expr) + require.NoError(t, err) + + result, err := dexcel.EvalBool(context.Background(), prog, map[string]any{ + "identity": tc.identity, + }) + require.NoError(t, err) + assert.Equal(t, tc.want, result) + }) + } +} + +func TestEvalString(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + // With ObjectType, identity.email is typed as string, so CompileString works. + prog, err := compiler.CompileString("identity.email") + require.NoError(t, err) + + result, err := dexcel.EvalString(context.Background(), prog, map[string]any{ + "identity": dexcel.IdentityVal{Email: "user@example.com"}, + }) + require.NoError(t, err) + assert.Equal(t, "user@example.com", result) +} + +func TestEvalWithIdentityAndRequest(t *testing.T) { + vars := append(dexcel.IdentityVariables(), dexcel.RequestVariables()...) + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + prog, err := compiler.CompileBool( + `identity.email.endsWith('@example.com') && 'admin' in identity.groups && request.connector_id == 'okta'`, + ) + require.NoError(t, err) + + identity := dexcel.IdentityFromConnector(connector.Identity{ + UserID: "123", + Username: "john", + Email: "john@example.com", + Groups: []string{"admin", "dev"}, + }) + request := dexcel.RequestFromContext(dexcel.RequestContext{ + ClientID: "my-app", + ConnectorID: "okta", + Scopes: []string{"openid", "email"}, + }) + + result, err := dexcel.EvalBool(context.Background(), prog, map[string]any{ + "identity": identity, + "request": request, + }) + require.NoError(t, err) + assert.True(t, result) +} + +func TestNewCompilerWithVariables(t *testing.T) { + // Claims variable โ€” remains map(string, dyn) + compiler, err := dexcel.NewCompiler(dexcel.ClaimsVariable()) + require.NoError(t, err) + + // claims.email returns dyn from map access, use Compile (not CompileString) + prog, err := compiler.Compile("claims.email") + require.NoError(t, err) + + result, err := dexcel.EvalString(context.Background(), prog, map[string]any{ + "claims": map[string]any{ + "email": "test@example.com", + }, + }) + require.NoError(t, err) + assert.Equal(t, "test@example.com", result) +} diff --git a/pkg/cel/cost.go b/pkg/cel/cost.go new file mode 100644 index 0000000000..d7a09102b1 --- /dev/null +++ b/pkg/cel/cost.go @@ -0,0 +1,105 @@ +package cel + +import ( + "fmt" + + "github.com/google/cel-go/checker" +) + +// DefaultCostBudget is the default cost budget for a single expression +// evaluation. Aligned with Kubernetes defaults: enough for typical identity +// operations but prevents runaway expressions. +const DefaultCostBudget uint64 = 10_000_000 + +// MaxExpressionLength is the maximum length of a CEL expression string. +const MaxExpressionLength = 10_240 + +// DefaultStringMaxLength is the estimated max length of string values +// (emails, usernames, group names, etc.) used for compile-time cost estimation. +const DefaultStringMaxLength = 256 + +// DefaultListMaxLength is the estimated max length of list values +// (groups, scopes) used for compile-time cost estimation. +const DefaultListMaxLength = 100 + +// CostEstimate holds the estimated cost range for a compiled expression. +type CostEstimate struct { + Min uint64 + Max uint64 +} + +// EstimateCost returns the estimated cost range for a compiled expression. +// This is computed statically at compile time without evaluating the expression. +func (c *Compiler) EstimateCost(result *CompilationResult) (CostEstimate, error) { + costEst, err := c.env.EstimateCost(result.ast, &defaultCostEstimator{}) + if err != nil { + return CostEstimate{}, fmt.Errorf("CEL cost estimation failed: %w", err) + } + + return CostEstimate{Min: costEst.Min, Max: costEst.Max}, nil +} + +// defaultCostEstimator provides size hints for compile-time cost estimation. +// Without these hints, the CEL cost estimator assumes unbounded sizes for +// variables, leading to wildly overestimated max costs. +type defaultCostEstimator struct{} + +func (defaultCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate { + // Provide size hints for map(string, dyn) variables: identity, request, claims. + // Without these, the estimator assumes lists/strings can be infinitely large. + if element.Path() == nil { + return nil + } + + path := element.Path() + if len(path) == 0 { + return nil + } + + root := path[0] + + switch root { + case "identity", "request", "claims": + // Nested field access (e.g. identity.email, identity.groups) + if len(path) >= 2 { + field := path[1] + switch field { + case "groups", "scopes": + // list(string) fields + return &checker.SizeEstimate{Min: 0, Max: DefaultListMaxLength} + case "email_verified": + // bool field โ€” size is always 1 + return &checker.SizeEstimate{Min: 1, Max: 1} + default: + // string fields (email, username, user_id, client_id, etc.) + return &checker.SizeEstimate{Min: 0, Max: DefaultStringMaxLength} + } + } + // The map itself: number of keys + return &checker.SizeEstimate{Min: 0, Max: 20} + } + + return nil +} + +func (defaultCostEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { + switch function { + case "dex.emailDomain", "dex.emailLocalPart": + // Simple string split โ€” O(n) where n is string length, bounded. + return &checker.CallEstimate{ + CostEstimate: checker.CostEstimate{Min: 1, Max: 2}, + } + case "dex.groupMatches": + // Iterates over groups list and matches each against a pattern. + return &checker.CallEstimate{ + CostEstimate: checker.CostEstimate{Min: 1, Max: DefaultListMaxLength}, + } + case "dex.groupFilter": + // Builds a set from allowed list, then iterates groups. + return &checker.CallEstimate{ + CostEstimate: checker.CostEstimate{Min: 1, Max: 2 * DefaultListMaxLength}, + } + } + + return nil +} diff --git a/pkg/cel/cost_test.go b/pkg/cel/cost_test.go new file mode 100644 index 0000000000..9a068be406 --- /dev/null +++ b/pkg/cel/cost_test.go @@ -0,0 +1,137 @@ +package cel_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dexcel "github.com/dexidp/dex/pkg/cel" +) + +func TestEstimateCost(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + tests := map[string]struct { + expr string + }{ + "simple bool": { + expr: "true", + }, + "string comparison": { + expr: "identity.email == 'test@example.com'", + }, + "group membership": { + expr: "identity.groups.exists(g, g == 'admin')", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + prog, err := compiler.Compile(tc.expr) + require.NoError(t, err) + + est, err := compiler.EstimateCost(prog) + require.NoError(t, err) + assert.True(t, est.Max >= est.Min, "max cost should be >= min cost") + assert.True(t, est.Max <= dexcel.DefaultCostBudget, + "estimated max cost %d should be within default budget %d", est.Max, dexcel.DefaultCostBudget) + }) + } +} + +func TestCompileTimeCostAcceptsSimpleExpressions(t *testing.T) { + vars := append(dexcel.IdentityVariables(), dexcel.RequestVariables()...) + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + tests := map[string]string{ + "literal": "true", + "email endsWith": "identity.email.endsWith('@example.com')", + "group check": "'admin' in identity.groups", + "emailDomain": `dex.emailDomain(identity.email)`, + "groupMatches": `dex.groupMatches(identity.groups, "team:*")`, + "groupFilter": `dex.groupFilter(identity.groups, ["admin", "dev"])`, + "combined policy": `identity.email.endsWith('@example.com') && 'admin' in identity.groups`, + "complex policy": `identity.email.endsWith('@example.com') && + identity.groups.exists(g, g == 'admin') && + request.connector_id == 'okta' && + request.scopes.exists(s, s == 'openid')`, + "filter+map chain": `identity.groups + .filter(g, g.startsWith('team:')) + .map(g, g.replace('team:', '')) + .size() > 0`, + } + + for name, expr := range tests { + t.Run(name, func(t *testing.T) { + _, err := compiler.Compile(expr) + assert.NoError(t, err, "expression should compile within default budget") + }) + } +} + +func TestCompileTimeCostRejection(t *testing.T) { + vars := append(dexcel.IdentityVariables(), dexcel.RequestVariables()...) + + tests := map[string]struct { + budget uint64 + expr string + }{ + "simple exists exceeds tiny budget": { + budget: 1, + expr: "identity.groups.exists(g, g == 'admin')", + }, + "endsWith exceeds tiny budget": { + budget: 2, + expr: "identity.email.endsWith('@example.com')", + }, + "nested comprehension over groups exceeds moderate budget": { + // Two nested iterations over groups: O(n^2) where n=100 โ†’ ~280K + budget: 10_000, + expr: `identity.groups.exists(g1, + identity.groups.exists(g2, + g1 != g2 && g1.startsWith(g2) + ) + )`, + }, + "cross-variable comprehension exceeds moderate budget": { + // filter groups then check each against scopes: O(n*m) โ†’ ~162K + budget: 10_000, + expr: `identity.groups + .filter(g, g.startsWith('team:')) + .exists(g, request.scopes.exists(s, s == g))`, + }, + "chained filter+map+filter+map exceeds small budget": { + budget: 1000, + expr: `identity.groups + .filter(g, g.startsWith('team:')) + .map(g, g.replace('team:', '')) + .filter(g, g.size() > 3) + .map(g, g.upperAscii()) + .size() > 0`, + }, + "many independent exists exceeds small budget": { + budget: 5000, + expr: `identity.groups.exists(g, g.contains('a')) && + identity.groups.exists(g, g.contains('b')) && + identity.groups.exists(g, g.contains('c')) && + identity.groups.exists(g, g.contains('d')) && + identity.groups.exists(g, g.contains('e'))`, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + compiler, err := dexcel.NewCompiler(vars, dexcel.WithCostBudget(tc.budget)) + require.NoError(t, err) + + _, err = compiler.Compile(tc.expr) + assert.Error(t, err) + assert.Contains(t, err.Error(), "estimated cost") + assert.Contains(t, err.Error(), "exceeds budget") + }) + } +} diff --git a/pkg/cel/doc.go b/pkg/cel/doc.go new file mode 100644 index 0000000000..64c1dbd303 --- /dev/null +++ b/pkg/cel/doc.go @@ -0,0 +1,5 @@ +// Package cel provides a safe, sandboxed CEL (Common Expression Language) +// environment for policy evaluation, claim mapping, and token customization +// in Dex. It includes cost budgets, Kubernetes-grade compatibility guarantees, +// and a curated set of extension libraries. +package cel diff --git a/pkg/cel/library/doc.go b/pkg/cel/library/doc.go new file mode 100644 index 0000000000..1452d2b939 --- /dev/null +++ b/pkg/cel/library/doc.go @@ -0,0 +1,4 @@ +// Package library provides custom CEL function libraries for Dex. +// Each library implements the cel.Library interface and can be registered +// in a CEL environment. +package library diff --git a/pkg/cel/library/email.go b/pkg/cel/library/email.go new file mode 100644 index 0000000000..38fe0dee94 --- /dev/null +++ b/pkg/cel/library/email.go @@ -0,0 +1,73 @@ +package library + +import ( + "strings" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// Email provides email-related CEL functions. +// +// Functions (V1): +// +// dex.emailDomain(email: string) -> string +// Returns the domain portion of an email address. +// Example: dex.emailDomain("user@example.com") == "example.com" +// +// dex.emailLocalPart(email: string) -> string +// Returns the local part of an email address. +// Example: dex.emailLocalPart("user@example.com") == "user" +type Email struct{} + +func (Email) CompileOptions() []cel.EnvOption { + return []cel.EnvOption{ + cel.Function("dex.emailDomain", + cel.Overload("dex_email_domain_string", + []*cel.Type{cel.StringType}, + cel.StringType, + cel.UnaryBinding(emailDomainImpl), + ), + ), + cel.Function("dex.emailLocalPart", + cel.Overload("dex_email_local_part_string", + []*cel.Type{cel.StringType}, + cel.StringType, + cel.UnaryBinding(emailLocalPartImpl), + ), + ), + } +} + +func (Email) ProgramOptions() []cel.ProgramOption { + return nil +} + +func emailDomainImpl(arg ref.Val) ref.Val { + email, ok := arg.Value().(string) + if !ok { + return types.NewErr("dex.emailDomain: expected string argument") + } + + _, domain, found := strings.Cut(email, "@") + if !found { + return types.String("") + } + + return types.String(domain) +} + +func emailLocalPartImpl(arg ref.Val) ref.Val { + email, ok := arg.Value().(string) + if !ok { + return types.NewErr("dex.emailLocalPart: expected string argument") + } + + localPart, _, found := strings.Cut(email, "@") + if !found { + return types.String(email) + } + + return types.String(localPart) +} diff --git a/pkg/cel/library/email_test.go b/pkg/cel/library/email_test.go new file mode 100644 index 0000000000..d13e73a1dd --- /dev/null +++ b/pkg/cel/library/email_test.go @@ -0,0 +1,106 @@ +package library_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dexcel "github.com/dexidp/dex/pkg/cel" +) + +func TestEmailDomain(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + tests := map[string]struct { + expr string + want string + }{ + "standard email": { + expr: `dex.emailDomain("user@example.com")`, + want: "example.com", + }, + "subdomain": { + expr: `dex.emailDomain("admin@sub.domain.org")`, + want: "sub.domain.org", + }, + "no at sign": { + expr: `dex.emailDomain("nodomain")`, + want: "", + }, + "empty string": { + expr: `dex.emailDomain("")`, + want: "", + }, + "multiple at signs": { + expr: `dex.emailDomain("user@name@example.com")`, + want: "name@example.com", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + prog, err := compiler.CompileString(tc.expr) + require.NoError(t, err) + + result, err := dexcel.EvalString(context.Background(), prog, map[string]any{}) + require.NoError(t, err) + assert.Equal(t, tc.want, result) + }) + } +} + +func TestEmailLocalPart(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + tests := map[string]struct { + expr string + want string + }{ + "standard email": { + expr: `dex.emailLocalPart("user@example.com")`, + want: "user", + }, + "no at sign": { + expr: `dex.emailLocalPart("justuser")`, + want: "justuser", + }, + "empty string": { + expr: `dex.emailLocalPart("")`, + want: "", + }, + "multiple at signs": { + expr: `dex.emailLocalPart("user@name@example.com")`, + want: "user", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + prog, err := compiler.CompileString(tc.expr) + require.NoError(t, err) + + result, err := dexcel.EvalString(context.Background(), prog, map[string]any{}) + require.NoError(t, err) + assert.Equal(t, tc.want, result) + }) + } +} + +func TestEmailDomainWithIdentityVariable(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + prog, err := compiler.CompileString(`dex.emailDomain(identity.email)`) + require.NoError(t, err) + + result, err := dexcel.EvalString(context.Background(), prog, map[string]any{ + "identity": dexcel.IdentityVal{Email: "admin@corp.example.com"}, + }) + require.NoError(t, err) + assert.Equal(t, "corp.example.com", result) +} diff --git a/pkg/cel/library/groups.go b/pkg/cel/library/groups.go new file mode 100644 index 0000000000..fd7f3603f1 --- /dev/null +++ b/pkg/cel/library/groups.go @@ -0,0 +1,123 @@ +package library + +import ( + "path" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" +) + +// Groups provides group-related CEL functions. +// +// Functions (V1): +// +// dex.groupMatches(groups: list(string), pattern: string) -> list(string) +// Returns groups matching a glob pattern. +// Example: dex.groupMatches(["team:dev", "team:ops", "admin"], "team:*") +// +// dex.groupFilter(groups: list(string), allowed: list(string)) -> list(string) +// Returns only groups present in the allowed list. +// Example: dex.groupFilter(["admin", "dev", "ops"], ["admin", "ops"]) +type Groups struct{} + +func (Groups) CompileOptions() []cel.EnvOption { + return []cel.EnvOption{ + cel.Function("dex.groupMatches", + cel.Overload("dex_group_matches_list_string", + []*cel.Type{cel.ListType(cel.StringType), cel.StringType}, + cel.ListType(cel.StringType), + cel.BinaryBinding(groupMatchesImpl), + ), + ), + cel.Function("dex.groupFilter", + cel.Overload("dex_group_filter_list_list", + []*cel.Type{cel.ListType(cel.StringType), cel.ListType(cel.StringType)}, + cel.ListType(cel.StringType), + cel.BinaryBinding(groupFilterImpl), + ), + ), + } +} + +func (Groups) ProgramOptions() []cel.ProgramOption { + return nil +} + +func groupMatchesImpl(lhs, rhs ref.Val) ref.Val { + groupList, ok := lhs.(traits.Lister) + if !ok { + return types.NewErr("dex.groupMatches: expected list(string) as first argument") + } + + pattern, ok := rhs.Value().(string) + if !ok { + return types.NewErr("dex.groupMatches: expected string pattern as second argument") + } + + iter := groupList.Iterator() + var matched []ref.Val + + for iter.HasNext() == types.True { + item := iter.Next() + + group, ok := item.Value().(string) + if !ok { + continue + } + + ok, err := path.Match(pattern, group) + if err != nil { + return types.NewErr("dex.groupMatches: invalid pattern %q: %v", pattern, err) + } + if ok { + matched = append(matched, types.String(group)) + } + } + + return types.NewRefValList(types.DefaultTypeAdapter, matched) +} + +func groupFilterImpl(lhs, rhs ref.Val) ref.Val { + groupList, ok := lhs.(traits.Lister) + if !ok { + return types.NewErr("dex.groupFilter: expected list(string) as first argument") + } + + allowedList, ok := rhs.(traits.Lister) + if !ok { + return types.NewErr("dex.groupFilter: expected list(string) as second argument") + } + + allowed := make(map[string]struct{}) + iter := allowedList.Iterator() + for iter.HasNext() == types.True { + item := iter.Next() + + s, ok := item.Value().(string) + if !ok { + continue + } + + allowed[s] = struct{}{} + } + + var filtered []ref.Val + iter = groupList.Iterator() + + for iter.HasNext() == types.True { + item := iter.Next() + + group, ok := item.Value().(string) + if !ok { + continue + } + + if _, exists := allowed[group]; exists { + filtered = append(filtered, types.String(group)) + } + } + + return types.NewRefValList(types.DefaultTypeAdapter, filtered) +} diff --git a/pkg/cel/library/groups_test.go b/pkg/cel/library/groups_test.go new file mode 100644 index 0000000000..70a68fb211 --- /dev/null +++ b/pkg/cel/library/groups_test.go @@ -0,0 +1,141 @@ +package library_test + +import ( + "context" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dexcel "github.com/dexidp/dex/pkg/cel" +) + +func TestGroupMatches(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + tests := map[string]struct { + expr string + groups []string + want []string + }{ + "wildcard pattern": { + expr: `dex.groupMatches(identity.groups, "team:*")`, + groups: []string{"team:dev", "team:ops", "admin"}, + want: []string{"team:dev", "team:ops"}, + }, + "exact match": { + expr: `dex.groupMatches(identity.groups, "admin")`, + groups: []string{"team:dev", "admin", "user"}, + want: []string{"admin"}, + }, + "no matches": { + expr: `dex.groupMatches(identity.groups, "nonexistent")`, + groups: []string{"team:dev", "admin"}, + want: []string{}, + }, + "question mark pattern": { + expr: `dex.groupMatches(identity.groups, "team?")`, + groups: []string{"teamA", "teamB", "teams-long"}, + want: []string{"teamA", "teamB"}, + }, + "match all": { + expr: `dex.groupMatches(identity.groups, "*")`, + groups: []string{"a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + prog, err := compiler.CompileStringList(tc.expr) + require.NoError(t, err) + + out, err := dexcel.Eval(context.Background(), prog, map[string]any{ + "identity": dexcel.IdentityVal{Groups: tc.groups}, + }) + require.NoError(t, err) + + nativeVal, err := out.ConvertToNative(reflect.TypeOf([]string{})) + require.NoError(t, err) + + got, ok := nativeVal.([]string) + require.True(t, ok, "expected []string, got %T", nativeVal) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestGroupMatchesInvalidPattern(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + prog, err := compiler.CompileStringList(`dex.groupMatches(identity.groups, "[invalid")`) + require.NoError(t, err) + + _, err = dexcel.Eval(context.Background(), prog, map[string]any{ + "identity": dexcel.IdentityVal{Groups: []string{"admin"}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid pattern") +} + +func TestGroupFilter(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + tests := map[string]struct { + expr string + groups []string + want []string + }{ + "filter to allowed": { + expr: `dex.groupFilter(identity.groups, ["admin", "ops"])`, + groups: []string{"admin", "dev", "ops"}, + want: []string{"admin", "ops"}, + }, + "no overlap": { + expr: `dex.groupFilter(identity.groups, ["marketing"])`, + groups: []string{"admin", "dev"}, + want: []string{}, + }, + "all allowed": { + expr: `dex.groupFilter(identity.groups, ["a", "b", "c"])`, + groups: []string{"a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + "empty allowed list": { + expr: `dex.groupFilter(identity.groups, [])`, + groups: []string{"admin", "dev"}, + want: []string{}, + }, + "preserves order": { + expr: `dex.groupFilter(identity.groups, ["z", "a"])`, + groups: []string{"a", "b", "z"}, + want: []string{"a", "z"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + prog, err := compiler.CompileStringList(tc.expr) + require.NoError(t, err) + + out, err := dexcel.Eval(context.Background(), prog, map[string]any{ + "identity": dexcel.IdentityVal{Groups: tc.groups}, + }) + require.NoError(t, err) + + nativeVal, err := out.ConvertToNative(reflect.TypeOf([]string{})) + require.NoError(t, err) + + got, ok := nativeVal.([]string) + require.True(t, ok, "expected []string, got %T", nativeVal) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/cel/types.go b/pkg/cel/types.go new file mode 100644 index 0000000000..4e65792290 --- /dev/null +++ b/pkg/cel/types.go @@ -0,0 +1,109 @@ +package cel + +import ( + "github.com/google/cel-go/cel" + + "github.com/dexidp/dex/connector" +) + +// VariableDeclaration declares a named variable and its CEL type +// that will be available in expressions. +type VariableDeclaration struct { + Name string + Type *cel.Type +} + +// IdentityVal is the CEL native type for the identity variable. +// Fields are typed so that the CEL compiler rejects unknown field access +// (e.g. identity.emial) at config load time rather than at evaluation time. +type IdentityVal struct { + UserID string `cel:"user_id"` + Username string `cel:"username"` + PreferredUsername string `cel:"preferred_username"` + Email string `cel:"email"` + EmailVerified bool `cel:"email_verified"` + Groups []string `cel:"groups"` +} + +// RequestVal is the CEL native type for the request variable. +type RequestVal struct { + ClientID string `cel:"client_id"` + ConnectorID string `cel:"connector_id"` + Scopes []string `cel:"scopes"` + RedirectURI string `cel:"redirect_uri"` +} + +// identityTypeName is the CEL type name for IdentityVal. +// Derived by ext.NativeTypes as simplePkgAlias(pkgPath) + "." + structName. +const identityTypeName = "cel.IdentityVal" + +// requestTypeName is the CEL type name for RequestVal. +const requestTypeName = "cel.RequestVal" + +// IdentityVariables provides the 'identity' variable with typed fields. +// +// identity.user_id โ€” string +// identity.username โ€” string +// identity.preferred_username โ€” string +// identity.email โ€” string +// identity.email_verified โ€” bool +// identity.groups โ€” list(string) +func IdentityVariables() []VariableDeclaration { + return []VariableDeclaration{ + {Name: "identity", Type: cel.ObjectType(identityTypeName)}, + } +} + +// RequestVariables provides the 'request' variable with typed fields. +// +// request.client_id โ€” string +// request.connector_id โ€” string +// request.scopes โ€” list(string) +// request.redirect_uri โ€” string +func RequestVariables() []VariableDeclaration { + return []VariableDeclaration{ + {Name: "request", Type: cel.ObjectType(requestTypeName)}, + } +} + +// ClaimsVariable provides a 'claims' map for raw upstream claims. +// Claims remain map(string, dyn) because their shape is genuinely +// unknown โ€” they carry arbitrary upstream IdP data. +// +// claims โ€” map(string, dyn) +func ClaimsVariable() []VariableDeclaration { + return []VariableDeclaration{ + {Name: "claims", Type: cel.MapType(cel.StringType, cel.DynType)}, + } +} + +// IdentityFromConnector converts a connector.Identity to a CEL-compatible IdentityVal. +func IdentityFromConnector(id connector.Identity) IdentityVal { + return IdentityVal{ + UserID: id.UserID, + Username: id.Username, + PreferredUsername: id.PreferredUsername, + Email: id.Email, + EmailVerified: id.EmailVerified, + Groups: id.Groups, + } +} + +// RequestContext represents the authentication/token request context +// available as the 'request' variable in CEL expressions. +type RequestContext struct { + ClientID string + ConnectorID string + Scopes []string + RedirectURI string +} + +// RequestFromContext converts a RequestContext to a CEL-compatible RequestVal. +func RequestFromContext(rc RequestContext) RequestVal { + return RequestVal{ + ClientID: rc.ClientID, + ConnectorID: rc.ConnectorID, + Scopes: rc.Scopes, + RedirectURI: rc.RedirectURI, + } +} diff --git a/pkg/featureflags/doc.go b/pkg/featureflags/doc.go new file mode 100644 index 0000000000..2703329361 --- /dev/null +++ b/pkg/featureflags/doc.go @@ -0,0 +1,3 @@ +// Package featureflags provides a mechanism for toggling experimental or +// optional Dex features via environment variables (DEX_). +package featureflags diff --git a/pkg/featureflags/flag.go b/pkg/featureflags/flag.go new file mode 100644 index 0000000000..98729ac9ed --- /dev/null +++ b/pkg/featureflags/flag.go @@ -0,0 +1,33 @@ +package featureflags + +import ( + "os" + "strconv" + "strings" +) + +type flag struct { + Name string + Default bool +} + +func (f *flag) env() string { + return "DEX_" + strings.ToUpper(f.Name) +} + +func (f *flag) Enabled() bool { + raw := os.Getenv(f.env()) + if raw == "" { + return f.Default + } + + res, err := strconv.ParseBool(raw) + if err != nil { + return f.Default + } + return res +} + +func newFlag(s string, d bool) *flag { + return &flag{Name: s, Default: d} +} diff --git a/pkg/featureflags/set.go b/pkg/featureflags/set.go new file mode 100644 index 0000000000..a63da72ce0 --- /dev/null +++ b/pkg/featureflags/set.go @@ -0,0 +1,30 @@ +package featureflags + +var ( + // EntEnabled enables experimental ent-based engine for the database storages. + // https://entgo.io/ + EntEnabled = newFlag("ent_enabled", false) + + // ExpandEnv can enable or disable env expansion in the config which can be useful in environments where, e.g., + // $ sign is a part of the password for LDAP user. + ExpandEnv = newFlag("expand_env", true) + + // APIConnectorsCRUD allows CRUD operations on connectors through the gRPC API + APIConnectorsCRUD = newFlag("api_connectors_crud", false) + + // ContinueOnConnectorFailure allows the server to start even if some connectors fail to initialize. + ContinueOnConnectorFailure = newFlag("continue_on_connector_failure", true) + + // ConfigDisallowUnknownFields enables to forbid unknown fields in the config while unmarshaling. + ConfigDisallowUnknownFields = newFlag("config_disallow_unknown_fields", false) + + // ClientCredentialGrantEnabledByDefault enables the client_credentials grant type by default + // without requiring explicit configuration in oauth2.grantTypes. + ClientCredentialGrantEnabledByDefault = newFlag("client_credential_grant_enabled_by_default", false) + + // SessionsEnabled enables experimental auth sessions support. + SessionsEnabled = newFlag("sessions_enabled", false) + + // APISessionsIdentitiesCRUD allows CRUD operations on auth sessions and user identities through the gRPC API. + APISessionsIdentitiesCRUD = newFlag("api_sessions_identities_crud", false) +) diff --git a/pkg/groups/doc.go b/pkg/groups/doc.go new file mode 100644 index 0000000000..f1a21d02b8 --- /dev/null +++ b/pkg/groups/doc.go @@ -0,0 +1,2 @@ +// Package groups contains helper functions related to groups. +package groups diff --git a/pkg/groups/groups.go b/pkg/groups/groups.go index 5dde65ab83..d31a5dee3b 100644 --- a/pkg/groups/groups.go +++ b/pkg/groups/groups.go @@ -1,4 +1,3 @@ -// Package groups contains helper functions related to groups package groups // Filter filters out any groups of given that are not in required. Thus it may diff --git a/pkg/httpclient/doc.go b/pkg/httpclient/doc.go new file mode 100644 index 0000000000..3d028a3a1f --- /dev/null +++ b/pkg/httpclient/doc.go @@ -0,0 +1,3 @@ +// Package httpclient provides a configurable HTTP client constructor with +// support for custom CA certificates, root CAs, and TLS settings. +package httpclient diff --git a/pkg/httpclient/httpclient.go b/pkg/httpclient/httpclient.go new file mode 100644 index 0000000000..671e0e7754 --- /dev/null +++ b/pkg/httpclient/httpclient.go @@ -0,0 +1,64 @@ +package httpclient + +import ( + "crypto/tls" + "crypto/x509" + "encoding/base64" + "fmt" + "net" + "net/http" + "os" + "time" +) + +func extractCAs(input []string) [][]byte { + result := make([][]byte, 0, len(input)) + for _, ca := range input { + if ca == "" { + continue + } + + pemData, err := os.ReadFile(ca) + if err != nil { + pemData, err = base64.StdEncoding.DecodeString(ca) + if err != nil { + pemData = []byte(ca) + } + } + + result = append(result, pemData) + } + return result +} + +func NewHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) { + pool, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + + tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify} + for index, rootCABytes := range extractCAs(rootCAs) { + if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { + return nil, fmt.Errorf("rootCAs.%d is not in PEM format, certificate must be "+ + "a PEM encoded string, a base64 encoded bytes that contain PEM encoded string, "+ + "or a path to a PEM encoded certificate", index) + } + } + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tlsConfig, + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + }, nil +} diff --git a/pkg/httpclient/httpclient_test.go b/pkg/httpclient/httpclient_test.go new file mode 100644 index 0000000000..6f561c1030 --- /dev/null +++ b/pkg/httpclient/httpclient_test.go @@ -0,0 +1,83 @@ +package httpclient_test + +import ( + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dexidp/dex/pkg/httpclient" +) + +func TestRootCAs(t *testing.T) { + ts, err := NewLocalHTTPSTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello, client") + })) + assert.Nil(t, err) + defer ts.Close() + + runTest := func(name string, certs []string) { + t.Run(name, func(t *testing.T) { + rootCAs := certs + testClient, err := httpclient.NewHTTPClient(rootCAs, false) + assert.Nil(t, err) + + res, err := testClient.Get(ts.URL) + assert.Nil(t, err) + + greeting, err := io.ReadAll(res.Body) + res.Body.Close() + assert.Nil(t, err) + + assert.Equal(t, "Hello, client", string(greeting)) + }) + } + + runTest("From file", []string{"testdata/rootCA.pem"}) + + content, err := os.ReadFile("testdata/rootCA.pem") + assert.NoError(t, err) + runTest("From string", []string{string(content)}) + + contentStr := base64.StdEncoding.EncodeToString(content) + runTest("From bytes", []string{contentStr}) +} + +func TestInsecureSkipVerify(t *testing.T) { + ts, err := NewLocalHTTPSTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello, client") + })) + assert.Nil(t, err) + defer ts.Close() + + insecureSkipVerify := true + + testClient, err := httpclient.NewHTTPClient(nil, insecureSkipVerify) + assert.Nil(t, err) + + res, err := testClient.Get(ts.URL) + assert.Nil(t, err) + + greeting, err := io.ReadAll(res.Body) + res.Body.Close() + assert.Nil(t, err) + + assert.Equal(t, "Hello, client", string(greeting)) +} + +func NewLocalHTTPSTestServer(handler http.Handler) (*httptest.Server, error) { + ts := httptest.NewUnstartedServer(handler) + cert, err := tls.LoadX509KeyPair("testdata/server.crt", "testdata/server.key") + if err != nil { + return nil, err + } + ts.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + ts.StartTLS() + return ts, nil +} diff --git a/pkg/httpclient/readme.md b/pkg/httpclient/readme.md new file mode 100644 index 0000000000..cc26252293 --- /dev/null +++ b/pkg/httpclient/readme.md @@ -0,0 +1,44 @@ +# Regenerate testdata + +### server.csr.cnf + +``` +[req] +default_bits = 2048 +prompt = no +default_md = sha256 +distinguished_name = dn + +[dn] +C=US +ST=RandomState +L=RandomCity +O=RandomOrganization +OU=RandomOrganizationUnit +emailAddress=hello@example.com +CN = localhost +``` + +and + +### v3.ext +``` +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 +``` + +### Then enter the following commands: + +`openssl genrsa -out rootCA.key 2048` + +`openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 3650 -out rootCA.pem -config server.csr.cnf` + +`openssl req -new -sha256 -nodes -out server.csr -newkey rsa:2048 -keyout server.key -config server.csr.cnf` + +`openssl x509 -req -in server.csr -CA rootCA.pem -CAkey rootCA.key -CAcreateserial -out server.crt -days 3650 -sha256 -extfile v3.ext` diff --git a/pkg/httpclient/testdata/rootCA.key b/pkg/httpclient/testdata/rootCA.key new file mode 100644 index 0000000000..9c4eeee12a --- /dev/null +++ b/pkg/httpclient/testdata/rootCA.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA4dB5aQCjCmMsW71u9F0WNm1TYjXQBZ4p7oNT+BQwCc/MZ2xc +5NexS2O86nbRkw5jwyfAAMSMKRr9s2FluVTHqiln78rg+XUgmrmNT3ZroLmW6QL6 +Ca8dbMPky+tQclZsvMd3HAeCyyrs4pf7wM1AyUJD7H0xAlVD1fsohkg7jhBFUfV+ +q2VMMdnsaV5vFrW/2vPBWz1SNPW/Xm+Ilny7xg9njQLcPMNtVtF+7EPB6sxD6qrj +BC+Kj5zQ3bZOfdrh7yy63dbh/Kh+3NScgO+k+x92HlAjRIvj5y4KrbGZl7CmOth5 +y7fPywApVbDfZRWJChI1PVflOyDdnC+vhMLbHQIDAQABAoIBAEmjrrQrXP/6L3EL +aa+O27uME3Enk1sBpTL+6Ncx3iiU91eS4whNvqeTMvxTGy0VuDrgL6EQd5TAFJP2 +4zF5EFPRhO+R/aPcKnHKqOaM+7RCUZBTRC78SGA70dUeO/HNdVBqy9D8Mg8HRJDw +d0z8om//iB8LBHx6SdDyQtjnnWRKFTzQRurBBoyLe2vPMFtINKtNUkahjc8HE4GO +aIv1LICJUzf4ZnkntKd5cFHZ42R2Tmfj0Y9G9DyJbuSA3+0u5IhYB39Uy6jFxLi8 +I5PoIVhgYZ0aivsVBIviShwQ9kgv6807YBxt22eSNovBDrSp+cAnIF9+p0b3MnkU +aCHSiBECgYEA84lssi6AqfCEsSiQMSM9kMCXJ4KQI/l7pmrIA50+V5HSEby9lg2Y +N6XJ4V4q46t8FcZBjmMvzn9fwiPMRw5e995cVNBQ31a1FX/1Hy6RNtEiLZRnkHI5 +WznY9IxQ+c9JXJeFY1sO0BfO0TS3WvOf1rwqOb92q+cQaItnPQ+4Ya8CgYEA7V7e +IqW3PpO4H+c5hH9egM0BjAxH71C9YpYzZpF9uiPIkuMnJ8nm9bB6RiuDaYCxvrfE +A0h/SQewoYJKL4OfKGjrbG7U4zLMZHIWlf8Za55Zik5BNjvgBqFFrrSgLUGxdRTX +N0+TlWlW1bvJblWpdjIbJbg/6kCU98TzK852fvMCgYAWYa/apElw1MjtGyQ9T9bN +odWCbQ5gMAJ8Jd4h7uaW17DtrmHiE3fEzXjDPItGhzENMz49HsJ7ANvFFNMmSJzT +vNzRcp+sFuTnh+34Iqh32DqC49usu8KnrqZQu0CJ5NICL26z1d+DolyAf47GThOH +gZ2D1yPJ4p9wbDddtj8kwwKBgCFKB68mPG+rOcxHmjppvnAj0A66/i+izBySYf0F +dHNxZ0SqVKhw2VIlgNBsc86M/OB5VyT6utccG/paklrdg6mgJTwcwwBl9GI12dMJ +ZqBAIeCSnvSjKwTjAynALSKLrv5zgMdCArmWf1YUMuilXNG1rzb4AwawLfQdi9jd +6KJfAoGBALFl6ldywl3sGPk9K2xCDYYhb1TNQyheA5YvoZzZ6XCo1q0Lbwy/FamZ +0TSWkoEmGB/Hck3HgtZDRo3CTI1vYfbpAtgI7oD1NA1zMaLulNQxKjH3iVvyb+R7 +ZcIT7EVPZgkUwr0bsp22yVDekh/CHoB6FZPCyoAb8WnfJfooTBzB +-----END RSA PRIVATE KEY----- diff --git a/pkg/httpclient/testdata/rootCA.pem b/pkg/httpclient/testdata/rootCA.pem new file mode 100644 index 0000000000..c03bdac0c0 --- /dev/null +++ b/pkg/httpclient/testdata/rootCA.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID1jCCAr4CCQCG4JBeSi6cDjANBgkqhkiG9w0BAQsFADCBrDELMAkGA1UEBhMC +VVMxFDASBgNVBAgMC1JhbmRvbVN0YXRlMRMwEQYDVQQHDApSYW5kb21DaXR5MRsw +GQYDVQQKDBJSYW5kb21Pcmdhbml6YXRpb24xHzAdBgNVBAsMFlJhbmRvbU9yZ2Fu +aXphdGlvblVuaXQxIDAeBgkqhkiG9w0BCQEWEWhlbGxvQGV4YW1wbGUuY29tMRIw +EAYDVQQDDAlsb2NhbGhvc3QwHhcNMjIxMDA3MjIwNjQwWhcNMzIxMDA0MjIwNjQw +WjCBrDELMAkGA1UEBhMCVVMxFDASBgNVBAgMC1JhbmRvbVN0YXRlMRMwEQYDVQQH +DApSYW5kb21DaXR5MRswGQYDVQQKDBJSYW5kb21Pcmdhbml6YXRpb24xHzAdBgNV +BAsMFlJhbmRvbU9yZ2FuaXphdGlvblVuaXQxIDAeBgkqhkiG9w0BCQEWEWhlbGxv +QGV4YW1wbGUuY29tMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDh0HlpAKMKYyxbvW70XRY2bVNiNdAFninug1P4FDAJ +z8xnbFzk17FLY7zqdtGTDmPDJ8AAxIwpGv2zYWW5VMeqKWfvyuD5dSCauY1Pdmug +uZbpAvoJrx1sw+TL61ByVmy8x3ccB4LLKuzil/vAzUDJQkPsfTECVUPV+yiGSDuO +EEVR9X6rZUwx2expXm8Wtb/a88FbPVI09b9eb4iWfLvGD2eNAtw8w21W0X7sQ8Hq +zEPqquMEL4qPnNDdtk592uHvLLrd1uH8qH7c1JyA76T7H3YeUCNEi+PnLgqtsZmX +sKY62HnLt8/LAClVsN9lFYkKEjU9V+U7IN2cL6+EwtsdAgMBAAEwDQYJKoZIhvcN +AQELBQADggEBAN6g0qit/3R2X+KdR0LgRXF/h4qQFgcV6cxnhRAmLIDNJlxKSHqN +IE5+bxzCbkblzGfr/jNPqW0s+yaN4CyMgKNYSzkLBPE4FF+19Uv+dyYfFms3mDJ7 +0rGjS5bCscThWhpaSw20LcwQcr/+X+/fGzJ01dVFK1UOjBKg4d4dMwxklbIkZqIq +siRW0GMy26mgVZ/BSjeh5kEjs6h6H3cJsGl7xYT+BI7wnxHwGeT9tkBgiyT5FwaS +vtdZkBpQ9q8f7FwsEm3woLHdWuOnrtUtVpY/oc6WFGdROQdGzjSk0D3kHs9YhueC +GSzZKrqX+TSIgpPrLYNHX4uxlo5TAwP/5GM= +-----END CERTIFICATE----- diff --git a/pkg/httpclient/testdata/rootCA.srl b/pkg/httpclient/testdata/rootCA.srl new file mode 100644 index 0000000000..214ae68bf1 --- /dev/null +++ b/pkg/httpclient/testdata/rootCA.srl @@ -0,0 +1 @@ +C1B35F0051A641BB diff --git a/pkg/httpclient/testdata/server.crt b/pkg/httpclient/testdata/server.crt new file mode 100644 index 0000000000..9b0f12ec58 --- /dev/null +++ b/pkg/httpclient/testdata/server.crt @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE5TCCA82gAwIBAgIJAMGzXwBRpkG7MA0GCSqGSIb3DQEBCwUAMIGsMQswCQYD +VQQGEwJVUzEUMBIGA1UECAwLUmFuZG9tU3RhdGUxEzARBgNVBAcMClJhbmRvbUNp +dHkxGzAZBgNVBAoMElJhbmRvbU9yZ2FuaXphdGlvbjEfMB0GA1UECwwWUmFuZG9t +T3JnYW5pemF0aW9uVW5pdDEgMB4GCSqGSIb3DQEJARYRaGVsbG9AZXhhbXBsZS5j +b20xEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMjEwMDcyMjA3MDhaFw0zMjEwMDQy +MjA3MDhaMIGsMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLUmFuZG9tU3RhdGUxEzAR +BgNVBAcMClJhbmRvbUNpdHkxGzAZBgNVBAoMElJhbmRvbU9yZ2FuaXphdGlvbjEf +MB0GA1UECwwWUmFuZG9tT3JnYW5pemF0aW9uVW5pdDEgMB4GCSqGSIb3DQEJARYR +aGVsbG9AZXhhbXBsZS5jb20xEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAMuKdpXP87Q7Kg3iafXzvBuVIyV1K5UmMYiN +koztkC5XrCzHaQRS/CoIb7/nUqmtAxx7RL0jzhZ93zBN4HY/Zcnrd9tXoPPxi0mG +ZZWfFU6nN8nOkMHWzEbHVBmhxpfGtwmLcajQ4HrK1TZwJUn6GqclHQRy/gjxkiw5 +KPqzfVOVlA6ht4KdKstKazQkWZ5gdWT4d8yrEy/IT4oaW05xALBMQ7YGjkzWKsSF +6ygXI7xqF9rg9jCnUsPYg4f8ut3N0c00KjsfKOOj2dF/ZyjedQ5c0u4hHmxSo3Ka +0ZTmIrMfbVXgGjxRG2HZXLpPvQKoCf/fOX8Irdr+lahFVKASxN0CAwEAAaOCAQYw +ggECMIHLBgNVHSMEgcMwgcChgbKkga8wgawxCzAJBgNVBAYTAlVTMRQwEgYDVQQI +DAtSYW5kb21TdGF0ZTETMBEGA1UEBwwKUmFuZG9tQ2l0eTEbMBkGA1UECgwSUmFu +ZG9tT3JnYW5pemF0aW9uMR8wHQYDVQQLDBZSYW5kb21Pcmdhbml6YXRpb25Vbml0 +MSAwHgYJKoZIhvcNAQkBFhFoZWxsb0BleGFtcGxlLmNvbTESMBAGA1UEAwwJbG9j +YWxob3N0ggkAhuCQXkounA4wCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwGgYDVR0R +BBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCWmh5ebpkm +v2B1yQgarSCSSkLZ5DZSAJjrPgW2IJqCW2q2D1HworbW1Yn5jqrM9FKGnJfjCyve +zBB5AOlGp+0bsZGgMRMCavgv4QhTThXUoJqqHcfEu4wHndcgrqSadxmV5aisSR4u +gXnjW43o3akby+h1K40RR3vVkpzPaoC3/bgk7WVpfpPiP32E24a01gETozRb/of/ +ATN3JBe0xh+e63CrPX1sago5+u3UETIoOr0fW8M/gU9GApmJiFAXwHag6j54hLCG +23EtVDwmlarG8Pj+i0yru8s22QqzAJi5E0OwR4aB8tqicLKYBVfzyLCOielIBUrK +OkuFKp+VjxQX +-----END CERTIFICATE----- diff --git a/pkg/httpclient/testdata/server.csr b/pkg/httpclient/testdata/server.csr new file mode 100644 index 0000000000..f422a853c3 --- /dev/null +++ b/pkg/httpclient/testdata/server.csr @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIC8jCCAdoCAQAwgawxCzAJBgNVBAYTAlVTMRQwEgYDVQQIDAtSYW5kb21TdGF0 +ZTETMBEGA1UEBwwKUmFuZG9tQ2l0eTEbMBkGA1UECgwSUmFuZG9tT3JnYW5pemF0 +aW9uMR8wHQYDVQQLDBZSYW5kb21Pcmdhbml6YXRpb25Vbml0MSAwHgYJKoZIhvcN +AQkBFhFoZWxsb0BleGFtcGxlLmNvbTESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy4p2lc/ztDsqDeJp9fO8G5UjJXUr +lSYxiI2SjO2QLlesLMdpBFL8Kghvv+dSqa0DHHtEvSPOFn3fME3gdj9lyet321eg +8/GLSYZllZ8VTqc3yc6QwdbMRsdUGaHGl8a3CYtxqNDgesrVNnAlSfoapyUdBHL+ +CPGSLDko+rN9U5WUDqG3gp0qy0prNCRZnmB1ZPh3zKsTL8hPihpbTnEAsExDtgaO +TNYqxIXrKBcjvGoX2uD2MKdSw9iDh/y63c3RzTQqOx8o46PZ0X9nKN51DlzS7iEe +bFKjcprRlOYisx9tVeAaPFEbYdlcuk+9AqgJ/985fwit2v6VqEVUoBLE3QIDAQAB +oAAwDQYJKoZIhvcNAQELBQADggEBADjuujIFoDJllR6Xo/w7j5vfNOeHO5GSgxF2 +XnuuDOI9Tomi7vURFZNbz3VAYiehpxRxYqLwFoQUwFtux2qRuGyg0P9fP1iQXPUE +QUfFXmvB80uf2bG4lkbUwnmlZLFOEwhGZyPxpvsrxp2Ei2ppkUopCkzOMsSk3m0X +MC50ZsTHOxfkA3r1WmS7oE2c0p0Fvyx+UJw0URAXFvDS1X0ONgww3FxqbBbm9W37 +5N4FZzGAK6j1wzuynKKXrn20YDCANXYH55PZyupfCeSZT0H0AZifWL7rz/G9uqme +RzbIYc/CNQQTympjinBegQdVeB3yjVNZIvpGOuPSKQqhwFtmDFo= +-----END CERTIFICATE REQUEST----- diff --git a/pkg/httpclient/testdata/server.csr.cnf b/pkg/httpclient/testdata/server.csr.cnf new file mode 100644 index 0000000000..6ff57d1a35 --- /dev/null +++ b/pkg/httpclient/testdata/server.csr.cnf @@ -0,0 +1,14 @@ +[req] +default_bits = 2048 +prompt = no +default_md = sha256 +distinguished_name = dn + +[dn] +C=US +ST=RandomState +L=RandomCity +O=RandomOrganization +OU=RandomOrganizationUnit +emailAddress=hello@example.com +CN = localhost diff --git a/pkg/httpclient/testdata/server.key b/pkg/httpclient/testdata/server.key new file mode 100644 index 0000000000..9708e1e6ea --- /dev/null +++ b/pkg/httpclient/testdata/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLinaVz/O0OyoN +4mn187wblSMldSuVJjGIjZKM7ZAuV6wsx2kEUvwqCG+/51KprQMce0S9I84Wfd8w +TeB2P2XJ63fbV6Dz8YtJhmWVnxVOpzfJzpDB1sxGx1QZocaXxrcJi3Go0OB6ytU2 +cCVJ+hqnJR0Ecv4I8ZIsOSj6s31TlZQOobeCnSrLSms0JFmeYHVk+HfMqxMvyE+K +GltOcQCwTEO2Bo5M1irEhesoFyO8ahfa4PYwp1LD2IOH/LrdzdHNNCo7Hyjjo9nR +f2co3nUOXNLuIR5sUqNymtGU5iKzH21V4Bo8URth2Vy6T70CqAn/3zl/CK3a/pWo +RVSgEsTdAgMBAAECggEAU6cxu7q+54kVbKVsdThaTF/MFR4F7oPHAd9lpuQQSOuh +iLngMHXGy6OyAgYZlEDWMYN8KdwoXFgZPaoUIaVGuWk8Vnq6XOgeHfbNk2PRhwT0 +yc1K80/Lnx9XMj2p+EEkgxi7eu12BSGN5ZTLzo6rG50GQwjb3WMjd2d6rybL0GjC +wg2arcBk3sSMYmvZOqlAsaQmtgwkJhvhVkVfEQSD3VKF7g0dh/h3LIPyM0Ff4M67 +KpLMPPwzUJ/0Z4ewAP06mMKUA86R93M+dWs2eh1oBGnRkVQdhCJLXJpuGHZ6BTiB +Ry0AeorHfnVXPbtpUeAq6m5/BBl6qX0ooB08BIFwAQKBgQDqJpTZS/ZzqL6Kcs14 +MyFu+7DungSxQ5oK9ju7EFSosanSk4UEa/lw992kM6nsIMwgSVQgba5zKcVMeSmk +AVbpznegQD1BYCwOGwbGvkJ8jbhPy+WLbbRjWT/E6AItZgUK+fyTIcNvSehcQqsT +fhgWsK7ueZCmLQfVhK1AxtvY3QKBgQDeiKuo8plsH/7IxDn7KVHBOHKPC2ZPzg03 +i7La6zomiRckwwPnhicRSYsjtfCCW6Ms+uzjTEItgFM+5PdrXheeku+z/sExRtZu +emqPqDomixlXDRQ6RN3gnBSk4RU+ROB1u1uBLWXqRz8Gp2zJGRxhHfYt2zefBv4w +/cIuPC3cAQKBgD2UsAkGJWb9tj8LOmama+CYaUwYWvuT3+uKHuNvxBQpxZQQICet +jgjb53rL66Cib4z+PBXbQsoe7jjSlNUBVS5gkq2et31+IZgEG6AhYbMIQrUZ1uD4 +lTybuF289vWhoynj3T2E37VhJq89CWky/HrbNOabKiPKLAlHv5kNs7wxAoGBANEJ +XQbU7J2O6Iy7FyQBSlTQq3wHX1Iz4mJ9DcNrFzK/sEfOEMrZT7WDefpPm984KW3F +P+S766ZGVuxLtMbcmh9RM23HLr8VJbSdtZ/AjO9L1r/Y/1lE+49TzmibLpNRq++r +0WbkuEl8J44ek6fLuMbZmDi3JeZycTCgDlnUGdgBAoGAYdliovtURZCm46t1uE3F +idCLCXCccjkt1hcNGNjck/b0trHA7wOEqICIguoWDlEBTc0PDvHEq6PfKyqptGkj +AgaZTMF/aZiGqlT7VRpBuzxM/uV5xzCg+i2ViaW/p3xq0z2PRljVZiEfe5aWcjiM +ouTtnC3TgmcjhTgGmb48QQE= +-----END PRIVATE KEY----- diff --git a/pkg/httpclient/testdata/v3.ext b/pkg/httpclient/testdata/v3.ext new file mode 100644 index 0000000000..68e35be863 --- /dev/null +++ b/pkg/httpclient/testdata/v3.ext @@ -0,0 +1,8 @@ +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 diff --git a/pkg/log/deprecated.go b/pkg/log/deprecated.go deleted file mode 100644 index f20e8b4cb8..0000000000 --- a/pkg/log/deprecated.go +++ /dev/null @@ -1,5 +0,0 @@ -package log - -func Deprecated(logger Logger, f string, args ...interface{}) { - logger.Warnf("Deprecated: "+f, args...) -} diff --git a/pkg/log/logger.go b/pkg/log/logger.go deleted file mode 100644 index 4f3cdd3851..0000000000 --- a/pkg/log/logger.go +++ /dev/null @@ -1,18 +0,0 @@ -// Package log provides a logger interface for logger libraries -// so that dex does not depend on any of them directly. -// It also includes a default implementation using Logrus (used by dex previously). -package log - -// Logger serves as an adapter interface for logger libraries -// so that dex does not depend on any of them directly. -type Logger interface { - Debug(args ...interface{}) - Info(args ...interface{}) - Warn(args ...interface{}) - Error(args ...interface{}) - - Debugf(format string, args ...interface{}) - Infof(format string, args ...interface{}) - Warnf(format string, args ...interface{}) - Errorf(format string, args ...interface{}) -} diff --git a/scripts/git-diff b/scripts/git-diff deleted file mode 100755 index 302ac2ce3e..0000000000 --- a/scripts/git-diff +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -e - -DIFF=$( git diff . ) -if [ "$DIFF" != "" ]; then - echo "$DIFF" >&2 - exit 1 -fi diff --git a/scripts/git-version b/scripts/git-version index 936641cb0b..a78a2716d0 100755 --- a/scripts/git-version +++ b/scripts/git-version @@ -1,15 +1,42 @@ #!/bin/sh -e -# Since this script will be run in a rkt container, use "/bin/sh" instead of "/bin/bash" # parse the current git commit hash -COMMIT=`git rev-parse HEAD` +COMMIT=`git rev-parse --short=8 HEAD` -# check if the current commit has a matching tag -TAG=$(git describe --exact-match --abbrev=0 --tags ${COMMIT} 2> /dev/null || true) +# check if the current commit has a matching tag (filter for v* tags, excluding api/) +TAG=$(git describe --exact-match --abbrev=0 --tags --match="v[0-9]*" 2> /dev/null || true) # use the matching tag as the version, if available if [ -z "$TAG" ]; then - VERSION=$COMMIT + # No exact tag on current commit, find the last version tag and bump minor version + # Get all tags matching v[0-9]*, sort them, and take the last one + LAST_TAG=$(git tag --list "v[0-9]*" --sort=-version:refname | head -1) + + if [ -z "$LAST_TAG" ]; then + # No tags found, use v0.1.0 as fallback + BASE_VERSION="v0.1.0" + else + # Parse the last tag and bump minor version + # Remove 'v' prefix + TAG_WITHOUT_V="${LAST_TAG#v}" + + # Split version into parts (major.minor.patch) + MAJOR=$(echo "$TAG_WITHOUT_V" | cut -d. -f1) + MINOR=$(echo "$TAG_WITHOUT_V" | cut -d. -f2) + PATCH=$(echo "$TAG_WITHOUT_V" | cut -d. -f3) + + # Bump minor version + MINOR=$((MINOR + 1)) + + # Construct base version with bumped minor + BASE_VERSION="v${MAJOR}.${MINOR}.0" + fi + + # Get commit timestamp in YYYYMMDDhhmmss format + TIMESTAMP=$(git log -1 --format=%ci HEAD | sed 's/[-: ]//g' | cut -c1-14) + + # Construct pseudo-version + VERSION="${BASE_VERSION}-${TIMESTAMP}-${COMMIT}" else VERSION=$TAG fi diff --git a/scripts/update-gomplate b/scripts/update-gomplate new file mode 100755 index 0000000000..4f8d59fd3f --- /dev/null +++ b/scripts/update-gomplate @@ -0,0 +1,53 @@ +#!/bin/sh -e +# Script to check for a new gomplate version and update it in Dockerfile + +GOMPLATE_REPO="hairyhenderson/gomplate" +DOCKERFILE="${1:-.}/Dockerfile" + +# Check if Dockerfile exists +if [ ! -f "$DOCKERFILE" ]; then + echo "Error: Dockerfile not found at $DOCKERFILE" + exit 1 +fi + +# Get the latest release version from GitHub +echo "Checking for the latest gomplate version on GitHub..." +LATEST_VERSION=$(curl -s "https://api.github.com/repos/${GOMPLATE_REPO}/releases/latest" | grep -o '"tag_name": "[^"]*"' | head -1 | cut -d'"' -f4) + +if [ -z "$LATEST_VERSION" ]; then + echo "Error: Could not fetch the latest version from GitHub" + exit 1 +fi + +echo "Latest gomplate version: $LATEST_VERSION" + +# Get the current version from Dockerfile +CURRENT_VERSION=$(grep 'ENV GOMPLATE_VERSION' "$DOCKERFILE" | sed 's/.*GOMPLATE_VERSION=//;s/[[:space:]]*$//') + +echo "Current gomplate version in Dockerfile: $CURRENT_VERSION" + +# Check if versions are different +if [ "$LATEST_VERSION" = "$CURRENT_VERSION" ]; then + echo "โœ“ Already on the latest version ($LATEST_VERSION)" + exit 0 +fi + +echo "โœ“ New version available: $LATEST_VERSION" +echo "Updating Dockerfile..." + +# Update the Dockerfile - use a more specific pattern to avoid multiple replacements +sed -i '' "s/ENV GOMPLATE_VERSION=.*/ENV GOMPLATE_VERSION=${LATEST_VERSION}/" "$DOCKERFILE" + +if grep -q "ENV GOMPLATE_VERSION=${LATEST_VERSION}" "$DOCKERFILE"; then + echo "โœ“ Successfully updated Dockerfile to version $LATEST_VERSION" + echo "" + echo "Changes made:" + echo " - GOMPLATE_VERSION: $CURRENT_VERSION โ†’ $LATEST_VERSION" +else + echo "Error: Failed to update Dockerfile" + exit 1 +fi + + + + diff --git a/server/api.go b/server/api.go deleted file mode 100644 index a68742b3cc..0000000000 --- a/server/api.go +++ /dev/null @@ -1,368 +0,0 @@ -package server - -import ( - "context" - "errors" - "fmt" - - "golang.org/x/crypto/bcrypt" - - "github.com/dexidp/dex/api/v2" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/server/internal" - "github.com/dexidp/dex/storage" -) - -// apiVersion increases every time a new call is added to the API. Clients should use this info -// to determine if the server supports specific features. -const apiVersion = 2 - -const ( - // recCost is the recommended bcrypt cost, which balances hash strength and - // efficiency. - recCost = 12 - - // upBoundCost is a sane upper bound on bcrypt cost determined by benchmarking: - // high enough to ensure secure encryption, low enough to not put unnecessary - // load on a dex server. - upBoundCost = 16 -) - -// NewAPI returns a server which implements the gRPC API interface. -func NewAPI(s storage.Storage, logger log.Logger, version string) api.DexServer { - return dexAPI{ - s: s, - logger: logger, - version: version, - } -} - -type dexAPI struct { - api.UnimplementedDexServer - - s storage.Storage - logger log.Logger - version string -} - -func (d dexAPI) CreateClient(ctx context.Context, req *api.CreateClientReq) (*api.CreateClientResp, error) { - if req.Client == nil { - return nil, errors.New("no client supplied") - } - - if req.Client.Id == "" { - req.Client.Id = storage.NewID() - } - if req.Client.Secret == "" && !req.Client.Public { - req.Client.Secret = storage.NewID() + storage.NewID() - } - - c := storage.Client{ - ID: req.Client.Id, - Secret: req.Client.Secret, - RedirectURIs: req.Client.RedirectUris, - TrustedPeers: req.Client.TrustedPeers, - Public: req.Client.Public, - Name: req.Client.Name, - LogoURL: req.Client.LogoUrl, - } - if err := d.s.CreateClient(c); err != nil { - if err == storage.ErrAlreadyExists { - return &api.CreateClientResp{AlreadyExists: true}, nil - } - d.logger.Errorf("api: failed to create client: %v", err) - return nil, fmt.Errorf("create client: %v", err) - } - - return &api.CreateClientResp{ - Client: req.Client, - }, nil -} - -func (d dexAPI) UpdateClient(ctx context.Context, req *api.UpdateClientReq) (*api.UpdateClientResp, error) { - if req.Id == "" { - return nil, errors.New("update client: no client ID supplied") - } - - err := d.s.UpdateClient(req.Id, func(old storage.Client) (storage.Client, error) { - if req.RedirectUris != nil { - old.RedirectURIs = req.RedirectUris - } - if req.TrustedPeers != nil { - old.TrustedPeers = req.TrustedPeers - } - if req.Name != "" { - old.Name = req.Name - } - if req.LogoUrl != "" { - old.LogoURL = req.LogoUrl - } - return old, nil - }) - if err != nil { - if err == storage.ErrNotFound { - return &api.UpdateClientResp{NotFound: true}, nil - } - d.logger.Errorf("api: failed to update the client: %v", err) - return nil, fmt.Errorf("update client: %v", err) - } - return &api.UpdateClientResp{}, nil -} - -func (d dexAPI) DeleteClient(ctx context.Context, req *api.DeleteClientReq) (*api.DeleteClientResp, error) { - err := d.s.DeleteClient(req.Id) - if err != nil { - if err == storage.ErrNotFound { - return &api.DeleteClientResp{NotFound: true}, nil - } - d.logger.Errorf("api: failed to delete client: %v", err) - return nil, fmt.Errorf("delete client: %v", err) - } - return &api.DeleteClientResp{}, nil -} - -// checkCost returns an error if the hash provided does not meet lower or upper -// bound cost requirements. -func checkCost(hash []byte) error { - actual, err := bcrypt.Cost(hash) - if err != nil { - return fmt.Errorf("parsing bcrypt hash: %v", err) - } - if actual < bcrypt.DefaultCost { - return fmt.Errorf("given hash cost = %d does not meet minimum cost requirement = %d", actual, bcrypt.DefaultCost) - } - if actual > upBoundCost { - return fmt.Errorf("given hash cost = %d is above upper bound cost = %d, recommended cost = %d", actual, upBoundCost, recCost) - } - return nil -} - -func (d dexAPI) CreatePassword(ctx context.Context, req *api.CreatePasswordReq) (*api.CreatePasswordResp, error) { - if req.Password == nil { - return nil, errors.New("no password supplied") - } - if req.Password.UserId == "" { - return nil, errors.New("no user ID supplied") - } - if req.Password.Hash != nil { - if err := checkCost(req.Password.Hash); err != nil { - return nil, err - } - } else { - return nil, errors.New("no hash of password supplied") - } - - p := storage.Password{ - Email: req.Password.Email, - Hash: req.Password.Hash, - Username: req.Password.Username, - UserID: req.Password.UserId, - } - if err := d.s.CreatePassword(p); err != nil { - if err == storage.ErrAlreadyExists { - return &api.CreatePasswordResp{AlreadyExists: true}, nil - } - d.logger.Errorf("api: failed to create password: %v", err) - return nil, fmt.Errorf("create password: %v", err) - } - - return &api.CreatePasswordResp{}, nil -} - -func (d dexAPI) UpdatePassword(ctx context.Context, req *api.UpdatePasswordReq) (*api.UpdatePasswordResp, error) { - if req.Email == "" { - return nil, errors.New("no email supplied") - } - if req.NewHash == nil && req.NewUsername == "" { - return nil, errors.New("nothing to update") - } - - if req.NewHash != nil { - if err := checkCost(req.NewHash); err != nil { - return nil, err - } - } - - updater := func(old storage.Password) (storage.Password, error) { - if req.NewHash != nil { - old.Hash = req.NewHash - } - - if req.NewUsername != "" { - old.Username = req.NewUsername - } - - return old, nil - } - - if err := d.s.UpdatePassword(req.Email, updater); err != nil { - if err == storage.ErrNotFound { - return &api.UpdatePasswordResp{NotFound: true}, nil - } - d.logger.Errorf("api: failed to update password: %v", err) - return nil, fmt.Errorf("update password: %v", err) - } - - return &api.UpdatePasswordResp{}, nil -} - -func (d dexAPI) DeletePassword(ctx context.Context, req *api.DeletePasswordReq) (*api.DeletePasswordResp, error) { - if req.Email == "" { - return nil, errors.New("no email supplied") - } - - err := d.s.DeletePassword(req.Email) - if err != nil { - if err == storage.ErrNotFound { - return &api.DeletePasswordResp{NotFound: true}, nil - } - d.logger.Errorf("api: failed to delete password: %v", err) - return nil, fmt.Errorf("delete password: %v", err) - } - return &api.DeletePasswordResp{}, nil -} - -func (d dexAPI) GetVersion(ctx context.Context, req *api.VersionReq) (*api.VersionResp, error) { - return &api.VersionResp{ - Server: d.version, - Api: apiVersion, - }, nil -} - -func (d dexAPI) ListPasswords(ctx context.Context, req *api.ListPasswordReq) (*api.ListPasswordResp, error) { - passwordList, err := d.s.ListPasswords() - if err != nil { - d.logger.Errorf("api: failed to list passwords: %v", err) - return nil, fmt.Errorf("list passwords: %v", err) - } - - passwords := make([]*api.Password, 0, len(passwordList)) - for _, password := range passwordList { - p := api.Password{ - Email: password.Email, - Username: password.Username, - UserId: password.UserID, - } - passwords = append(passwords, &p) - } - - return &api.ListPasswordResp{ - Passwords: passwords, - }, nil -} - -func (d dexAPI) VerifyPassword(ctx context.Context, req *api.VerifyPasswordReq) (*api.VerifyPasswordResp, error) { - if req.Email == "" { - return nil, errors.New("no email supplied") - } - - if req.Password == "" { - return nil, errors.New("no password to verify supplied") - } - - password, err := d.s.GetPassword(req.Email) - if err != nil { - if err == storage.ErrNotFound { - return &api.VerifyPasswordResp{ - NotFound: true, - }, nil - } - d.logger.Errorf("api: there was an error retrieving the password: %v", err) - return nil, fmt.Errorf("verify password: %v", err) - } - - if err := bcrypt.CompareHashAndPassword(password.Hash, []byte(req.Password)); err != nil { - d.logger.Infof("api: password check failed: %v", err) - return &api.VerifyPasswordResp{ - Verified: false, - }, nil - } - return &api.VerifyPasswordResp{ - Verified: true, - }, nil -} - -func (d dexAPI) ListRefresh(ctx context.Context, req *api.ListRefreshReq) (*api.ListRefreshResp, error) { - id := new(internal.IDTokenSubject) - if err := internal.Unmarshal(req.UserId, id); err != nil { - d.logger.Errorf("api: failed to unmarshal ID Token subject: %v", err) - return nil, err - } - - offlineSessions, err := d.s.GetOfflineSessions(id.UserId, id.ConnId) - if err != nil { - if err == storage.ErrNotFound { - // This means that this user-client pair does not have a refresh token yet. - // An empty list should be returned instead of an error. - return &api.ListRefreshResp{}, nil - } - d.logger.Errorf("api: failed to list refresh tokens %t here : %v", err == storage.ErrNotFound, err) - return nil, err - } - - refreshTokenRefs := make([]*api.RefreshTokenRef, 0, len(offlineSessions.Refresh)) - for _, session := range offlineSessions.Refresh { - r := api.RefreshTokenRef{ - Id: session.ID, - ClientId: session.ClientID, - CreatedAt: session.CreatedAt.Unix(), - LastUsed: session.LastUsed.Unix(), - } - refreshTokenRefs = append(refreshTokenRefs, &r) - } - - return &api.ListRefreshResp{ - RefreshTokens: refreshTokenRefs, - }, nil -} - -func (d dexAPI) RevokeRefresh(ctx context.Context, req *api.RevokeRefreshReq) (*api.RevokeRefreshResp, error) { - id := new(internal.IDTokenSubject) - if err := internal.Unmarshal(req.UserId, id); err != nil { - d.logger.Errorf("api: failed to unmarshal ID Token subject: %v", err) - return nil, err - } - - var ( - refreshID string - notFound bool - ) - updater := func(old storage.OfflineSessions) (storage.OfflineSessions, error) { - refreshRef := old.Refresh[req.ClientId] - if refreshRef == nil || refreshRef.ID == "" { - d.logger.Errorf("api: refresh token issued to client %q for user %q not found for deletion", req.ClientId, id.UserId) - notFound = true - return old, storage.ErrNotFound - } - - refreshID = refreshRef.ID - - // Remove entry from Refresh list of the OfflineSession object. - delete(old.Refresh, req.ClientId) - - return old, nil - } - - if err := d.s.UpdateOfflineSessions(id.UserId, id.ConnId, updater); err != nil { - if err == storage.ErrNotFound { - return &api.RevokeRefreshResp{NotFound: true}, nil - } - d.logger.Errorf("api: failed to update offline session object: %v", err) - return nil, err - } - - if notFound { - return &api.RevokeRefreshResp{NotFound: true}, nil - } - - // Delete the refresh token from the storage - // - // TODO(ericchiang): we don't have any good recourse if this call fails. - // Consider garbage collection of refresh tokens with no associated ref. - if err := d.s.DeleteRefresh(refreshID); err != nil { - d.logger.Errorf("failed to delete refresh token: %v", err) - return nil, err - } - - return &api.RevokeRefreshResp{}, nil -} diff --git a/server/api_test.go b/server/api_test.go deleted file mode 100644 index 01c59cf875..0000000000 --- a/server/api_test.go +++ /dev/null @@ -1,509 +0,0 @@ -package server - -import ( - "context" - "net" - "os" - "testing" - "time" - - "github.com/sirupsen/logrus" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - - "github.com/dexidp/dex/api/v2" - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/server/internal" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" -) - -// apiClient is a test gRPC client. When constructed, it runs a server in -// the background to exercise the serialization and network configuration -// instead of just this package's server implementation. -type apiClient struct { - // Embedded gRPC client to talk to the server. - api.DexClient - // Close releases resources associated with this client, including shutting - // down the background server. - Close func() -} - -// newAPI constructs a gRCP client connected to a backing server. -func newAPI(s storage.Storage, logger log.Logger, t *testing.T) *apiClient { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - - serv := grpc.NewServer() - api.RegisterDexServer(serv, NewAPI(s, logger, "test")) - go serv.Serve(l) - - // Dial will retry automatically if the serv.Serve() goroutine - // hasn't started yet. - conn, err := grpc.Dial(l.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - t.Fatal(err) - } - - return &apiClient{ - DexClient: api.NewDexClient(conn), - Close: func() { - conn.Close() - serv.Stop() - l.Close() - }, - } -} - -// Attempts to create, update and delete a test Password -func TestPassword(t *testing.T) { - logger := &logrus.Logger{ - Out: os.Stderr, - Formatter: &logrus.TextFormatter{DisableColors: true}, - Level: logrus.DebugLevel, - } - - s := memory.New(logger) - client := newAPI(s, logger, t) - defer client.Close() - - ctx := context.Background() - email := "test@example.com" - p := api.Password{ - Email: email, - // bcrypt hash of the value "test1" with cost 10 - Hash: []byte("$2a$10$XVMN/Fid.Ks4CXgzo8fpR.iU1khOMsP5g9xQeXuBm1wXjRX8pjUtO"), - Username: "test", - UserId: "test123", - } - - createReq := api.CreatePasswordReq{ - Password: &p, - } - - if resp, err := client.CreatePassword(ctx, &createReq); err != nil || resp.AlreadyExists { - if resp.AlreadyExists { - t.Fatalf("Unable to create password since %s already exists", createReq.Password.Email) - } - t.Fatalf("Unable to create password: %v", err) - } - - // Attempt to create a password that already exists. - if resp, _ := client.CreatePassword(ctx, &createReq); !resp.AlreadyExists { - t.Fatalf("Created password %s twice", createReq.Password.Email) - } - - // Attempt to verify valid password and email - goodVerifyReq := &api.VerifyPasswordReq{ - Email: email, - Password: "test1", - } - goodVerifyResp, err := client.VerifyPassword(ctx, goodVerifyReq) - if err != nil { - t.Fatalf("Unable to run verify password we expected to be valid for correct email: %v", err) - } - if !goodVerifyResp.Verified { - t.Fatalf("verify password failed for password expected to be valid for correct email. expected %t, found %t", true, goodVerifyResp.Verified) - } - if goodVerifyResp.NotFound { - t.Fatalf("verify password failed to return not found response. expected %t, found %t", false, goodVerifyResp.NotFound) - } - - // Check not found response for valid password with wrong email - badEmailVerifyReq := &api.VerifyPasswordReq{ - Email: "somewrongaddress@email.com", - Password: "test1", - } - badEmailVerifyResp, err := client.VerifyPassword(ctx, badEmailVerifyReq) - if err != nil { - t.Fatalf("Unable to run verify password for incorrect email: %v", err) - } - if badEmailVerifyResp.Verified { - t.Fatalf("verify password passed for password expected to be not found. expected %t, found %t", false, badEmailVerifyResp.Verified) - } - if !badEmailVerifyResp.NotFound { - t.Fatalf("expected not found response for verify password with bad email. expected %t, found %t", true, badEmailVerifyResp.NotFound) - } - - // Check that wrong password fails - badPassVerifyReq := &api.VerifyPasswordReq{ - Email: email, - Password: "wrong_password", - } - badPassVerifyResp, err := client.VerifyPassword(ctx, badPassVerifyReq) - if err != nil { - t.Fatalf("Unable to run verify password for password we expected to be invalid: %v", err) - } - if badPassVerifyResp.Verified { - t.Fatalf("verify password passed for password we expected to fail. expected %t, found %t", false, badPassVerifyResp.Verified) - } - if badPassVerifyResp.NotFound { - t.Fatalf("did not expect expected not found response for verify password with bad email. expected %t, found %t", false, badPassVerifyResp.NotFound) - } - - updateReq := api.UpdatePasswordReq{ - Email: email, - NewUsername: "test1", - } - - if _, err := client.UpdatePassword(ctx, &updateReq); err != nil { - t.Fatalf("Unable to update password: %v", err) - } - - pass, err := s.GetPassword(updateReq.Email) - if err != nil { - t.Fatalf("Unable to retrieve password: %v", err) - } - - if pass.Username != updateReq.NewUsername { - t.Fatalf("UpdatePassword failed. Expected username %s retrieved %s", updateReq.NewUsername, pass.Username) - } - - deleteReq := api.DeletePasswordReq{ - Email: "test@example.com", - } - - if _, err := client.DeletePassword(ctx, &deleteReq); err != nil { - t.Fatalf("Unable to delete password: %v", err) - } -} - -// Ensures checkCost returns expected values -func TestCheckCost(t *testing.T) { - logger := &logrus.Logger{ - Out: os.Stderr, - Formatter: &logrus.TextFormatter{DisableColors: true}, - Level: logrus.DebugLevel, - } - - s := memory.New(logger) - client := newAPI(s, logger, t) - defer client.Close() - - tests := []struct { - name string - inputHash []byte - - wantErr bool - }{ - { - name: "valid cost", - // bcrypt hash of the value "test1" with cost 12 (default) - inputHash: []byte("$2a$12$M2Ot95Qty1MuQdubh1acWOiYadJDzeVg3ve4n5b.dgcgPdjCseKx2"), - }, - { - name: "invalid hash", - inputHash: []byte(""), - wantErr: true, - }, - { - name: "cost below default", - // bcrypt hash of the value "test1" with cost 4 - inputHash: []byte("$2a$04$8bSTbuVCLpKzaqB3BmgI7edDigG5tIQKkjYUu/mEO9gQgIkw9m7eG"), - wantErr: true, - }, - { - name: "cost above recommendation", - // bcrypt hash of the value "test1" with cost 17 - inputHash: []byte("$2a$17$tWuZkTxtSmRyWZAGWVHQE.7npdl.TgP8adjzLJD.SyjpFznKBftPe"), - wantErr: true, - }, - } - - for _, tc := range tests { - if err := checkCost(tc.inputHash); err != nil { - if !tc.wantErr { - t.Errorf("%s: %s", tc.name, err) - } - continue - } - - if tc.wantErr { - t.Errorf("%s: expected err", tc.name) - continue - } - } -} - -// Attempts to list and revoke an existing refresh token. -func TestRefreshToken(t *testing.T) { - logger := &logrus.Logger{ - Out: os.Stderr, - Formatter: &logrus.TextFormatter{DisableColors: true}, - Level: logrus.DebugLevel, - } - - s := memory.New(logger) - client := newAPI(s, logger, t) - defer client.Close() - - ctx := context.Background() - - // Creating a storage with an existing refresh token and offline session for the user. - id := storage.NewID() - r := storage.RefreshToken{ - ID: id, - Token: "bar", - Nonce: "foo", - ClientID: "client_id", - ConnectorID: "client_secret", - Scopes: []string{"openid", "email", "profile"}, - CreatedAt: time.Now().UTC().Round(time.Millisecond), - LastUsed: time.Now().UTC().Round(time.Millisecond), - Claims: storage.Claims{ - UserID: "1", - Username: "jane", - Email: "jane.doe@example.com", - EmailVerified: true, - Groups: []string{"a", "b"}, - }, - ConnectorData: []byte(`{"some":"data"}`), - } - - if err := s.CreateRefresh(r); err != nil { - t.Fatalf("create refresh token: %v", err) - } - - tokenRef := storage.RefreshTokenRef{ - ID: r.ID, - ClientID: r.ClientID, - CreatedAt: r.CreatedAt, - LastUsed: r.LastUsed, - } - - session := storage.OfflineSessions{ - UserID: r.Claims.UserID, - ConnID: r.ConnectorID, - Refresh: make(map[string]*storage.RefreshTokenRef), - } - session.Refresh[tokenRef.ClientID] = &tokenRef - - if err := s.CreateOfflineSessions(session); err != nil { - t.Fatalf("create offline session: %v", err) - } - - subjectString, err := internal.Marshal(&internal.IDTokenSubject{ - UserId: r.Claims.UserID, - ConnId: r.ConnectorID, - }) - if err != nil { - t.Errorf("failed to marshal offline session ID: %v", err) - } - - // Testing the api. - listReq := api.ListRefreshReq{ - UserId: subjectString, - } - - listResp, err := client.ListRefresh(ctx, &listReq) - if err != nil { - t.Fatalf("Unable to list refresh tokens for user: %v", err) - } - - for _, tok := range listResp.RefreshTokens { - if tok.CreatedAt != r.CreatedAt.Unix() { - t.Errorf("Expected CreatedAt timestamp %v, got %v", r.CreatedAt.Unix(), tok.CreatedAt) - } - - if tok.LastUsed != r.LastUsed.Unix() { - t.Errorf("Expected LastUsed timestamp %v, got %v", r.LastUsed.Unix(), tok.LastUsed) - } - } - - revokeReq := api.RevokeRefreshReq{ - UserId: subjectString, - ClientId: r.ClientID, - } - - resp, err := client.RevokeRefresh(ctx, &revokeReq) - if err != nil { - t.Fatalf("Unable to revoke refresh tokens for user: %v", err) - } - if resp.NotFound { - t.Errorf("refresh token session wasn't found") - } - - // Try to delete again. - // - // See https://github.com/dexidp/dex/issues/1055 - resp, err = client.RevokeRefresh(ctx, &revokeReq) - if err != nil { - t.Fatalf("Unable to revoke refresh tokens for user: %v", err) - } - if !resp.NotFound { - t.Errorf("refresh token session was found") - } - - if resp, _ := client.ListRefresh(ctx, &listReq); len(resp.RefreshTokens) != 0 { - t.Fatalf("Refresh token returned inspite of revoking it.") - } -} - -func TestUpdateClient(t *testing.T) { - logger := &logrus.Logger{ - Out: os.Stderr, - Formatter: &logrus.TextFormatter{DisableColors: true}, - Level: logrus.DebugLevel, - } - - s := memory.New(logger) - client := newAPI(s, logger, t) - defer client.Close() - ctx := context.Background() - - createClient := func(t *testing.T, clientId string) { - resp, err := client.CreateClient(ctx, &api.CreateClientReq{ - Client: &api.Client{ - Id: clientId, - Secret: "", - RedirectUris: []string{}, - TrustedPeers: nil, - Public: true, - Name: "", - LogoUrl: "", - }, - }) - if err != nil { - t.Fatalf("unable to create the client: %v", err) - } - - if resp == nil { - t.Fatalf("create client returned no response") - } - if resp.AlreadyExists { - t.Error("existing client was found") - } - - if resp.Client == nil { - t.Fatalf("no client created") - } - } - - deleteClient := func(t *testing.T, clientId string) { - resp, err := client.DeleteClient(ctx, &api.DeleteClientReq{ - Id: clientId, - }) - if err != nil { - t.Fatalf("unable to delete the client: %v", err) - } - if resp == nil { - t.Fatalf("delete client delete client returned no response") - } - } - - tests := map[string]struct { - setup func(t *testing.T, clientId string) - cleanup func(t *testing.T, clientId string) - req *api.UpdateClientReq - wantErr bool - want *api.UpdateClientResp - }{ - "update client": { - setup: createClient, - cleanup: deleteClient, - req: &api.UpdateClientReq{ - Id: "test", - RedirectUris: []string{"https://redirect"}, - TrustedPeers: []string{"test"}, - Name: "test", - LogoUrl: "https://logout", - }, - wantErr: false, - want: &api.UpdateClientResp{ - NotFound: false, - }, - }, - "update client without ID": { - setup: createClient, - cleanup: deleteClient, - req: &api.UpdateClientReq{ - Id: "", - RedirectUris: nil, - TrustedPeers: nil, - Name: "test", - LogoUrl: "test", - }, - wantErr: true, - want: &api.UpdateClientResp{ - NotFound: false, - }, - }, - "update client which not exists ": { - req: &api.UpdateClientReq{ - Id: "test", - RedirectUris: nil, - TrustedPeers: nil, - Name: "test", - LogoUrl: "test", - }, - wantErr: true, - want: &api.UpdateClientResp{ - NotFound: false, - }, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - if tc.setup != nil { - tc.setup(t, tc.req.Id) - } - resp, err := client.UpdateClient(ctx, tc.req) - if err != nil && !tc.wantErr { - t.Fatalf("failed to update the client: %v", err) - } - - if !tc.wantErr { - if resp == nil { - t.Fatalf("update client response not found") - } - - if tc.want.NotFound != resp.NotFound { - t.Errorf("expected in response NotFound: %t", tc.want.NotFound) - } - - client, err := s.GetClient(tc.req.Id) - if err != nil { - t.Errorf("no client found in the storage: %v", err) - } - - if tc.req.Id != client.ID { - t.Errorf("expected stored client with ID: %s, found %s", tc.req.Id, client.ID) - } - if tc.req.Name != client.Name { - t.Errorf("expected stored client with Name: %s, found %s", tc.req.Name, client.Name) - } - if tc.req.LogoUrl != client.LogoURL { - t.Errorf("expected stored client with LogoURL: %s, found %s", tc.req.LogoUrl, client.LogoURL) - } - for _, redirectURI := range tc.req.RedirectUris { - found := find(redirectURI, client.RedirectURIs) - if !found { - t.Errorf("expected redirect URI: %s", redirectURI) - } - } - for _, peer := range tc.req.TrustedPeers { - found := find(peer, client.TrustedPeers) - if !found { - t.Errorf("expected trusted peer: %s", peer) - } - } - } - - if tc.cleanup != nil { - tc.cleanup(t, tc.req.Id) - } - }) - } -} - -func find(item string, items []string) bool { - for _, i := range items { - if item == i { - return true - } - } - return false -} diff --git a/server/apiserver/api.go b/server/apiserver/api.go new file mode 100644 index 0000000000..32201383fe --- /dev/null +++ b/server/apiserver/api.go @@ -0,0 +1,84 @@ +package apiserver + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/server/backchannel" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/discovery" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// apiVersion increases every time a new call is added to the API. Clients should use this info +// to determine if the server supports specific features. +const apiVersion = 4 + +// NewAPI returns a server which implements the gRPC API interface. It takes only +// the narrow dependencies it needs โ€” the connector cache to invalidate on +// connector CRUD, the discovery handler to serve the same document as HTTP, and +// the back-channel notifier to tell relying parties about the sessions it ends โ€” +// rather than the whole Server. +func NewAPI(s storage.Storage, logger *slog.Logger, version string, conns *connectors.Cache, disc *discovery.Handler, bc *backchannel.Notifier) api.DexServer { + apiLogger := logger.With("component", "api") + return dexAPI{ + s: s, + logger: apiLogger, + version: version, + connectors: conns, + discovery: disc, + backchannel: bc, + refresh: tokens.NewRefreshStore(s, time.Now, apiLogger), + } +} + +type dexAPI struct { + api.UnimplementedDexServer + + s storage.Storage + logger *slog.Logger + version string + connectors *connectors.Cache + discovery *discovery.Handler + backchannel *backchannel.Notifier + refresh *tokens.RefreshStore +} + +func (d dexAPI) GetVersion(ctx context.Context, req *api.VersionReq) (*api.VersionResp, error) { + return &api.VersionResp{ + Server: d.version, + Api: apiVersion, + }, nil +} + +func (d dexAPI) GetDiscovery(ctx context.Context, req *api.DiscoveryReq) (*api.DiscoveryResp, error) { + if d.discovery == nil { + return nil, fmt.Errorf("discovery is not configured") + } + discoveryDoc := d.discovery.Construct(ctx) + data, err := json.Marshal(discoveryDoc) + if err != nil { + return nil, fmt.Errorf("failed to marshal discovery data: %v", err) + } + resp := api.DiscoveryResp{} + err = json.Unmarshal(data, &resp) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal discovery data: %v", err) + } + return &resp, nil +} + +// unixOrZero returns the Unix timestamp for t, or 0 when t is the zero value. +// A naive t.Unix() on a zero time.Time yields -62135596800 (a year-1 epoch), +// which is a misleading value to expose through the API; callers want 0/unset. +func unixOrZero(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} diff --git a/server/apiserver/api_test.go b/server/apiserver/api_test.go new file mode 100644 index 0000000000..00abe1d4bf --- /dev/null +++ b/server/apiserver/api_test.go @@ -0,0 +1,1776 @@ +package apiserver + +import ( + "bytes" + "log/slog" + "net" + "slices" + "strings" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" +) + +// apiClient is a test gRPC client. When constructed, it runs a server in +// the background to exercise the serialization and network configuration +// instead of just this package's server implementation. +type apiClient struct { + // Embedded gRPC client to talk to the server. + api.DexClient + // Close releases resources associated with this client, including shutting + // down the background server. + Close func() +} + +func newLogger(t *testing.T) *slog.Logger { + return slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +// newAPI constructs a gRCP client connected to a backing server. +func newAPI(t *testing.T, s storage.Storage, logger *slog.Logger) *apiClient { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + serv := grpc.NewServer() + api.RegisterDexServer(serv, NewAPI(s, logger, "test", nil, nil, nil)) + go serv.Serve(l) + + // NewClient will retry automatically if the serv.Serve() goroutine + // hasn't started yet. + conn, err := grpc.NewClient(l.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + + return &apiClient{ + DexClient: api.NewDexClient(conn), + Close: func() { + conn.Close() + serv.Stop() + l.Close() + }, + } +} + +// Attempts to create, update and delete a test Password +func TestPassword(t *testing.T) { + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + email := "test@example.com" + p := api.Password{ + Email: email, + // bcrypt hash of the value "test1" with cost 10 + Hash: []byte("$2a$10$XVMN/Fid.Ks4CXgzo8fpR.iU1khOMsP5g9xQeXuBm1wXjRX8pjUtO"), + Username: "test", + UserId: "test123", + } + + createReq := api.CreatePasswordReq{ + Password: &p, + } + + if resp, err := client.CreatePassword(ctx, &createReq); err != nil || resp.AlreadyExists { + if resp.AlreadyExists { + t.Fatalf("Unable to create password since %s already exists", createReq.Password.Email) + } + t.Fatalf("Unable to create password: %v", err) + } + + // Attempt to create a password that already exists. + if resp, _ := client.CreatePassword(ctx, &createReq); !resp.AlreadyExists { + t.Fatalf("Created password %s twice", createReq.Password.Email) + } + + // Attempt to verify valid password and email + goodVerifyReq := &api.VerifyPasswordReq{ + Email: email, + Password: "test1", + } + goodVerifyResp, err := client.VerifyPassword(ctx, goodVerifyReq) + if err != nil { + t.Fatalf("Unable to run verify password we expected to be valid for correct email: %v", err) + } + if !goodVerifyResp.Verified { + t.Fatalf("verify password failed for password expected to be valid for correct email. expected %t, found %t", true, goodVerifyResp.Verified) + } + if goodVerifyResp.NotFound { + t.Fatalf("verify password failed to return not found response. expected %t, found %t", false, goodVerifyResp.NotFound) + } + + // Check not found response for valid password with wrong email + badEmailVerifyReq := &api.VerifyPasswordReq{ + Email: "somewrongaddress@email.com", + Password: "test1", + } + badEmailVerifyResp, err := client.VerifyPassword(ctx, badEmailVerifyReq) + if err != nil { + t.Fatalf("Unable to run verify password for incorrect email: %v", err) + } + if badEmailVerifyResp.Verified { + t.Fatalf("verify password passed for password expected to be not found. expected %t, found %t", false, badEmailVerifyResp.Verified) + } + if !badEmailVerifyResp.NotFound { + t.Fatalf("expected not found response for verify password with bad email. expected %t, found %t", true, badEmailVerifyResp.NotFound) + } + + // Check that wrong password fails + badPassVerifyReq := &api.VerifyPasswordReq{ + Email: email, + Password: "wrong_password", + } + badPassVerifyResp, err := client.VerifyPassword(ctx, badPassVerifyReq) + if err != nil { + t.Fatalf("Unable to run verify password for password we expected to be invalid: %v", err) + } + if badPassVerifyResp.Verified { + t.Fatalf("verify password passed for password we expected to fail. expected %t, found %t", false, badPassVerifyResp.Verified) + } + if badPassVerifyResp.NotFound { + t.Fatalf("did not expect expected not found response for verify password with bad email. expected %t, found %t", false, badPassVerifyResp.NotFound) + } + + updateReq := api.UpdatePasswordReq{ + Email: email, + NewUsername: "test1", + } + + if _, err := client.UpdatePassword(ctx, &updateReq); err != nil { + t.Fatalf("Unable to update password: %v", err) + } + + pass, err := s.GetPassword(ctx, updateReq.Email) + if err != nil { + t.Fatalf("Unable to retrieve password: %v", err) + } + + if pass.Username != updateReq.NewUsername { + t.Fatalf("UpdatePassword failed. Expected username %s retrieved %s", updateReq.NewUsername, pass.Username) + } + + deleteReq := api.DeletePasswordReq{ + Email: "test@example.com", + } + + if _, err := client.DeletePassword(ctx, &deleteReq); err != nil { + t.Fatalf("Unable to delete password: %v", err) + } +} + +// Attempts to list and revoke an existing refresh token. +func TestRefreshToken(t *testing.T) { + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + // Creating a storage with an existing refresh token and offline session for the user. + id := storage.NewID() + r := storage.RefreshToken{ + ID: id, + Token: "bar", + Nonce: "foo", + ClientID: "client_id", + ConnectorID: "client_secret", + Scopes: []string{"openid", "email", "profile"}, + CreatedAt: time.Now().UTC().Round(time.Millisecond), + LastUsed: time.Now().UTC().Round(time.Millisecond), + Claims: storage.Claims{ + UserID: "1", + Username: "jane", + Email: "jane.doe@example.com", + EmailVerified: true, + Groups: []string{"a", "b"}, + }, + ConnectorData: []byte(`{"some":"data"}`), + } + + if err := s.CreateRefresh(ctx, r); err != nil { + t.Fatalf("create refresh token: %v", err) + } + + tokenRef := storage.RefreshTokenRef{ + ID: r.ID, + ClientID: r.ClientID, + CreatedAt: r.CreatedAt, + LastUsed: r.LastUsed, + } + + session := storage.OfflineSessions{ + UserID: r.Claims.UserID, + ConnID: r.ConnectorID, + Refresh: make(map[string]*storage.RefreshTokenRef), + } + session.Refresh[tokenRef.ClientID] = &tokenRef + + if err := s.CreateOfflineSessions(ctx, session); err != nil { + t.Fatalf("create offline session: %v", err) + } + + subjectString, err := internal.Marshal(&internal.IDTokenSubject{ + UserId: r.Claims.UserID, + ConnId: r.ConnectorID, + }) + if err != nil { + t.Errorf("failed to marshal offline session ID: %v", err) + } + + // Testing the api. + listReq := api.ListRefreshReq{ + UserId: subjectString, + } + + listResp, err := client.ListRefresh(ctx, &listReq) + if err != nil { + t.Fatalf("Unable to list refresh tokens for user: %v", err) + } + + for _, tok := range listResp.RefreshTokens { + if tok.CreatedAt != r.CreatedAt.Unix() { + t.Errorf("Expected CreatedAt timestamp %v, got %v", r.CreatedAt.Unix(), tok.CreatedAt) + } + + if tok.LastUsed != r.LastUsed.Unix() { + t.Errorf("Expected LastUsed timestamp %v, got %v", r.LastUsed.Unix(), tok.LastUsed) + } + } + + revokeReq := api.RevokeRefreshReq{ + UserId: subjectString, + ClientId: r.ClientID, + } + + resp, err := client.RevokeRefresh(ctx, &revokeReq) + if err != nil { + t.Fatalf("Unable to revoke refresh tokens for user: %v", err) + } + if resp.NotFound { + t.Errorf("refresh token session wasn't found") + } + + // Try to delete again. + // + // See https://github.com/dexidp/dex/issues/1055 + resp, err = client.RevokeRefresh(ctx, &revokeReq) + if err != nil { + t.Fatalf("Unable to revoke refresh tokens for user: %v", err) + } + if !resp.NotFound { + t.Errorf("refresh token session was found") + } + + if resp, _ := client.ListRefresh(ctx, &listReq); len(resp.RefreshTokens) != 0 { + t.Fatalf("Refresh token returned in spite of revoking it.") + } +} + +func TestUpdateClient(t *testing.T) { + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + createClient := func(t *testing.T, clientId string) { + resp, err := client.CreateClient(ctx, &api.CreateClientReq{ + Client: &api.Client{ + Id: clientId, + Secret: "", + RedirectUris: []string{}, + TrustedPeers: nil, + Public: true, + Name: "", + LogoUrl: "", + }, + }) + if err != nil { + t.Fatalf("unable to create the client: %v", err) + } + + if resp == nil { + t.Fatalf("create client returned no response") + } + if resp.AlreadyExists { + t.Error("existing client was found") + } + + if resp.Client == nil { + t.Fatalf("no client created") + } + } + + deleteClient := func(t *testing.T, clientId string) { + resp, err := client.DeleteClient(ctx, &api.DeleteClientReq{ + Id: clientId, + }) + if err != nil { + t.Fatalf("unable to delete the client: %v", err) + } + if resp == nil { + t.Fatalf("delete client delete client returned no response") + } + } + + tests := map[string]struct { + setup func(t *testing.T, clientId string) + cleanup func(t *testing.T, clientId string) + req *api.UpdateClientReq + wantErr bool + want *api.UpdateClientResp + }{ + "update client": { + setup: createClient, + cleanup: deleteClient, + req: &api.UpdateClientReq{ + Id: "test", + RedirectUris: []string{"https://redirect"}, + TrustedPeers: []string{"test"}, + Name: "test", + LogoUrl: "https://logout", + }, + wantErr: false, + want: &api.UpdateClientResp{ + NotFound: false, + }, + }, + "update client without ID": { + setup: createClient, + cleanup: deleteClient, + req: &api.UpdateClientReq{ + Id: "", + RedirectUris: nil, + TrustedPeers: nil, + Name: "test", + LogoUrl: "test", + }, + wantErr: true, + want: &api.UpdateClientResp{ + NotFound: false, + }, + }, + "update client which not exists ": { + req: &api.UpdateClientReq{ + Id: "test", + RedirectUris: nil, + TrustedPeers: nil, + Name: "test", + LogoUrl: "test", + }, + wantErr: true, + want: &api.UpdateClientResp{ + NotFound: false, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.setup != nil { + tc.setup(t, tc.req.Id) + } + resp, err := client.UpdateClient(ctx, tc.req) + if err != nil && !tc.wantErr { + t.Fatalf("failed to update the client: %v", err) + } + + if !tc.wantErr { + if resp == nil { + t.Fatalf("update client response not found") + } + + if tc.want.NotFound != resp.NotFound { + t.Errorf("expected in response NotFound: %t", tc.want.NotFound) + } + + client, err := s.GetClient(ctx, tc.req.Id) + if err != nil { + t.Errorf("no client found in the storage: %v", err) + } + + if tc.req.Id != client.ID { + t.Errorf("expected stored client with ID: %s, found %s", tc.req.Id, client.ID) + } + if tc.req.Name != client.Name { + t.Errorf("expected stored client with Name: %s, found %s", tc.req.Name, client.Name) + } + if tc.req.LogoUrl != client.LogoURL { + t.Errorf("expected stored client with LogoURL: %s, found %s", tc.req.LogoUrl, client.LogoURL) + } + for _, redirectURI := range tc.req.RedirectUris { + found := slices.Contains(client.RedirectURIs, redirectURI) + if !found { + t.Errorf("expected redirect URI: %s", redirectURI) + } + } + for _, peer := range tc.req.TrustedPeers { + found := slices.Contains(client.TrustedPeers, peer) + if !found { + t.Errorf("expected trusted peer: %s", peer) + } + } + } + + if tc.cleanup != nil { + tc.cleanup(t, tc.req.Id) + } + }) + } +} + +func TestCreateConnector(t *testing.T) { + t.Setenv("DEX_API_CONNECTORS_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + connectorID := "connector123" + connectorName := "TestConnector" + connectorType := "TestType" + connectorConfig := []byte(`{"key": "value"}`) + + createReq := api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: connectorID, + Name: connectorName, + Type: connectorType, + Config: connectorConfig, + }, + } + + // Test valid connector creation + if resp, err := client.CreateConnector(ctx, &createReq); err != nil || resp.AlreadyExists { + if err != nil { + t.Fatalf("Unable to create connector: %v", err) + } else if resp.AlreadyExists { + t.Fatalf("Unable to create connector since %s already exists", connectorID) + } + t.Fatalf("Unable to create connector: %v", err) + } + + // Test creating the same connector again (expecting failure) + if resp, _ := client.CreateConnector(ctx, &createReq); !resp.AlreadyExists { + t.Fatalf("Created connector %s twice", connectorID) + } + + createReq.Connector.Config = []byte("invalid_json") + + // Test invalid JSON config + if _, err := client.CreateConnector(ctx, &createReq); err == nil { + t.Fatal("Expected an error for invalid JSON config, but none occurred") + } else if !strings.Contains(err.Error(), "invalid config supplied") { + t.Fatalf("Unexpected error: %v", err) + } +} + +func TestUpdateConnector(t *testing.T) { + t.Setenv("DEX_API_CONNECTORS_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + connectorID := "connector123" + newConnectorName := "UpdatedConnector" + newConnectorType := "UpdatedType" + newConnectorConfig := []byte(`{"updated_key": "updated_value"}`) + + // Create a connector for testing + createReq := api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: connectorID, + Name: "TestConnector", + Type: "TestType", + Config: []byte(`{"key": "value"}`), + }, + } + client.CreateConnector(ctx, &createReq) + + updateReq := api.UpdateConnectorReq{ + Id: connectorID, + NewName: newConnectorName, + NewType: newConnectorType, + NewConfig: newConnectorConfig, + } + + // Test valid connector update + if _, err := client.UpdateConnector(ctx, &updateReq); err != nil { + t.Fatalf("Unable to update connector: %v", err) + } + + resp, err := client.ListConnectors(ctx, &api.ListConnectorReq{}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + for _, connector := range resp.Connectors { + if connector.Id == connectorID { + if connector.Name != newConnectorName { + t.Fatal("connector name should have been updated") + } + if string(connector.Config) != string(newConnectorConfig) { + t.Fatal("connector config should have been updated") + } + if connector.Type != newConnectorType { + t.Fatal("connector type should have been updated") + } + } + } + + updateReq.NewConfig = []byte("invalid_json") + + // Test invalid JSON config in update request + if _, err := client.UpdateConnector(ctx, &updateReq); err == nil { + t.Fatal("Expected an error for invalid JSON config in update, but none occurred") + } else if !strings.Contains(err.Error(), "invalid config supplied") { + t.Fatalf("Unexpected error: %v", err) + } +} + +func TestUpdateConnectorGrantTypes(t *testing.T) { + t.Setenv("DEX_API_CONNECTORS_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + connectorID := "connector-gt" + + // Create a connector without grant types + createReq := api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: connectorID, + Name: "TestConnector", + Type: "TestType", + Config: []byte(`{"key": "value"}`), + }, + } + _, err := client.CreateConnector(ctx, &createReq) + if err != nil { + t.Fatalf("failed to create connector: %v", err) + } + + // Set grant types + _, err = client.UpdateConnector(ctx, &api.UpdateConnectorReq{ + Id: connectorID, + NewGrantTypes: &api.GrantTypes{GrantTypes: []string{"authorization_code", "refresh_token"}}, + }) + if err != nil { + t.Fatalf("failed to update connector grant types: %v", err) + } + + resp, err := client.ListConnectors(ctx, &api.ListConnectorReq{}) + if err != nil { + t.Fatalf("failed to list connectors: %v", err) + } + for _, c := range resp.Connectors { + if c.Id == connectorID { + if !slices.Equal(c.GrantTypes, []string{"authorization_code", "refresh_token"}) { + t.Fatalf("expected grant types [authorization_code refresh_token], got %v", c.GrantTypes) + } + } + } + + // Clear grant types by passing empty GrantTypes message + _, err = client.UpdateConnector(ctx, &api.UpdateConnectorReq{ + Id: connectorID, + NewGrantTypes: &api.GrantTypes{}, + }) + if err != nil { + t.Fatalf("failed to clear connector grant types: %v", err) + } + + resp, err = client.ListConnectors(ctx, &api.ListConnectorReq{}) + if err != nil { + t.Fatalf("failed to list connectors: %v", err) + } + for _, c := range resp.Connectors { + if c.Id == connectorID { + if len(c.GrantTypes) != 0 { + t.Fatalf("expected empty grant types after clear, got %v", c.GrantTypes) + } + } + } + + // Reject invalid grant type on update + _, err = client.UpdateConnector(ctx, &api.UpdateConnectorReq{ + Id: connectorID, + NewGrantTypes: &api.GrantTypes{GrantTypes: []string{"bogus"}}, + }) + if err == nil { + t.Fatal("expected error for invalid grant type, got nil") + } + if !strings.Contains(err.Error(), `unknown grant type "bogus"`) { + t.Fatalf("unexpected error: %v", err) + } + + // Reject invalid grant type on create + _, err = client.CreateConnector(ctx, &api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: "bad-gt", + Name: "Bad", + Type: "TestType", + Config: []byte(`{}`), + GrantTypes: []string{"invalid_type"}, + }, + }) + if err == nil { + t.Fatal("expected error for invalid grant type on create, got nil") + } + if !strings.Contains(err.Error(), `unknown grant type "invalid_type"`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDeleteConnector(t *testing.T) { + t.Setenv("DEX_API_CONNECTORS_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + connectorID := "connector123" + + // Create a connector for testing + createReq := api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: connectorID, + Name: "TestConnector", + Type: "TestType", + Config: []byte(`{"key": "value"}`), + }, + } + client.CreateConnector(ctx, &createReq) + + deleteReq := api.DeleteConnectorReq{ + Id: connectorID, + } + + // Test valid connector deletion + if _, err := client.DeleteConnector(ctx, &deleteReq); err != nil { + t.Fatalf("Unable to delete connector: %v", err) + } + + // Test non existent connector deletion + resp, err := client.DeleteConnector(ctx, &deleteReq) + if err != nil { + t.Fatalf("Unable to delete connector: %v", err) + } + + if !resp.NotFound { + t.Fatal("Should return not found") + } +} + +func TestListConnectors(t *testing.T) { + t.Setenv("DEX_API_CONNECTORS_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + // Create connectors for testing + createReq1 := api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: "connector1", + Name: "Connector1", + Type: "Type1", + Config: []byte(`{"key": "value1"}`), + }, + } + client.CreateConnector(ctx, &createReq1) + + createReq2 := api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: "connector2", + Name: "Connector2", + Type: "Type2", + Config: []byte(`{"key": "value2"}`), + }, + } + client.CreateConnector(ctx, &createReq2) + + listReq := api.ListConnectorReq{} + + // Test listing connectors + if resp, err := client.ListConnectors(ctx, &listReq); err != nil { + t.Fatalf("Unable to list connectors: %v", err) + } else if len(resp.Connectors) != 2 { // Check the number of connectors in the response + t.Fatalf("Expected 2 connectors, found %d", len(resp.Connectors)) + } +} + +func TestMissingConnectorsCRUDFeatureFlag(t *testing.T) { + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + // Create connectors for testing + createReq1 := api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: "connector1", + Name: "Connector1", + Type: "Type1", + Config: []byte(`{"key": "value1"}`), + }, + } + client.CreateConnector(ctx, &createReq1) + + createReq2 := api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: "connector2", + Name: "Connector2", + Type: "Type2", + Config: []byte(`{"key": "value2"}`), + }, + } + client.CreateConnector(ctx, &createReq2) + + listReq := api.ListConnectorReq{} + + if _, err := client.ListConnectors(ctx, &listReq); err == nil { + t.Fatal("ListConnectors should have returned an error") + } +} + +func TestListClients(t *testing.T) { + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + // List Clients + listResp, err := client.ListClients(ctx, &api.ListClientReq{}) + if err != nil { + t.Fatalf("Unable to list clients: %v", err) + } + if len(listResp.Clients) != 0 { + t.Fatalf("Expected 0 clients, got %d", len(listResp.Clients)) + } + + client1 := &api.Client{ + Id: "client1", + Secret: "secret1", + RedirectUris: []string{"http://localhost:8080/callback"}, + TrustedPeers: []string{"peer1"}, + Public: false, + Name: "Test Client 1", + LogoUrl: "http://example.com/logo1.png", + } + + client2 := &api.Client{ + Id: "client2", + Secret: "secret2", + RedirectUris: []string{"http://localhost:8081/callback"}, + TrustedPeers: []string{"peer2"}, + Public: true, + Name: "Test Client 2", + LogoUrl: "http://example.com/logo2.png", + } + + _, err = client.CreateClient(ctx, &api.CreateClientReq{Client: client1}) + if err != nil { + t.Fatalf("Unable to create client1: %v", err) + } + + _, err = client.CreateClient(ctx, &api.CreateClientReq{Client: client2}) + if err != nil { + t.Fatalf("Unable to create client2: %v", err) + } + + listResp, err = client.ListClients(ctx, &api.ListClientReq{}) + if err != nil { + t.Fatalf("Unable to list clients: %v", err) + } + + if len(listResp.Clients) != 2 { + t.Fatalf("Expected 2 clients, got %d", len(listResp.Clients)) + } + + clientMap := make(map[string]*api.ClientInfo) + for _, c := range listResp.Clients { + clientMap[c.Id] = c + } + + if c1, exists := clientMap["client1"]; !exists { + t.Fatal("client1 not found in list") + } else { + if c1.Name != "Test Client 1" { + t.Errorf("Expected client1 name 'Test Client 1', got '%s'", c1.Name) + } + if len(c1.RedirectUris) != 1 || c1.RedirectUris[0] != "http://localhost:8080/callback" { + t.Errorf("Expected client1 redirect URIs ['http://localhost:8080/callback'], got %v", c1.RedirectUris) + } + if c1.Public != false { + t.Errorf("Expected client1 public false, got %v", c1.Public) + } + if c1.LogoUrl != "http://example.com/logo1.png" { + t.Errorf("Expected client1 logo URL 'http://example.com/logo1.png', got '%s'", c1.LogoUrl) + } + } + + if c2, exists := clientMap["client2"]; !exists { + t.Fatal("client2 not found in list") + } else { + if c2.Name != "Test Client 2" { + t.Errorf("Expected client2 name 'Test Client 2', got '%s'", c2.Name) + } + if len(c2.RedirectUris) != 1 || c2.RedirectUris[0] != "http://localhost:8081/callback" { + t.Errorf("Expected client2 redirect URIs ['http://localhost:8081/callback'], got %v", c2.RedirectUris) + } + if c2.Public != true { + t.Errorf("Expected client2 public true, got %v", c2.Public) + } + if c2.LogoUrl != "http://example.com/logo2.png" { + t.Errorf("Expected client2 logo URL 'http://example.com/logo2.png', got '%s'", c2.LogoUrl) + } + } +} + +func TestGetAuthSession(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + session := storage.AuthSession{ + UserID: "user1", + ConnectorID: "conn1", + ID: "nonce123", Secret: "nonce123", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + AuthenticatedAt: now, + LastActivity: now, + LastTokenIssuedAt: now, + }, + }, + CreatedAt: now, + LastActivity: now, + IPAddress: "10.0.0.1", + UserAgent: "TestAgent/1.0", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(1 * time.Hour), + } + + if err := s.CreateAuthSession(ctx, session); err != nil { + t.Fatalf("create auth session: %v", err) + } + + resp, err := client.GetAuthSession(ctx, &api.GetAuthSessionReq{Id: "nonce123"}) + if err != nil { + t.Fatalf("get auth session: %v", err) + } + + if resp.Session.UserId != "user1" { + t.Errorf("expected user_id 'user1', got '%s'", resp.Session.UserId) + } + if resp.Session.IpAddress != "10.0.0.1" { + t.Errorf("expected ip_address '10.0.0.1', got '%s'", resp.Session.IpAddress) + } + if len(resp.Session.ClientStates) != 1 { + t.Fatalf("expected 1 client state, got %d", len(resp.Session.ClientStates)) + } + cs := resp.Session.ClientStates[0] + if cs.ClientId != "client-a" { + t.Errorf("expected client_id 'client-a', got '%s'", cs.ClientId) + } + if cs.AuthenticatedAt == 0 { + t.Error("expected client state to record an authentication") + } + if resp.Session.Id != "nonce123" { + t.Errorf("expected session id 'nonce123', got '%s'", resp.Session.Id) + } + + // Not found case. + _, err = client.GetAuthSession(ctx, &api.GetAuthSessionReq{Id: "nonexistent"}) + if err == nil { + t.Fatal("expected error for non-existent session") + } +} + +func TestListAuthSessions(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + for _, sess := range []storage.AuthSession{ + {UserID: "user1", ConnectorID: "conn1", ID: "n1", Secret: "n1", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)}, + {UserID: "user1", ConnectorID: "conn2", ID: "n2", Secret: "n2", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)}, + {UserID: "user2", ConnectorID: "conn1", ID: "n3", Secret: "n3", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)}, + } { + if err := s.CreateAuthSession(ctx, sess); err != nil { + t.Fatalf("create auth session: %v", err) + } + } + + // List all. + resp, err := client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{}) + if err != nil { + t.Fatalf("list auth sessions: %v", err) + } + if len(resp.Sessions) != 3 { + t.Fatalf("expected 3 sessions, got %d", len(resp.Sessions)) + } + + // Filter by user_id. + resp, err = client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{UserId: "user1"}) + if err != nil { + t.Fatalf("list auth sessions with filter: %v", err) + } + if len(resp.Sessions) != 2 { + t.Fatalf("expected 2 sessions for user1, got %d", len(resp.Sessions)) + } + + // Filter by connector_id, and by both: one user signed in through two + // connectors is two sessions, and a caller may want either or one of them. + resp, err = client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{ConnectorId: "conn1"}) + if err != nil { + t.Fatalf("list auth sessions by connector: %v", err) + } + if len(resp.Sessions) != 2 { + t.Fatalf("expected 2 sessions on conn1, got %d", len(resp.Sessions)) + } + + resp, err = client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{UserId: "user1", ConnectorId: "conn2"}) + if err != nil { + t.Fatalf("list auth sessions by user and connector: %v", err) + } + if len(resp.Sessions) != 1 { + t.Fatalf("expected 1 session for user1 on conn2, got %d", len(resp.Sessions)) + } +} + +func TestDeleteAuthSession(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + + // Create session. + session := storage.AuthSession{ + UserID: "user1", ConnectorID: "conn1", ID: "n1", Secret: "n1", + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now, + LastActivity: now, + AbsoluteExpiry: now.Add(time.Hour), + IdleExpiry: now.Add(time.Hour), + } + if err := s.CreateAuthSession(ctx, session); err != nil { + t.Fatalf("create auth session: %v", err) + } + + // Create refresh token + offline session to verify cascading revocation. + refreshID := storage.NewID() + if err := s.CreateRefresh(ctx, storage.RefreshToken{ + ID: refreshID, Token: "tok", Nonce: "n", ClientID: "client1", ConnectorID: "conn1", + Scopes: []string{"openid"}, CreatedAt: now, LastUsed: now, + Claims: storage.Claims{UserID: "user1", Username: "test", Email: "test@test.com"}, + }); err != nil { + t.Fatalf("create refresh: %v", err) + } + if err := s.CreateOfflineSessions(ctx, storage.OfflineSessions{ + UserID: "user1", ConnID: "conn1", + Refresh: map[string]*storage.RefreshTokenRef{ + "client1": {ID: refreshID, ClientID: "client1", CreatedAt: now, LastUsed: now}, + }, + }); err != nil { + t.Fatalf("create offline sessions: %v", err) + } + + // Delete session. + resp, err := client.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{Id: "n1"}) + if err != nil { + t.Fatalf("delete auth session: %v", err) + } + if resp.NotFound { + t.Error("expected session to be found") + } + + // Verify session is gone. + _, err = s.GetAuthSession(ctx, "n1") + if err == nil { + t.Error("expected auth session to be deleted") + } + + // Verify refresh token was revoked. + _, err = s.GetRefresh(ctx, refreshID) + if err == nil { + t.Error("expected refresh token to be revoked") + } + + // Not found case. + resp, err = client.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{Id: "n1"}) + if err != nil { + t.Fatalf("delete auth session: %v", err) + } + if !resp.NotFound { + t.Error("expected not_found for already deleted session") + } +} + +func TestTerminateSessionsByConnector(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + for _, sess := range []storage.AuthSession{ + {UserID: "user1", ConnectorID: "target-conn", ID: "n1", Secret: "n1", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)}, + {UserID: "user2", ConnectorID: "target-conn", ID: "n2", Secret: "n2", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)}, + {UserID: "user3", ConnectorID: "other-conn", ID: "n3", Secret: "n3", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)}, + } { + if err := s.CreateAuthSession(ctx, sess); err != nil { + t.Fatalf("create auth session: %v", err) + } + } + + resp, err := client.TerminateSessionsByConnector(ctx, &api.TerminateSessionsByConnectorReq{ + ConnectorId: "target-conn", + }) + if err != nil { + t.Fatalf("terminate sessions by connector: %v", err) + } + if resp.SessionsTerminated != 2 { + t.Errorf("expected 2 terminated, got %d", resp.SessionsTerminated) + } + + // Verify remaining session is untouched. + remaining, err := s.ListAuthSessions(ctx) + if err != nil { + t.Fatalf("list auth sessions: %v", err) + } + if len(remaining) != 1 || remaining[0].ConnectorID != "other-conn" { + t.Errorf("expected only other-conn session to remain, got %v", remaining) + } +} + +func TestTerminateSessionsByUser(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + for _, sess := range []storage.AuthSession{ + {UserID: "target-user", ConnectorID: "conn1", ID: "n1", Secret: "n1", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)}, + {UserID: "target-user", ConnectorID: "conn2", ID: "n2", Secret: "n2", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)}, + {UserID: "other-user", ConnectorID: "conn1", ID: "n3", Secret: "n3", ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour)}, + } { + if err := s.CreateAuthSession(ctx, sess); err != nil { + t.Fatalf("create auth session: %v", err) + } + } + + resp, err := client.TerminateSessionsByUser(ctx, &api.TerminateSessionsByUserReq{ + UserId: "target-user", + }) + if err != nil { + t.Fatalf("terminate sessions by user: %v", err) + } + if resp.SessionsTerminated != 2 { + t.Errorf("expected 2 terminated, got %d", resp.SessionsTerminated) + } + + remaining, err := s.ListAuthSessions(ctx) + if err != nil { + t.Fatalf("list auth sessions: %v", err) + } + if len(remaining) != 1 || remaining[0].UserID != "other-user" { + t.Errorf("expected only other-user session to remain") + } +} + +func TestGetUserIdentity(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + identity := storage.UserIdentity{ + UserID: "user1", + ConnectorID: "conn1", + Claims: storage.Claims{ + UserID: "user1", + Username: "testuser", + Email: "test@example.com", + EmailVerified: true, + Groups: []string{"admins"}, + }, + Consents: map[string][]string{ + "client-a": {"openid", "email"}, + }, + MFASecrets: map[string]*storage.MFASecret{ + "totp-1": {AuthenticatorID: "totp-1", Type: "TOTP", Secret: "secret123", Confirmed: true, CreatedAt: now}, + }, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "webauthn-1": {{CredentialID: []byte("cred1"), AttestationType: "none", DisplayName: "YubiKey", CreatedAt: now}}, + }, + CreatedAt: now, + LastLogin: now, + } + + if err := s.CreateUserIdentity(ctx, identity); err != nil { + t.Fatalf("create user identity: %v", err) + } + + resp, err := client.GetUserIdentity(ctx, &api.GetUserIdentityReq{ + UserId: "user1", ConnectorId: "conn1", + }) + if err != nil { + t.Fatalf("get user identity: %v", err) + } + + if resp.Identity.Email != "test@example.com" { + t.Errorf("expected email 'test@example.com', got '%s'", resp.Identity.Email) + } + if !resp.Identity.EmailVerified { + t.Error("expected email_verified true") + } + if resp.Identity.Username != "testuser" { + t.Errorf("expected username 'testuser', got '%s'", resp.Identity.Username) + } + if len(resp.Identity.Consents) != 1 { + t.Fatalf("expected 1 consent entry, got %d", len(resp.Identity.Consents)) + } + if len(resp.Identity.MfaDevices) == 0 { + t.Fatal("expected MFA devices") + } +} + +func TestListUserIdentities(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + for _, id := range []storage.UserIdentity{ + {UserID: "user1", ConnectorID: "conn1", Claims: storage.Claims{Email: "a@test.com"}, CreatedAt: now, LastLogin: now}, + {UserID: "user2", ConnectorID: "conn1", Claims: storage.Claims{Email: "b@test.com"}, CreatedAt: now, LastLogin: now}, + } { + if err := s.CreateUserIdentity(ctx, id); err != nil { + t.Fatalf("create user identity: %v", err) + } + } + + resp, err := client.ListUserIdentities(ctx, &api.ListUserIdentitiesReq{}) + if err != nil { + t.Fatalf("list user identities: %v", err) + } + if len(resp.Identities) != 2 { + t.Fatalf("expected 2 identities, got %d", len(resp.Identities)) + } +} + +func TestDeleteUserIdentity(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + + // Create identity + session + offline sessions + refresh token. + if err := s.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user1", ConnectorID: "conn1", + Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now, + }); err != nil { + t.Fatalf("create user identity: %v", err) + } + if err := s.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user1", ConnectorID: "conn1", ID: "n", Secret: "n", + ClientStates: map[string]*storage.ClientAuthState{}, CreatedAt: now, LastActivity: now, + AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour), + }); err != nil { + t.Fatalf("create auth session: %v", err) + } + refreshID := storage.NewID() + if err := s.CreateRefresh(ctx, storage.RefreshToken{ + ID: refreshID, Token: "tok", Nonce: "n", ClientID: "c1", ConnectorID: "conn1", + Scopes: []string{"openid"}, CreatedAt: now, LastUsed: now, + Claims: storage.Claims{UserID: "user1", Email: "test@test.com"}, + }); err != nil { + t.Fatalf("create refresh: %v", err) + } + if err := s.CreateOfflineSessions(ctx, storage.OfflineSessions{ + UserID: "user1", ConnID: "conn1", + Refresh: map[string]*storage.RefreshTokenRef{ + "c1": {ID: refreshID, ClientID: "c1", CreatedAt: now, LastUsed: now}, + }, + }); err != nil { + t.Fatalf("create offline sessions: %v", err) + } + // Password record linked by the identity's email โ€” must be purged too (GDPR). + if err := s.CreatePassword(ctx, storage.Password{ + Email: "test@test.com", Hash: []byte("$2y$10$XXXXXXXXXXXXXXXXXXXXXX"), Username: "test", UserID: "user1", + }); err != nil { + t.Fatalf("create password: %v", err) + } + + // Delete identity (cascading). + resp, err := client.DeleteUserIdentity(ctx, &api.DeleteUserIdentityReq{ + UserId: "user1", ConnectorId: "conn1", + }) + if err != nil { + t.Fatalf("delete user identity: %v", err) + } + if resp.NotFound { + t.Error("expected identity to be found") + } + + // Verify everything is deleted. + if _, err := s.GetUserIdentity(ctx, "user1", "conn1"); err == nil { + t.Error("expected user identity to be deleted") + } + if _, err := s.GetAuthSession(ctx, "n"); err == nil { + t.Error("expected auth session to be deleted") + } + if _, err := s.GetRefresh(ctx, refreshID); err == nil { + t.Error("expected refresh token to be deleted") + } + if _, err := s.GetOfflineSessions(ctx, "user1", "conn1"); err == nil { + t.Error("expected offline sessions to be deleted") + } + if _, err := s.GetPassword(ctx, "test@test.com"); err == nil { + t.Error("expected password record to be deleted") + } + + // Not found case. + resp, err = client.DeleteUserIdentity(ctx, &api.DeleteUserIdentityReq{ + UserId: "user1", ConnectorId: "conn1", + }) + if err != nil { + t.Fatalf("delete user identity: %v", err) + } + if !resp.NotFound { + t.Error("expected not_found for already deleted identity") + } +} + +func TestResetMFA(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + if err := s.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user1", ConnectorID: "conn1", + Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now, + MFASecrets: map[string]*storage.MFASecret{ + "totp-1": {AuthenticatorID: "totp-1", Type: "TOTP", Secret: "s", Confirmed: true, CreatedAt: now}, + }, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "webauthn-1": {{CredentialID: []byte("c1"), CreatedAt: now}}, + }, + }); err != nil { + t.Fatalf("create user identity: %v", err) + } + + resp, err := client.ResetMFA(ctx, &api.ResetMFAReq{ + UserId: "user1", ConnectorId: "conn1", + }) + if err != nil { + t.Fatalf("reset MFA: %v", err) + } + if resp.NotFound { + t.Error("expected identity to be found") + } + + // Verify MFA data is cleared. + identity, err := s.GetUserIdentity(ctx, "user1", "conn1") + if err != nil { + t.Fatalf("get user identity: %v", err) + } + if len(identity.MFASecrets) != 0 { + t.Errorf("expected MFASecrets to be cleared, got %d", len(identity.MFASecrets)) + } + if len(identity.WebAuthnCredentials) != 0 { + t.Errorf("expected WebAuthnCredentials to be cleared, got %d", len(identity.WebAuthnCredentials)) + } + // Verify other fields are preserved. + if identity.Claims.Email != "test@test.com" { + t.Errorf("expected email to be preserved, got '%s'", identity.Claims.Email) + } +} + +func TestListMFADevices(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + if err := s.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user1", ConnectorID: "conn1", + Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now, + MFASecrets: map[string]*storage.MFASecret{ + "totp-1": {AuthenticatorID: "totp-1", Type: "TOTP", Secret: "secret123", Confirmed: true, CreatedAt: now}, + }, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "webauthn-1": { + {CredentialID: []byte("cred1"), PublicKey: []byte("pk1"), DisplayName: "Key1", CreatedAt: now}, + {CredentialID: []byte("cred2"), PublicKey: []byte("pk2"), DisplayName: "Key2", CreatedAt: now}, + }, + }, + }); err != nil { + t.Fatalf("create user identity: %v", err) + } + + resp, err := client.ListMFADevices(ctx, &api.ListMFADevicesReq{ + UserId: "user1", ConnectorId: "conn1", + }) + if err != nil { + t.Fatalf("list MFA devices: %v", err) + } + if len(resp.Devices) != 2 { + t.Fatalf("expected 2 device groups, got %d", len(resp.Devices)) + } + + // Find the TOTP device and verify secret is not exposed. + for _, device := range resp.Devices { + if device.AuthenticatorId == "totp-1" { + if device.MfaSecret == nil { + t.Fatal("expected MFA secret for totp-1") + } + if device.MfaSecret.Type != "TOTP" { + t.Errorf("expected type TOTP, got %s", device.MfaSecret.Type) + } + } + if device.AuthenticatorId == "webauthn-1" { + if len(device.WebauthnCredentials) != 2 { + t.Errorf("expected 2 webauthn credentials, got %d", len(device.WebauthnCredentials)) + } + } + } +} + +func TestDeleteWebAuthnCredential(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + if err := s.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user1", ConnectorID: "conn1", + Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "auth-1": { + {CredentialID: []byte("cred-to-delete"), DisplayName: "Key1", CreatedAt: now}, + {CredentialID: []byte("cred-to-keep"), DisplayName: "Key2", CreatedAt: now}, + }, + }, + }); err != nil { + t.Fatalf("create user identity: %v", err) + } + + // Delete one credential. + resp, err := client.DeleteWebAuthnCredential(ctx, &api.DeleteWebAuthnCredentialReq{ + UserId: "user1", ConnectorId: "conn1", CredentialId: []byte("cred-to-delete"), + }) + if err != nil { + t.Fatalf("delete webauthn credential: %v", err) + } + if resp.NotFound { + t.Error("expected credential to be found") + } + + // Verify only one credential remains. + identity, err := s.GetUserIdentity(ctx, "user1", "conn1") + if err != nil { + t.Fatalf("get user identity: %v", err) + } + creds := identity.WebAuthnCredentials["auth-1"] + if len(creds) != 1 { + t.Fatalf("expected 1 credential remaining, got %d", len(creds)) + } + if !bytes.Equal(creds[0].CredentialID, []byte("cred-to-keep")) { + t.Error("wrong credential was deleted") + } + + // Not found case. + resp, err = client.DeleteWebAuthnCredential(ctx, &api.DeleteWebAuthnCredentialReq{ + UserId: "user1", ConnectorId: "conn1", CredentialId: []byte("nonexistent"), + }) + if err != nil { + t.Fatalf("delete webauthn credential: %v", err) + } + if !resp.NotFound { + t.Error("expected not_found for nonexistent credential") + } +} + +func TestDeleteMFASecret(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + if err := s.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user1", ConnectorID: "conn1", + Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now, + MFASecrets: map[string]*storage.MFASecret{ + "totp-1": {AuthenticatorID: "totp-1", Type: "TOTP", Secret: "s", Confirmed: true, CreatedAt: now}, + "totp-2": {AuthenticatorID: "totp-2", Type: "TOTP", Secret: "s2", Confirmed: true, CreatedAt: now}, + }, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "totp-1": {{CredentialID: []byte("c1"), CreatedAt: now}}, + }, + }); err != nil { + t.Fatalf("create user identity: %v", err) + } + + // Delete totp-1 (should also remove associated webauthn credentials). + resp, err := client.DeleteMFASecret(ctx, &api.DeleteMFASecretReq{ + UserId: "user1", ConnectorId: "conn1", AuthenticatorId: "totp-1", + }) + if err != nil { + t.Fatalf("delete MFA secret: %v", err) + } + if resp.NotFound { + t.Error("expected authenticator to be found") + } + + identity, err := s.GetUserIdentity(ctx, "user1", "conn1") + if err != nil { + t.Fatalf("get user identity: %v", err) + } + if _, ok := identity.MFASecrets["totp-1"]; ok { + t.Error("expected totp-1 to be deleted") + } + if _, ok := identity.MFASecrets["totp-2"]; !ok { + t.Error("expected totp-2 to remain") + } + if _, ok := identity.WebAuthnCredentials["totp-1"]; ok { + t.Error("expected webauthn credentials for totp-1 to be deleted") + } + + // Not found case. + resp, err = client.DeleteMFASecret(ctx, &api.DeleteMFASecretReq{ + UserId: "user1", ConnectorId: "conn1", AuthenticatorId: "nonexistent", + }) + if err != nil { + t.Fatalf("delete MFA secret: %v", err) + } + if !resp.NotFound { + t.Error("expected not_found for nonexistent authenticator") + } +} + +func TestRevokeConsent(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + if err := s.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user1", ConnectorID: "conn1", + Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, LastLogin: now, + Consents: map[string][]string{ + "client-a": {"openid", "email"}, + "client-b": {"openid"}, + }, + }); err != nil { + t.Fatalf("create user identity: %v", err) + } + + // Revoke consent for client-a. + resp, err := client.RevokeConsent(ctx, &api.RevokeConsentReq{ + UserId: "user1", ConnectorId: "conn1", ClientId: "client-a", + }) + if err != nil { + t.Fatalf("revoke consent: %v", err) + } + if resp.NotFound { + t.Error("expected consent to be found") + } + + // Verify only client-b consent remains. + identity, err := s.GetUserIdentity(ctx, "user1", "conn1") + if err != nil { + t.Fatalf("get user identity: %v", err) + } + if _, ok := identity.Consents["client-a"]; ok { + t.Error("expected client-a consent to be revoked") + } + if _, ok := identity.Consents["client-b"]; !ok { + t.Error("expected client-b consent to remain") + } + + // Not found case. + resp, err = client.RevokeConsent(ctx, &api.RevokeConsentReq{ + UserId: "user1", ConnectorId: "conn1", ClientId: "nonexistent", + }) + if err != nil { + t.Fatalf("revoke consent: %v", err) + } + if !resp.NotFound { + t.Error("expected not_found for nonexistent consent") + } +} + +func TestMissingSessionsIdentitiesCRUDFeatureFlag(t *testing.T) { + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + if _, err := client.GetAuthSession(ctx, &api.GetAuthSessionReq{Id: "s"}); err == nil { + t.Error("GetAuthSession should fail without feature flag") + } + if _, err := client.ListAuthSessions(ctx, &api.ListAuthSessionsReq{}); err == nil { + t.Error("ListAuthSessions should fail without feature flag") + } + if _, err := client.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{Id: "s"}); err == nil { + t.Error("DeleteAuthSession should fail without feature flag") + } + if _, err := client.TerminateSessionsByConnector(ctx, &api.TerminateSessionsByConnectorReq{ConnectorId: "c"}); err == nil { + t.Error("TerminateSessionsByConnector should fail without feature flag") + } + if _, err := client.TerminateSessionsByUser(ctx, &api.TerminateSessionsByUserReq{UserId: "u"}); err == nil { + t.Error("TerminateSessionsByUser should fail without feature flag") + } + if _, err := client.GetUserIdentity(ctx, &api.GetUserIdentityReq{UserId: "u", ConnectorId: "c"}); err == nil { + t.Error("GetUserIdentity should fail without feature flag") + } + if _, err := client.ListUserIdentities(ctx, &api.ListUserIdentitiesReq{}); err == nil { + t.Error("ListUserIdentities should fail without feature flag") + } + if _, err := client.DeleteUserIdentity(ctx, &api.DeleteUserIdentityReq{UserId: "u", ConnectorId: "c"}); err == nil { + t.Error("DeleteUserIdentity should fail without feature flag") + } + if _, err := client.ResetMFA(ctx, &api.ResetMFAReq{UserId: "u", ConnectorId: "c"}); err == nil { + t.Error("ResetMFA should fail without feature flag") + } + if _, err := client.ListMFADevices(ctx, &api.ListMFADevicesReq{UserId: "u", ConnectorId: "c"}); err == nil { + t.Error("ListMFADevices should fail without feature flag") + } + if _, err := client.DeleteWebAuthnCredential(ctx, &api.DeleteWebAuthnCredentialReq{UserId: "u", ConnectorId: "c", CredentialId: []byte("cred")}); err == nil { + t.Error("DeleteWebAuthnCredential should fail without feature flag") + } + if _, err := client.DeleteMFASecret(ctx, &api.DeleteMFASecretReq{UserId: "u", ConnectorId: "c", AuthenticatorId: "a"}); err == nil { + t.Error("DeleteMFASecret should fail without feature flag") + } + if _, err := client.RevokeConsent(ctx, &api.RevokeConsentReq{UserId: "u", ConnectorId: "c", ClientId: "cl"}); err == nil { + t.Error("RevokeConsent should fail without feature flag") + } +} + +// TestSessionsIdentitiesZeroTimeConversion verifies that unset time.Time fields +// serialize to 0 rather than the misleading year-1 epoch (-62135596800) that a +// naive t.Unix() produces. +func TestSessionsIdentitiesZeroTimeConversion(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + now := time.Now().UTC().Round(time.Second) + + // Client authenticated but no token issued yet: LastTokenIssuedAt is zero. + if err := s.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user1", ConnectorID: "conn1", ID: "n", Secret: "n", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": {AuthenticatedAt: now, LastActivity: now}, + }, + CreatedAt: now, LastActivity: now, AbsoluteExpiry: now.Add(time.Hour), IdleExpiry: now.Add(time.Hour), + }); err != nil { + t.Fatalf("create auth session: %v", err) + } + + sessResp, err := client.GetAuthSession(ctx, &api.GetAuthSessionReq{Id: "n"}) + if err != nil { + t.Fatalf("get auth session: %v", err) + } + if len(sessResp.Session.ClientStates) != 1 { + t.Fatalf("expected 1 client state, got %d", len(sessResp.Session.ClientStates)) + } + if got := sessResp.Session.ClientStates[0].LastTokenIssuedAt; got != 0 { + t.Errorf("expected last_token_issued_at 0 for unset time, got %d", got) + } + + // Identity that has never logged in and is not blocked: LastLogin and + // BlockedUntil are zero. + if err := s.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user1", ConnectorID: "conn1", + Claims: storage.Claims{Email: "test@test.com"}, CreatedAt: now, + }); err != nil { + t.Fatalf("create user identity: %v", err) + } + + idResp, err := client.GetUserIdentity(ctx, &api.GetUserIdentityReq{UserId: "user1", ConnectorId: "conn1"}) + if err != nil { + t.Fatalf("get user identity: %v", err) + } + if got := idResp.Identity.LastLogin; got != 0 { + t.Errorf("expected last_login 0 for unset time, got %d", got) + } + if got := idResp.Identity.BlockedUntil; got != 0 { + t.Errorf("expected blocked_until 0 for unset time, got %d", got) + } +} + +// TestSessionsIdentitiesValidation verifies that handlers reject requests +// missing required fields. +func TestSessionsIdentitiesValidation(t *testing.T) { + t.Setenv("DEX_API_SESSIONS_IDENTITIES_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + client := newAPI(t, s, logger) + defer client.Close() + + ctx := t.Context() + + if _, err := client.GetAuthSession(ctx, &api.GetAuthSessionReq{}); err == nil { + t.Error("GetAuthSession should reject an empty id") + } + if _, err := client.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{}); err == nil { + t.Error("DeleteAuthSession should reject an empty id") + } + if _, err := client.TerminateSessionsByConnector(ctx, &api.TerminateSessionsByConnectorReq{}); err == nil { + t.Error("TerminateSessionsByConnector should reject empty connector_id") + } + if _, err := client.TerminateSessionsByUser(ctx, &api.TerminateSessionsByUserReq{}); err == nil { + t.Error("TerminateSessionsByUser should reject empty user_id") + } + if _, err := client.DeleteWebAuthnCredential(ctx, &api.DeleteWebAuthnCredentialReq{UserId: "u", ConnectorId: "c"}); err == nil { + t.Error("DeleteWebAuthnCredential should reject empty credential_id") + } + if _, err := client.DeleteMFASecret(ctx, &api.DeleteMFASecretReq{UserId: "u", ConnectorId: "c"}); err == nil { + t.Error("DeleteMFASecret should reject empty authenticator_id") + } + if _, err := client.RevokeConsent(ctx, &api.RevokeConsentReq{UserId: "u", ConnectorId: "c"}); err == nil { + t.Error("RevokeConsent should reject empty client_id") + } +} diff --git a/server/apiserver/clients.go b/server/apiserver/clients.go new file mode 100644 index 0000000000..7b08b447da --- /dev/null +++ b/server/apiserver/clients.go @@ -0,0 +1,169 @@ +package apiserver + +import ( + "context" + "errors" + "fmt" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/storage" +) + +func (d dexAPI) GetClient(ctx context.Context, req *api.GetClientReq) (*api.GetClientResp, error) { + c, err := d.s.GetClient(ctx, req.Id) + if err != nil { + return nil, err + } + + return &api.GetClientResp{ + Client: &api.Client{ + Id: c.ID, + Name: c.Name, + Secret: c.Secret, + RedirectUris: c.RedirectURIs, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + LogoUrl: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, + SsoSharedWith: c.SSOSharedWith, + BackchannelLogoutUri: c.BackchannelLogoutURI, + PostLogoutRedirectUris: c.PostLogoutRedirectURIs, + RefreshTokenLifetime: c.RefreshTokenLifetime, + }, + }, nil +} + +func (d dexAPI) CreateClient(ctx context.Context, req *api.CreateClientReq) (*api.CreateClientResp, error) { + if req.Client == nil { + return nil, errors.New("no client supplied") + } + + if req.Client.Id == "" { + req.Client.Id = storage.NewID() + } + if req.Client.Secret == "" && !req.Client.Public { + req.Client.Secret = storage.NewID() + storage.NewID() + } + + if err := storage.ValidateRefreshTokenLifetime(req.Client.RefreshTokenLifetime); err != nil { + return nil, err + } + + c := storage.Client{ + ID: req.Client.Id, + Secret: req.Client.Secret, + RedirectURIs: req.Client.RedirectUris, + TrustedPeers: req.Client.TrustedPeers, + Public: req.Client.Public, + Name: req.Client.Name, + LogoURL: req.Client.LogoUrl, + AllowedConnectors: req.Client.AllowedConnectors, + SSOSharedWith: req.Client.SsoSharedWith, + BackchannelLogoutURI: req.Client.BackchannelLogoutUri, + PostLogoutRedirectURIs: req.Client.PostLogoutRedirectUris, + RefreshTokenLifetime: req.Client.RefreshTokenLifetime, + } + if err := d.s.CreateClient(ctx, c); err != nil { + if err == storage.ErrAlreadyExists { + return &api.CreateClientResp{AlreadyExists: true}, nil + } + d.logger.Error("failed to create client", "err", err) + return nil, fmt.Errorf("create client: %v", err) + } + + return &api.CreateClientResp{ + Client: req.Client, + }, nil +} + +func (d dexAPI) UpdateClient(ctx context.Context, req *api.UpdateClientReq) (*api.UpdateClientResp, error) { + if req.Id == "" { + return nil, errors.New("update client: no client ID supplied") + } + if err := storage.ValidateRefreshTokenLifetime(req.GetRefreshTokenLifetime()); err != nil { + return nil, err + } + + err := d.s.UpdateClient(ctx, req.Id, func(old storage.Client) (storage.Client, error) { + if req.RedirectUris != nil { + old.RedirectURIs = req.RedirectUris + } + if req.TrustedPeers != nil { + old.TrustedPeers = req.TrustedPeers + } + if req.Name != "" { + old.Name = req.Name + } + if req.LogoUrl != "" { + old.LogoURL = req.LogoUrl + } + if req.AllowedConnectors != nil { + old.AllowedConnectors = req.AllowedConnectors + } + if req.SsoSharedWith != nil { + old.SSOSharedWith = req.SsoSharedWith + } + // Explicit presence, so that sending an empty string clears the URI rather + // than being indistinguishable from not mentioning it. + if req.BackchannelLogoutUri != nil { + old.BackchannelLogoutURI = req.GetBackchannelLogoutUri() + } + if req.PostLogoutRedirectUris != nil { + old.PostLogoutRedirectURIs = req.PostLogoutRedirectUris + } + if req.RefreshTokenLifetime != nil { + old.RefreshTokenLifetime = req.GetRefreshTokenLifetime() + } + return old, nil + }) + if err != nil { + if err == storage.ErrNotFound { + return &api.UpdateClientResp{NotFound: true}, nil + } + d.logger.Error("failed to update the client", "err", err) + return nil, fmt.Errorf("update client: %v", err) + } + return &api.UpdateClientResp{}, nil +} + +func (d dexAPI) DeleteClient(ctx context.Context, req *api.DeleteClientReq) (*api.DeleteClientResp, error) { + err := d.s.DeleteClient(ctx, req.Id) + if err != nil { + if err == storage.ErrNotFound { + return &api.DeleteClientResp{NotFound: true}, nil + } + d.logger.Error("failed to delete client", "err", err) + return nil, fmt.Errorf("delete client: %v", err) + } + return &api.DeleteClientResp{}, nil +} + +func (d dexAPI) ListClients(ctx context.Context, req *api.ListClientReq) (*api.ListClientResp, error) { + clientList, err := d.s.ListClients(ctx) + if err != nil { + d.logger.Error("failed to list clients", "err", err) + return nil, fmt.Errorf("list clients: %v", err) + } + + clients := make([]*api.ClientInfo, 0, len(clientList)) + for _, client := range clientList { + c := api.ClientInfo{ + Id: client.ID, + Name: client.Name, + RedirectUris: client.RedirectURIs, + TrustedPeers: client.TrustedPeers, + Public: client.Public, + LogoUrl: client.LogoURL, + AllowedConnectors: client.AllowedConnectors, + SsoSharedWith: client.SSOSharedWith, + BackchannelLogoutUri: client.BackchannelLogoutURI, + PostLogoutRedirectUris: client.PostLogoutRedirectURIs, + RefreshTokenLifetime: client.RefreshTokenLifetime, + } + clients = append(clients, &c) + } + + return &api.ListClientResp{ + Clients: clients, + }, nil +} diff --git a/server/apiserver/connectors.go b/server/apiserver/connectors.go new file mode 100644 index 0000000000..ed800227ed --- /dev/null +++ b/server/apiserver/connectors.go @@ -0,0 +1,194 @@ +package apiserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/pkg/featureflags" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/storage" +) + +func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq) (*api.CreateConnectorResp, error) { + if !featureflags.APIConnectorsCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APIConnectorsCRUD.Name) + } + + if req.Connector == nil { + return nil, errors.New("no connector supplied") + } + + if req.Connector.Id == "" { + return nil, errors.New("no id supplied") + } + + if req.Connector.Type == "" { + return nil, errors.New("no type supplied") + } + + if req.Connector.Name == "" { + return nil, errors.New("no name supplied") + } + + if len(req.Connector.Config) == 0 { + return nil, errors.New("no config supplied") + } + + if !json.Valid(req.Connector.Config) { + return nil, errors.New("invalid config supplied") + } + + for _, gt := range req.Connector.GrantTypes { + if !connectors.ConnectorGrantTypes[gt] { + return nil, fmt.Errorf("unknown grant type %q", gt) + } + } + + c := storage.Connector{ + ID: req.Connector.Id, + Name: req.Connector.Name, + Type: req.Connector.Type, + ResourceVersion: "1", + Config: req.Connector.Config, + GrantTypes: req.Connector.GrantTypes, + } + if err := d.s.CreateConnector(ctx, c); err != nil { + if err == storage.ErrAlreadyExists { + return &api.CreateConnectorResp{AlreadyExists: true}, nil + } + d.logger.Error("api: failed to create connector", "err", err) + return nil, fmt.Errorf("create connector: %v", err) + } + + // Make sure we don't reuse stale entries in the cache + if d.connectors != nil { + d.connectors.Close(req.Connector.Id) + } + + return &api.CreateConnectorResp{}, nil +} + +func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq) (*api.UpdateConnectorResp, error) { + if !featureflags.APIConnectorsCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APIConnectorsCRUD.Name) + } + + if req.Id == "" { + return nil, errors.New("no email supplied") + } + + hasUpdate := len(req.NewConfig) != 0 || + req.NewName != "" || + req.NewType != "" || + req.NewGrantTypes != nil + if !hasUpdate { + return nil, errors.New("nothing to update") + } + + if len(req.NewConfig) != 0 && !json.Valid(req.NewConfig) { + return nil, errors.New("invalid config supplied") + } + + if req.NewGrantTypes != nil { + for _, gt := range req.NewGrantTypes.GrantTypes { + if !connectors.ConnectorGrantTypes[gt] { + return nil, fmt.Errorf("unknown grant type %q", gt) + } + } + } + + updater := func(old storage.Connector) (storage.Connector, error) { + if req.NewType != "" { + old.Type = req.NewType + } + + if req.NewName != "" { + old.Name = req.NewName + } + + if len(req.NewConfig) != 0 { + old.Config = req.NewConfig + } + + if req.NewGrantTypes != nil { + old.GrantTypes = req.NewGrantTypes.GrantTypes + } + + if rev, err := strconv.Atoi(defaultTo(old.ResourceVersion, "0")); err == nil { + old.ResourceVersion = strconv.Itoa(rev + 1) + } + + return old, nil + } + + if err := d.s.UpdateConnector(ctx, req.Id, updater); err != nil { + if err == storage.ErrNotFound { + return &api.UpdateConnectorResp{NotFound: true}, nil + } + d.logger.Error("api: failed to update connector", "err", err) + return nil, fmt.Errorf("update connector: %v", err) + } + + return &api.UpdateConnectorResp{}, nil +} + +func (d dexAPI) DeleteConnector(ctx context.Context, req *api.DeleteConnectorReq) (*api.DeleteConnectorResp, error) { + if !featureflags.APIConnectorsCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APIConnectorsCRUD.Name) + } + + if req.Id == "" { + return nil, errors.New("no id supplied") + } + + err := d.s.DeleteConnector(ctx, req.Id) + if err != nil { + if err == storage.ErrNotFound { + return &api.DeleteConnectorResp{NotFound: true}, nil + } + d.logger.Error("api: failed to delete connector", "err", err) + return nil, fmt.Errorf("delete connector: %v", err) + } + + return &api.DeleteConnectorResp{}, nil +} + +func (d dexAPI) ListConnectors(ctx context.Context, req *api.ListConnectorReq) (*api.ListConnectorResp, error) { + if !featureflags.APIConnectorsCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APIConnectorsCRUD.Name) + } + + connectorList, err := d.s.ListConnectors(ctx) + if err != nil { + d.logger.Error("api: failed to list connectors", "err", err) + return nil, fmt.Errorf("list connectors: %v", err) + } + + connectors := make([]*api.Connector, 0, len(connectorList)) + for _, connector := range connectorList { + c := api.Connector{ + Id: connector.ID, + Name: connector.Name, + Type: connector.Type, + Config: connector.Config, + GrantTypes: connector.GrantTypes, + } + connectors = append(connectors, &c) + } + + return &api.ListConnectorResp{ + Connectors: connectors, + }, nil +} + +func defaultTo[T comparable](v, def T) T { + var zeroT T + if v == zeroT { + return def + } + return v +} diff --git a/server/apiserver/connectors_test.go b/server/apiserver/connectors_test.go new file mode 100644 index 0000000000..c0e6f2da16 --- /dev/null +++ b/server/apiserver/connectors_test.go @@ -0,0 +1,136 @@ +package apiserver + +import ( + "context" + "encoding/json" + "testing" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/connector/mock" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/storage/memory" +) + +func TestConnectorCacheInvalidation(t *testing.T) { + t.Setenv("DEX_API_CONNECTORS_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + // Only the connector type this test creates needs to resolve; the config map + // is injected, so the API's tests need none of dex's real connectors. + conns := connectors.NewCache(s, connectors.Resolver(s, logger, map[string]func() connectors.ConnectorConfig{ + "mockPassword": func() connectors.ConnectorConfig { return new(mock.PasswordConfig) }, + })) + + // This test exercises connector-cache invalidation, not discovery, so no + // discovery handler is wired (GetDiscovery guards against nil). + apiServer := NewAPI(s, logger, "test", conns, nil, nil) + ctx := context.Background() + + connID := "mock-conn" + + // 1. Create a connector via API + config1 := mock.PasswordConfig{ + Username: "user", + Password: "first-password", + } + config1Bytes, _ := json.Marshal(config1) + + _, err := apiServer.CreateConnector(ctx, &api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: connID, + Type: "mockPassword", + Name: "Mock", + Config: config1Bytes, + }, + }) + if err != nil { + t.Fatalf("failed to create connector: %v", err) + } + + // 2. Load it into server cache + c1, err := conns.Get(ctx, connID) + if err != nil { + t.Fatalf("failed to get connector: %v", err) + } + + pc1 := c1.Connector.(connector.PasswordConnector) + _, valid, err := pc1.Login(ctx, connector.Scopes{}, "user", "first-password") + if err != nil || !valid { + t.Fatalf("failed to login with first password: %v", err) + } + + // 3. Delete it via API + _, err = apiServer.DeleteConnector(ctx, &api.DeleteConnectorReq{Id: connID}) + if err != nil { + t.Fatalf("failed to delete connector: %v", err) + } + + // 4. Create it again with different password + config2 := mock.PasswordConfig{ + Username: "user", + Password: "second-password", + } + config2Bytes, _ := json.Marshal(config2) + + _, err = apiServer.CreateConnector(ctx, &api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: connID, + Type: "mockPassword", + Name: "Mock", + Config: config2Bytes, + }, + }) + if err != nil { + t.Fatalf("failed to create connector: %v", err) + } + + // 5. Load it again + c2, err := conns.Get(ctx, connID) + if err != nil { + t.Fatalf("failed to get connector second time: %v", err) + } + + pc2 := c2.Connector.(connector.PasswordConnector) + + // If the fix works, it should now use the second password. + _, valid2, err := pc2.Login(ctx, connector.Scopes{}, "user", "second-password") + if err != nil || !valid2 { + t.Errorf("failed to login with second password, cache might still be stale") + } + + _, valid1, _ := pc2.Login(ctx, connector.Scopes{}, "user", "first-password") + if valid1 { + t.Errorf("unexpectedly logged in with first password, cache is definitely stale") + } + + // 6. Update it via API with a third password + config3 := mock.PasswordConfig{ + Username: "user", + Password: "third-password", + } + config3Bytes, _ := json.Marshal(config3) + + _, err = apiServer.UpdateConnector(ctx, &api.UpdateConnectorReq{ + Id: connID, + NewConfig: config3Bytes, + }) + if err != nil { + t.Fatalf("failed to update connector: %v", err) + } + + // 7. Load it again + c3, err := conns.Get(ctx, connID) + if err != nil { + t.Fatalf("failed to get connector third time: %v", err) + } + + pc3 := c3.Connector.(connector.PasswordConnector) + + _, valid3, err := pc3.Login(ctx, connector.Scopes{}, "user", "third-password") + if err != nil || !valid3 { + t.Errorf("failed to login with third password, UpdateConnector might be missing cache invalidation") + } +} diff --git a/server/apiserver/doc.go b/server/apiserver/doc.go new file mode 100644 index 0000000000..a2ab309184 --- /dev/null +++ b/server/apiserver/doc.go @@ -0,0 +1,6 @@ +// Package apiserver implements the gRPC management API (api.DexServer): the CRUD +// and administrative calls for clients, passwords, connectors, refresh tokens, +// auth sessions, user identities and MFA devices. Each domain lives in its own +// file. It depends only on storage, the connector cache and a discovery-document +// builder, not on the whole Server. +package apiserver diff --git a/server/apiserver/identities.go b/server/apiserver/identities.go new file mode 100644 index 0000000000..db96dceba4 --- /dev/null +++ b/server/apiserver/identities.go @@ -0,0 +1,151 @@ +package apiserver + +import ( + "context" + "errors" + "fmt" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/pkg/featureflags" + "github.com/dexidp/dex/storage" +) + +func storageUserIdentityToAPI(u storage.UserIdentity) *api.UserIdentity { + consents := make([]*api.ConsentEntry, 0, len(u.Consents)) + for clientID, scopes := range u.Consents { + consents = append(consents, &api.ConsentEntry{ + ClientId: clientID, + Scopes: scopes, + }) + } + + identity := &api.UserIdentity{ + UserId: u.UserID, + ConnectorId: u.ConnectorID, + Email: u.Claims.Email, + EmailVerified: u.Claims.EmailVerified, + Username: u.Claims.Username, + Groups: u.Claims.Groups, + Consents: consents, + MfaDevices: storageMFADevicesToAPI(u.MFASecrets, u.WebAuthnCredentials), + CreatedAt: unixOrZero(u.CreatedAt), + LastLogin: unixOrZero(u.LastLogin), + BlockedUntil: unixOrZero(u.BlockedUntil), + } + + return identity +} + +func (d dexAPI) GetUserIdentity(ctx context.Context, req *api.GetUserIdentityReq) (*api.GetUserIdentityResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.UserId == "" { + return nil, errors.New("no user_id supplied") + } + if req.ConnectorId == "" { + return nil, errors.New("no connector_id supplied") + } + + identity, err := d.s.GetUserIdentity(ctx, req.UserId, req.ConnectorId) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil, storage.ErrNotFound + } + d.logger.Error("api: failed to get user identity", "err", err) + return nil, fmt.Errorf("get user identity: %v", err) + } + + return &api.GetUserIdentityResp{ + Identity: storageUserIdentityToAPI(identity), + }, nil +} + +func (d dexAPI) ListUserIdentities(ctx context.Context, req *api.ListUserIdentitiesReq) (*api.ListUserIdentitiesResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + identityList, err := d.s.ListUserIdentities(ctx) + if err != nil { + d.logger.Error("api: failed to list user identities", "err", err) + return nil, fmt.Errorf("list user identities: %v", err) + } + + identities := make([]*api.UserIdentity, 0, len(identityList)) + for _, u := range identityList { + identities = append(identities, storageUserIdentityToAPI(u)) + } + + return &api.ListUserIdentitiesResp{ + Identities: identities, + }, nil +} + +func (d dexAPI) DeleteUserIdentity(ctx context.Context, req *api.DeleteUserIdentityReq) (*api.DeleteUserIdentityResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.UserId == "" { + return nil, errors.New("no user_id supplied") + } + if req.ConnectorId == "" { + return nil, errors.New("no connector_id supplied") + } + + // Look up the identity first: report not-found cleanly without performing any + // cascade, and capture the email needed to purge the linked password record. + identity, err := d.s.GetUserIdentity(ctx, req.UserId, req.ConnectorId) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return &api.DeleteUserIdentityResp{NotFound: true}, nil + } + d.logger.Error("api: failed to get user identity during purge", "err", err) + return nil, fmt.Errorf("delete user identity: %v", err) + } + + // Cascade deletes. A real (non-not-found) failure aborts the purge and returns + // an error so the caller is never told a GDPR purge succeeded while data was + // left behind. + + // Cascade: delete every session this identity signed in with, telling each + // session's relying parties on the way out. + if _, err := d.terminateSessions(ctx, func(s storage.AuthSession) bool { + return s.UserID == req.UserId && s.ConnectorID == req.ConnectorId + }); err != nil { + return nil, fmt.Errorf("purge auth sessions: %v", err) + } + + // Cascade: revoke all refresh tokens (best-effort). A purge has to take every + // credential with it, so the unscoped revoke is the right one here. + d.revokeUserRefreshTokens(ctx, req.UserId, req.ConnectorId) + + // Cascade: delete offline sessions. + if err := d.s.DeleteOfflineSessions(ctx, req.UserId, req.ConnectorId); err != nil && !errors.Is(err, storage.ErrNotFound) { + d.logger.Error("api: failed to delete offline sessions during identity purge", "err", err) + return nil, fmt.Errorf("purge offline sessions: %v", err) + } + + // Cascade: delete the password record (keyed by email, may not exist for + // non-password connectors). + if email := identity.Claims.Email; email != "" { + if err := d.s.DeletePassword(ctx, email); err != nil && !errors.Is(err, storage.ErrNotFound) { + d.logger.Error("api: failed to delete password during identity purge", "err", err) + return nil, fmt.Errorf("purge password: %v", err) + } + } + + // Delete the user identity itself. + if err := d.s.DeleteUserIdentity(ctx, req.UserId, req.ConnectorId); err != nil { + if errors.Is(err, storage.ErrNotFound) { + return &api.DeleteUserIdentityResp{NotFound: true}, nil + } + d.logger.Error("api: failed to delete user identity", "err", err) + return nil, fmt.Errorf("delete user identity: %v", err) + } + + d.logger.Info("api: purged user identity", "user_id", req.UserId, "connector_id", req.ConnectorId) + return &api.DeleteUserIdentityResp{}, nil +} diff --git a/server/apiserver/mfa.go b/server/apiserver/mfa.go new file mode 100644 index 0000000000..45dac50d83 --- /dev/null +++ b/server/apiserver/mfa.go @@ -0,0 +1,195 @@ +package apiserver + +import ( + "bytes" + "context" + "errors" + "fmt" + "slices" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/pkg/featureflags" + "github.com/dexidp/dex/storage" +) + +// errIdentityUnchanged signals that an UpdateUserIdentity callback found nothing +// to change, so the mutation (and its resource-version bump) is skipped. +var errIdentityUnchanged = errors.New("identity unchanged") + +func storageMFADevicesToAPI(secrets map[string]*storage.MFASecret, credentials map[string][]storage.WebAuthnCredential) []*api.MFADeviceInfo { + // Collect all authenticator IDs from both maps. + authIDs := make(map[string]struct{}) + for id := range secrets { + authIDs[id] = struct{}{} + } + for id := range credentials { + authIDs[id] = struct{}{} + } + + devices := make([]*api.MFADeviceInfo, 0, len(authIDs)) + for authID := range authIDs { + device := &api.MFADeviceInfo{ + AuthenticatorId: authID, + } + + if secret, ok := secrets[authID]; ok { + device.MfaSecret = &api.MFASecret{ + AuthenticatorId: secret.AuthenticatorID, + Type: secret.Type, + Confirmed: secret.Confirmed, + CreatedAt: unixOrZero(secret.CreatedAt), + } + } + + if creds, ok := credentials[authID]; ok { + apiCreds := make([]*api.WebAuthnCredential, 0, len(creds)) + for _, c := range creds { + apiCreds = append(apiCreds, &api.WebAuthnCredential{ + CredentialId: c.CredentialID, + AttestationType: c.AttestationType, + Aaguid: c.AAGUID, + SignCount: c.SignCount, + CloneWarning: c.CloneWarning, + Transport: c.Transport, + BackupEligible: c.BackupEligible, + BackupState: c.BackupState, + DisplayName: c.DisplayName, + CreatedAt: unixOrZero(c.CreatedAt), + }) + } + device.WebauthnCredentials = apiCreds + } + + devices = append(devices, device) + } + return devices +} + +func (d dexAPI) ResetMFA(ctx context.Context, req *api.ResetMFAReq) (*api.ResetMFAResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.UserId == "" { + return nil, errors.New("no user_id supplied") + } + if req.ConnectorId == "" { + return nil, errors.New("no connector_id supplied") + } + + if err := d.s.UpdateUserIdentity(ctx, req.UserId, req.ConnectorId, func(old storage.UserIdentity) (storage.UserIdentity, error) { + old.MFASecrets = nil + old.WebAuthnCredentials = nil + return old, nil + }); err != nil { + if errors.Is(err, storage.ErrNotFound) { + return &api.ResetMFAResp{NotFound: true}, nil + } + d.logger.Error("api: failed to reset MFA", "err", err) + return nil, fmt.Errorf("reset MFA: %v", err) + } + + d.logger.Info("api: reset MFA", "user_id", req.UserId, "connector_id", req.ConnectorId) + return &api.ResetMFAResp{}, nil +} + +func (d dexAPI) ListMFADevices(ctx context.Context, req *api.ListMFADevicesReq) (*api.ListMFADevicesResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.UserId == "" { + return nil, errors.New("no user_id supplied") + } + if req.ConnectorId == "" { + return nil, errors.New("no connector_id supplied") + } + + identity, err := d.s.GetUserIdentity(ctx, req.UserId, req.ConnectorId) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil, storage.ErrNotFound + } + d.logger.Error("api: failed to get user identity for MFA devices", "err", err) + return nil, fmt.Errorf("list MFA devices: %v", err) + } + + return &api.ListMFADevicesResp{ + Devices: storageMFADevicesToAPI(identity.MFASecrets, identity.WebAuthnCredentials), + }, nil +} + +func (d dexAPI) DeleteWebAuthnCredential(ctx context.Context, req *api.DeleteWebAuthnCredentialReq) (*api.DeleteWebAuthnCredentialResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.UserId == "" { + return nil, errors.New("no user_id supplied") + } + if req.ConnectorId == "" { + return nil, errors.New("no connector_id supplied") + } + if len(req.CredentialId) == 0 { + return nil, errors.New("no credential_id supplied") + } + + if err := d.s.UpdateUserIdentity(ctx, req.UserId, req.ConnectorId, func(old storage.UserIdentity) (storage.UserIdentity, error) { + for authID, creds := range old.WebAuthnCredentials { + for i, cred := range creds { + if bytes.Equal(cred.CredentialID, req.CredentialId) { + old.WebAuthnCredentials[authID] = slices.Delete(creds, i, i+1) + if len(old.WebAuthnCredentials[authID]) == 0 { + delete(old.WebAuthnCredentials, authID) + } + return old, nil + } + } + } + return old, errIdentityUnchanged + }); err != nil { + if errors.Is(err, errIdentityUnchanged) || errors.Is(err, storage.ErrNotFound) { + return &api.DeleteWebAuthnCredentialResp{NotFound: true}, nil + } + d.logger.Error("api: failed to delete WebAuthn credential", "err", err) + return nil, fmt.Errorf("delete WebAuthn credential: %v", err) + } + + d.logger.Info("api: deleted WebAuthn credential", "user_id", req.UserId, "connector_id", req.ConnectorId) + return &api.DeleteWebAuthnCredentialResp{}, nil +} + +func (d dexAPI) DeleteMFASecret(ctx context.Context, req *api.DeleteMFASecretReq) (*api.DeleteMFASecretResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.UserId == "" { + return nil, errors.New("no user_id supplied") + } + if req.ConnectorId == "" { + return nil, errors.New("no connector_id supplied") + } + if req.AuthenticatorId == "" { + return nil, errors.New("no authenticator_id supplied") + } + + if err := d.s.UpdateUserIdentity(ctx, req.UserId, req.ConnectorId, func(old storage.UserIdentity) (storage.UserIdentity, error) { + if _, ok := old.MFASecrets[req.AuthenticatorId]; !ok { + return old, errIdentityUnchanged + } + delete(old.MFASecrets, req.AuthenticatorId) + // Also remove associated WebAuthn credentials for the same authenticator. + delete(old.WebAuthnCredentials, req.AuthenticatorId) + return old, nil + }); err != nil { + if errors.Is(err, errIdentityUnchanged) || errors.Is(err, storage.ErrNotFound) { + return &api.DeleteMFASecretResp{NotFound: true}, nil + } + d.logger.Error("api: failed to delete MFA secret", "err", err) + return nil, fmt.Errorf("delete MFA secret: %v", err) + } + + d.logger.Info("api: deleted MFA secret", "user_id", req.UserId, "connector_id", req.ConnectorId) + return &api.DeleteMFASecretResp{}, nil +} diff --git a/server/apiserver/passwords.go b/server/apiserver/passwords.go new file mode 100644 index 0000000000..db1c7946be --- /dev/null +++ b/server/apiserver/passwords.go @@ -0,0 +1,151 @@ +package apiserver + +import ( + "context" + "errors" + "fmt" + + "golang.org/x/crypto/bcrypt" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/server/passwords" + "github.com/dexidp/dex/storage" +) + +func (d dexAPI) CreatePassword(ctx context.Context, req *api.CreatePasswordReq) (*api.CreatePasswordResp, error) { + if req.Password == nil { + return nil, errors.New("no password supplied") + } + if req.Password.UserId == "" { + return nil, errors.New("no user ID supplied") + } + if req.Password.Hash != nil { + if err := passwords.CheckCost(req.Password.Hash); err != nil { + return nil, err + } + } else { + return nil, errors.New("no hash of password supplied") + } + + p := storage.Password{ + Email: req.Password.Email, + Hash: req.Password.Hash, + Username: req.Password.Username, + UserID: req.Password.UserId, + } + if err := d.s.CreatePassword(ctx, p); err != nil { + if err == storage.ErrAlreadyExists { + return &api.CreatePasswordResp{AlreadyExists: true}, nil + } + d.logger.Error("failed to create password", "err", err) + return nil, fmt.Errorf("create password: %v", err) + } + + return &api.CreatePasswordResp{}, nil +} + +func (d dexAPI) UpdatePassword(ctx context.Context, req *api.UpdatePasswordReq) (*api.UpdatePasswordResp, error) { + if req.Email == "" { + return nil, errors.New("no email supplied") + } + if req.NewHash == nil && req.NewUsername == "" { + return nil, errors.New("nothing to update") + } + + if req.NewHash != nil { + if err := passwords.CheckCost(req.NewHash); err != nil { + return nil, err + } + } + + updater := func(old storage.Password) (storage.Password, error) { + if req.NewHash != nil { + old.Hash = req.NewHash + } + + if req.NewUsername != "" { + old.Username = req.NewUsername + } + + return old, nil + } + + if err := d.s.UpdatePassword(ctx, req.Email, updater); err != nil { + if err == storage.ErrNotFound { + return &api.UpdatePasswordResp{NotFound: true}, nil + } + d.logger.Error("failed to update password", "err", err) + return nil, fmt.Errorf("update password: %v", err) + } + + return &api.UpdatePasswordResp{}, nil +} + +func (d dexAPI) DeletePassword(ctx context.Context, req *api.DeletePasswordReq) (*api.DeletePasswordResp, error) { + if req.Email == "" { + return nil, errors.New("no email supplied") + } + + err := d.s.DeletePassword(ctx, req.Email) + if err != nil { + if err == storage.ErrNotFound { + return &api.DeletePasswordResp{NotFound: true}, nil + } + d.logger.Error("failed to delete password", "err", err) + return nil, fmt.Errorf("delete password: %v", err) + } + return &api.DeletePasswordResp{}, nil +} + +func (d dexAPI) ListPasswords(ctx context.Context, req *api.ListPasswordReq) (*api.ListPasswordResp, error) { + passwordList, err := d.s.ListPasswords(ctx) + if err != nil { + d.logger.Error("failed to list passwords", "err", err) + return nil, fmt.Errorf("list passwords: %v", err) + } + + passwords := make([]*api.Password, 0, len(passwordList)) + for _, password := range passwordList { + p := api.Password{ + Email: password.Email, + Username: password.Username, + UserId: password.UserID, + } + passwords = append(passwords, &p) + } + + return &api.ListPasswordResp{ + Passwords: passwords, + }, nil +} + +func (d dexAPI) VerifyPassword(ctx context.Context, req *api.VerifyPasswordReq) (*api.VerifyPasswordResp, error) { + if req.Email == "" { + return nil, errors.New("no email supplied") + } + + if req.Password == "" { + return nil, errors.New("no password to verify supplied") + } + + password, err := d.s.GetPassword(ctx, req.Email) + if err != nil { + if err == storage.ErrNotFound { + return &api.VerifyPasswordResp{ + NotFound: true, + }, nil + } + d.logger.Error("there was an error retrieving the password", "err", err) + return nil, fmt.Errorf("verify password: %v", err) + } + + if err := bcrypt.CompareHashAndPassword(password.Hash, []byte(req.Password)); err != nil { + d.logger.Info("password check failed", "err", err) + return &api.VerifyPasswordResp{ + Verified: false, + }, nil + } + return &api.VerifyPasswordResp{ + Verified: true, + }, nil +} diff --git a/server/apiserver/refresh.go b/server/apiserver/refresh.go new file mode 100644 index 0000000000..d07edafa9a --- /dev/null +++ b/server/apiserver/refresh.go @@ -0,0 +1,108 @@ +package apiserver + +import ( + "context" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/storage" +) + +func (d dexAPI) ListRefresh(ctx context.Context, req *api.ListRefreshReq) (*api.ListRefreshResp, error) { + id := new(internal.IDTokenSubject) + if err := internal.Unmarshal(req.UserId, id); err != nil { + d.logger.Error("failed to unmarshal ID Token subject", "err", err) + return nil, err + } + + offlineSessions, err := d.s.GetOfflineSessions(ctx, id.UserId, id.ConnId) + if err != nil { + if err == storage.ErrNotFound { + // This means that this user-client pair does not have a refresh token yet. + // An empty list should be returned instead of an error. + return &api.ListRefreshResp{}, nil + } + d.logger.Error("failed to list refresh tokens here", "err", err) + return nil, err + } + + refreshTokenRefs := make([]*api.RefreshTokenRef, 0, len(offlineSessions.Refresh)) + for _, session := range offlineSessions.Refresh { + r := api.RefreshTokenRef{ + Id: session.ID, + ClientId: session.ClientID, + CreatedAt: session.CreatedAt.Unix(), + LastUsed: session.LastUsed.Unix(), + } + refreshTokenRefs = append(refreshTokenRefs, &r) + } + + return &api.ListRefreshResp{ + RefreshTokens: refreshTokenRefs, + }, nil +} + +func (d dexAPI) RevokeRefresh(ctx context.Context, req *api.RevokeRefreshReq) (*api.RevokeRefreshResp, error) { + id := new(internal.IDTokenSubject) + if err := internal.Unmarshal(req.UserId, id); err != nil { + d.logger.Error("failed to unmarshal ID Token subject", "err", err) + return nil, err + } + + var ( + refreshID string + notFound bool + ) + updater := func(old storage.OfflineSessions) (storage.OfflineSessions, error) { + refreshRef := old.Refresh[req.ClientId] + if refreshRef == nil || refreshRef.ID == "" { + d.logger.Error("refresh token issued to client not found for deletion", "client_id", req.ClientId, "user_id", id.UserId) + notFound = true + return old, storage.ErrNotFound + } + + refreshID = refreshRef.ID + + // Remove entry from Refresh list of the OfflineSession object. + delete(old.Refresh, req.ClientId) + + return old, nil + } + + if err := d.s.UpdateOfflineSessions(ctx, id.UserId, id.ConnId, updater); err != nil { + if err == storage.ErrNotFound { + return &api.RevokeRefreshResp{NotFound: true}, nil + } + d.logger.Error("failed to update offline session object", "err", err) + return nil, err + } + + if notFound { + return &api.RevokeRefreshResp{NotFound: true}, nil + } + + // Delete the refresh token from the storage + // + // TODO(ericchiang): we don't have any good recourse if this call fails. + // Consider garbage collection of refresh tokens with no associated ref. + if err := d.s.DeleteRefresh(ctx, refreshID); err != nil { + d.logger.Error("failed to delete refresh token", "err", err) + return nil, err + } + + return &api.RevokeRefreshResp{}, nil +} + +// revokeUserRefreshTokens revokes all refresh tokens for a user/connector pair +// and cleans up offline session references. Errors are logged but not returned +// (best-effort). +// +// This is deliberately broader than what RP-initiated logout does. That flow revokes +// only the client that asked for it, because it has a requesting client to scope to +// and no mandate to touch anyone else's credentials (see revokeRequestingClient in +// server/logout). An administrative call has neither: there is no client_id in the +// request, and ending access is the entire point of the operation. Callers that want +// one client's token gone use RevokeRefresh. +func (d dexAPI) revokeUserRefreshTokens(ctx context.Context, userID, connectorID string) { + d.refresh.RevokeAll(ctx, userID, connectorID) +} diff --git a/server/apiserver/sessions.go b/server/apiserver/sessions.go new file mode 100644 index 0000000000..acc96dfd3b --- /dev/null +++ b/server/apiserver/sessions.go @@ -0,0 +1,230 @@ +package apiserver + +import ( + "context" + "errors" + "fmt" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/pkg/featureflags" + "github.com/dexidp/dex/storage" +) + +func storageAuthSessionToAPI(s storage.AuthSession) *api.AuthSession { + clientStates := make([]*api.ClientAuthState, 0, len(s.ClientStates)) + for clientID, state := range s.ClientStates { + if state == nil { + continue + } + clientStates = append(clientStates, &api.ClientAuthState{ + ClientId: clientID, + AuthenticatedAt: unixOrZero(state.AuthenticatedAt), + LastActivity: unixOrZero(state.LastActivity), + LastTokenIssuedAt: unixOrZero(state.LastTokenIssuedAt), + ViaSso: state.ViaSSO, + }) + } + + return &api.AuthSession{ + Id: s.ID, + UserId: s.UserID, + ConnectorId: s.ConnectorID, + ClientStates: clientStates, + CreatedAt: unixOrZero(s.CreatedAt), + LastActivity: unixOrZero(s.LastActivity), + IpAddress: s.IPAddress, + UserAgent: s.UserAgent, + AbsoluteExpiry: unixOrZero(s.AbsoluteExpiry), + IdleExpiry: unixOrZero(s.IdleExpiry), + } +} + +func (d dexAPI) GetAuthSession(ctx context.Context, req *api.GetAuthSessionReq) (*api.GetAuthSessionResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.Id == "" { + return nil, errors.New("no id supplied") + } + + session, err := d.s.GetAuthSession(ctx, req.Id) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil, storage.ErrNotFound + } + d.logger.Error("api: failed to get auth session", "err", err) + return nil, fmt.Errorf("get auth session: %v", err) + } + + return &api.GetAuthSessionResp{ + Session: storageAuthSessionToAPI(session), + }, nil +} + +func (d dexAPI) ListAuthSessions(ctx context.Context, req *api.ListAuthSessionsReq) (*api.ListAuthSessionsResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + sessionList, err := d.s.ListAuthSessions(ctx) + if err != nil { + d.logger.Error("api: failed to list auth sessions", "err", err) + return nil, fmt.Errorf("list auth sessions: %v", err) + } + + sessions := make([]*api.AuthSession, 0, len(sessionList)) + for _, s := range sessionList { + if req.UserId != "" && s.UserID != req.UserId { + continue + } + if req.ConnectorId != "" && s.ConnectorID != req.ConnectorId { + continue + } + sessions = append(sessions, storageAuthSessionToAPI(s)) + } + + return &api.ListAuthSessionsResp{ + Sessions: sessions, + }, nil +} + +func (d dexAPI) DeleteAuthSession(ctx context.Context, req *api.DeleteAuthSessionReq) (*api.DeleteAuthSessionResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.Id == "" { + return nil, errors.New("no id supplied") + } + + session, err := d.s.GetAuthSession(ctx, req.Id) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return &api.DeleteAuthSessionResp{NotFound: true}, nil + } + d.logger.Error("api: failed to get auth session", "err", err) + return nil, fmt.Errorf("get auth session: %v", err) + } + + if d.backchannel != nil { + d.backchannel.Notify(ctx, &session) + } + + // Revoke every refresh token the user holds on this connector, not just one + // client's. See revokeUserRefreshTokens for why the administrative path is + // deliberately broader than RP-initiated logout. + d.revokeUserRefreshTokens(ctx, session.UserID, session.ConnectorID) + + if err := d.s.DeleteAuthSession(ctx, req.Id); err != nil { + if errors.Is(err, storage.ErrNotFound) { + return &api.DeleteAuthSessionResp{NotFound: true}, nil + } + d.logger.Error("api: failed to delete auth session", "err", err) + return nil, fmt.Errorf("delete auth session: %v", err) + } + + d.logger.Info("api: deleted auth session", "session_id", req.Id, "user_id", session.UserID) + return &api.DeleteAuthSessionResp{}, nil +} + +func (d dexAPI) terminateSessions(ctx context.Context, match func(storage.AuthSession) bool) (int64, error) { + sessionList, err := d.s.ListAuthSessions(ctx) + if err != nil { + d.logger.Error("api: failed to list auth sessions", "err", err) + return 0, fmt.Errorf("list auth sessions: %v", err) + } + + var terminated int64 + for _, s := range sessionList { + if !match(s) { + continue + } + + if d.backchannel != nil { + d.backchannel.Notify(ctx, &s) + } + d.revokeUserRefreshTokens(ctx, s.UserID, s.ConnectorID) + + if err := d.s.DeleteAuthSession(ctx, s.ID); err != nil { + d.logger.Error("api: failed to delete auth session during batch terminate", + "session_id", s.ID, "user_id", s.UserID, "err", err) + continue + } + terminated++ + } + return terminated, nil +} + +func (d dexAPI) TerminateSessionsByConnector(ctx context.Context, req *api.TerminateSessionsByConnectorReq) (*api.TerminateSessionsByConnectorResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.ConnectorId == "" { + return nil, errors.New("no connector_id supplied") + } + + terminated, err := d.terminateSessions(ctx, func(s storage.AuthSession) bool { + return s.ConnectorID == req.ConnectorId + }) + if err != nil { + return nil, err + } + + d.logger.Info("api: terminated sessions by connector", "connector_id", req.ConnectorId, "count", terminated) + return &api.TerminateSessionsByConnectorResp{SessionsTerminated: terminated}, nil +} + +func (d dexAPI) TerminateSessionsByUser(ctx context.Context, req *api.TerminateSessionsByUserReq) (*api.TerminateSessionsByUserResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.UserId == "" { + return nil, errors.New("no user_id supplied") + } + + terminated, err := d.terminateSessions(ctx, func(s storage.AuthSession) bool { + return s.UserID == req.UserId + }) + if err != nil { + return nil, err + } + + d.logger.Info("api: terminated sessions by user", "user_id", req.UserId, "count", terminated) + return &api.TerminateSessionsByUserResp{SessionsTerminated: terminated}, nil +} + +func (d dexAPI) RevokeConsent(ctx context.Context, req *api.RevokeConsentReq) (*api.RevokeConsentResp, error) { + if !featureflags.APISessionsIdentitiesCRUD.Enabled() { + return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APISessionsIdentitiesCRUD.Name) + } + + if req.UserId == "" { + return nil, errors.New("no user_id supplied") + } + if req.ConnectorId == "" { + return nil, errors.New("no connector_id supplied") + } + if req.ClientId == "" { + return nil, errors.New("no client_id supplied") + } + + if err := d.s.UpdateUserIdentity(ctx, req.UserId, req.ConnectorId, func(old storage.UserIdentity) (storage.UserIdentity, error) { + if _, ok := old.Consents[req.ClientId]; !ok { + return old, errIdentityUnchanged + } + delete(old.Consents, req.ClientId) + return old, nil + }); err != nil { + if errors.Is(err, errIdentityUnchanged) || errors.Is(err, storage.ErrNotFound) { + return &api.RevokeConsentResp{NotFound: true}, nil + } + d.logger.Error("api: failed to revoke consent", "err", err) + return nil, fmt.Errorf("revoke consent: %v", err) + } + + d.logger.Info("api: revoked consent", "user_id", req.UserId, "connector_id", req.ConnectorId, "client_id", req.ClientId) + return &api.RevokeConsentResp{}, nil +} diff --git a/server/apiserver/sessions_test.go b/server/apiserver/sessions_test.go new file mode 100644 index 0000000000..62b3bc0192 --- /dev/null +++ b/server/apiserver/sessions_test.go @@ -0,0 +1,108 @@ +package apiserver + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/api/v2" + "github.com/dexidp/dex/pkg/featureflags" + "github.com/dexidp/dex/server/backchannel" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" +) + +// TestTerminateSessionNotifiesRelyingParties: an operator ending a session leaves the +// relying parties as the only thing keeping the user signed in, so they are told. +func TestTerminateSessionNotifiesRelyingParties(t *testing.T) { + t.Setenv("DEX_"+strings.ToUpper(featureflags.APISessionsIdentitiesCRUD.Name), "true") + + const userID, connectorID, clientID, sessionID = "u1", "mock", "web", "s1" + + tests := []struct { + name string + call func(context.Context, api.DexServer) error + }{ + { + name: "delete one session", + call: func(ctx context.Context, d api.DexServer) error { + _, err := d.DeleteAuthSession(ctx, &api.DeleteAuthSessionReq{Id: sessionID}) + return err + }, + }, + { + name: "terminate every session of a user", + call: func(ctx context.Context, d api.DexServer) error { + _, err := d.TerminateSessionsByUser(ctx, &api.TerminateSessionsByUserReq{ + UserId: userID, + }) + return err + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + logger := newLogger(t) + s := memory.New(logger) + + var mu sync.Mutex + var tokens []string + rp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + mu.Lock() + tokens = append(tokens, r.PostForm.Get("logout_token")) + mu.Unlock() + })) + defer rp.Close() + + require.NoError(t, s.CreateClient(ctx, storage.Client{ + ID: clientID, Secret: "secret", + RedirectURIs: []string{"https://example.com/cb"}, + BackchannelLogoutURI: rp.URL, + })) + require.NoError(t, s.CreateAuthSession(ctx, storage.AuthSession{ + ID: sessionID, Secret: "session-secret", + UserID: userID, ConnectorID: connectorID, + CreatedAt: time.Now(), LastActivity: time.Now(), + ClientStates: map[string]*storage.ClientAuthState{clientID: {AuthenticatedAt: time.Now()}}, + })) + + sign, err := (&signer.MockConfig{}).Open(ctx) + require.NoError(t, err) + + d := NewAPI(s, logger, "test", nil, nil, &backchannel.Notifier{ + Storage: s, Signer: sign, IssuerURL: issuerURL(t), Logger: logger, + }) + + require.NoError(t, tc.call(ctx, d)) + + // Delivery is fire-and-forget, so the call returns before the POST lands. + require.Eventually(t, func() bool { + mu.Lock() + defer mu.Unlock() + return len(tokens) == 1 + }, 2*time.Second, 10*time.Millisecond) + + _, err = s.GetAuthSession(ctx, sessionID) + require.ErrorIs(t, err, storage.ErrNotFound) + }) + } +} + +func issuerURL(t *testing.T) oauth2.IssuerURL { + t.Helper() + u, err := url.Parse("https://dex.example.com") + require.NoError(t, err) + return oauth2.IssuerURL{URL: *u} +} diff --git a/server/authflow/authorize.go b/server/authflow/authorize.go new file mode 100644 index 0000000000..259e12516e --- /dev/null +++ b/server/authflow/authorize.go @@ -0,0 +1,186 @@ +package authflow + +// authorize.go handles the /auth authorization endpoint: parsing the request, +// selecting a connector, and the browser-facing (HTML/redirect) error surface. + +import ( + "context" + "html/template" + "net/http" + "net/url" + "strings" + + conns "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/templates" + "github.com/dexidp/dex/storage" +) + +// grantTypeFromAuthRequest determines the grant type from the authorization request parameters. +func (h *Handler) grantTypeFromAuthRequest(r *http.Request) string { + redirectURI := r.Form.Get("redirect_uri") + if redirectURI == oauth2.DeviceCallbackURI || strings.HasSuffix(redirectURI, oauth2.DeviceCallbackURI) { + return oauth2.GrantTypeDeviceCode + } + responseType := r.Form.Get("response_type") + for _, rt := range strings.Fields(responseType) { + if rt == "token" || rt == "id_token" { + return oauth2.GrantTypeImplicit + } + } + return oauth2.GrantTypeAuthorizationCode +} + +// handleAuthorization handles the OAuth2 auth endpoint. It is both the entry and +// the exit of the flow: a fresh request starts login, while a request carrying an +// auth-request id (req) is the consent step returning to issue the response โ€” +// issuance is the authorize endpoint's own job. +func (h *Handler) handleAuthorization(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // Extract the arguments + if err := r.ParseForm(); err != nil { + h.Logger.ErrorContext(r.Context(), "failed to parse arguments", "err", err) + + h.renderError(r, w, http.StatusBadRequest, ErrMsgInvalidRequest) + return + } + + // A request with an auth-request id is a step returning to the dispatcher. + if r.Form.Get("req") != "" { + h.handleContinue(w, r) + return + } + + connectorID := r.Form.Get("connector_id") + allConnectors, err := h.Storage.ListConnectors(ctx) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to get list of connectors", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve connector list.") + return + } + + // Determine the grant type from the authorization request to filter connectors. + grantType := h.grantTypeFromAuthRequest(r) + connectors := make([]storage.Connector, 0, len(allConnectors)) + for _, c := range allConnectors { + if conns.GrantTypeAllowed(c.GrantTypes, grantType) { + connectors = append(connectors, c) + } + } + + // Filter connectors based on the client's allowed connectors list. + // client_id is required per RFC 6749 ยง4.1.1. + client, authErr := h.getClientWithAuthError(ctx, r.Form.Get("client_id")) + if authErr != nil { + h.renderError(r, w, authErr.Status, authErr.Error()) + return + } + connectors = conns.Filter(connectors, client.AllowedConnectors) + + if len(connectors) == 0 { + h.renderError(r, w, http.StatusBadRequest, "No connectors available for this client.") + return + } + + // We don't need connector_id any more + r.Form.Del("connector_id") + + // Construct a URL with all of the arguments in its query + connURL := url.URL{ + RawQuery: r.Form.Encode(), + } + + // Redirect if a client chooses a specific connector_id + if connectorID != "" { + for _, c := range connectors { + if c.ID == connectorID { + connURL.Path = h.IssuerURL.AbsPath("/auth", url.PathEscape(c.ID)) + http.Redirect(w, r, connURL.String(), http.StatusFound) + return + } + } + h.renderError(r, w, http.StatusBadRequest, "Connector ID does not match a valid Connector") + return + } + + if len(connectors) == 1 && !h.AlwaysShowLogin { + connURL.Path = h.IssuerURL.AbsPath("/auth", url.PathEscape(connectors[0].ID)) + http.Redirect(w, r, connURL.String(), http.StatusFound) + return + } + + // Skip connector selection if a valid session exists, unless prompt=select_account or alwaysShowLogin. + if h.Sessions.Enabled() { + authReq, _, err := h.parseAuthorizationRequest(r) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to parse authorization request", "err", err) + + switch authErr := err.(type) { + case *redirectedAuthErr: + authErr.Handler().ServeHTTP(w, r) + case *displayedAuthErr: + h.renderError(r, w, authErr.Status, err.Error()) + default: + panic("unsupported error type") + } + return + } + prompt, err := oauth2.ParsePrompt(authReq.Prompt) + if err != nil { + // Server error because authReq was validated before saving it to database. + h.redirectWithError(w, r, authReq, oauth2.ServerError, "Invalid authentication request") + return + } + + // Invalid prompts will be validated and properly redirected later + if !h.AlwaysShowLogin && !prompt.SelectAccount() { + session := h.Sessions.ValidSession(ctx, w, r) + if session != nil { + for _, c := range connectors { + if c.ID != session.ConnectorID { + continue + } + connURL.Path = h.IssuerURL.AbsPath("/auth", url.PathEscape(session.ConnectorID)) + http.Redirect(w, r, connURL.String(), http.StatusFound) + return + } + } + } + if prompt.None() { + // Cannot authenticate silently with prompt=none. + h.redirectWithError(w, r, authReq, oauth2.LoginRequired, "id_token_hint does not match authenticated user") + return + } + } + + connectorInfos := make([]templates.ConnectorInfo, 0, len(connectors)) + for _, conn := range connectors { + connURL.Path = h.IssuerURL.AbsPath("/auth", url.PathEscape(conn.ID)) + connectorInfos = append(connectorInfos, templates.ConnectorInfo{ + ID: conn.ID, + Name: conn.Name, + Type: conn.Type, + URL: template.URL(connURL.String()), + }) + } + + if err := h.Templates.Login(r, w, connectorInfos); err != nil { + h.Logger.ErrorContext(r.Context(), "server template error", "err", err) + } +} + +// getClientWithAuthError retrieves a client by ID and returns a displayedAuthErr on failure. +// Invalid client_id is not treated as a redirect error per RFC 6749 ยง4.1.2.1. +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 +func (h *Handler) getClientWithAuthError(ctx context.Context, clientID string) (storage.Client, *displayedAuthErr) { + client, err := h.Storage.GetClient(ctx, clientID) + if err != nil { + if err == storage.ErrNotFound { + h.Logger.ErrorContext(ctx, "invalid client_id provided", "client_id", clientID) + return storage.Client{}, newDisplayedErr(http.StatusBadRequest, "Invalid client_id provided.") + } + h.Logger.ErrorContext(ctx, "failed to get client", "client_id", clientID, "err", err) + return storage.Client{}, newDisplayedErr(http.StatusInternalServerError, "Database error.") + } + return client, nil +} diff --git a/server/authflow/callback.go b/server/authflow/callback.go new file mode 100644 index 0000000000..bdc2ee6725 --- /dev/null +++ b/server/authflow/callback.go @@ -0,0 +1,114 @@ +package authflow + +// callback.go implements the connector callback mechanism: the return leg of +// redirect-based connectors (OAuth2 callback and SAML POST binding). + +import ( + "errors" + "net/http" + "net/url" + + "github.com/gorilla/mux" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +func (h *Handler) handleConnectorCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var authID string + switch r.Method { + case http.MethodGet: // OAuth2 callback + if authID = r.URL.Query().Get("state"); authID == "" { + h.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } + case http.MethodPost: // SAML POST binding + if authID = r.PostFormValue("RelayState"); authID == "" { + h.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } + default: + h.renderError(r, w, http.StatusBadRequest, "Method not supported") + return + } + + authReq, err := h.Storage.GetAuthRequest(ctx, authID) + if err != nil { + if err == storage.ErrNotFound { + h.Logger.ErrorContext(r.Context(), "invalid 'state' parameter provided", "err", err) + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + return + } + h.Logger.ErrorContext(r.Context(), "failed to get auth request", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + + connID, err := url.PathUnescape(mux.Vars(r)["connector"]) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to parse connector", "err", err) + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + return + } else if connID != "" && connID != authReq.ConnectorID { + h.Logger.ErrorContext(r.Context(), "connector mismatch: callback triggered for different connector than authentication start", "authentication_start_connector_id", authReq.ConnectorID, "connector_id", connID) + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + return + } + + conn, err := h.Connectors.Get(ctx, authReq.ConnectorID) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to get connector", "connector_id", authReq.ConnectorID, "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") + return + } + + var identity connector.Identity + switch conn := conn.Connector.(type) { + case connector.CallbackConnector: + if r.Method != http.MethodGet { + h.Logger.ErrorContext(r.Context(), "SAML request mapped to OAuth2 connector") + h.renderError(r, w, http.StatusBadRequest, "Invalid request") + return + } + identity, err = conn.HandleCallback(tokens.ParseScopes(authReq.Scopes), authReq.ConnectorData, r) + case connector.SAMLConnector: + if r.Method != http.MethodPost { + h.Logger.ErrorContext(r.Context(), "OAuth2 request mapped to SAML connector") + h.renderError(r, w, http.StatusBadRequest, "Invalid request") + return + } + identity, err = conn.HandlePOST(tokens.ParseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"), authReq.ID) + default: + h.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") + return + } + + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to authenticate", "err", err) + var groupsErr *connector.UserNotInRequiredGroupsError + if errors.As(err, &groupsErr) { + h.renderError(r, w, http.StatusForbidden, ErrMsgNotInRequiredGroups) + } else { + h.renderError(r, w, http.StatusInternalServerError, ErrMsgAuthenticationFailed) + } + return + } + + authReq, err = h.finalizeLogin(ctx, identity, authReq, conn.Connector) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to finalize login", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + // Connector callbacks don't render the remember_me checkbox, so we use the server default. + // The password login handler reads r.FormValue("remember_me") from the submitted form instead. + rememberMe := h.Sessions.RememberMeDefault() + if err := h.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, rememberMe != nil && *rememberMe); err != nil { + h.Logger.ErrorContext(ctx, "failed to create/update auth session", "err", err) + } + + http.Redirect(w, r, h.buildContinueURL(authReq), http.StatusSeeOther) +} diff --git a/server/authflow/dispatch.go b/server/authflow/dispatch.go new file mode 100644 index 0000000000..02d81d86d6 --- /dev/null +++ b/server/authflow/dispatch.go @@ -0,0 +1,113 @@ +package authflow + +// dispatch.go is the /auth flow dispatcher. After login and after every step, +// the browser re-enters /auth carrying an HMAC verifier; the dispatcher inspects +// the auth request and decides the next step โ€” an MFA factor, the consent screen, +// or issuing the response. This mirrors hydra's authorize strategy, which routes +// by which verifier (login/consent) is present. The steps only ever redirect +// back here, never to one another. + +import ( + "context" + "net/http" + + "github.com/dexidp/dex/server/consent" + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/storage" +) + +func (h *Handler) handleContinue(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + mac := r.FormValue("hmac") + if mac == "" { + h.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") + return + } + authReq, err := h.Storage.GetAuthRequest(ctx, r.FormValue("req")) + if err != nil { + if err == storage.ErrNotFound { + h.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } + h.Logger.ErrorContext(ctx, "failed to get auth request", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + if !authReq.LoggedIn { + h.Logger.ErrorContext(ctx, "flow dispatcher reached for auth request without an identity") + h.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.") + return + } + + // A step returns with the "continue" verifier (login, MFA) or the "approved" + // verifier (consent). The latter proves the user just approved, so consent is + // resolved for this request even under prompt=consent / ForceApprovalPrompt. + consentApproved := internal.VerifyStep(authReq, mac, internal.StepApproved) + if !consentApproved && !internal.VerifyStep(authReq, mac, internal.StepContinue) { + h.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") + return + } + + h.dispatch(w, r, authReq, consentApproved) +} + +// dispatch runs the auth request's next step. It is the single place the +// post-identity decision lives: MFA, then consent, then issuance. Each +// user-facing step is forbidden under prompt=none, which allows only a silent +// issue. +func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, consentApproved bool) { + ctx := r.Context() + prompt, _ := oauth2.ParsePrompt(authReq.Prompt) + + // MFA: if the client requires it and it is not yet satisfied, hand off to the + // MFA entry, which resolves the effective chain and picks the factor. The + // dispatcher only decides that MFA applies โ€” the requested chain is client + // state (client.MFAChain, else the server default), not a query into MFA. + if h.MFAEnabled && !authReq.MFAValidated { + required, err := h.mfaRequired(ctx, authReq.ClientID) + if err != nil { + h.Logger.ErrorContext(ctx, "failed to determine MFA requirement", "err", err) + h.renderError(r, w, http.StatusInternalServerError, ErrMsgInternalServerError) + return + } + if required { + if prompt.None() { + h.redirectWithError(w, r, &authReq, oauth2.InteractionRequired, "User interaction required") + return + } + http.Redirect(w, r, h.buildMFAURL(authReq), http.StatusSeeOther) + return + } + } + + // Consent: the "approved" verifier resolves it for this request; otherwise ask + // whether it can be skipped from persisted state. + if !consentApproved && !consent.Satisfied(ctx, h.Storage, h.SkipApproval, &authReq) { + if prompt.None() { + h.redirectWithError(w, r, &authReq, oauth2.InteractionRequired, "User interaction required") + return + } + http.Redirect(w, r, h.buildApprovalURL(authReq), http.StatusSeeOther) + return + } + + // Fully authorized โ€” issue the response. + h.writeResponse(w, r, authReq) +} + +// mfaRequired reports whether the client requests any MFA โ€” its own chain, or the +// server default when the client sets none. This is only the dispatcher's cheap +// gate; the MFA entry does the precise, provider-aware resolution and, when +// nothing applies, records MFA as satisfied so control does not return here. +func (h *Handler) mfaRequired(ctx context.Context, clientID string) (bool, error) { + client, err := h.Storage.GetClient(ctx, clientID) + if err != nil { + return false, err + } + chain := client.MFAChain + if chain == nil { + chain = h.DefaultMFAChain + } + return len(chain) > 0, nil +} diff --git a/server/authflow/doc.go b/server/authflow/doc.go new file mode 100644 index 0000000000..012d906d51 --- /dev/null +++ b/server/authflow/doc.go @@ -0,0 +1,20 @@ +// Package authflow implements dex's interactive, browser-facing authorization +// flow: the /auth authorization endpoint, connector and password login, the +// session (SSO) shortcut, and the connector callback. +// +// The flow is a state machine over a storage.AuthRequest. /auth is the +// dispatcher (dispatch.go): it parses the request, starts login, and on each +// return decides the next step from persisted state โ€” hand off to MFA, to the +// consent screen, or issue the response (response.go). Steps never route to one +// another; each returns to /auth carrying an HMAC verifier that proves the +// transition ("continue" after login or a factor, "approved" after consent). +// +// /auth parse the request; pick a connector, reuse a session, or dispatch the next step +// /auth/{c}, .../login connector or password login -> finalizeLogin -> /auth +// /callback connector callback -> finalizeLogin -> /auth +// +// The MFA, consent and logout steps live in sibling packages (server/mfa, +// server/consent, server/logout); they mount their own routes (/mfa, /approval, +// /logout), and the dispatcher sends users there and back. Shared session state +// lives in server/session (cookie, SSO, auth-session CRUD). +package authflow diff --git a/server/authflow/errors.go b/server/authflow/errors.go new file mode 100644 index 0000000000..05291c513e --- /dev/null +++ b/server/authflow/errors.go @@ -0,0 +1,30 @@ +package authflow + +// Safe error messages for user-facing responses. +// These messages are intentionally generic to avoid leaking internal details. +// All actual error details should be logged server-side. + +const ( + // ErrMsgLoginError is a generic login error message shown to users. + // Used when authentication fails due to internal server errors. + ErrMsgLoginError = "Login error. Please contact your administrator or try again later." + + // ErrMsgAuthenticationFailed is shown when callback/SAML authentication fails. + ErrMsgAuthenticationFailed = "Authentication failed. Please contact your administrator or try again later." + + // ErrMsgInternalServerError is a generic internal server error message. + ErrMsgInternalServerError = "Internal server error. Please contact your administrator or try again later." + + // ErrMsgDatabaseError is shown when database operations fail. + ErrMsgDatabaseError = "A database error occurred. Please try again later." + + // ErrMsgInvalidRequest is shown when request parsing fails. + ErrMsgInvalidRequest = "Invalid request. Please try again." + + // ErrMsgMethodNotAllowed is shown when an unsupported HTTP method is used. + ErrMsgMethodNotAllowed = "Method not allowed." + + // ErrMsgNotInRequiredGroups is shown when a user authenticates successfully + // but is not a member of any of the groups required by the connector. + ErrMsgNotInRequiredGroups = "You are not a member of any of the required groups to authenticate." +) diff --git a/server/authflow/errors_test.go b/server/authflow/errors_test.go new file mode 100644 index 0000000000..1688900bd3 --- /dev/null +++ b/server/authflow/errors_test.go @@ -0,0 +1,69 @@ +package authflow + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRenderErrorSafeMessages tests that renderError uses safe messages. +func TestRenderErrorSafeMessages(t *testing.T) { + tests := []struct { + name string + statusCode int + message string + expectedInBody []string + notInBody []string + }{ + { + name: "Login error message", + statusCode: http.StatusInternalServerError, + message: ErrMsgLoginError, + expectedInBody: []string{"Login error", "administrator"}, + notInBody: []string{"stack", "panic", ".go:"}, + }, + { + name: "Authentication failed message", + statusCode: http.StatusInternalServerError, + message: ErrMsgAuthenticationFailed, + expectedInBody: []string{"Authentication failed", "administrator"}, + notInBody: []string{"stack", "panic", ".go:"}, + }, + { + name: "Database error message", + statusCode: http.StatusInternalServerError, + message: ErrMsgDatabaseError, + expectedInBody: []string{"database error"}, + notInBody: []string{"sql:", "connection", "timeout"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, s := newTestHandler(t, nil) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + s.renderError(req, rr, tc.statusCode, tc.message) + + resp := rr.Result() + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + require.Equal(t, tc.statusCode, resp.StatusCode) + + for _, expected := range tc.expectedInBody { + require.Contains(t, bodyStr, expected, "Response should contain: %s", expected) + } + for _, notExpected := range tc.notInBody { + require.NotContains(t, bodyStr, notExpected, "Response should not contain: %s", notExpected) + } + }) + } +} diff --git a/server/authflow/finalize.go b/server/authflow/finalize.go new file mode 100644 index 0000000000..33c0bdbeba --- /dev/null +++ b/server/authflow/finalize.go @@ -0,0 +1,152 @@ +package authflow + +// finalize.go implements the post-authentication step shared by every login +// mechanism: it persists the identity onto the AuthRequest, records the offline +// session and the user identity, then returns the finalized request. + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// finalizeLogin associates the user's identity with the current AuthRequest, then returns +// the approval page's path. +func (h *Handler) finalizeLogin(ctx context.Context, identity connector.Identity, authReq storage.AuthRequest, conn connector.Connector) (storage.AuthRequest, error) { + // Refuse to complete login for a locked account. BlockedUntil lives on the + // persisted UserIdentity, which only exists when the sessions feature is on; + // a first-time login (no stored identity yet) cannot be blocked. + if h.Sessions.Enabled() { + storedIdentity, err := h.Storage.GetUserIdentity(ctx, identity.UserID, authReq.ConnectorID) + switch { + case err == nil: + if !storedIdentity.BlockedUntil.IsZero() && h.Now().Before(storedIdentity.BlockedUntil) { + h.Logger.WarnContext(ctx, "login rejected for locked account", + "connector_id", authReq.ConnectorID, "user_id", identity.UserID, "blocked_until", storedIdentity.BlockedUntil) + return storage.AuthRequest{}, fmt.Errorf("account is locked until %s", storedIdentity.BlockedUntil.Format(time.RFC3339)) + } + case !errors.Is(err, storage.ErrNotFound): + return storage.AuthRequest{}, fmt.Errorf("failed to look up user identity: %w", err) + } + } + + claims := storage.Claims{ + UserID: identity.UserID, + Username: identity.Username, + PreferredUsername: identity.PreferredUsername, + Email: identity.Email, + EmailVerified: identity.EmailVerified, + Groups: identity.Groups, + } + + updater := func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.LoggedIn = true + a.Claims = claims + a.ConnectorData = identity.ConnectorData + a.AuthTime = h.Now() + return a, nil + } + if err := h.Storage.UpdateAuthRequest(ctx, authReq.ID, updater); err != nil { + return storage.AuthRequest{}, fmt.Errorf("failed to update auth request: %v", err) + } + // Keep the in-memory copy in sync with what was persisted so later reads + // (the next-step decision below) see the identity we just stored. + authReq, _ = updater(authReq) + + email := claims.Email + if !claims.EmailVerified { + email += " (unverified)" + } + + h.Logger.InfoContext(ctx, "login successful", + "connector_id", authReq.ConnectorID, "user_id", claims.UserID, + "username", claims.Username, "preferred_username", claims.PreferredUsername, + "email", email, "groups", claims.Groups) + + offlineAccessRequested := false + for _, scope := range authReq.Scopes { + if scope == tokens.ScopeOfflineAccess { + offlineAccessRequested = true + break + } + } + _, canRefresh := conn.(connector.RefreshConnector) + + if offlineAccessRequested && canRefresh { + // Try to retrieve an existing OfflineSession object for the corresponding user. + session, err := h.Storage.GetOfflineSessions(ctx, identity.UserID, authReq.ConnectorID) + switch { + case err != nil && err == storage.ErrNotFound: + offlineSessions := storage.OfflineSessions{ + UserID: identity.UserID, + ConnID: authReq.ConnectorID, + Refresh: make(map[string]*storage.RefreshTokenRef), + ConnectorData: identity.ConnectorData, + } + + // Create a new OfflineSession object for the user and add a reference object for + // the newly received refreshtoken. + if err := h.Storage.CreateOfflineSessions(ctx, offlineSessions); err != nil { + h.Logger.ErrorContext(ctx, "failed to create offline session", "err", err) + return storage.AuthRequest{}, err + } + case err == nil: + // Update existing OfflineSession obj with new RefreshTokenRef. + if err := h.Storage.UpdateOfflineSessions(ctx, session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { + if len(identity.ConnectorData) > 0 { + old.ConnectorData = identity.ConnectorData + } + return old, nil + }); err != nil { + h.Logger.ErrorContext(ctx, "failed to update offline session", "err", err) + return storage.AuthRequest{}, err + } + default: + h.Logger.ErrorContext(ctx, "failed to get offline session", "err", err) + return storage.AuthRequest{}, err + } + } + + // Create or update UserIdentity to persist user claims across sessions. + if h.Sessions.Enabled() { + now := h.Now() + + _, err := h.Storage.GetUserIdentity(ctx, identity.UserID, authReq.ConnectorID) + switch { + case err != nil && errors.Is(err, storage.ErrNotFound): + ui := storage.UserIdentity{ + UserID: identity.UserID, + ConnectorID: authReq.ConnectorID, + Claims: claims, + Consents: make(map[string][]string), + CreatedAt: now, + LastLogin: now, + } + if err := h.Storage.CreateUserIdentity(ctx, ui); err != nil { + h.Logger.ErrorContext(ctx, "failed to create user identity", "err", err) + return storage.AuthRequest{}, err + } + case err == nil: + if err := h.Storage.UpdateUserIdentity(ctx, identity.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + old.Claims = claims + old.LastLogin = now + return old, nil + }); err != nil { + h.Logger.ErrorContext(ctx, "failed to update user identity", "err", err) + return storage.AuthRequest{}, err + } + default: + h.Logger.ErrorContext(ctx, "failed to get user identity", "err", err) + return storage.AuthRequest{}, err + } + } + + // The identity is persisted; return the finalized request so the caller can + // create the session and advance the flow. + return h.Storage.GetAuthRequest(ctx, authReq.ID) +} diff --git a/server/authflow/finalize_test.go b/server/authflow/finalize_test.go new file mode 100644 index 0000000000..8dea85837c --- /dev/null +++ b/server/authflow/finalize_test.go @@ -0,0 +1,60 @@ +package authflow + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/storage" +) + +func TestFinalizeLoginBlockedAccount(t *testing.T) { + t.Setenv("DEX_SESSIONS_ENABLED", "true") + + httpServer, server := newTestHandler(t, func(c *testFlowConfig) { + c.SessionConfig = &session.Config{AbsoluteLifetime: time.Hour, ValidIfNotUsedFor: time.Hour} + }) + defer httpServer.Close() + + ctx := t.Context() + + ident := connector.Identity{UserID: "user-1", Email: "user@example.com"} + authReq := storage.AuthRequest{ + ID: "login-req", + ClientID: "example-app", + Expiry: time.Now().Add(time.Hour), + ConnectorID: "mock", + } + require.NoError(t, server.Storage.CreateAuthRequest(ctx, authReq)) + require.NoError(t, server.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Email: "user@example.com"}, + Consents: map[string][]string{}, + MFASecrets: map[string]*storage.MFASecret{}, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{}, + CreatedAt: time.Now(), + LastLogin: time.Now(), + BlockedUntil: time.Now().Add(time.Hour), + })) + + // Blocked: finalizeLogin must reject without marking the request logged in. + _, err := server.finalizeLogin(ctx, ident, authReq, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "locked") + + updated, err := server.Storage.GetAuthRequest(ctx, authReq.ID) + require.NoError(t, err) + require.False(t, updated.LoggedIn, "blocked account must not be logged in") + + // Clear the block: login should now proceed. + require.NoError(t, server.Storage.UpdateUserIdentity(ctx, "user-1", "mock", func(u storage.UserIdentity) (storage.UserIdentity, error) { + u.BlockedUntil = time.Time{} + return u, nil + })) + _, err = server.finalizeLogin(ctx, ident, authReq, nil) + require.NoError(t, err) +} diff --git a/server/authflow/handler.go b/server/authflow/handler.go new file mode 100644 index 0000000000..c24731d6a9 --- /dev/null +++ b/server/authflow/handler.go @@ -0,0 +1,78 @@ +package authflow + +import ( + "log/slog" + "net/http" + "strings" + "time" + + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/router" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/server/templates" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// Handler serves the interactive login flow (connector selection, connector and +// password login, the callback) and the /auth dispatcher that decides each next +// step and issues the response. The /auth endpoint is the flow dispatcher: it +// starts login, then on each return decides the next step (MFA, consent) or +// issues. It decides those from persisted state and config alone โ€” it holds no +// reference to the MFA or consent handlers; each step only redirects back to +// /auth, never to another step. +type Handler struct { + IssuerURL oauth2.IssuerURL + Connectors *connectors.Cache + Storage storage.Storage + Templates *templates.Templates + Signer signer.Signer + Now func() time.Time + Logger *slog.Logger + AlwaysShowLogin bool + SupportedResponseTypes map[string]bool + PKCE PKCEConfig + AuthRequestsValidFor time.Duration + + // Sessions owns the session cookie, SSO lookup and auth-session CRUD. + Sessions *session.Manager + // Issuer mints tokens for the authorization response (see response.go). + Issuer *tokens.Issuer + + // MFAEnabled reports whether any authenticator is configured; DefaultMFAChain + // is the chain applied to clients that set none. Together they let the + // dispatcher gate MFA without the MFA handler โ€” see mfaRequired. + MFAEnabled bool + DefaultMFAChain []string + // SkipApproval disables the consent screen server-wide (see consent.Satisfied). + SkipApproval bool +} + +// Mount registers the login routes. The /auth endpoint is both the entry +// (login) and the exit (issuance, see response.go). The mfa, consent and logout +// steps are mounted separately by the server. +func (h *Handler) Mount(m router.Mux) { + m.HandleFunc("/auth", h.handleAuthorization) + m.HandleFunc("/auth/{connector}", h.handleConnectorLogin) + m.HandleFunc("/auth/{connector}/login", h.handlePasswordLogin) + // The bare /callback serves OAuth/OIDC redirects, where X-Remote-* never + // belongs, so strip it: a client must not spoof the authproxy connector here. + // The /callback/{connector} route is authproxy's own and passes them through. + m.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + stripRemoteHeaders(r) + h.handleConnectorCallback(w, r) + }) + m.HandleFunc("/callback/{connector}", h.handleConnectorCallback) +} + +// stripRemoteHeaders drops the X-Remote-* request headers the authproxy +// connector trusts, so they cannot be forged on a route that does not set them. +func stripRemoteHeaders(r *http.Request) { + for key := range r.Header { + if strings.HasPrefix(strings.ToLower(key), "x-remote-") { + r.Header.Del(key) + } + } +} diff --git a/server/authflow/handler_test.go b/server/authflow/handler_test.go new file mode 100644 index 0000000000..ed7ccc2b90 --- /dev/null +++ b/server/authflow/handler_test.go @@ -0,0 +1,180 @@ +package authflow + +import ( + "crypto/rand" + "crypto/rsa" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/connector/mock" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/consent" + "github.com/dexidp/dex/server/logout" + "github.com/dexidp/dex/server/mfa" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/server/templates" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" + dexweb "github.com/dexidp/dex/web" +) + +func newLogger(t *testing.T) *slog.Logger { + return slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +// testResolveConnector is the connector resolver used by the flow's unit tests. +// They set connectors in the cache directly; the mock callback connector covers +// the few paths that open one. +func testResolveConnector(conn storage.Connector) (connector.Connector, error) { + return mock.NewCallbackConnector(nil), nil +} + +// testMux adapts a gorilla router to router.Mux so a Handler can mount its +// routes (the handlers read path variables with mux.Vars). +type testMux struct{ r *mux.Router } + +func (m testMux) Handle(p string, h http.Handler) { m.r.Handle(p, h) } +func (m testMux) HandleFunc(p string, h http.HandlerFunc) { m.r.HandleFunc(p, h) } +func (m testMux) HandleCORS(p string, h http.HandlerFunc) { m.r.HandleFunc(p, h) } +func (m testMux) HandlePrefix(p string, h http.Handler) { + m.r.PathPrefix(p).Handler(http.StripPrefix(p, h)) +} + +// testServer wraps a Handler with the router it is mounted on so tests can both +// call flow methods directly (promoted from the embedded Handler) and drive it +// over HTTP via ServeHTTP. +type testServer struct { + *Handler + mux http.Handler +} + +func (ts *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ts.mux.ServeHTTP(w, r) +} + +// testFlowConfig bundles the login Config with the raw inputs the server uses to +// build the shared flow components, so a test can tweak either before assembly. +type testFlowConfig struct { + Handler + SessionConfig *session.Config + MFAProviders map[string]mfa.Provider + DefaultMFAChain []string + SkipApproval bool +} + +// newTestHandler builds the login flow and its shared components wired to an +// httptest server, assembling them exactly as the server package does. +// updateConfig may tweak the config before the components are built. +func newTestHandler(t *testing.T, updateConfig func(c *testFlowConfig)) (*httptest.Server, *testServer) { + t.Helper() + logger := newLogger(t) + ctx := t.Context() + + sig, err := signer.NewMockSigner(testKey) + require.NoError(t, err) + + store := memory.New(logger) + + var handler http.Handler + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + + issuerURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + //nolint:dogsled // only the templates are needed here + _, _, _, tmpls, err := templates.LoadWebConfig(templates.Config{ + WebFS: dexweb.FS(), + IssuerURL: srv.URL, + }) + require.NoError(t, err) + + now := func() time.Time { return time.Now() } + conns := connectors.NewCache(store, testResolveConnector) + issuer := tokens.NewIssuer(store, sig, *issuerURL, 24*time.Hour, now, logger) + + tc := testFlowConfig{ + Handler: Handler{ + IssuerURL: oauth2.IssuerURL{URL: *issuerURL}, + Connectors: conns, + Storage: store, + Templates: tmpls, + Signer: sig, + Now: now, + Logger: logger, + SupportedResponseTypes: map[string]bool{"code": true, "token": true, "id_token": true}, + PKCE: PKCEConfig{CodeChallengeMethodsSupported: []string{"S256", "plain"}}, + AuthRequestsValidFor: 24 * time.Hour, + }, + SkipApproval: true, + } + if updateConfig != nil { + updateConfig(&tc) + } + + // Assemble the flow the same way the server does: shared infrastructure plus + // independent step handlers that hand off by redirect. + sessions := &session.Manager{Storage: store, Config: tc.SessionConfig, Now: now, Logger: logger, IssuerURL: oauth2.IssuerURL{URL: *issuerURL}} + mfaManager := &mfa.Handler{IssuerURL: oauth2.IssuerURL{URL: *issuerURL}, Storage: store, Templates: tmpls, Logger: logger, MFAProviders: tc.MFAProviders, DefaultMFAChain: tc.DefaultMFAChain, Now: now, Connectors: conns} + consentManager := &consent.Handler{IssuerURL: oauth2.IssuerURL{URL: *issuerURL}, Storage: store, Templates: tmpls, Logger: logger, Sessions: sessions, SkipApproval: tc.SkipApproval} + logoutManager := &logout.Handler{Storage: store, Templates: tmpls, Logger: logger, Sessions: sessions, Connectors: conns, Issuer: issuer, Signer: sig, IssuerURL: oauth2.IssuerURL{URL: *issuerURL}} + + tc.Sessions = sessions + tc.Issuer = issuer + tc.Handler.MFAEnabled = len(tc.MFAProviders) > 0 + tc.Handler.DefaultMFAChain = tc.DefaultMFAChain + tc.Handler.SkipApproval = tc.SkipApproval + + h := &tc.Handler + + router := mux.NewRouter() + h.Mount(testMux{router}) + mfaManager.Mount(testMux{router}) + consentManager.Mount(testMux{router}) + logoutManager.Mount(testMux{router}) + handler = router + + for _, id := range []string{"mock", "mock2"} { + require.NoError(t, store.CreateConnector(ctx, storage.Connector{ + ID: id, + Type: "mockCallback", + Name: "Mock", + ResourceVersion: "1", + })) + } + + return srv, &testServer{Handler: h, mux: router} +} + +// testKey is a throwaway RSA key for the mock signer; the flow's unit tests +// don't verify signatures, so a freshly generated key is enough. +var testKey = func() *rsa.PrivateKey { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + return key +}() + +// toResponseTypeSet converts a list of response types to the set form the +// Handler expects. +func toResponseTypeSet(types []string) map[string]bool { + m := make(map[string]bool, len(types)) + for _, t := range types { + m[t] = true + } + return m +} diff --git a/server/authflow/login.go b/server/authflow/login.go new file mode 100644 index 0000000000..e0584a7e5c --- /dev/null +++ b/server/authflow/login.go @@ -0,0 +1,225 @@ +package authflow + +// login.go is the login entry point: it validates the chosen connector against +// the client and OIDC prompt/session rules, then kicks off that connector's +// mechanism (redirect for OAuth2/SAML, the password form for password +// connectors). + +import ( + "fmt" + "maps" + "net/http" + "net/url" + + "github.com/gorilla/mux" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +func (h *Handler) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + authReq, hintSubject, err := h.parseAuthorizationRequest(r) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to parse authorization request", "err", err) + + switch authErr := err.(type) { + case *redirectedAuthErr: + authErr.Handler().ServeHTTP(w, r) + case *displayedAuthErr: + h.renderError(r, w, authErr.Status, err.Error()) + default: + panic("unsupported error type") + } + + return + } + + connID, err := url.PathUnescape(mux.Vars(r)["connector"]) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to parse connector", "err", err) + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist") + return + } + + // Validate that the connector is allowed for this client. + client, authErr := h.getClientWithAuthError(ctx, authReq.ClientID) + if authErr != nil { + h.renderError(r, w, authErr.Status, authErr.Error()) + return + } + if !connectors.ConnectorAllowed(client.AllowedConnectors, connID) { + h.Logger.ErrorContext(r.Context(), "connector not allowed for client", + "connector_id", connID, "client_id", authReq.ClientID) + h.renderError(r, w, http.StatusForbidden, "Connector not allowed for this client.") + return + } + + conn, err := h.Connectors.Get(ctx, connID) + if err != nil { + h.Logger.ErrorContext(r.Context(), "Failed to get connector", "err", err) + h.renderError(r, w, http.StatusBadRequest, "Connector failed to initialize") + return + } + + // Check if the connector allows the requested grant type. + grantType := h.grantTypeFromAuthRequest(r) + if !connectors.GrantTypeAllowed(conn.GrantTypes, grantType) { + h.Logger.ErrorContext(r.Context(), "connector does not allow requested grant type", + "connector_id", connID, "grant_type", grantType) + h.renderError(r, w, http.StatusBadRequest, "Requested connector does not support this grant type.") + return + } + + // Set the connector being used for the login. + if authReq.ConnectorID != "" && authReq.ConnectorID != connID { + h.Logger.ErrorContext(r.Context(), "mismatched connector ID in auth request", + "auth_request_connector_id", authReq.ConnectorID, "connector_id", connID) + h.renderError(r, w, http.StatusBadRequest, "Bad connector ID") + return + } + + authReq.ConnectorID = connID + + // Actually create the auth request + authReq.Expiry = h.Now().Add(h.AuthRequestsValidFor) + if err := h.Storage.CreateAuthRequest(ctx, *authReq); err != nil { + h.Logger.ErrorContext(r.Context(), "failed to create authorization request", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Failed to connect to the database.") + return + } + + // Handle OIDC prompt parameter and session-based login. + prompt, err := oauth2.ParsePrompt(authReq.Prompt) + if err != nil { + // Server error because authReq was validated before saving it to database. + h.redirectWithError(w, r, authReq, oauth2.ServerError, "Invalid authentication request") + return + } + // handle prompt only if sessions are enabled + if h.Sessions.Enabled() { + // Retrieve the session once for use in both hint and prompt logic. + session := h.Sessions.ValidAuthSession(ctx, w, r, authReq) + + // id_token_hint logic (OIDC Core 1.0 3.1.2.1): + // When a hint is provided, verify that the session user matches. + if hintSubject != "" { + if !sessionMatchesHint(session, hintSubject) { + // Clear the session if the user is different from the hint. + session = nil + } + if session == nil && prompt.None() { + // Cannot authenticate silently with prompt=none. + h.redirectWithError(w, r, authReq, oauth2.LoginRequired, "id_token_hint does not match authenticated user") + return + } + } + + // prompt=none: no UI allowed. + if prompt.None() { + // prompt=none: no UI allowed. advance reports interaction_required if the + // session login can't complete silently; a missing session is login_required. + if !h.trySessionLoginWithSession(ctx, r, w, authReq, session) { + h.redirectWithError(w, r, authReq, oauth2.LoginRequired, "User not authenticated") + } + return + } + + if !prompt.Login() { + // Normal flow: try session-based login (skip if prompt=login forces re-auth). + if h.trySessionLoginWithSession(ctx, r, w, authReq, session) { + return + } + } + } + + scopes := tokens.ParseScopes(authReq.Scopes) + + // Work out where the "Select another login method" link should go. + // Include prompt=select_account so that handleAuthorization skips + // session-based connector reuse and shows the connector list. + backLink := "" + if h.Connectors.Len() > 1 { + backLinkParams := make(url.Values) + maps.Copy(backLinkParams, r.Form) + if h.Sessions.Enabled() { + backLinkParams.Set("prompt", "select_account") + } + backLinkURL := url.URL{ + Path: h.IssuerURL.AbsPath("/auth"), + RawQuery: backLinkParams.Encode(), + } + backLink = backLinkURL.String() + } + + switch r.Method { + case http.MethodGet: + switch conn := conn.Connector.(type) { + case connector.CallbackConnector: + // Use the auth request ID as the "state" token. + // + // TODO(ericchiang): Is this appropriate or should we also be using a nonce? + callbackURL, connData, err := conn.LoginURL(scopes, h.IssuerURL.AbsURL("/callback"), authReq.ID) + if err != nil { + h.Logger.ErrorContext(r.Context(), "connector returned error when creating callback", "connector_id", connID, "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + if len(connData) > 0 { + updater := func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.ConnectorData = connData + return a, nil + } + err := h.Storage.UpdateAuthRequest(ctx, authReq.ID, updater) + if err != nil { + h.Logger.ErrorContext(r.Context(), "Failed to set connector data on auth request", "connector_id", connID, "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + } + http.Redirect(w, r, callbackURL, http.StatusFound) + case connector.PasswordConnector: + loginURL := url.URL{ + Path: h.IssuerURL.AbsPath("/auth", connID, "login"), + } + q := loginURL.Query() + q.Set("state", authReq.ID) + q.Set("back", backLink) + loginURL.RawQuery = q.Encode() + + http.Redirect(w, r, loginURL.String(), http.StatusFound) + case connector.SAMLConnector: + action, value, err := conn.POSTData(scopes, authReq.ID) + if err != nil { + h.Logger.ErrorContext(r.Context(), "creating SAML data", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Connector Login Error") + return + } + + // TODO(ericchiang): Don't inline this. + fmt.Fprintf(w, ` + + + + SAML login + + +
+ + +
+ + + `, action, value, authReq.ID) + default: + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + } + default: + h.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") + } +} diff --git a/server/authflow/password.go b/server/authflow/password.go new file mode 100644 index 0000000000..e38d54d8a9 --- /dev/null +++ b/server/authflow/password.go @@ -0,0 +1,163 @@ +package authflow + +// password.go implements the password-credential login mechanism: the login +// form and the credential check for password connectors. + +import ( + "net/http" + "net/url" + + "github.com/gorilla/mux" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +func (h *Handler) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + authID := r.URL.Query().Get("state") + if authID == "" { + h.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } + + backLink := sanitizeBackLink(r.URL.Query().Get("back")) + + authReq, err := h.Storage.GetAuthRequest(ctx, authID) + if err != nil { + if err == storage.ErrNotFound { + h.Logger.ErrorContext(r.Context(), "invalid 'state' parameter provided", "err", err) + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + return + } + h.Logger.ErrorContext(r.Context(), "failed to get auth request", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + + connID, err := url.PathUnescape(mux.Vars(r)["connector"]) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to parse connector", "err", err) + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist") + return + } else if connID != "" && connID != authReq.ConnectorID { + h.Logger.ErrorContext(r.Context(), "connector mismatch: password login triggered for different connector from authentication start", "start_connector_id", authReq.ConnectorID, "password_connector_id", connID) + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + return + } + + conn, err := h.Connectors.Get(ctx, authReq.ConnectorID) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to get connector", "connector_id", authReq.ConnectorID, "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Connector failed to initialize.") + return + } + + pwConn, ok := conn.Connector.(connector.PasswordConnector) + if !ok { + h.Logger.ErrorContext(r.Context(), "expected password connector in handlePasswordLogin()", "password_connector", pwConn) + h.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") + return + } + + rememberMe := h.Sessions.RememberMeDefault() + + switch r.Method { + case http.MethodGet: + // Before rendering the password form, allow connectors that support SPNEGO to try Kerberos auth. + if sp, ok := pwConn.(connector.SPNEGOAware); ok { + scopes := tokens.ParseScopes(authReq.Scopes) + if ident, handled, err := sp.TrySPNEGO(ctx, scopes, w, r); bool(handled) { + if err != nil { + // SPNEGO handled the request but reported an error (e.g., LDAP lookup failed + // after successful Kerberos auth). Log error details, show generic message to user. + h.Logger.ErrorContext(ctx, "SPNEGO authentication error", "err", err) + h.renderError(r, w, http.StatusUnauthorized, ErrMsgAuthenticationFailed) + return + } + if ident != nil { + authReq, err = h.finalizeLogin(ctx, *ident, authReq, conn.Connector) + if err != nil { + h.Logger.ErrorContext(ctx, "failed to finalize login", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + http.Redirect(w, r, h.buildContinueURL(authReq), http.StatusSeeOther) + return + } + // handled with no identity typically means the SPNEGO middleware + // wrote its own 401 (bare challenge, continuation, or reject); do + // not render the password form on top of it. + return + } + } + if err := h.Templates.Password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink, rememberMe); err != nil { + h.Logger.ErrorContext(r.Context(), "server template error", "err", err) + } + case http.MethodPost: + username := r.FormValue("login") + password := r.FormValue("password") + scopes := tokens.ParseScopes(authReq.Scopes) + + identity, ok, err := pwConn.Login(r.Context(), scopes, username, password) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to login user", "err", err) + h.renderError(r, w, http.StatusInternalServerError, ErrMsgLoginError) + return + } + if !ok { + if err := h.Templates.Password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink, rememberMe); err != nil { + h.Logger.ErrorContext(r.Context(), "server template error", "err", err) + } + h.Logger.ErrorContext(r.Context(), "failed login attempt: Invalid credentials.", "user", username) + return + } + authReq, err = h.finalizeLogin(r.Context(), identity, authReq, conn.Connector) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to finalize login", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + rememberMe := r.FormValue("remember_me") == "on" + if err := h.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, rememberMe); err != nil { + h.Logger.ErrorContext(ctx, "failed to create/update auth session", "err", err) + } + + http.Redirect(w, r, h.buildContinueURL(authReq), http.StatusSeeOther) + default: + h.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") + } +} + +// sanitizeBackLink permits only a same-origin absolute path as the "Select +// another login method" target. The legitimate value is always a rooted path +// built from the issuer path (see login.go), so anything that could redirect +// off-origin โ€” an absolute URL, a scheme-relative "//host" or "/\host" that +// browsers treat as protocol-relative, or a value that fails to parse โ€” is +// dropped rather than rendered as a link (open-redirect prevention). +func sanitizeBackLink(back string) string { + if back == "" { + return "" + } + u, err := url.Parse(back) + if err != nil || u.IsAbs() || u.Host != "" { + return "" + } + if back[0] != '/' { + return "" + } + if len(back) >= 2 && (back[1] == '/' || back[1] == '\\') { + return "" + } + return back +} + +// Check for username prompt override from connector. Defaults to "Username". +func usernamePrompt(conn connector.PasswordConnector) string { + if attr := conn.Prompt(); attr != "" { + return attr + } + return "Username" +} diff --git a/server/authflow/password_test.go b/server/authflow/password_test.go new file mode 100644 index 0000000000..6be2192a06 --- /dev/null +++ b/server/authflow/password_test.go @@ -0,0 +1,23 @@ +package authflow + +import "testing" + +func TestSanitizeBackLink(t *testing.T) { + tests := map[string]string{ + "": "", + "/auth?prompt=select_account": "/auth?prompt=select_account", + "/dex/auth?client_id=x": "/dex/auth?client_id=x", + "https://evil.example": "", + "http://evil.example/auth": "", + "//evil.example": "", + "/\\evil.example": "", + "javascript:alert(1)": "", + "relative/path": "", // not rooted + "/auth#frag": "/auth#frag", + } + for in, want := range tests { + if got := sanitizeBackLink(in); got != want { + t.Errorf("sanitizeBackLink(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/server/authflow/render.go b/server/authflow/render.go new file mode 100644 index 0000000000..e8b31cf8c2 --- /dev/null +++ b/server/authflow/render.go @@ -0,0 +1,12 @@ +package authflow + +import ( + "net/http" + + "github.com/dexidp/dex/server/templates" +) + +// renderError renders a user-facing HTML error page. +func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) { + templates.RenderError(h.Templates, h.Logger, r, w, status, description) +} diff --git a/server/authflow/request.go b/server/authflow/request.go new file mode 100644 index 0000000000..4b47887e6f --- /dev/null +++ b/server/authflow/request.go @@ -0,0 +1,384 @@ +package authflow + +import ( + "context" + "crypto" + "fmt" + "net" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + + conns "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// request.go parses and validates the OAuth2 /auth authorization request into a +// storage.AuthRequest, and defines the request-error surface (displayed vs +// redirected). + +// displayedAuthErr is an error that should be displayed to the user as a web page. +// See RFC 6749 ยง4.1.2.1: an invalid client_id or redirect_uri is shown, not +// redirected. +type displayedAuthErr struct { + Status int + Description string +} + +func (err *displayedAuthErr) Error() string { return err.Description } + +// newDisplayedErr builds a displayedAuthErr. +func newDisplayedErr(status int, format string, a ...interface{}) *displayedAuthErr { + return &displayedAuthErr{status, fmt.Sprintf(format, a...)} +} + +// redirectedAuthErr is an error reported back to the client by 302 redirect. +type redirectedAuthErr struct { + State string + RedirectURI string + Type string + Description string +} + +func (err *redirectedAuthErr) Error() string { return err.Description } + +// Handler returns an http.Handler that redirects to the client with the error. +func (err *redirectedAuthErr) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v := url.Values{} + v.Add("state", err.State) + v.Add("error", err.Type) + if err.Description != "" { + v.Add("error_description", err.Description) + } + + // Parse the redirect URI to ensure it's valid before redirecting. + u, parseErr := url.Parse(err.RedirectURI) + if parseErr != nil { + http.Error(w, "Invalid redirect URI", http.StatusBadRequest) + return + } + + query := u.Query() + for key, values := range v { + for _, value := range values { + query.Add(key, value) + } + } + u.RawQuery = query.Encode() + + http.Redirect(w, r, u.String(), http.StatusSeeOther) + }) +} + +// redirectWithError redirects back to the client with an OAuth2 error response. +// Used for prompt=none when login or consent is required. +func (h *Handler) redirectWithError(w http.ResponseWriter, r *http.Request, authReq *storage.AuthRequest, errType, description string) { + err := &redirectedAuthErr{State: authReq.State, RedirectURI: authReq.RedirectURI, Type: errType, Description: description} + err.Handler().ServeHTTP(w, r) +} + +func validateRedirectURI(client storage.Client, redirectURI string) bool { + // Allow named RedirectURIs for both public and non-public clients. + // This is required to make PKCE-enabled web apps work when configured as public clients. + for _, uri := range client.RedirectURIs { + if redirectURI == uri { + return true + } + } + // For non-public clients or when RedirectURIs is set, we allow only explicitly named RedirectURIs. + if !client.Public || len(client.RedirectURIs) > 0 { + return false + } + + if redirectURI == oauth2.RedirectURIOOB || redirectURI == oauth2.DeviceCallbackURI { + return true + } + + // Verify the host is a loopback form ("http://localhost:(port)(path)" etc). + u, err := url.Parse(redirectURI) + if err != nil { + return false + } + if u.Scheme != "http" { + return false + } + return isHostLocal(u.Host) +} + +func isHostLocal(host string) bool { + if host == "localhost" || net.ParseIP(host).IsLoopback() { + return true + } + + host, _, err := net.SplitHostPort(host) + if err != nil { + return false + } + + return host == "localhost" || net.ParseIP(host).IsLoopback() +} + +func validateConnectorID(connectors []storage.Connector, connectorID string) bool { + for _, c := range connectors { + if c.ID == connectorID { + return true + } + } + return false +} + +// sessionMatchesHint checks whether the session's user identity matches the +// subject from an id_token_hint by encoding the session's (userID, connectorID) +// via GenSubject and doing a string comparison. +func sessionMatchesHint(session *storage.AuthSession, hintSubject string) bool { + if session == nil { + return false + } + encoded, err := tokens.GenSubject(session.UserID, session.ConnectorID) + if err != nil { + return false + } + return encoded == hintSubject +} + +// PKCEConfig holds PKCE (Proof Key for Code Exchange) settings. +type PKCEConfig struct { + // If true, PKCE is required for all authorization code flows. + Enforce bool + // Supported code challenge methods. Defaults to ["S256", "plain"]. + CodeChallengeMethodsSupported []string +} + +// ValidateIDTokenHint verifies the signature and issuer of an id_token_hint. +// Expired tokens are accepted per OIDC Core 1.0 ยง3.1.2.1. It returns the verified +// token so callers can extract Subject, Audience, etc. +func (h *Handler) validateIDTokenHint(ctx context.Context, hint string) (*oidc.IDToken, error) { + verifier := oidc.NewVerifier(h.IssuerURL.String(), &signer.KeySet{Signer: h.Signer}, &oidc.Config{ + SkipExpiryCheck: true, + // SkipClientIDCheck is set because the hint may originate from any client that + // Dex issued a token to โ€” the caller does not know the expected audience in advance. + // The signature verification via signer.KeySet already guarantees the token was + // issued by this server. Dex does the client id check later during session validation. + SkipClientIDCheck: true, + }) + return verifier.Verify(ctx, hint) +} + +// Parse parses the initial request from the OAuth2 client. It returns the auth +// request, the raw subject from id_token_hint (empty if not provided), and any +// error (a *displayedAuthErr or *redirectedAuthErr). +func (h *Handler) parseAuthorizationRequest(r *http.Request) (*storage.AuthRequest, string, error) { + ctx := r.Context() + if err := r.ParseForm(); err != nil { + return nil, "", newDisplayedErr(http.StatusBadRequest, "Failed to parse request.") + } + q := r.Form + // r.ParseForm already URL-decodes query values once; decoding redirect_uri a + // second time created a normalization differential with the token endpoint. + redirectURI := q.Get("redirect_uri") + + clientID := q.Get("client_id") + state := q.Get("state") + nonce := q.Get("nonce") + connectorID := q.Get("connector_id") + // Some clients, like the old go-oidc, provide extra whitespace. Tolerate this. + scopes := strings.Fields(q.Get("scope")) + responseTypes := strings.Fields(q.Get("response_type")) + + codeChallenge := q.Get("code_challenge") + codeChallengeMethod := q.Get("code_challenge_method") + + if codeChallengeMethod == "" { + codeChallengeMethod = oauth2.PKCEMethodPlain + } + + client, err := h.Storage.GetClient(ctx, clientID) + if err != nil { + if err == storage.ErrNotFound { + h.Logger.ErrorContext(ctx, "invalid client_id provided", "client_id", clientID) + return nil, "", newDisplayedErr(http.StatusNotFound, "Invalid client_id.") + } + h.Logger.ErrorContext(ctx, "failed to get client", "err", err) + return nil, "", newDisplayedErr(http.StatusInternalServerError, "Database error.") + } + + if !validateRedirectURI(client, redirectURI) { + h.Logger.ErrorContext(ctx, "unregistered redirect_uri", "redirect_uri", redirectURI, "client_id", clientID) + return nil, "", newDisplayedErr(http.StatusBadRequest, "Unregistered redirect_uri.") + } + if redirectURI == oauth2.DeviceCallbackURI && client.Public { + redirectURI = h.IssuerURL.AbsPath(oauth2.DeviceCallbackURI) + } + + // From here on out, we want to redirect back to the client with an error. + newredirectedAuthErr := func(typ, format string, a ...interface{}) *redirectedAuthErr { + return &redirectedAuthErr{state, redirectURI, typ, fmt.Sprintf(format, a...)} + } + + if connectorID != "" { + connectors, err := h.Storage.ListConnectors(ctx) + if err != nil { + h.Logger.ErrorContext(ctx, "failed to list connectors", "err", err) + return nil, "", newredirectedAuthErr(oauth2.ServerError, "Unable to retrieve connectors") + } + if !validateConnectorID(connectors, connectorID) { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid ConnectorID") + } + if !conns.ConnectorAllowed(client.AllowedConnectors, connectorID) { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Connector not allowed for this client") + } + } + + // dex doesn't support the request parameter and must return request_not_supported. + // https://openid.net/specs/openid-connect-core-1_0.html#6.1 + if q.Get("request") != "" { + return nil, "", newredirectedAuthErr(oauth2.RequestNotSupported, "Server does not support request parameter.") + } + + if codeChallenge != "" && !slices.Contains(h.PKCE.CodeChallengeMethodsSupported, codeChallengeMethod) { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Unsupported PKCE challenge method (%q).", codeChallengeMethod) + } + + // Enforce PKCE if configured. + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.1 + if h.PKCE.Enforce && codeChallenge == "" { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "PKCE is required. The code_challenge parameter must be provided.") + } + + var ( + unrecognized []string + invalidScopes []string + ) + hasOpenIDScope := false + for _, scope := range scopes { + switch scope { + case tokens.ScopeOpenID: + hasOpenIDScope = true + case tokens.ScopeOfflineAccess, tokens.ScopeEmail, tokens.ScopeProfile, tokens.ScopeGroups, tokens.ScopeFederatedID: + default: + peerID, ok := tokens.ParseCrossClientScope(scope) + if !ok { + unrecognized = append(unrecognized, scope) + continue + } + + isTrusted, err := tokens.CrossClientTrusted(ctx, h.Storage, clientID, peerID) + if err != nil { + return nil, "", newredirectedAuthErr(oauth2.ServerError, "Internal server error.") + } + if !isTrusted { + invalidScopes = append(invalidScopes, scope) + } + } + } + if !hasOpenIDScope { + return nil, "", newredirectedAuthErr(oauth2.InvalidScope, `Missing required scope(s) ["openid"].`) + } + if len(unrecognized) > 0 { + return nil, "", newredirectedAuthErr(oauth2.InvalidScope, "Unrecognized scope(s) %q", unrecognized) + } + if len(invalidScopes) > 0 { + return nil, "", newredirectedAuthErr(oauth2.InvalidScope, "Client can't request scope(s) %q", invalidScopes) + } + + var rt struct { + code bool + idToken bool + token bool + } + + for _, responseType := range responseTypes { + switch responseType { + case oauth2.ResponseTypeCode: + rt.code = true + case oauth2.ResponseTypeIDToken: + rt.idToken = true + case oauth2.ResponseTypeToken: + rt.token = true + default: + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid response type %q", responseType) + } + + if !h.SupportedResponseTypes[responseType] { + return nil, "", newredirectedAuthErr(oauth2.UnsupportedResponseType, "Unsupported response type %q", responseType) + } + } + + if len(responseTypes) == 0 { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "No response_type provided") + } + + if rt.token && !rt.code && !rt.idToken { + // "token" can't be provided on its own. + // https://openid.net/specs/openid-connect-core-1_0.html#Authentication + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Response type 'token' must be provided with type 'id_token' and/or 'code'") + } + if !rt.code { + // Either "id_token token" or "id_token" implies the implicit flow, which + // requires a nonce value. + // https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest + if nonce == "" { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Response type 'token' requires a 'nonce' value.") + } + } + if rt.token { + if redirectURI == oauth2.RedirectURIOOB { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Cannot use response type 'token' with redirect_uri '%s'.", oauth2.RedirectURIOOB) + } + } + + prompt, err := oauth2.ParsePrompt(q.Get("prompt")) + if err != nil { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid prompt parameter: %v", err) + } + + // Parse max_age: -1 means not specified. + maxAge := -1 + if maxAgeStr := q.Get("max_age"); maxAgeStr != "" { + v, err := strconv.Atoi(maxAgeStr) + if err != nil || v < 0 { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid max_age value %q", maxAgeStr) + } + maxAge = v + } + + // OIDC prompt=consent implies force approval. + forceApproval := q.Get("approval_prompt") == "force" || prompt.Consent() + + // Validate id_token_hint if provided (OIDC Core 1.0 ยง3.1.2.1). + var idTokenHintSubject string + if hint := q.Get("id_token_hint"); hint != "" { + idToken, err := h.validateIDTokenHint(ctx, hint) + if err != nil { + return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Invalid id_token_hint.") + } + idTokenHintSubject = idToken.Subject + } + + return &storage.AuthRequest{ + ID: storage.NewID(), + ClientID: client.ID, + State: state, + Nonce: nonce, + ForceApprovalPrompt: forceApproval, + Prompt: prompt.String(), + MaxAge: maxAge, + Scopes: scopes, + RedirectURI: redirectURI, + ResponseTypes: responseTypes, + ConnectorID: connectorID, + PKCE: storage.PKCE{ + CodeChallenge: codeChallenge, + CodeChallengeMethod: codeChallengeMethod, + }, + HMACKey: storage.NewHMACKey(crypto.SHA256), + }, idTokenHintSubject, nil +} diff --git a/server/oauth2_test.go b/server/authflow/request_test.go similarity index 51% rename from server/oauth2_test.go rename to server/authflow/request_test.go index 710382aa23..b874a9201e 100644 --- a/server/oauth2_test.go +++ b/server/authflow/request_test.go @@ -1,19 +1,25 @@ -package server +package authflow import ( - "context" "crypto/rand" "crypto/rsa" + "encoding/json" + "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" - "gopkg.in/square/go-jose.v2" + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/server/tokens" "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" ) func TestParseAuthorizationRequest(t *testing.T) { @@ -21,6 +27,7 @@ func TestParseAuthorizationRequest(t *testing.T) { name string clients []storage.Client supportedResponseTypes []string + pkce PKCEConfig usePOST bool @@ -126,7 +133,7 @@ func TestParseAuthorizationRequest(t *testing.T) { "response_type": "code id_token", "scope": "openid email profile", }, - expectedError: &redirectedAuthErr{Type: errUnsupportedResponseType}, + expectedError: &redirectedAuthErr{Type: oauth2.UnsupportedResponseType}, }, { name: "only token response type", @@ -143,7 +150,7 @@ func TestParseAuthorizationRequest(t *testing.T) { "response_type": "token", "scope": "openid email profile", }, - expectedError: &redirectedAuthErr{Type: errInvalidRequest}, + expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest}, }, { name: "choose connector_id", @@ -195,7 +202,7 @@ func TestParseAuthorizationRequest(t *testing.T) { "response_type": "code id_token", "scope": "openid email profile", }, - expectedError: &redirectedAuthErr{Type: errInvalidRequest}, + expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest}, }, { name: "PKCE code_challenge_method plain", @@ -267,7 +274,7 @@ func TestParseAuthorizationRequest(t *testing.T) { "code_challenge_method": "invalid_method", "scope": "openid email profile", }, - expectedError: &redirectedAuthErr{Type: errInvalidRequest}, + expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest}, }, { name: "No response type", @@ -285,18 +292,104 @@ func TestParseAuthorizationRequest(t *testing.T) { "code_challenge_method": "plain", "scope": "openid email profile", }, - expectedError: &redirectedAuthErr{Type: errInvalidRequest}, + expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest}, + }, + { + name: "PKCE enforced, no code_challenge provided", + clients: []storage.Client{ + { + ID: "bar", + RedirectURIs: []string{"https://example.com/bar"}, + }, + }, + supportedResponseTypes: []string{"code"}, + pkce: PKCEConfig{ + Enforce: true, + CodeChallengeMethodsSupported: []string{"S256", "plain"}, + }, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com/bar", + "response_type": "code", + "scope": "openid email profile", + }, + expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest}, + }, + { + name: "PKCE enforced, code_challenge provided", + clients: []storage.Client{ + { + ID: "bar", + RedirectURIs: []string{"https://example.com/bar"}, + }, + }, + supportedResponseTypes: []string{"code"}, + pkce: PKCEConfig{ + Enforce: true, + CodeChallengeMethodsSupported: []string{"S256", "plain"}, + }, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com/bar", + "response_type": "code", + "code_challenge": "123", + "code_challenge_method": "S256", + "scope": "openid email profile", + }, + }, + { + name: "PKCE only S256 allowed, plain rejected", + clients: []storage.Client{ + { + ID: "bar", + RedirectURIs: []string{"https://example.com/bar"}, + }, + }, + supportedResponseTypes: []string{"code"}, + pkce: PKCEConfig{ + CodeChallengeMethodsSupported: []string{"S256"}, + }, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com/bar", + "response_type": "code", + "code_challenge": "123", + "code_challenge_method": "plain", + "scope": "openid email profile", + }, + expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest}, + }, + { + name: "PKCE only S256 allowed, S256 accepted", + clients: []storage.Client{ + { + ID: "bar", + RedirectURIs: []string{"https://example.com/bar"}, + }, + }, + supportedResponseTypes: []string{"code"}, + pkce: PKCEConfig{ + CodeChallengeMethodsSupported: []string{"S256"}, + }, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com/bar", + "response_type": "code", + "code_challenge": "123", + "code_challenge_method": "S256", + "scope": "openid email profile", + }, }, } for _, tc := range tests { - func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - httpServer, server := newTestServerMultipleConnectors(ctx, t, func(c *Config) { - c.SupportedResponseTypes = tc.supportedResponseTypes + t.Run(tc.name, func(t *testing.T) { + httpServer, server := newTestHandler(t, func(c *testFlowConfig) { + c.SupportedResponseTypes = toResponseTypeSet(tc.supportedResponseTypes) c.Storage = storage.WithStaticClients(c.Storage, tc.clients) + if len(tc.pkce.CodeChallengeMethodsSupported) > 0 || tc.pkce.Enforce { + c.PKCE = tc.pkce + } }) defer httpServer.Close() @@ -313,7 +406,7 @@ func TestParseAuthorizationRequest(t *testing.T) { req = httptest.NewRequest("GET", httpServer.URL+"/auth?"+params.Encode(), nil) } - _, err := server.parseAuthorizationRequest(req) + _, _, err := server.parseAuthorizationRequest(req) if tc.expectedError == nil { if err != nil { t.Errorf("%s: expected no error", tc.name) @@ -343,24 +436,7 @@ func TestParseAuthorizationRequest(t *testing.T) { t.Fatalf("%s: unsupported error type", tc.name) } } - }() - } -} - -const ( - // at_hash value and access_token returned by Google. - googleAccessTokenHash = "piwt8oCH-K2D9pXlaS1Y-w" - googleAccessToken = "ya29.CjHSA1l5WUn8xZ6HanHFzzdHdbXm-14rxnC7JHch9eFIsZkQEGoWzaYG4o7k5f6BnPLj" - googleSigningAlg = jose.RS256 -) - -func TestAccessTokenHash(t *testing.T) { - atHash, err := accessTokenHash(googleSigningAlg, googleAccessToken) - if err != nil { - t.Fatal(err) - } - if atHash != googleAccessTokenHash { - t.Errorf("expected %q got %q", googleAccessTokenHash, atHash) + }) } } @@ -420,6 +496,27 @@ func TestValidRedirectURI(t *testing.T) { redirectURI: "http://localhost", wantValid: true, }, + { + client: storage.Client{ + Public: true, + }, + redirectURI: "http://127.0.0.1:8080/", + wantValid: true, + }, + { + client: storage.Client{ + Public: true, + }, + redirectURI: "http://127.0.0.1:991/bar", + wantValid: true, + }, + { + client: storage.Client{ + Public: true, + }, + redirectURI: "http://127.0.0.1", + wantValid: true, + }, // Both Public + RedirectURIs configured: Could e.g. be a PKCE-enabled web app. { client: storage.Client{ @@ -544,86 +641,294 @@ func TestValidRedirectURI(t *testing.T) { } } -func TestStorageKeySet(t *testing.T) { - s := memory.New(logger) - if err := s.UpdateKeys(func(keys storage.Keys) (storage.Keys, error) { - keys.SigningKey = &jose.JSONWebKey{ - Key: testKey, - KeyID: "testkey", - Algorithm: "RS256", - Use: "sig", - } - keys.SigningKeyPub = &jose.JSONWebKey{ - Key: testKey.Public(), - KeyID: "testkey", - Algorithm: "RS256", - Use: "sig", - } - return keys, nil - }); err != nil { - t.Fatal(err) - } - +func TestRedirectedAuthErrHandler(t *testing.T) { tests := []struct { - name string - tokenGenerator func() (jwt string, err error) - wantErr bool + name string + redirectURI string + state string + errType string + description string + wantStatus int + wantErr bool }{ { - name: "valid token", - tokenGenerator: func() (string, error) { - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: testKey}, nil) - if err != nil { - return "", err - } + name: "valid redirect uri with error parameters", + redirectURI: "https://example.com/callback", + state: "state123", + errType: oauth2.InvalidRequest, + description: "Invalid request parameter", + wantStatus: http.StatusSeeOther, + wantErr: false, + }, + { + name: "valid redirect uri with query params", + redirectURI: "https://example.com/callback?existing=param&another=value", + state: "state456", + errType: oauth2.AccessDenied, + description: "User denied access", + wantStatus: http.StatusSeeOther, + wantErr: false, + }, + { + name: "valid redirect uri without description", + redirectURI: "https://example.com/callback", + state: "state789", + errType: oauth2.ServerError, + description: "", + wantStatus: http.StatusSeeOther, + wantErr: false, + }, + { + name: "invalid redirect uri", + redirectURI: "not a valid url ://", + state: "state", + errType: oauth2.InvalidRequest, + description: "Test error", + wantStatus: http.StatusBadRequest, + wantErr: true, + }, + } - jws, err := signer.Sign([]byte("payload")) - if err != nil { - return "", err + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + err := &redirectedAuthErr{ + State: tc.state, + RedirectURI: tc.redirectURI, + Type: tc.errType, + Description: tc.description, + } + + handler := err.Handler() + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + + handler.ServeHTTP(w, r) + + if w.Code != tc.wantStatus { + t.Errorf("expected status %d, got %d", tc.wantStatus, w.Code) + } + + if tc.wantStatus == http.StatusSeeOther { + // Verify the redirect location is a valid URL + location := w.Header().Get("Location") + if location == "" { + t.Fatalf("expected Location header, got empty string") } - return jws.CompactSerialize() - }, - wantErr: false, - }, - { - name: "token signed by different key", - tokenGenerator: func() (string, error) { - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return "", err + // Parse the redirect URL to verify it's valid + redirectURL, parseErr := url.Parse(location) + if parseErr != nil { + t.Fatalf("invalid redirect URL: %v", parseErr) } - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, nil) - if err != nil { - return "", err + // Verify error parameters are present in the query string + query := redirectURL.Query() + if query.Get("state") != tc.state { + t.Errorf("expected state %q, got %q", tc.state, query.Get("state")) + } + if query.Get("error") != tc.errType { + t.Errorf("expected error type %q, got %q", tc.errType, query.Get("error")) + } + if tc.description != "" && query.Get("error_description") != tc.description { + t.Errorf("expected error_description %q, got %q", tc.description, query.Get("error_description")) } - jws, err := signer.Sign([]byte("payload")) - if err != nil { - return "", err + // Verify that existing query parameters are preserved + if tc.name == "valid redirect uri with query params" { + if query.Get("existing") != "param" { + t.Errorf("expected existing parameter 'param', got %q", query.Get("existing")) + } + if query.Get("another") != "value" { + t.Errorf("expected another parameter 'value', got %q", query.Get("another")) + } } + } + }) + } +} - return jws.CompactSerialize() - }, - wantErr: true, - }, +// signTestIDToken creates a signed JWT with the given claims using the test key. +func signTestIDToken(t *testing.T, claims interface{}) string { + t.Helper() + payload, err := json.Marshal(claims) + require.NoError(t, err) + + joseSigner, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: testKey}, nil) + require.NoError(t, err) + + jws, err := joseSigner.Sign(payload) + require.NoError(t, err) + + token, err := jws.CompactSerialize() + require.NoError(t, err) + return token +} + +func TestValidateIDTokenHint(t *testing.T) { + sig, err := signer.NewMockSigner(testKey) + require.NoError(t, err) + + issuerURL, err := url.Parse("https://issuer.example.com") + require.NoError(t, err) + + s := &Handler{ + Signer: sig, + IssuerURL: oauth2.IssuerURL{URL: *issuerURL}, + Logger: slog.Default(), } - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { - jwt, err := tc.tokenGenerator() - if err != nil { - t.Fatal(err) - } + now := time.Now() - keySet := &storageKeySet{s} + t.Run("valid hint (not expired)", func(t *testing.T) { + token := signTestIDToken(t, tokens.IDTokenClaims{ + Issuer: "https://issuer.example.com", + Subject: "CgNmb28SA2Jhcg", + Expiry: now.Add(1 * time.Hour).Unix(), + }) + idToken, err := s.validateIDTokenHint(t.Context(), token) + require.NoError(t, err) + assert.Equal(t, "CgNmb28SA2Jhcg", idToken.Subject) + }) - _, err = keySet.VerifySignature(context.Background(), jwt) - if (err != nil && !tc.wantErr) || (err == nil && tc.wantErr) { - t.Fatalf("wantErr = %v, but got err = %v", tc.wantErr, err) - } + t.Run("valid hint (expired)", func(t *testing.T) { + token := signTestIDToken(t, tokens.IDTokenClaims{ + Issuer: "https://issuer.example.com", + Subject: "CgNmb28SA2Jhcg", + Expiry: now.Add(-1 * time.Hour).Unix(), }) - } + idToken, err := s.validateIDTokenHint(t.Context(), token) + require.NoError(t, err) + assert.Equal(t, "CgNmb28SA2Jhcg", idToken.Subject) + }) + + t.Run("invalid signature", func(t *testing.T) { + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + payload, err := json.Marshal(tokens.IDTokenClaims{ + Issuer: "https://issuer.example.com", + Subject: "CgNmb28SA2Jhcg", + Expiry: now.Add(1 * time.Hour).Unix(), + }) + require.NoError(t, err) + + joseSigner, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: otherKey}, nil) + require.NoError(t, err) + jws, err := joseSigner.Sign(payload) + require.NoError(t, err) + token, err := jws.CompactSerialize() + require.NoError(t, err) + + _, err = s.validateIDTokenHint(t.Context(), token) + assert.Error(t, err) + }) + + t.Run("wrong issuer", func(t *testing.T) { + token := signTestIDToken(t, tokens.IDTokenClaims{ + Issuer: "https://wrong-issuer.example.com", + Subject: "CgNmb28SA2Jhcg", + Expiry: now.Add(1 * time.Hour).Unix(), + }) + _, err := s.validateIDTokenHint(t.Context(), token) + assert.Error(t, err) + }) + + t.Run("malformed token", func(t *testing.T) { + _, err := s.validateIDTokenHint(t.Context(), "not-a-valid-jwt") + assert.Error(t, err) + }) +} + +func TestSessionMatchesHint(t *testing.T) { + // tokens.GenSubject("foo", "bar") == "CgNmb28SA2Jhcg" (from TestGetSubject) + assert.True(t, sessionMatchesHint(&storage.AuthSession{UserID: "foo", ConnectorID: "bar"}, "CgNmb28SA2Jhcg")) + assert.False(t, sessionMatchesHint(&storage.AuthSession{UserID: "other", ConnectorID: "bar"}, "CgNmb28SA2Jhcg")) + assert.False(t, sessionMatchesHint(&storage.AuthSession{UserID: "foo", ConnectorID: "other"}, "CgNmb28SA2Jhcg")) + assert.False(t, sessionMatchesHint(nil, "CgNmb28SA2Jhcg")) +} + +func TestParseAuthorizationRequest_IDTokenHint(t *testing.T) { + sig, err := signer.NewMockSigner(testKey) + require.NoError(t, err) + + now := time.Now() + + t.Run("valid id_token_hint populates subject", func(t *testing.T) { + httpServer, server := newTestHandler(t, func(c *testFlowConfig) { + c.SupportedResponseTypes = map[string]bool{"code": true} + c.Storage = storage.WithStaticClients(c.Storage, []storage.Client{ + {ID: "foo", RedirectURIs: []string{"https://example.com/foo"}}, + }) + c.Signer = sig + }) + defer httpServer.Close() + + token := signTestIDToken(t, tokens.IDTokenClaims{ + Issuer: httpServer.URL, + Subject: "CgNmb28SA2Jhcg", + Expiry: now.Add(1 * time.Hour).Unix(), + }) + + params := url.Values{ + "client_id": {"foo"}, + "redirect_uri": {"https://example.com/foo"}, + "response_type": {"code"}, + "scope": {"openid"}, + "id_token_hint": {token}, + } + req := httptest.NewRequest("GET", httpServer.URL+"/auth?"+params.Encode(), nil) + + _, hintSubject, err := server.parseAuthorizationRequest(req) + require.NoError(t, err) + assert.Equal(t, "CgNmb28SA2Jhcg", hintSubject) + }) + + t.Run("invalid id_token_hint returns error", func(t *testing.T) { + httpServer, server := newTestHandler(t, func(c *testFlowConfig) { + c.SupportedResponseTypes = map[string]bool{"code": true} + c.Storage = storage.WithStaticClients(c.Storage, []storage.Client{ + {ID: "foo", RedirectURIs: []string{"https://example.com/foo"}}, + }) + c.Signer = sig + }) + defer httpServer.Close() + + params := url.Values{ + "client_id": {"foo"}, + "redirect_uri": {"https://example.com/foo"}, + "response_type": {"code"}, + "scope": {"openid"}, + "id_token_hint": {"invalid-token"}, + } + req := httptest.NewRequest("GET", httpServer.URL+"/auth?"+params.Encode(), nil) + + _, _, err := server.parseAuthorizationRequest(req) + require.Error(t, err) + redirectErr, ok := err.(*redirectedAuthErr) + require.True(t, ok) + assert.Equal(t, oauth2.InvalidRequest, redirectErr.Type) + }) + + t.Run("no id_token_hint leaves subject empty", func(t *testing.T) { + httpServer, server := newTestHandler(t, func(c *testFlowConfig) { + c.SupportedResponseTypes = map[string]bool{"code": true} + c.Storage = storage.WithStaticClients(c.Storage, []storage.Client{ + {ID: "foo", RedirectURIs: []string{"https://example.com/foo"}}, + }) + }) + defer httpServer.Close() + + params := url.Values{ + "client_id": {"foo"}, + "redirect_uri": {"https://example.com/foo"}, + "response_type": {"code"}, + "scope": {"openid"}, + } + req := httptest.NewRequest("GET", httpServer.URL+"/auth?"+params.Encode(), nil) + + _, hintSubject, err := server.parseAuthorizationRequest(req) + require.NoError(t, err) + assert.Equal(t, "", hintSubject) + }) } diff --git a/server/authflow/response.go b/server/authflow/response.go new file mode 100644 index 0000000000..3a63e25273 --- /dev/null +++ b/server/authflow/response.go @@ -0,0 +1,241 @@ +package authflow + +// response.go writes the authorization response once the dispatcher determines +// the request is fully authorized: it mints the auth code and, for +// implicit/hybrid flows, the access and ID tokens, then redirects the browser +// back to the client (or renders the out-of-band page). This is the issuance +// half of the authorize endpoint โ€” fosite's WriteAuthorizeResponse. + +import ( + "context" + "net/http" + "net/url" + "slices" + "strconv" + "time" + + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// writeResponse issues the authorization response for a completed auth request: +// it mints the code (and, for implicit/hybrid flows, the tokens) and redirects +// the browser back to the client, or renders the out-of-band page. +func (h *Handler) writeResponse(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) { + h.Sessions.UpdateTokenIssuedAt(r, authReq.ClientID) + + ctx := r.Context() + if h.Now().After(authReq.Expiry) { + h.renderError(r, w, http.StatusBadRequest, "User session has expired.") + return + } + + if err := h.Storage.DeleteAuthRequest(ctx, authReq.ID); err != nil { + if err != storage.ErrNotFound { + h.Logger.ErrorContext(r.Context(), "Failed to delete authorization request", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + } else { + h.renderError(r, w, http.StatusBadRequest, "User session error.") + } + return + } + u, err := url.Parse(authReq.RedirectURI) + if err != nil { + h.renderError(r, w, http.StatusInternalServerError, "Invalid redirect URI.") + return + } + + // Resolved once for the whole response. Every artifact below has to name the + // same session, and resolving it is a storage read that can clear a stale + // cookie โ€” not something to repeat two or three times while writing one + // response. + resp := &authResponse{sessionID: h.sessionID(ctx, w, r)} + for _, handle := range []responseTypeHandler{ + h.issueCode, + h.issueAccessToken, + h.issueIDToken, + } { + if !handle(ctx, w, r, authReq, resp) { + return // the handler already wrote the response (error or OOB) + } + } + + if resp.implicitOrHybrid { + v := url.Values{} + if resp.accessToken != "" { + v.Set("access_token", resp.accessToken) + v.Set("token_type", "bearer") + // The hybrid flow with "code token" or "code id_token token" doesn't return an + // "expires_in" value. If "code" wasn't provided, indicating the implicit flow, + // don't add it. + // + // https://openid.net/specs/openid-connect-core-1_0.html#HybridAuthResponse + if resp.code.ID == "" { + v.Set("expires_in", strconv.Itoa(int(resp.idTokenExpiry.Sub(h.Now()).Seconds()))) + } + } + v.Set("state", authReq.State) + if resp.idToken != "" { + v.Set("id_token", resp.idToken) + } + if resp.code.ID != "" { + v.Set("code", resp.code.ID) + } + + // Implicit and hybrid flows return their values as part of the fragment. + // + // HTTP/1.1 303 See Other + // Location: https://client.example.org/cb# + // access_token=SlAV32hkKG + // &token_type=bearer + // &id_token=eyJ0 ... NiJ9.eyJ1c ... I6IjIifX0.DeWt4Qu ... ZXso + // &expires_in=3600 + // &state=af0ifjsldkj + // + u.Fragment = v.Encode() + } else { + // The code flow add values to the URL query. + // + // HTTP/1.1 303 See Other + // Location: https://client.example.org/cb? + // code=SplxlOBeZQQYbYS6WxSbIA + // &state=af0ifjsldkj + // + q := u.Query() + q.Set("code", resp.code.ID) + q.Set("state", authReq.State) + u.RawQuery = q.Encode() + } + + http.Redirect(w, r, u.String(), http.StatusSeeOther) +} + +// authResponse accumulates the artifacts each response-type handler produces +// for the authorization response. +type authResponse struct { + // Was the initial request using the implicit or hybrid flow instead of the + // "normal" code flow? + implicitOrHybrid bool + + // Only present in hybrid or code flow. code.ID == "" if this is not set. + code storage.AuthCode + + // Access token, present when response_type includes "token". + accessToken string + + // ID token, present when response_type includes "id_token". Only valid for + // implicit and hybrid flows. + idToken string + idTokenExpiry time.Time + + // sessionID is the browser session everything in this response comes from. + sessionID string +} + +// responseTypeHandler produces the response for a single OAuth2 response_type. +// It self-selects on authReq.ResponseTypes, populates resp, and returns false +// (after writing an error or OOB page itself) to abort the response. +type responseTypeHandler func(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, resp *authResponse) bool + +// sessionID names the browser session this response is issued from, or "" when there +// is none. The browser is on the other end of this request, so its cookie is the +// answer โ€” a lookup by user would pick whichever session that user has open, which on +// a second device is somebody else's. +func (h *Handler) sessionID(ctx context.Context, w http.ResponseWriter, r *http.Request) string { + if s := h.Sessions.ValidSession(ctx, w, r); s != nil { + return s.ID + } + return "" +} + +// issueCode handles the "code" response_type: it mints and stores an auth code. +func (h *Handler) issueCode(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, resp *authResponse) bool { + if !slices.Contains(authReq.ResponseTypes, oauth2.ResponseTypeCode) { + return true + } + resp.code = storage.AuthCode{ + ID: storage.NewID(), + SessionID: resp.sessionID, + ClientID: authReq.ClientID, + ConnectorID: authReq.ConnectorID, + Nonce: authReq.Nonce, + Scopes: authReq.Scopes, + Claims: authReq.Claims, + Expiry: h.Now().Add(time.Minute * 30), + RedirectURI: authReq.RedirectURI, + ConnectorData: authReq.ConnectorData, + PKCE: authReq.PKCE, + AuthTime: authReq.AuthTime, + } + if err := h.Storage.CreateAuthCode(ctx, resp.code); err != nil { + h.Logger.ErrorContext(r.Context(), "Failed to create auth code", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return false + } + + // Implicit and hybrid flows that try to use the OOB redirect URI are + // rejected earlier. If we got here we're using the code flow. + if authReq.RedirectURI == oauth2.RedirectURIOOB { + if err := h.Templates.OOB(r, w, resp.code.ID); err != nil { + h.Logger.ErrorContext(r.Context(), "server template error", "err", err) + } + return false // OOB fully rendered the response + } + return true +} + +// issueAccessToken handles the "token" response_type: it signs an access token. +func (h *Handler) issueAccessToken(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, resp *authResponse) bool { + if !slices.Contains(authReq.ResponseTypes, oauth2.ResponseTypeToken) { + return true + } + resp.implicitOrHybrid = true + accessToken, _, err := h.Issuer.SignAccessToken(ctx, tokens.Authorization{ + Client: storage.Client{ID: authReq.ClientID}, + Claims: authReq.Claims, + Scopes: authReq.Scopes, + ConnectorID: authReq.ConnectorID, + Nonce: authReq.Nonce, + AuthTime: authReq.AuthTime, + SessionID: resp.sessionID, + }) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to create new access token", "err", err) + h.writeError(w, oauth2.ServerError, "", http.StatusInternalServerError) + return false + } + resp.accessToken = accessToken + return true +} + +// issueIDToken handles the "id_token" response_type. It runs after issueCode and +// issueAccessToken because the id_token signature binds the code and access token. +func (h *Handler) issueIDToken(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, resp *authResponse) bool { + if !slices.Contains(authReq.ResponseTypes, oauth2.ResponseTypeIDToken) { + return true + } + resp.implicitOrHybrid = true + idToken, idTokenExpiry, err := h.Issuer.SignIDToken(ctx, tokens.Authorization{ + Client: storage.Client{ID: authReq.ClientID}, + Claims: authReq.Claims, + Scopes: authReq.Scopes, + ConnectorID: authReq.ConnectorID, + Nonce: authReq.Nonce, + AuthTime: authReq.AuthTime, + SessionID: resp.sessionID, + }, resp.accessToken, resp.code.ID) + if err != nil { + h.Logger.ErrorContext(r.Context(), "failed to create ID token", "err", err) + h.writeError(w, oauth2.ServerError, "", http.StatusInternalServerError) + return false + } + resp.idToken = idToken + resp.idTokenExpiry = idTokenExpiry + return true +} + +// writeError writes an OAuth2 error response for the token-bearing flows. +func (h *Handler) writeError(w http.ResponseWriter, typ string, description string, statusCode int) { + oauth2.WriteErrorResponse(h.Logger, w, typ, description, statusCode) +} diff --git a/server/authflow/sessionlogin.go b/server/authflow/sessionlogin.go new file mode 100644 index 0000000000..bd0200a7cf --- /dev/null +++ b/server/authflow/sessionlogin.go @@ -0,0 +1,123 @@ +package authflow + +import ( + "context" + "net/http" + "time" + + "github.com/dexidp/dex/storage" +) + +func (h *Handler) trySessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest) bool { + session := h.Sessions.ValidAuthSession(ctx, w, r, authReq) + return h.trySessionLoginWithSession(ctx, r, w, authReq, session) +} + +// trySessionLoginWithSession completes the login from an existing session: a +// direct session for the client, or, failing that, an SSO session shared by +// another client. SSO sharing is unidirectional โ€” a source sharing with a target +// does not mean the target shares back. Returns false when no session applies. +func (h *Handler) trySessionLoginWithSession(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest, session *storage.AuthSession) bool { + if session == nil { + return false + } + + now := h.Now() + + _, directLogin := session.ClientStates[authReq.ClientID] + if !directLogin { + // No direct session for this client โ€” try SSO from a sharing client. + sourceState := h.Sessions.FindSSO(ctx, session, authReq.ClientID) + if sourceState == nil { + return false + } + + // Create a new client state for the target client via SSO. It carries the + // source's authentication time: the user did not authenticate again here. + if err := h.Storage.UpdateAuthSession(ctx, session.ID, func(old storage.AuthSession) (storage.AuthSession, error) { + if old.ClientStates == nil { + old.ClientStates = make(map[string]*storage.ClientAuthState) + } + old.ClientStates[authReq.ClientID] = &storage.ClientAuthState{ + AuthenticatedAt: sourceState.AuthenticatedAt, + LastActivity: now, + ViaSSO: true, + } + old.LastActivity = now + old.IdleExpiry = h.Sessions.IdleExpiry(now) + return old, nil + }); err != nil { + h.Logger.ErrorContext(ctx, "session: failed to create SSO client state", "err", err) + return false + } + + h.Logger.DebugContext(ctx, "session: SSO login from sharing client", + "user_id", session.UserID, "connector_id", session.ConnectorID, "client_id", authReq.ClientID) + } + + // Load identity from storage (same path for direct and SSO login). + ui, err := h.Storage.GetUserIdentity(ctx, session.UserID, session.ConnectorID) + if err != nil { + h.Logger.ErrorContext(ctx, "session: failed to get user identity", "err", err) + return false + } + + // Check max_age: if the user's last authentication is too old, force re-auth. + if authReq.MaxAge >= 0 { + if now.Sub(ui.LastLogin) > time.Duration(authReq.MaxAge)*time.Second { + return false + } + } + + if directLogin { + h.Logger.DebugContext(ctx, "session: re-authenticated from session", + "session_id", session.ID, "user_id", session.UserID) + } + + return h.finishSessionLogin(ctx, r, w, authReq, session, &ui, now) +} + +// finishSessionLogin completes a session-based login (direct or SSO) by updating the auth request +// with the user's identity, refreshing session activity, and returning the appropriate redirect URL. +func (h *Handler) finishSessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest, session *storage.AuthSession, ui *storage.UserIdentity, now time.Time) bool { + claims := storage.Claims{ + UserID: ui.Claims.UserID, + Username: ui.Claims.Username, + PreferredUsername: ui.Claims.PreferredUsername, + Email: ui.Claims.Email, + EmailVerified: ui.Claims.EmailVerified, + Groups: ui.Claims.Groups, + } + + // Update AuthRequest with stored identity and auth_time from last login. + if err := h.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.LoggedIn = true + a.Claims = claims + a.ConnectorID = session.ConnectorID + a.AuthTime = ui.LastLogin + return a, nil + }); err != nil { + h.Logger.ErrorContext(ctx, "session: failed to update auth request", "err", err) + return false + } + + // Update session activity. + _ = h.Storage.UpdateAuthSession(ctx, session.ID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.LastActivity = now + old.IdleExpiry = h.Sessions.IdleExpiry(now) + if cs, ok := old.ClientStates[authReq.ClientID]; ok { + cs.LastActivity = now + } + return old, nil + }) + + // Re-read to get the updated AuthRequest (LoggedIn, Claims, ConnectorID set above), + // then let the shared decision pick the next step. + updated, err := h.Storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + h.Logger.ErrorContext(ctx, "session: failed to get auth request", "err", err) + return false + } + http.Redirect(w, r, h.buildContinueURL(updated), http.StatusSeeOther) + return true +} diff --git a/server/authflow/sessionlogin_test.go b/server/authflow/sessionlogin_test.go new file mode 100644 index 0000000000..39da51aa11 --- /dev/null +++ b/server/authflow/sessionlogin_test.go @@ -0,0 +1,2049 @@ +package authflow + +import ( + "crypto" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/server/mfa" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" +) + +// sessionTestServer wraps the login Handler together with the standalone consent +// component. trySessionLogin now hands off to the dispatcher by redirect, so the +// consent decision happens downstream; the wrapper keeps consent reachable for +// the few tests that toggle SkipApproval. +type sessionTestServer struct { + *Handler +} + +func newTestSessionServer(t *testing.T) *sessionTestServer { + t.Helper() + + now := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC) + issuerURL, err := url.Parse("https://example.com/dex") + require.NoError(t, err) + + sessionCfg := &session.Config{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: 1 * time.Hour, + } + h := &Handler{ + Storage: memory.New(nil), + Now: func() time.Time { return now }, + Logger: slog.Default(), + IssuerURL: oauth2.IssuerURL{URL: *issuerURL}, + } + h.Connectors = connectors.NewCache(h.Storage, testResolveConnector) + h.Sessions = &session.Manager{Storage: h.Storage, Config: sessionCfg, Now: h.Now, Logger: slog.Default(), IssuerURL: oauth2.IssuerURL{URL: *issuerURL}} + return &sessionTestServer{Handler: h} +} + +func TestSetSessionCookie(t *testing.T) { + s := newTestSessionServer(t) + w := httptest.NewRecorder() + + s.Sessions.SetCookie(w, "session1", "secret1", false) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + + c := cookies[0] + assert.Equal(t, "dex_session", c.Name) + assert.Equal(t, internal.SessionCookieValue("session1", "secret1", nil), c.Value) + assert.Equal(t, "/dex", c.Path) + assert.True(t, c.HttpOnly) + assert.True(t, c.Secure) + assert.Equal(t, http.SameSiteLaxMode, c.SameSite) +} + +func TestSetSessionCookie_HTTP(t *testing.T) { + s := newTestSessionServer(t) + u, _ := url.Parse("http://localhost:5556/dex") + resetSessions(s, &session.Config{CookieName: "dex_session"}, *u) + w := httptest.NewRecorder() + + s.Sessions.SetCookie(w, "session1", "secret1", false) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.False(t, cookies[0].Secure) +} + +func TestClearSessionCookie(t *testing.T) { + s := newTestSessionServer(t) + w := httptest.NewRecorder() + + s.Sessions.ClearCookie(w) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, -1, cookies[0].MaxAge) + assert.Equal(t, "", cookies[0].Value) +} + +func TestSessionCookieValueRoundtrip(t *testing.T) { + tests := []struct { + name string + sessionID string + secret string + }{ + {"simple", "session1", "abc123"}, + {"with special chars", "session@1", "xyz789"}, + {"unicode", "ัะตััะธั", "ัะตะบั€ะตั‚"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := internal.SessionCookieValue(tt.sessionID, tt.secret, nil) + gotID, gotSecret, err := internal.ParseSessionCookie(value, nil) + require.NoError(t, err) + assert.Equal(t, tt.sessionID, gotID) + assert.Equal(t, tt.secret, gotSecret) + }) + } +} + +func TestSessionCookieValueEncryptedRoundtrip(t *testing.T) { + key := []byte("0123456789abcdef") // 16 bytes = AES-128 + + value := internal.SessionCookieValue("session1", "secret1", key) + // Encrypted value must differ from unencrypted. + unencrypted := internal.SessionCookieValue("session1", "secret1", nil) + assert.NotEqual(t, unencrypted, value) + + // Must decrypt correctly. + gotID, gotSecret, err := internal.ParseSessionCookie(value, key) + require.NoError(t, err) + assert.Equal(t, "session1", gotID) + assert.Equal(t, "secret1", gotSecret) + + // Wrong key must fail. + wrongKey := []byte("abcdef0123456789") + _, _, err = internal.ParseSessionCookie(value, wrongKey) + assert.Error(t, err) + + // No key must fail (encrypted value isn't valid protobuf). + _, _, err = internal.ParseSessionCookie(value, nil) + assert.Error(t, err) +} + +func TestParseSessionCookie_Invalid(t *testing.T) { + _, _, err := internal.ParseSessionCookie("invalid", nil) + assert.Error(t, err) + _, _, err = internal.ParseSessionCookie("a.b", nil) + assert.Error(t, err) +} + +func TestGetValidAuthSession(t *testing.T) { + ctx := t.Context() + authReq := &storage.AuthRequest{ConnectorID: "conn1"} + + t.Run("no session config", func(t *testing.T) { + s := newTestSessionServer(t) + resetSessions(s, nil, url.URL{}) + r := httptest.NewRequest(http.MethodGet, "/", nil) + assert.Nil(t, s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), r, authReq)) + }) + + t.Run("no cookie", func(t *testing.T) { + s := newTestSessionServer(t) + r := httptest.NewRequest(http.MethodGet, "/", nil) + assert.Nil(t, s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), r, authReq)) + }) + + t.Run("invalid cookie format", func(t *testing.T) { + s := newTestSessionServer(t) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: "invalid-format"}) + w := httptest.NewRecorder() + assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, authReq)) + // Cookie should be cleared. + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + }) + + t.Run("session not found", func(t *testing.T) { + s := newTestSessionServer(t) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("nonce", "nonce", nil)}) + w := httptest.NewRecorder() + assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, authReq)) + // Cookie should be cleared. + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + }) + + t.Run("valid session", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + nonce := "test-nonce" + + session := storage.AuthSession{ + UserID: "user1", + ConnectorID: "conn1", + ID: nonce, Secret: nonce, + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-5 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(1 * time.Hour), + } + require.NoError(t, s.Storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)}) + + result := s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), r, authReq) + require.NotNil(t, result) + assert.Equal(t, "user1", result.UserID) + assert.Equal(t, "conn1", result.ConnectorID) + }) + + t.Run("connector mismatch", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + nonce := "test-nonce-conn" + + session := storage.AuthSession{ + UserID: "user1", + ConnectorID: "ldap", + ID: nonce, Secret: nonce, + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-5 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(1 * time.Hour), + } + require.NoError(t, s.Storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)}) + + githubReq := &storage.AuthRequest{ConnectorID: "github"} + assert.Nil(t, s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), r, githubReq)) + }) + + t.Run("nonce mismatch", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + session := storage.AuthSession{ + UserID: "user2", + ConnectorID: "conn2", + ID: "correct-nonce", Secret: "correct-nonce", + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-5 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(1 * time.Hour), + } + require.NoError(t, s.Storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("wrong-nonce", "wrong-nonce", nil)}) + + conn2Req := &storage.AuthRequest{ConnectorID: "conn2"} + w := httptest.NewRecorder() + assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, conn2Req)) + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + }) + + t.Run("expired absolute lifetime", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + nonce := "expired-nonce" + + session := storage.AuthSession{ + UserID: "user3", + ConnectorID: "conn3", + ID: nonce, Secret: nonce, + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-25 * time.Hour), + LastActivity: now.Add(-1 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(-1 * time.Hour), + IdleExpiry: now.Add(1 * time.Hour), + } + require.NoError(t, s.Storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)}) + + conn3Req := &storage.AuthRequest{ConnectorID: "conn3"} + w := httptest.NewRecorder() + assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, conn3Req)) + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + + // Session should be deleted. + _, err := s.Storage.GetAuthSession(ctx, nonce) + assert.ErrorIs(t, err, storage.ErrNotFound) + }) + + t.Run("expired idle timeout", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + nonce := "idle-nonce" + + session := storage.AuthSession{ + UserID: "user4", + ConnectorID: "conn4", + ID: nonce, Secret: nonce, + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-2 * time.Hour), + LastActivity: now.Add(-2 * time.Hour), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(22 * time.Hour), + IdleExpiry: now.Add(-1 * time.Hour), + } + require.NoError(t, s.Storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)}) + + conn4Req := &storage.AuthRequest{ConnectorID: "conn4"} + w := httptest.NewRecorder() + assert.Nil(t, s.Sessions.ValidAuthSession(ctx, w, r, conn4Req)) + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + + // Session should be deleted. + _, err := s.Storage.GetAuthSession(ctx, nonce) + assert.ErrorIs(t, err, storage.ErrNotFound) + }) +} + +func TestCreateOrUpdateAuthSession(t *testing.T) { + ctx := t.Context() + + t.Run("create new session", func(t *testing.T) { + s := newTestSessionServer(t) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + authReq := storage.AuthRequest{ + ID: "auth-1", + ClientID: "client-1", + Claims: storage.Claims{UserID: "user-1"}, + ConnectorID: "mock", + } + + err := s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, false) + require.NoError(t, err) + + // Cookie should be set. + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + + sessionID, secret, err := internal.ParseSessionCookie(cookies[0].Value, nil) + require.NoError(t, err) + assert.NotEmpty(t, sessionID) + assert.NotEmpty(t, secret) + assert.NotEqual(t, sessionID, secret, "the published id must not be the proof") + + // Session should exist in storage. + session, err := s.Storage.GetAuthSession(ctx, sessionID) + require.NoError(t, err) + assert.Equal(t, "user-1", session.UserID) + assert.Equal(t, "mock", session.ConnectorID) + require.Contains(t, session.ClientStates, "client-1") + assert.False(t, session.ClientStates["client-1"].AuthenticatedAt.IsZero()) + }) + + t.Run("update existing session", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + nonce := "existing-nonce" + + existingSession := storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ID: nonce, Secret: nonce, + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + AuthenticatedAt: now.Add(-10 * time.Minute), + LastActivity: now.Add(-10 * time.Minute), + }, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-10 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(50 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthSession(ctx, existingSession)) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + // Same browser: it presents the cookie of the session it already has. + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(nonce, nonce, nil)}) + + authReq := storage.AuthRequest{ + ID: "auth-2", + ClientID: "client-2", + Claims: storage.Claims{UserID: "user-1"}, + ConnectorID: "mock", + } + + err := s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, false) + require.NoError(t, err) + + // Cookie should name the same session. + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + gotID, _, err := internal.ParseSessionCookie(cookies[0].Value, nil) + require.NoError(t, err) + assert.Equal(t, nonce, gotID) + + // Session should have both clients. + session, err := s.Storage.GetAuthSession(ctx, nonce) + require.NoError(t, err) + assert.Len(t, session.ClientStates, 2) + assert.Contains(t, session.ClientStates, "client-1") + assert.Contains(t, session.ClientStates, "client-2") + }) + + // The whole point of keying a session by its own id: the cookie decides whether + // there is a session to continue, so a second browser starts its own instead of + // joining the first and taking its cookie. + t.Run("a second browser gets its own session", func(t *testing.T) { + s := newTestSessionServer(t) + + authReq := storage.AuthRequest{ + ID: "auth-1", + ClientID: "client-1", + Claims: storage.Claims{UserID: "user-1"}, + ConnectorID: "mock", + } + + signIn := func(t *testing.T, r *http.Request) (id, secret string) { + t.Helper() + w := httptest.NewRecorder() + require.NoError(t, s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, false)) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + id, secret, err := internal.ParseSessionCookie(cookies[0].Value, nil) + require.NoError(t, err) + return id, secret + } + + // Same user, same connector, two browsers โ€” neither carrying a cookie. + firstID, firstSecret := signIn(t, httptest.NewRequest(http.MethodGet, "/", nil)) + secondID, secondSecret := signIn(t, httptest.NewRequest(http.MethodGet, "/", nil)) + + assert.NotEqual(t, firstID, secondID, "a browser without a cookie must not join an existing session") + assert.NotEqual(t, firstSecret, secondSecret) + + // Both are stored, and neither took the other's cookie. + first, err := s.Storage.GetAuthSession(ctx, firstID) + require.NoError(t, err, "the first browser's session must survive the second signing in") + assert.Equal(t, firstSecret, first.Secret) + + second, err := s.Storage.GetAuthSession(ctx, secondID) + require.NoError(t, err) + assert.Equal(t, secondSecret, second.Secret) + + // The same browser signing in again continues the session it already has. + againID, _ := signIn(t, sessionCookieRequest2(firstID, firstSecret)) + assert.Equal(t, firstID, againID, "a browser with its cookie must continue its own session") + + sessions, err := s.Storage.ListAuthSessions(ctx) + require.NoError(t, err) + assert.Len(t, sessions, 2) + }) + + t.Run("nil session config", func(t *testing.T) { + s := newTestSessionServer(t) + resetSessions(s, nil, url.URL{}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + err := s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, storage.AuthRequest{}, false) + assert.NoError(t, err) + assert.Empty(t, w.Result().Cookies()) + }) +} + +// setupSessionLoginFixture creates the necessary storage objects for trySessionLogin tests. +func setupSessionLoginFixture(t *testing.T, s *sessionTestServer) storage.AuthRequest { + t.Helper() + ctx := t.Context() + now := s.Now() + + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + AuthenticatedAt: now.Add(-1 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + }, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(59 * time.Minute), + })) + + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: storage.Claims{ + UserID: "user-1", + Username: "testuser", + Email: "test@example.com", + }, + Consents: map[string][]string{"client-1": {"openid", "email"}}, + CreatedAt: now.Add(-1 * time.Hour), + LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-1", + ConnectorID: "mock", + Scopes: []string{"openid", "email"}, + RedirectURI: "http://localhost/callback", + MaxAge: -1, + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + return authReq +} + +// sessionCookieRequest2 is sessionCookieRequest for the tests that care that the +// two halves of the cookie differ. +func sessionCookieRequest2(sessionID, secret string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(sessionID, secret, nil)}) + return r +} + +func sessionCookieRequest(sessionID string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue(sessionID, sessionID, nil)}) + return r +} + +func TestTrySessionLogin(t *testing.T) { + ctx := t.Context() + + t.Run("no session", func(t *testing.T) { + s := newTestSessionServer(t) + authReq := storage.AuthRequest{ConnectorID: "mock"} + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok) + }) + + t.Run("successful login with skipApproval", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + authReq := setupSessionLoginFixture(t, s) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.True(t, ok) + }) + + t.Run("successful login redirects to approval", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = false + authReq := setupSessionLoginFixture(t, s) + authReq.ForceApprovalPrompt = true + + require.NoError(t, s.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.ForceApprovalPrompt = true + return a, nil + })) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + redirectURL := w.Header().Get("Location") + assert.True(t, ok) + assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher") + assert.Contains(t, redirectURL, "req="+authReq.ID) + }) + + t.Run("skips approval when consent already given", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = false + authReq := setupSessionLoginFixture(t, s) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.True(t, ok) + }) + + t.Run("connector mismatch returns false", func(t *testing.T) { + s := newTestSessionServer(t) + authReq := setupSessionLoginFixture(t, s) + authReq.ConnectorID = "github" + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok) + }) + + t.Run("no client state for requested client", func(t *testing.T) { + s := newTestSessionServer(t) + authReq := setupSessionLoginFixture(t, s) + authReq.ClientID = "unknown-client" + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok) + }) + + t.Run("updates session activity", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + authReq := setupSessionLoginFixture(t, s) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + require.True(t, ok) + + session, err := s.Storage.GetAuthSession(ctx, "test-nonce") + require.NoError(t, err) + assert.Equal(t, s.Now(), session.LastActivity) + }) +} + +// setupSessionWithIdentity creates an AuthSession, UserIdentity, and AuthRequest in storage +// for use in trySessionLogin tests. Returns the authReq. +func setupSessionWithIdentity(t *testing.T, s *sessionTestServer, now time.Time, lastLogin time.Time) storage.AuthRequest { + t.Helper() + ctx := t.Context() + nonce := "test-nonce" + + session := storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ID: nonce, Secret: nonce, + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + AuthenticatedAt: now.Add(-1 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + }, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + } + require.NoError(t, s.Storage.CreateAuthSession(ctx, session)) + + ui := storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: storage.Claims{ + UserID: "user-1", + Username: "testuser", + Email: "test@example.com", + }, + Consents: make(map[string][]string), + CreatedAt: now.Add(-1 * time.Hour), + LastLogin: lastLogin, + } + require.NoError(t, s.Storage.CreateUserIdentity(ctx, ui)) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-1", + ConnectorID: "mock", + Scopes: []string{"openid"}, + RedirectURI: "http://localhost/callback", + MaxAge: -1, + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + + return authReq +} + +func TestTrySessionLogin_MaxAge(t *testing.T) { + ctx := t.Context() + + t.Run("max_age not specified, session reused", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + authReq := setupSessionWithIdentity(t, s, now, now.Add(-2*time.Hour)) + authReq.MaxAge = -1 // not specified + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)}) + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.True(t, ok, "session should be reused when max_age is not specified") + }) + + t.Run("max_age satisfied, session reused", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + // User logged in 10 minutes ago, max_age=3600 (1 hour) + authReq := setupSessionWithIdentity(t, s, now, now.Add(-10*time.Minute)) + authReq.MaxAge = 3600 + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)}) + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.True(t, ok, "session should be reused when max_age is satisfied") + }) + + t.Run("max_age exceeded, force re-auth", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + // User logged in 2 hours ago, max_age=3600 (1 hour) + authReq := setupSessionWithIdentity(t, s, now, now.Add(-2*time.Hour)) + authReq.MaxAge = 3600 + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)}) + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok, "session should NOT be reused when max_age is exceeded") + }) + + t.Run("max_age=0, always force re-auth", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + // User logged in 1 second ago, max_age=0 + authReq := setupSessionWithIdentity(t, s, now, now.Add(-1*time.Second)) + authReq.MaxAge = 0 + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)}) + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok, "max_age=0 should always force re-authentication") + }) + + t.Run("auth_time is set from UserIdentity.LastLogin", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = false + now := s.Now() + lastLogin := now.Add(-10 * time.Minute) + + authReq := setupSessionWithIdentity(t, s, now, lastLogin) + authReq.ForceApprovalPrompt = true // force approval so AuthRequest is not deleted + + require.NoError(t, s.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.ForceApprovalPrompt = true + return a, nil + })) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: internal.SessionCookieValue("test-nonce", "test-nonce", nil)}) + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + redirectURL := w.Header().Get("Location") + require.True(t, ok) + assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher") + + // Verify AuthTime was set on the auth request. + updated, err := s.Storage.GetAuthRequest(ctx, authReq.ID) + require.NoError(t, err) + assert.Equal(t, lastLogin.Unix(), updated.AuthTime.Unix()) + }) +} + +func TestTrySessionLoginWithSession_IDTokenHint(t *testing.T) { + ctx := t.Context() + + // tokens.GenSubject("user-1", "mock") produces a deterministic subject string. + hintSubjectForUser1Mock, err := tokens.GenSubject("user-1", "mock") + require.NoError(t, err) + + hintSubjectOther, err := tokens.GenSubject("other-user", "mock") + require.NoError(t, err) + + t.Run("hint matches session user - session login succeeds", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + authReq := setupSessionLoginFixture(t, s) + + session := s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("test-nonce"), &authReq) + require.NotNil(t, session) + + // Verify hint matches. + assert.True(t, sessionMatchesHint(session, hintSubjectForUser1Mock)) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + assert.True(t, ok) + }) + + t.Run("hint does not match session user - session invalidated", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + authReq := setupSessionLoginFixture(t, s) + + session := s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("test-nonce"), &authReq) + require.NotNil(t, session) + + // Verify hint does NOT match. + assert.False(t, sessionMatchesHint(session, hintSubjectOther)) + + // Simulating the hint mismatch logic from handleConnectorLogin: + // when hint doesn't match and prompt is not none, session is set to nil. + var nilSession *storage.AuthSession + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, nilSession) + assert.False(t, ok, "session login should fail when session is invalidated due to hint mismatch") + }) + + t.Run("hint with no session - trySessionLoginWithSession returns false", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + authReq := setupSessionLoginFixture(t, s) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, nil) + assert.False(t, ok) + }) + + t.Run("no hint - unchanged behavior", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + authReq := setupSessionLoginFixture(t, s) + + session := s.Sessions.ValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("test-nonce"), &authReq) + require.NotNil(t, session) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + assert.True(t, ok) + }) +} + +func TestParseAuthRequest_PromptAndMaxAge(t *testing.T) { + t.Run("prompt=consent sets ForceApprovalPrompt", func(t *testing.T) { + authReq := storage.AuthRequest{ + Prompt: "consent", + ForceApprovalPrompt: true, + } + assert.True(t, authReq.ForceApprovalPrompt) + assert.Equal(t, "consent", authReq.Prompt) + }) + + t.Run("max_age default is -1", func(t *testing.T) { + authReq := storage.AuthRequest{ + MaxAge: -1, + } + assert.Equal(t, -1, authReq.MaxAge) + }) +} + +func TestClientSharesSessionWith(t *testing.T) { + tests := []struct { + name string + ssoSharedWith []string + defaultPolicy string + targetClientID string + want bool + }{ + { + name: "nil uses default none", + ssoSharedWith: nil, + defaultPolicy: "none", + targetClientID: "client-b", + want: false, + }, + { + name: "nil uses default all", + ssoSharedWith: nil, + defaultPolicy: "all", + targetClientID: "client-b", + want: true, + }, + { + name: "nil with empty default", + ssoSharedWith: nil, + defaultPolicy: "", + targetClientID: "client-b", + want: false, + }, + { + name: "empty slice means no sharing", + ssoSharedWith: []string{}, + defaultPolicy: "all", + targetClientID: "client-b", + want: false, + }, + { + name: "wildcard shares with everyone", + ssoSharedWith: []string{"*"}, + defaultPolicy: "none", + targetClientID: "any-client", + want: true, + }, + { + name: "explicit match", + ssoSharedWith: []string{"client-b", "client-c"}, + defaultPolicy: "none", + targetClientID: "client-b", + want: true, + }, + { + name: "no match in list", + ssoSharedWith: []string{"client-b", "client-c"}, + defaultPolicy: "none", + targetClientID: "client-d", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newTestSessionServer(t) + resetSessions(s, &session.Config{CookieName: "dex_session", AbsoluteLifetime: 24 * time.Hour, ValidIfNotUsedFor: time.Hour, SSOSharedWithDefault: tt.defaultPolicy}, url.URL{}) + + client := storage.Client{ + ID: "source-client", + SSOSharedWith: tt.ssoSharedWith, + } + got := s.Sessions.ClientSharesWith(client, tt.targetClientID) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFindSSOSession(t *testing.T) { + ctx := t.Context() + + t.Run("finds SSO session from sharing client", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", + Secret: "secret", + Name: "Client A", + SSOSharedWith: []string{"client-b"}, + })) + + session := &storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + AuthenticatedAt: now.Add(-5 * time.Minute), + LastActivity: now.Add(-5 * time.Minute), + }, + }, + } + + assert.NotNil(t, s.Sessions.FindSSO(ctx, session, "client-b")) + }) + + t.Run("no SSO when client does not share", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", + Secret: "secret", + Name: "Client A", + SSOSharedWith: []string{"client-c"}, // Does not share with client-b + })) + + session := &storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + AuthenticatedAt: now.Add(-5 * time.Minute), + LastActivity: now.Add(-5 * time.Minute), + }, + }, + } + + assert.Nil(t, s.Sessions.FindSSO(ctx, session, "client-b")) + }) + + t.Run("wildcard SSO with default all", func(t *testing.T) { + s := newTestSessionServer(t) + resetSessions(s, &session.Config{CookieName: "dex_session", AbsoluteLifetime: 24 * time.Hour, ValidIfNotUsedFor: time.Hour, SSOSharedWithDefault: "all"}, url.URL{}) + now := s.Now() + + // Client with nil SSOSharedWith โ€” uses default "all" + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", + Secret: "secret", + Name: "Client A", + // SSOSharedWith is nil โ†’ uses ssoSharedWithDefault="all" + })) + + session := &storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + AuthenticatedAt: now.Add(-5 * time.Minute), + LastActivity: now.Add(-5 * time.Minute), + }, + }, + } + + assert.NotNil(t, s.Sessions.FindSSO(ctx, session, "client-b")) + }) +} + +func TestTrySessionLogin_SSO(t *testing.T) { + ctx := t.Context() + + t.Run("SSO login from sharing client", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + now := s.Now() + + // Create source client that shares with target + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", + Secret: "secret", + Name: "Client A", + SSOSharedWith: []string{"client-b"}, + })) + + // Create session with client-a authenticated + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + AuthenticatedAt: now.Add(-1 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + }, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(59 * time.Minute), + })) + + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: storage.Claims{ + UserID: "user-1", + Username: "testuser", + Email: "test@example.com", + }, + Consents: map[string][]string{"client-b": {"openid", "email"}}, + CreatedAt: now.Add(-1 * time.Hour), + LastLogin: now.Add(-30 * time.Minute), + })) + + // Auth request for client-b (not directly in session) + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-b", + ConnectorID: "mock", + Scopes: []string{"openid", "email"}, + RedirectURI: "http://localhost/callback", + MaxAge: -1, + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + assert.True(t, ok, "SSO login should succeed") + + // Verify client-b state was created in session + updated, err := s.Storage.GetAuthSession(ctx, "test-nonce") + require.NoError(t, err) + assert.Contains(t, updated.ClientStates, "client-b") + assert.False(t, updated.ClientStates["client-b"].AuthenticatedAt.IsZero()) + }) + + t.Run("SSO derived state inherits the source authentication time", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + now := s.Now() + + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", + Secret: "secret", + Name: "Client A", + SSOSharedWith: []string{"client-b"}, + })) + + // When the source client authenticated. The derived state must say the same: + // the user did not authenticate again to reach the target client. + sourceAuthTime := now.Add(-1 * time.Minute) + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + AuthenticatedAt: sourceAuthTime, + LastActivity: now.Add(-1 * time.Minute), + }, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(59 * time.Minute), + })) + + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: storage.Claims{ + UserID: "user-1", + Username: "testuser", + Email: "test@example.com", + }, + Consents: map[string][]string{"client-b": {"openid", "email"}}, + CreatedAt: now.Add(-1 * time.Hour), + LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-b", + ConnectorID: "mock", + Scopes: []string{"openid", "email"}, + RedirectURI: "http://localhost/callback", + MaxAge: -1, + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + assert.True(t, ok, "SSO login should succeed") + + updated, err := s.Storage.GetAuthSession(ctx, "test-nonce") + require.NoError(t, err) + require.Contains(t, updated.ClientStates, "client-b") + assert.Equal(t, sourceAuthTime, updated.ClientStates["client-b"].AuthenticatedAt, + "derived state should carry the source's authentication time") + assert.True(t, updated.ClientStates["client-b"].ViaSSO) + }) + + t.Run("no SSO when client does not share", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", + Secret: "secret", + Name: "Client A", + SSOSharedWith: []string{}, // Shares with nobody + })) + + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + AuthenticatedAt: now.Add(-1 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + }, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(59 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-b", + ConnectorID: "mock", + MaxAge: -1, + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + assert.False(t, ok, "SSO login should fail when client does not share") + }) +} + +func TestFinishSessionLogin_MFA(t *testing.T) { + ctx := t.Context() + + setupMFAFixture := func(t *testing.T, mfaProviders map[string]mfa.Provider, clientMFAChain []string) (*sessionTestServer, storage.AuthRequest) { + t.Helper() + s := newTestSessionServer(t) + s.SkipApproval = true + s.MFAEnabled = len(mfaProviders) > 0 + + // Create connector in storage and register it in the connectors map. + require.NoError(t, s.Storage.CreateConnector(ctx, storage.Connector{ + ID: "mock", + Type: "ldap", + Name: "Mock LDAP", + ResourceVersion: "1", + })) + s.Connectors.Set("mock", connectors.Connector{Type: "ldap", ResourceVersion: "1"}) + + // Create client with MFA chain. + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-1", + Secret: "secret", + Name: "Test Client", + MFAChain: clientMFAChain, + })) + + authReq := setupSessionLoginFixture(t, s) + return s, authReq + } + + t.Run("MFA required redirects to MFA page", func(t *testing.T) { + s, authReq := setupMFAFixture(t, map[string]mfa.Provider{ + "totp": mfa.NewTOTPProvider("test-issuer", nil), // nil connectorTypes = enabled for all + }, []string{"totp"}) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + redirectURL := w.Header().Get("Location") + require.True(t, ok) + assert.Contains(t, redirectURL, "/auth?", "should redirect to MFA page") + assert.Contains(t, redirectURL, "req="+authReq.ID, "redirect should include auth request ID") + + // MFAValidated should NOT be set. + updated, err := s.Storage.GetAuthRequest(ctx, authReq.ID) + require.NoError(t, err) + assert.False(t, updated.MFAValidated, "MFAValidated should be false when MFA is required") + // LoggedIn should still be set even though MFA is pending. + assert.True(t, updated.LoggedIn, "LoggedIn should be true even when MFA is pending") + }) + + t.Run("MFA provider not enabled for connector type skips MFA", func(t *testing.T) { + // TOTP provider only enabled for "oidc" connectors, but our connector is "ldap". + s, authReq := setupMFAFixture(t, map[string]mfa.Provider{ + "totp": mfa.NewTOTPProvider("test-issuer", []string{"oidc"}), + }, []string{"totp"}) + require.NoError(t, s.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.ForceApprovalPrompt = true + return a, nil + })) + authReq.ForceApprovalPrompt = true + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + redirectURL := w.Header().Get("Location") + require.True(t, ok) + assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher") + }) +} + +// TestNonceVerificationRejectsForgedCookie verifies that a session cookie +// with a valid (userID, connectorID) but wrong nonce is rejected. +// The nonce comparison uses constant-time comparison to prevent timing attacks. +// TestSecretVerificationRejectsForgedCookie: the session id travels in every id +// token as the sid claim, so a cookie naming a real session proves nothing on its +// own. Only the secret does, and these cases are the ones that reach that check. +func TestSecretVerificationRejectsForgedCookie(t *testing.T) { + ctx := t.Context() + s := newTestSessionServer(t) + now := s.Now() + + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", ID: "real-session", Secret: "real-secret", + CreatedAt: now.Add(-10 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + + tests := []struct { + name string + sessionID string + secret string + }{ + // The published id with every secret an attacker could try from it. + {"right id, wrong secret", "real-session", "wrong-secret"}, + {"right id, no secret", "real-session", ""}, + {"right id, secret is the id", "real-session", "real-session"}, + {"right id, prefix of the secret", "real-session", "real"}, + {"right id, secret with suffix", "real-session", "real-secret-extra"}, + // And an id that names nothing, which never reaches the comparison. + {"unknown id", "other-session", "real-secret"}, + {"empty id", "", "real-secret"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{ + Name: "dex_session", + Value: internal.SessionCookieValue(tc.sessionID, tc.secret, nil), + }) + w := httptest.NewRecorder() + + session := s.Sessions.ValidSession(ctx, w, r) + assert.Nil(t, session, "forged cookie (%q, %q) should be rejected", tc.sessionID, tc.secret) + + // Cookie should be cleared on nonce mismatch. + for _, c := range w.Result().Cookies() { + if c.Name == "dex_session" { + assert.Equal(t, -1, c.MaxAge, "cookie should be cleared") + } + } + }) + } + + t.Run("right id and secret accepted", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{ + Name: "dex_session", + Value: internal.SessionCookieValue("real-session", "real-secret", nil), + }) + w := httptest.NewRecorder() + + session := s.Sessions.ValidSession(ctx, w, r) + require.NotNil(t, session) + assert.Equal(t, "user-1", session.UserID) + }) +} + +// TestPromptNone tests the prompt=none silent authentication scenarios. +// These verify the code paths in handleConnectorLogin (handlers.go:444-457) +// where prompt=none requires session-based login without any UI. +func TestPromptNone(t *testing.T) { + ctx := t.Context() + + t.Run("valid session with consent issues code silently", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = false + authReq := setupSessionLoginFixture(t, s) + // Fixture already sets up Consents: {"client-1": {"openid", "email"}} + // and authReq.Scopes = {"openid", "email"} โ€” consent is satisfied. + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + redirectURL := w.Header().Get("Location") + require.True(t, ok, "session login should succeed") + assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher") + }) + + t.Run("valid session without consent returns approval URL", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = false + now := s.Now() + + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(59 * time.Minute), + })) + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, // No consent for any client. + CreatedAt: now.Add(-1 * time.Hour), + LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-1", + ConnectorID: "mock", + Scopes: []string{"openid", "email"}, + RedirectURI: "http://localhost/callback", + MaxAge: -1, + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + // In handleConnectorLogin, a non-empty redirectURL with prompt=none + // triggers oauth2.InteractionRequired ("Consent required"). + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + redirectURL := w.Header().Get("Location") + require.True(t, ok, "session login should succeed (user is authenticated)") + assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher") + }) + + t.Run("no session returns false", func(t *testing.T) { + s := newTestSessionServer(t) + authReq := storage.AuthRequest{ConnectorID: "mock"} + r := httptest.NewRequest(http.MethodGet, "/", nil) // No cookie. + w := httptest.NewRecorder() + + // In handleConnectorLogin, this triggers oauth2.LoginRequired. + ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok, "should fail without session") + }) + + t.Run("SSO available issues code silently", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + now := s.Now() + + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"}, + })) + + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock", + Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + redirectURL := w.Header().Get("Location") + require.True(t, ok, "SSO silent login should succeed") + assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher") + + // Verify SSO created a new client state. + updated, err := s.Storage.GetAuthSession(ctx, "test-nonce") + require.NoError(t, err) + assert.Contains(t, updated.ClientStates, "client-b", "SSO should create client state for target") + }) + + t.Run("MFA required returns redirect not silent", func(t *testing.T) { + // This is the prompt=none + MFA case: finishSessionLogin returns MFA redirect URL. + // In handleConnectorLogin, this is a successful (ok=true) redirect, not oauth2.LoginRequired. + s := newTestSessionServer(t) + s.SkipApproval = true + s.MFAEnabled = true + + require.NoError(t, s.Storage.CreateConnector(ctx, storage.Connector{ + ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1", + })) + s.Connectors.Set("mock", connectors.Connector{Type: "ldap", ResourceVersion: "1"}) + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-1", Secret: "secret", Name: "Test", MFAChain: []string{"totp"}, + })) + + authReq := setupSessionLoginFixture(t, s) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + redirectURL := w.Header().Get("Location") + require.True(t, ok) + assert.Contains(t, redirectURL, "/auth?", "prompt=none with MFA should redirect to MFA page") + }) +} + +// TestPromptConsent tests that prompt=consent forces the approval screen +// even when consent is already given. +func TestPromptConsent(t *testing.T) { + ctx := t.Context() + + t.Run("ForceApprovalPrompt overrides existing consent in session login", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = false + authReq := setupSessionLoginFixture(t, s) + + // Set ForceApprovalPrompt (set by prompt=consent in parseAuthorizationRequest). + require.NoError(t, s.Storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.ForceApprovalPrompt = true + return a, nil + })) + authReq.ForceApprovalPrompt = true + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + ok := s.trySessionLogin(ctx, r, w, &authReq) + redirectURL := w.Header().Get("Location") + require.True(t, ok) + assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher") + }) + + t.Run("login+consent parsed correctly", func(t *testing.T) { + prompt, err := oauth2.ParsePrompt("login consent") + require.NoError(t, err) + assert.True(t, prompt.Login(), "login flag should be set") + assert.True(t, prompt.Consent(), "consent flag should be set") + }) +} + +// TestSSO_ConsentAndMFA tests SSO interactions with consent and MFA. +func TestSSO_ConsentAndMFA(t *testing.T) { + ctx := t.Context() + + // setupSSOFixture creates a two-client SSO scenario where client-a shares with client-b. + setupSSOFixture := func(t *testing.T, s *sessionTestServer, consentsForB []string) storage.AuthRequest { + t.Helper() + now := s.Now() + + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"}, + })) + + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + + consents := map[string][]string{} + if len(consentsForB) > 0 { + consents["client-b"] = consentsForB + } + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: consents, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock", + Scopes: []string{"openid", "email"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + return authReq + } + + t.Run("SSO without consent for target shows approval", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = false + authReq := setupSSOFixture(t, s, nil) // No consent for client-b. + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + redirectURL := w.Header().Get("Location") + require.True(t, ok, "SSO login should succeed") + assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher") + }) + + t.Run("SSO with consent for target skips approval", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = false + authReq := setupSSOFixture(t, s, []string{"openid", "email"}) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + redirectURL := w.Header().Get("Location") + require.True(t, ok, "SSO login should succeed") + assert.Contains(t, redirectURL, "/auth?", "session login hands off to the dispatcher") + }) + + t.Run("SSO with MFA required on target client redirects to MFA", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + s.MFAEnabled = true + + require.NoError(t, s.Storage.CreateConnector(ctx, storage.Connector{ + ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1", + })) + s.Connectors.Set("mock", connectors.Connector{Type: "ldap", ResourceVersion: "1"}) + + // client-b requires MFA. + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-b", Secret: "secret", Name: "B", MFAChain: []string{"totp"}, + })) + + authReq := setupSSOFixture(t, s, []string{"openid", "email"}) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + redirectURL := w.Header().Get("Location") + require.True(t, ok) + assert.Contains(t, redirectURL, "/auth?", "SSO to MFA-requiring client should redirect to MFA") + }) + + t.Run("SSO source without MFA target with MFA enforces MFA", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + s.MFAEnabled = true + + require.NoError(t, s.Storage.CreateConnector(ctx, storage.Connector{ + ID: "mock", Type: "ldap", Name: "Mock", ResourceVersion: "1", + })) + s.Connectors.Set("mock", connectors.Connector{Type: "ldap", ResourceVersion: "1"}) + + // client-a has NO MFA, client-b requires MFA. + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "secret", Name: "A", SSOSharedWith: []string{"client-b"}, + MFAChain: []string{}, // Explicitly no MFA. + })) + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-b", Secret: "secret", Name: "B", + MFAChain: []string{"totp"}, + })) + + now := s.Now() + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: "client-b", ConnectorID: "mock", + Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + redirectURL := w.Header().Get("Location") + require.True(t, ok) + assert.Contains(t, redirectURL, "/auth?", + "SSO from no-MFA source to MFA-requiring target must enforce MFA") + }) +} + +// TestUpdateSessionTokenIssuedAt tests session activity tracking +// when tokens are issued via sendCodeResponse (handlers.go:1016). +func TestUpdateSessionTokenIssuedAt(t *testing.T) { + ctx := t.Context() + + t.Run("updates session fields for correct client", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": {AuthenticatedAt: now.Add(-10 * time.Minute), LastActivity: now.Add(-10 * time.Minute)}, + "client-2": {AuthenticatedAt: now.Add(-10 * time.Minute), LastActivity: now.Add(-10 * time.Minute)}, + }, + CreatedAt: now.Add(-1 * time.Hour), LastActivity: now.Add(-10 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(50 * time.Minute), + })) + + r := sessionCookieRequest("test-nonce") + s.Sessions.UpdateTokenIssuedAt(r, "client-1") + + session, err := s.Storage.GetAuthSession(ctx, "test-nonce") + require.NoError(t, err) + + assert.Equal(t, now, session.LastActivity, "session LastActivity should be updated") + assert.Equal(t, s.Sessions.IdleExpiry(now), session.IdleExpiry, "IdleExpiry should be extended") + assert.Equal(t, now, session.ClientStates["client-1"].LastTokenIssuedAt, "client-1 LastTokenIssuedAt should be set") + assert.Equal(t, now, session.ClientStates["client-1"].LastActivity, "client-1 LastActivity should be updated") + // client-2 should be untouched. + assert.Equal(t, now.Add(-10*time.Minute), session.ClientStates["client-2"].LastActivity, + "client-2 should not be affected") + }) + + t.Run("noop when sessions disabled", func(t *testing.T) { + s := newTestSessionServer(t) + resetSessions(s, nil, url.URL{}) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + // Should not panic. + s.Sessions.UpdateTokenIssuedAt(r, "any-client") + }) +} + +// TestIdleExpiryExtension verifies that session activity pushes +// IdleExpiry forward, preventing premature session expiration. +func TestIdleExpiryExtension(t *testing.T) { + ctx := t.Context() + + t.Run("createOrUpdateAuthSession extends IdleExpiry", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.Now() + + // Create an existing session with IdleExpiry close to now. + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-50 * time.Minute), + LastActivity: now.Add(-50 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(10 * time.Minute), // Only 10 minutes left. + })) + + // The browser presents its cookie, so this is the same session continuing โ€” + // without one it would be a new device and a new session. + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + authReq := storage.AuthRequest{ + ClientID: "client-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1"}, + } + + err := s.Sessions.CreateOrUpdateAuthSession(ctx, r, w, authReq, false) + require.NoError(t, err) + + session, err := s.Storage.GetAuthSession(ctx, "test-nonce") + require.NoError(t, err) + assert.Equal(t, s.Sessions.IdleExpiry(now), session.IdleExpiry, + "IdleExpiry should be reset to now + ValidIfNotUsedFor") + }) + + t.Run("finishSessionLogin extends IdleExpiry", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + now := s.Now() + + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": {AuthenticatedAt: now.Add(-50 * time.Minute), LastActivity: now.Add(-50 * time.Minute)}, + }, + CreatedAt: now.Add(-50 * time.Minute), LastActivity: now.Add(-50 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(10 * time.Minute), // About to expire. + })) + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-50 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: "client-1", ConnectorID: "mock", + Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + require.NotNil(t, session) + + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + require.True(t, ok) + + updated, err := s.Storage.GetAuthSession(ctx, "test-nonce") + require.NoError(t, err) + assert.Equal(t, s.Sessions.IdleExpiry(now), updated.IdleExpiry, + "IdleExpiry should be extended after session login") + }) +} + +// TestSSO_Unidirectional verifies that SSO sharing is one-way: +// A sharing with B does NOT mean B shares with A. +func TestSSO_Unidirectional(t *testing.T) { + ctx := t.Context() + + setup := func(t *testing.T, s *sessionTestServer, loginClient, targetClient string) (storage.AuthRequest, *storage.AuthSession) { + t.Helper() + now := s.Now() + + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + loginClient: {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), ClientID: targetClient, ConnectorID: "mock", + Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + session := s.Sessions.ValidAuthSession(ctx, w, r, &authReq) + return authReq, session + } + + t.Run("A shares with B, login A request B succeeds", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"}, + })) + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{}, // Does NOT share back. + })) + + authReq, session := setup(t, s, "client-a", "client-b") + require.NotNil(t, session) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + assert.True(t, ok, "Aโ†’B SSO should succeed") + }) + + t.Run("B does not share with A, login B request A fails", func(t *testing.T) { + s := newTestSessionServer(t) + s.SkipApproval = true + + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"}, + })) + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{}, // Does NOT share. + })) + + authReq, session := setup(t, s, "client-b", "client-a") + require.NotNil(t, session) + + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session) + assert.False(t, ok, "Bโ†’A SSO should fail because B does not share with A") + }) +} + +// TestSSO_TransitiveTrustChain verifies SSO sharing does not chain: A shares +// only with B and B shares only with C (A never shares with C), so a user +// authenticated only to A must not be SSO'd into C via B. +func TestSSO_TransitiveTrustChain(t *testing.T) { + ctx := t.Context() + + s := newTestSessionServer(t) + s.SkipApproval = true + now := s.Now() + + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-a", Secret: "s", Name: "A", SSOSharedWith: []string{"client-b"}, + })) + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-b", Secret: "s", Name: "B", SSOSharedWith: []string{"client-c"}, + })) + require.NoError(t, s.Storage.CreateClient(ctx, storage.Client{ + ID: "client-c", Secret: "s", Name: "C", SSOSharedWith: []string{}, + })) + + // User authenticated only to A. + require.NoError(t, s.Storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", ConnectorID: "mock", ID: "test-nonce", Secret: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": {AuthenticatedAt: now.Add(-1 * time.Minute), LastActivity: now.Add(-1 * time.Minute)}, + }, + CreatedAt: now.Add(-30 * time.Minute), LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(24 * time.Hour), IdleExpiry: now.Add(59 * time.Minute), + })) + require.NoError(t, s.Storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-1", Username: "testuser", Email: "test@example.com"}, + Consents: map[string][]string{}, + CreatedAt: now.Add(-1 * time.Hour), LastLogin: now.Add(-30 * time.Minute), + })) + + tryLogin := func(target string) bool { + req := storage.AuthRequest{ + ID: storage.NewID(), ClientID: target, ConnectorID: "mock", + Scopes: []string{"openid"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.Storage.CreateAuthRequest(ctx, req)) + r := sessionCookieRequest("test-nonce") + w := httptest.NewRecorder() + session := s.Sessions.ValidAuthSession(ctx, w, r, &req) + require.NotNil(t, session) + ok := s.trySessionLoginWithSession(ctx, r, w, &req, session) + return ok + } + + // Hop 1: Aโ†’B succeeds and records an SSO-derived state for B. + require.True(t, tryLogin("client-b"), "Aโ†’B SSO should succeed") + + // The derived B state must be marked ViaSSO, which is what makes it ineligible + // as a source below โ€” assert it directly so a regression localizes here. + sess, err := s.Storage.GetAuthSession(ctx, "test-nonce") + require.NoError(t, err) + require.NotNil(t, sess.ClientStates["client-b"]) + assert.True(t, sess.ClientStates["client-b"].ViaSSO, "B's SSO-derived state must be marked ViaSSO") + + // Hop 2: C has no eligible SSO source โ€” A does not share with C and B's state + // is SSO-derived. Pin the exact invariant, then the login outcome. + assert.Nil(t, s.Sessions.FindSSO(ctx, &sess, "client-c"), "no eligible SSO source for C") + assert.False(t, tryLogin("client-c"), "transitive Aโ†’Bโ†’C SSO must be denied") +} + +// TestRememberMeDefault tests that the rememberMeDefault helper +// returns the correct value based on session configuration. +func TestRememberMeDefault(t *testing.T) { + t.Run("sessions disabled returns nil", func(t *testing.T) { + s := &session.Manager{} + assert.Nil(t, s.RememberMeDefault()) + }) + + t.Run("default false", func(t *testing.T) { + s := &session.Manager{Config: &session.Config{RememberMeCheckedByDefault: false}} + v := s.RememberMeDefault() + require.NotNil(t, v) + assert.False(t, *v) + }) + + t.Run("default true", func(t *testing.T) { + s := &session.Manager{Config: &session.Config{RememberMeCheckedByDefault: true}} + v := s.RememberMeDefault() + require.NotNil(t, v) + assert.True(t, *v) + }) +} + +// resetSessions rebuilds the Handler's session manager with the given config and +// issuer, for tests that exercise Manager behavior under a different config. +func resetSessions(s *sessionTestServer, cfg *session.Config, issuer url.URL) { + s.Sessions = &session.Manager{Storage: s.Storage, Config: cfg, Now: s.Now, Logger: slog.Default(), IssuerURL: oauth2.IssuerURL{URL: issuer}} +} diff --git a/server/authflow/urls.go b/server/authflow/urls.go new file mode 100644 index 0000000000..78278ad0ce --- /dev/null +++ b/server/authflow/urls.go @@ -0,0 +1,26 @@ +package authflow + +import ( + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/storage" +) + +// buildContinueURL builds the HMAC-protected URL that returns to the /auth +// dispatcher, used once login completes so the dispatcher can pick the next step. +func (h *Handler) buildContinueURL(authReq storage.AuthRequest) string { + return internal.StepURL(h.IssuerURL.AbsPath("/auth"), authReq, internal.StepContinue, nil) +} + +// buildMFAURL builds the HMAC-protected URL of the MFA entry, where the +// dispatcher sends the user when the client requires MFA. MFA resolves the +// effective chain and picks the factor; the dispatcher only decides that MFA +// applies. +func (h *Handler) buildMFAURL(authReq storage.AuthRequest) string { + return internal.StepURL(h.IssuerURL.AbsPath("/mfa"), authReq, internal.StepMFA, nil) +} + +// buildApprovalURL builds the HMAC-protected URL of the consent screen, where the +// dispatcher sends the user when consent is required. +func (h *Handler) buildApprovalURL(authReq storage.AuthRequest) string { + return internal.StepURL(h.IssuerURL.AbsPath("/approval"), authReq, internal.StepApproval, nil) +} diff --git a/server/backchannel/backchannel.go b/server/backchannel/backchannel.go new file mode 100644 index 0000000000..d1ed698fa0 --- /dev/null +++ b/server/backchannel/backchannel.go @@ -0,0 +1,203 @@ +package backchannel + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "maps" + "net/http" + "net/url" + "slices" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/storage" +) + +const ( + // backchannelLogoutEvent is the event identifier a logout token must carry, per + // OIDC Back-Channel Logout 1.0 ยง2.4. + backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout" + + // backchannelTokenLifetime bounds the replay window for a logout token. The spec + // recommends no more than two minutes. + backchannelTokenLifetime = 2 * time.Minute + + // backchannelTimeout caps how long dex waits on one RP. Logout must not hang on a + // wedged relying party. + backchannelTimeout = 5 * time.Second +) + +// Notifier posts logout tokens to the relying parties of a session that has ended. +// +// It lives outside server/logout because a session also ends by an operator's hand +// over the gRPC API, and every path that ends one goes through here. +type Notifier struct { + Storage storage.Storage + Signer signer.Signer + IssuerURL oauth2.IssuerURL + Logger *slog.Logger + + // Now is the clock, for tests. Defaults to time.Now. + Now func() time.Time + + // HTTPClient delivers the logout tokens. Defaults to one that refuses redirects. + HTTPClient *http.Client +} + +// logoutTokenClaims is the JWT dex POSTs to a relying party's backchannel_logout_uri. +// +// Note the absences: there is no "nonce" (the spec forbids it, to keep a logout token +// from being mistaken for an ID token) and no "events" payload beyond an empty object. +type logoutTokenClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + IssuedAt int64 `json:"iat"` + Expiry int64 `json:"exp"` + JWTID string `json:"jti"` + SessionID string `json:"sid"` + Events map[string]json.RawMessage `json:"events"` +} + +// Notify tells every relying party in the session that it is over. +// +// Delivery is best-effort and fire-and-forget: RP-Initiated Logout treats notifying +// other RPs as a courtesy, and a relying party that is down must not be able to block +// or fail the user's logout. Failures are logged and dropped. +// +// ponytail: no retries and no durable queue. An RP that is unreachable for these few +// seconds keeps its session until it expires on its own. If that becomes a real +// problem, the upgrade path is to persist pending notifications and drain them from +// the garbage collector, not to make the user wait here. +func (n *Notifier) Notify(ctx context.Context, authSession *storage.AuthSession) { + if len(authSession.ClientStates) == 0 { + return + } + + subject, err := internal.Marshal(&internal.IDTokenSubject{ + UserId: authSession.UserID, + ConnId: authSession.ConnectorID, + }) + if err != nil { + n.Logger.ErrorContext(ctx, "logout: failed to marshal backchannel subject", "err", err) + return + } + + sid := authSession.ID + clientIDs := slices.Sorted(maps.Keys(authSession.ClientStates)) + + // Read above while the session is still in hand: the caller deletes it the moment + // this returns. Delivery runs off the request โ€” one wedged relying party would + // otherwise hold the user's redirect for the whole timeout โ€” so it gets a context + // that outlives the one dying at that redirect. + ctx = context.WithoutCancel(ctx) + go func() { + ctx, cancel := context.WithTimeout(ctx, backchannelTimeout) + defer cancel() + + var wg sync.WaitGroup + for _, clientID := range clientIDs { + wg.Go(func() { + client, err := n.Storage.GetClient(ctx, clientID) + if err != nil { + n.Logger.DebugContext(ctx, "logout: backchannel skipped, client not found", + "client_id", clientID, "err", err) + return + } + if client.BackchannelLogoutURI == "" { + return + } + n.deliverLogoutToken(ctx, client, subject, sid) + }) + } + wg.Wait() + }() +} + +// deliverLogoutToken mints a logout token for one client and POSTs it. +func (n *Notifier) deliverLogoutToken(ctx context.Context, client storage.Client, subject, sid string) { + token, err := n.signLogoutToken(ctx, client.ID, subject, sid) + if err != nil { + n.Logger.ErrorContext(ctx, "logout: failed to sign logout token", + "client_id", client.ID, "err", err) + return + } + + body := url.Values{"logout_token": {token}}.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.BackchannelLogoutURI, strings.NewReader(body)) + if err != nil { + n.Logger.ErrorContext(ctx, "logout: failed to build backchannel request", + "client_id", client.ID, "err", err) + return + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cache-Control", "no-cache, no-store") + + resp, err := n.client().Do(req) + if err != nil { + n.Logger.WarnContext(ctx, "logout: backchannel delivery failed", + "client_id", client.ID, "uri", client.BackchannelLogoutURI, "err", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + n.Logger.WarnContext(ctx, "logout: backchannel delivery rejected", + "client_id", client.ID, "uri", client.BackchannelLogoutURI, "status", resp.StatusCode) + return + } + + n.Logger.DebugContext(ctx, "logout: backchannel delivered", "client_id", client.ID) +} + +// signLogoutToken builds and signs the logout token for one audience. +func (n *Notifier) signLogoutToken(ctx context.Context, clientID, subject, sid string) (string, error) { + now := time.Now() + if n.Now != nil { + now = n.Now() + } + + claims := logoutTokenClaims{ + Issuer: n.IssuerURL.String(), + Subject: subject, + Audience: clientID, + IssuedAt: now.Unix(), + Expiry: now.Add(backchannelTokenLifetime).Unix(), + JWTID: uuid.New().String(), + SessionID: sid, + Events: map[string]json.RawMessage{backchannelLogoutEvent: json.RawMessage(`{}`)}, + } + + payload, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshal logout token: %w", err) + } + + token, err := n.Signer.Sign(ctx, payload) + if err != nil { + return "", fmt.Errorf("sign logout token: %w", err) + } + return token, nil +} + +// client returns the HTTP client used for delivery, defaulting to one with +// no redirect following: a logout token must reach the URI the client registered, not +// wherever that URI happens to point today. +func (n *Notifier) client() *http.Client { + if n.HTTPClient != nil { + return n.HTTPClient + } + return &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} diff --git a/server/config.go b/server/config.go new file mode 100644 index 0000000000..554d37ca07 --- /dev/null +++ b/server/config.go @@ -0,0 +1,293 @@ +package server + +import ( + "errors" + "fmt" + "io/fs" + "log/slog" + "net/http" + "net/netip" + "net/url" + "os" + "sort" + "time" + + gosundheit "github.com/AppsFlyer/go-sundheit" + "github.com/prometheus/client_golang/prometheus" + + "github.com/dexidp/dex/server/authflow" + "github.com/dexidp/dex/server/mfa" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/server/templates" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/web" +) + +// Config holds the server's configuration options. +// +// Multiple servers using the same storage are expected to be configured identically. +type Config struct { + Issuer string + + // The backing persistence layer. + Storage storage.Storage + + AllowedGrantTypes []string + + // Valid values are "code" to enable the code flow and "token" to enable the implicit + // flow. If no response types are supplied this value defaults to "code". + SupportedResponseTypes []string + + // Headers is a map of headers to be added to the all responses. + Headers http.Header + + // Header to extract real ip from. + RealIPHeader string + TrustedRealIPCIDRs []netip.Prefix + + // List of allowed origins for CORS requests on discovery, token and keys endpoint. + // If none are indicated, CORS requests are disabled. Passing in "*" will allow any + // domain. + AllowedOrigins []string + + // List of allowed headers for CORS requests on discovery, token, and keys endpoint. + AllowedHeaders []string + + // If enabled, the server won't prompt the user to approve authorization requests. + // Logging in implies approval. + SkipApprovalScreen bool + + // If enabled, the connectors selection page will always be shown even if there's only one + AlwaysShowLoginScreen bool + + IDTokensValidFor time.Duration // Defaults to 24 hours + AuthRequestsValidFor time.Duration // Defaults to 24 hours + DeviceRequestsValidFor time.Duration // Defaults to 5 minutes + + // Refresh token expiration settings + RefreshTokenPolicy *tokens.RefreshStrategy + + // If set, the server will use this connector to handle password grants + PasswordConnector string + + // PKCE configuration + PKCE authflow.PKCEConfig + + GCFrequency time.Duration // Defaults to 5 minutes + + // If specified, the server will use this function for determining time. + Now func() time.Time + + Web WebConfig + + Logger *slog.Logger + + // Signer is used to sign tokens. + Signer signer.Signer + + PrometheusRegistry *prometheus.Registry + + HealthChecker gosundheit.Health + + // If enabled, the server will continue starting even if some connectors fail to initialize. + // This allows the server to operate with a subset of connectors if some are misconfigured. + ContinueOnConnectorFailure bool + + // SessionConfig holds session settings. Nil when sessions are disabled. + SessionConfig *session.Config + + // MFAProviders maps authenticator IDs to their provider implementations. + MFAProviders map[string]mfa.Provider + + // DefaultMFAChain is applied to clients that don't specify their own mfaChain. + DefaultMFAChain []string +} + +// WebConfig holds the server's frontend templates and asset configuration. +type WebConfig struct { + // A file path to static web assets. + // + // It is expected to contain the following directories: + // + // * static - Static static served at "( issuer URL )/static". + // * templates - HTML templates controlled by dex. + // * themes/(theme) - Static static served at "( issuer URL )/theme". + Dir string + + // Alternative way to programmatically configure static web assets. + // If Dir is specified, WebFS is ignored. + // It's expected to contain the same files and directories as mentioned above. + // + // Note: this is experimental. Might get removed without notice! + WebFS fs.FS + + // Defaults to "( issuer URL )/theme/logo.png" + LogoURL string + + // Defaults to "dex" + Issuer string + + // Defaults to "light" + Theme string + + // Map of extra values passed into the templates + Extra map[string]string +} + +func value(val, defaultValue time.Duration) time.Duration { + if val == 0 { + return defaultValue + } + return val +} + +// resolvedConfig is Config after defaults are filled in and values validated: +// everything the handlers are wired from, derived once so newServer only has to +// hand the pieces out. +type resolvedConfig struct { + issuerURL oauth2.IssuerURL + now func() time.Time + + // responseTypes and grantTypes are what the server advertises and accepts, + // narrowed to the configured subset. + responseTypes map[string]bool + grantTypes []string + + authRequestsValidFor time.Duration + deviceRequestsValidFor time.Duration + idTokensValidFor time.Duration + + templates *templates.Templates + static http.Handler + theme http.Handler + robots http.HandlerFunc +} + +// normalizeConfig validates c and derives everything the server is built from. +// It fills c's own defaults in place (response types, allowed headers, PKCE +// methods), because the handlers read those fields directly. +func normalizeConfig(c *Config) (resolvedConfig, error) { + if c.Storage == nil { + return resolvedConfig{}, errors.New("server: storage cannot be nil") + } + + issuerURL, err := url.Parse(c.Issuer) + if err != nil { + return resolvedConfig{}, fmt.Errorf("server: can't parse issuer URL") + } + + if len(c.SupportedResponseTypes) == 0 { + c.SupportedResponseTypes = []string{oauth2.ResponseTypeCode} + } + if len(c.AllowedHeaders) == 0 { + c.AllowedHeaders = []string{"Authorization"} + } + if len(c.PKCE.CodeChallengeMethodsSupported) == 0 { + c.PKCE.CodeChallengeMethodsSupported = []string{oauth2.PKCEMethodS256, oauth2.PKCEMethodPlain} + } + for _, m := range c.PKCE.CodeChallengeMethodsSupported { + if m != oauth2.PKCEMethodS256 && m != oauth2.PKCEMethodPlain { + return resolvedConfig{}, fmt.Errorf("unsupported PKCE challenge method %q", m) + } + } + + responseTypes, grantTypes, err := supportedTypes(c) + if err != nil { + return resolvedConfig{}, err + } + + static, theme, robots, tmpls, err := templates.LoadWebConfig(webConfig(c)) + if err != nil { + return resolvedConfig{}, fmt.Errorf("server: failed to load web static: %v", err) + } + + now := c.Now + if now == nil { + now = time.Now + } + + return resolvedConfig{ + issuerURL: oauth2.IssuerURL{URL: *issuerURL}, + now: now, + responseTypes: responseTypes, + grantTypes: grantTypes, + authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), + deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), + idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), + templates: tmpls, + static: static, + theme: theme, + robots: robots, + }, nil +} + +// supportedTypes resolves the response types the server accepts and the grant +// types it advertises. A response type enabling the implicit flow adds the +// implicit grant; AllowedGrantTypes, when set, narrows the result to it. +func supportedTypes(c *Config) (map[string]bool, []string, error) { + allGrants := map[string]bool{ + oauth2.GrantTypeAuthorizationCode: true, + oauth2.GrantTypeRefreshToken: true, + oauth2.GrantTypeDeviceCode: true, + oauth2.GrantTypeTokenExchange: true, + oauth2.GrantTypeClientCredentials: true, + } + responseTypes := make(map[string]bool) + + for _, respType := range c.SupportedResponseTypes { + switch respType { + case oauth2.ResponseTypeCode, oauth2.ResponseTypeIDToken, oauth2.ResponseTypeCodeIDToken: + // continue + case oauth2.ResponseTypeToken, oauth2.ResponseTypeCodeToken, oauth2.ResponseTypeIDTokenToken, oauth2.ResponseTypeCodeIDTokenToken: + // response_type=token is an implicit flow, let's add it to the discovery info + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.1 + allGrants[oauth2.GrantTypeImplicit] = true + default: + return nil, nil, fmt.Errorf("unsupported response_type %q", respType) + } + responseTypes[respType] = true + } + + if c.PasswordConnector != "" { + allGrants[oauth2.GrantTypePassword] = true + } + + var grantTypes []string + if len(c.AllowedGrantTypes) > 0 { + for _, grant := range c.AllowedGrantTypes { + if allGrants[grant] { + grantTypes = append(grantTypes, grant) + } + } + } else { + for grant := range allGrants { + grantTypes = append(grantTypes, grant) + } + } + sort.Strings(grantTypes) + + return responseTypes, grantTypes, nil +} + +// webConfig resolves where the frontend assets are loaded from: an explicit +// directory, a caller-supplied filesystem, or the assets embedded in dex. +func webConfig(c *Config) templates.Config { + webFS := web.FS() + if c.Web.Dir != "" { + webFS = os.DirFS(c.Web.Dir) + } else if c.Web.WebFS != nil { + webFS = c.Web.WebFS + } + + return templates.Config{ + WebFS: webFS, + LogoURL: c.Web.LogoURL, + IssuerURL: c.Issuer, + Issuer: c.Web.Issuer, + Theme: c.Web.Theme, + Extra: c.Web.Extra, + } +} diff --git a/server/config_test.go b/server/config_test.go new file mode 100644 index 0000000000..b62df7615a --- /dev/null +++ b/server/config_test.go @@ -0,0 +1,117 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/storage/memory" +) + +// baseConfig is the minimum normalizeConfig accepts: storage and an issuer. +// Building it needs no server, so the config rules can be tested on their own. +func baseConfig(t *testing.T) Config { + return Config{ + Issuer: "https://dex.example.com", + Storage: memory.New(newLogger(t)), + Web: WebConfig{Dir: "../web"}, + } +} + +func TestNormalizeConfigDefaults(t *testing.T) { + c := baseConfig(t) + + rc, err := normalizeConfig(&c) + require.NoError(t, err) + + require.Equal(t, []string{oauth2.ResponseTypeCode}, c.SupportedResponseTypes) + require.Equal(t, []string{"Authorization"}, c.AllowedHeaders) + require.Equal(t, []string{oauth2.PKCEMethodS256, oauth2.PKCEMethodPlain}, c.PKCE.CodeChallengeMethodsSupported) + + require.Equal(t, "https://dex.example.com", rc.issuerURL.String()) + require.NotNil(t, rc.now) + require.NotNil(t, rc.templates) + require.Equal(t, map[string]bool{oauth2.ResponseTypeCode: true}, rc.responseTypes) + + // Without AllowedGrantTypes every implemented grant is advertised, sorted, + // and the implicit grant is absent because no implicit response type is set. + // Sorted by value, so the two grant-type URNs come last. + require.Equal(t, []string{ + oauth2.GrantTypeAuthorizationCode, + oauth2.GrantTypeClientCredentials, + oauth2.GrantTypeRefreshToken, + oauth2.GrantTypeDeviceCode, + oauth2.GrantTypeTokenExchange, + }, rc.grantTypes) +} + +func TestNormalizeConfigGrantTypes(t *testing.T) { + t.Run("an implicit response type adds the implicit grant", func(t *testing.T) { + c := baseConfig(t) + c.SupportedResponseTypes = []string{oauth2.ResponseTypeCode, oauth2.ResponseTypeToken} + + rc, err := normalizeConfig(&c) + require.NoError(t, err) + require.Contains(t, rc.grantTypes, oauth2.GrantTypeImplicit) + }) + + t.Run("a password connector adds the password grant", func(t *testing.T) { + c := baseConfig(t) + c.PasswordConnector = "local" + + rc, err := normalizeConfig(&c) + require.NoError(t, err) + require.Contains(t, rc.grantTypes, oauth2.GrantTypePassword) + }) + + t.Run("AllowedGrantTypes narrows the set", func(t *testing.T) { + c := baseConfig(t) + c.AllowedGrantTypes = []string{oauth2.GrantTypeRefreshToken, "not-a-grant"} + + rc, err := normalizeConfig(&c) + require.NoError(t, err) + require.Equal(t, []string{oauth2.GrantTypeRefreshToken}, rc.grantTypes) + }) +} + +func TestNormalizeConfigRejects(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + errMsg string + }{ + { + name: "nil storage", + mutate: func(c *Config) { c.Storage = nil }, + errMsg: "storage cannot be nil", + }, + { + name: "unparseable issuer", + mutate: func(c *Config) { c.Issuer = "://" }, + errMsg: "can't parse issuer URL", + }, + { + name: "unknown response type", + mutate: func(c *Config) { c.SupportedResponseTypes = []string{"nonsense"} }, + errMsg: `unsupported response_type "nonsense"`, + }, + { + name: "unknown PKCE method", + mutate: func(c *Config) { + c.PKCE.CodeChallengeMethodsSupported = []string{"S512"} + }, + errMsg: `unsupported PKCE challenge method "S512"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := baseConfig(t) + tc.mutate(&c) + + _, err := normalizeConfig(&c) + require.ErrorContains(t, err, tc.errMsg) + }) + } +} diff --git a/server/connector.go b/server/connector.go new file mode 100644 index 0000000000..172031c68b --- /dev/null +++ b/server/connector.go @@ -0,0 +1,46 @@ +package server + +import ( + "github.com/dexidp/dex/connector/atlassiancrowd" + "github.com/dexidp/dex/connector/authproxy" + "github.com/dexidp/dex/connector/bitbucketcloud" + "github.com/dexidp/dex/connector/gitea" + "github.com/dexidp/dex/connector/github" + "github.com/dexidp/dex/connector/gitlab" + "github.com/dexidp/dex/connector/google" + "github.com/dexidp/dex/connector/keystone" + "github.com/dexidp/dex/connector/ldap" + "github.com/dexidp/dex/connector/linkedin" + "github.com/dexidp/dex/connector/microsoft" + "github.com/dexidp/dex/connector/mock" + "github.com/dexidp/dex/connector/oauth" + "github.com/dexidp/dex/connector/oidc" + "github.com/dexidp/dex/connector/openshift" + "github.com/dexidp/dex/connector/saml" + "github.com/dexidp/dex/server/connectors" +) + +// ConnectorsConfig maps each built-in connector type to its config factory. It +// is handed to connectors.Resolver so the connectors package itself imports no +// connector implementation; a library consumer can pass a different map. +var ConnectorsConfig = map[string]func() connectors.ConnectorConfig{ + "keystone": func() connectors.ConnectorConfig { return new(keystone.Config) }, + "mockCallback": func() connectors.ConnectorConfig { return new(mock.CallbackConfig) }, + "mockPassword": func() connectors.ConnectorConfig { return new(mock.PasswordConfig) }, + "ldap": func() connectors.ConnectorConfig { return new(ldap.Config) }, + "gitea": func() connectors.ConnectorConfig { return new(gitea.Config) }, + "github": func() connectors.ConnectorConfig { return new(github.Config) }, + "gitlab": func() connectors.ConnectorConfig { return new(gitlab.Config) }, + "google": func() connectors.ConnectorConfig { return new(google.Config) }, + "oidc": func() connectors.ConnectorConfig { return new(oidc.Config) }, + "oauth": func() connectors.ConnectorConfig { return new(oauth.Config) }, + "saml": func() connectors.ConnectorConfig { return new(saml.Config) }, + "authproxy": func() connectors.ConnectorConfig { return new(authproxy.Config) }, + "linkedin": func() connectors.ConnectorConfig { return new(linkedin.Config) }, + "microsoft": func() connectors.ConnectorConfig { return new(microsoft.Config) }, + "bitbucket-cloud": func() connectors.ConnectorConfig { return new(bitbucketcloud.Config) }, + "openshift": func() connectors.ConnectorConfig { return new(openshift.Config) }, + "atlassian-crowd": func() connectors.ConnectorConfig { return new(atlassiancrowd.Config) }, + // Keep around for backwards compatibility. + "samlExperimental": func() connectors.ConnectorConfig { return new(saml.Config) }, +} diff --git a/server/connectors/connectors.go b/server/connectors/connectors.go new file mode 100644 index 0000000000..a028d54945 --- /dev/null +++ b/server/connectors/connectors.go @@ -0,0 +1,115 @@ +package connectors + +import ( + "context" + "fmt" + "sync" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/storage" +) + +// Connector is a connector with resource version metadata. +type Connector struct { + Type string + ResourceVersion string + Connector connector.Connector + GrantTypes []string +} + +// ResolveFunc builds the underlying connector implementation for a stored +// connector. The server injects it so that connector construction (the local +// password DB and the connector-config registry) stays in the server package. +type ResolveFunc func(storage.Connector) (connector.Connector, error) + +// Cache resolves connectors from storage and keeps the opened instances in +// memory, refreshing an entry when its stored resource version changes. It is +// the sole owner of the connector map and its mutex. +type Cache struct { + mu sync.Mutex + conns map[string]Connector + storage storage.Storage + resolve ResolveFunc +} + +// NewCache returns an empty cache backed by the given storage and resolver. +func NewCache(storage storage.Storage, resolve ResolveFunc) *Cache { + return &Cache{ + conns: make(map[string]Connector), + storage: storage, + resolve: resolve, + } +} + +// Open builds the connector for the given stored connector and records it in the +// cache, replacing any existing entry for the same ID. +func (c *Cache) Open(conn storage.Connector) (Connector, error) { + impl, err := c.resolve(conn) + if err != nil { + return Connector{}, fmt.Errorf("failed to open connector: %v", err) + } + + opened := Connector{ + Type: conn.Type, + ResourceVersion: conn.ResourceVersion, + Connector: impl, + GrantTypes: conn.GrantTypes, + } + + c.mu.Lock() + c.conns[conn.ID] = opened + c.mu.Unlock() + + return opened, nil +} + +// Set records an already-opened connector under the given id, replacing any +// existing entry. It is used to inject connectors that are not built from stored +// config (for example the built-in local connector, or mocks in tests). +func (c *Cache) Set(id string, conn Connector) { + c.mu.Lock() + c.conns[id] = conn + c.mu.Unlock() +} + +// Cached returns the connector currently held for id without consulting storage. +func (c *Cache) Cached(id string) (Connector, bool) { + c.mu.Lock() + defer c.mu.Unlock() + conn, ok := c.conns[id] + return conn, ok +} + +// Len reports the number of cached connectors. +func (c *Cache) Len() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.conns) +} + +// Close removes the connector from the in-memory cache. +func (c *Cache) Close(id string) { + c.mu.Lock() + delete(c.conns, id) + c.mu.Unlock() +} + +// Get returns the connector with the given id, opening (or reopening) it when it +// is missing from the cache or its stored resource version has changed. +func (c *Cache) Get(ctx context.Context, id string) (Connector, error) { + storageConnector, err := c.storage.GetConnector(ctx, id) + if err != nil { + return Connector{}, fmt.Errorf("failed to get connector object from storage: %v", err) + } + + c.mu.Lock() + conn, ok := c.conns[id] + c.mu.Unlock() + + if !ok || storageConnector.ResourceVersion != conn.ResourceVersion { + // Not cached, or updated in storage since we last opened it. + return c.Open(storageConnector) + } + + return conn, nil +} diff --git a/server/connectors/connectors_test.go b/server/connectors/connectors_test.go new file mode 100644 index 0000000000..c035684e70 --- /dev/null +++ b/server/connectors/connectors_test.go @@ -0,0 +1,122 @@ +package connectors + +import ( + "context" + "errors" + "log/slog" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" +) + +// stubConn is a trivial connector.Connector (which is interface{}) tagged with +// the version it was resolved from, so tests can tell reopens apart. +type stubConn struct{ version string } + +func newTestCache(t *testing.T) (*Cache, storage.Storage, *int) { + t.Helper() + store := memory.New(slog.New(slog.DiscardHandler)) + calls := 0 + resolve := func(c storage.Connector) (connector.Connector, error) { + calls++ + return stubConn{version: c.ResourceVersion}, nil + } + return NewCache(store, resolve), store, &calls +} + +func TestGetOpensAndCaches(t *testing.T) { + ctx := context.Background() + cache, store, calls := newTestCache(t) + + require.NoError(t, store.CreateConnector(ctx, storage.Connector{ + ID: "c1", Type: "mock", ResourceVersion: "1", GrantTypes: []string{"authorization_code"}, + })) + + got, err := cache.Get(ctx, "c1") + require.NoError(t, err) + require.Equal(t, 1, *calls) + require.Equal(t, "mock", got.Type) + require.Equal(t, "1", got.ResourceVersion) + require.Equal(t, []string{"authorization_code"}, got.GrantTypes) + require.Equal(t, stubConn{version: "1"}, got.Connector) + + // Second Get hits the cache; the connector is not resolved again. + got2, err := cache.Get(ctx, "c1") + require.NoError(t, err) + require.Equal(t, 1, *calls) + require.Equal(t, got, got2) +} + +func TestGetReopensOnVersionChange(t *testing.T) { + ctx := context.Background() + cache, store, calls := newTestCache(t) + + require.NoError(t, store.CreateConnector(ctx, storage.Connector{ID: "c1", Type: "mock", ResourceVersion: "1"})) + _, err := cache.Get(ctx, "c1") + require.NoError(t, err) + require.Equal(t, 1, *calls) + + // A stored resource-version bump must invalidate the cached entry. + require.NoError(t, store.UpdateConnector(ctx, "c1", func(old storage.Connector) (storage.Connector, error) { + old.ResourceVersion = "2" + return old, nil + })) + + got, err := cache.Get(ctx, "c1") + require.NoError(t, err) + require.Equal(t, 2, *calls) + require.Equal(t, "2", got.ResourceVersion) + require.Equal(t, stubConn{version: "2"}, got.Connector) +} + +func TestGetNotFound(t *testing.T) { + ctx := context.Background() + cache, _, calls := newTestCache(t) + + _, err := cache.Get(ctx, "missing") + require.Error(t, err) + require.Equal(t, 0, *calls) +} + +func TestOpenResolveErrorNotCached(t *testing.T) { + store := memory.New(slog.New(slog.DiscardHandler)) + cache := NewCache(store, func(storage.Connector) (connector.Connector, error) { + return nil, errors.New("boom") + }) + + _, err := cache.Open(storage.Connector{ID: "c1"}) + require.Error(t, err) + + _, ok := cache.Cached("c1") + require.False(t, ok) + require.Equal(t, 0, cache.Len()) +} + +func TestSetCachedCloseLen(t *testing.T) { + cache, _, _ := newTestCache(t) + + require.Equal(t, 0, cache.Len()) + _, ok := cache.Cached("c1") + require.False(t, ok) + + cache.Set("c1", Connector{Type: "mock", ResourceVersion: "1", Connector: stubConn{version: "1"}}) + cache.Set("c2", Connector{Type: "ldap"}) + require.Equal(t, 2, cache.Len()) + + got, ok := cache.Cached("c1") + require.True(t, ok) + require.Equal(t, "mock", got.Type) + + cache.Close("c1") + _, ok = cache.Cached("c1") + require.False(t, ok) + require.Equal(t, 1, cache.Len()) + + // Closing an unknown id is a no-op. + cache.Close("nope") + require.Equal(t, 1, cache.Len()) +} diff --git a/server/connectors/doc.go b/server/connectors/doc.go new file mode 100644 index 0000000000..942135c257 --- /dev/null +++ b/server/connectors/doc.go @@ -0,0 +1,2 @@ +// Package connectors holds the server's in-memory cache of opened connectors. +package connectors diff --git a/server/connectors/filter.go b/server/connectors/filter.go new file mode 100644 index 0000000000..df23341715 --- /dev/null +++ b/server/connectors/filter.go @@ -0,0 +1,25 @@ +package connectors + +import "github.com/dexidp/dex/storage" + +// Filter returns the connectors allowed for a client. When allowedConnectors is +// empty the list is returned unfiltered. It is the browser auth flow's counterpart +// to ConnectorAllowed (which checks a single id). +func Filter(conns []storage.Connector, allowedConnectors []string) []storage.Connector { + if len(allowedConnectors) == 0 { + return conns + } + + allowed := make(map[string]bool, len(allowedConnectors)) + for _, id := range allowedConnectors { + allowed[id] = true + } + + filtered := make([]storage.Connector, 0, len(conns)) + for _, c := range conns { + if allowed[c.ID] { + filtered = append(filtered, c) + } + } + return filtered +} diff --git a/server/connectors/filter_test.go b/server/connectors/filter_test.go new file mode 100644 index 0000000000..0cc37950aa --- /dev/null +++ b/server/connectors/filter_test.go @@ -0,0 +1,106 @@ +package connectors + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/storage" +) + +func TestFilterConnectors(t *testing.T) { + connectors := []storage.Connector{ + {ID: "github", Type: "github", Name: "GitHub"}, + {ID: "google", Type: "oidc", Name: "Google"}, + {ID: "ldap", Type: "ldap", Name: "LDAP"}, + } + + tests := []struct { + name string + allowedConnectors []string + wantIDs []string + }{ + { + name: "No filter - all connectors returned", + allowedConnectors: nil, + wantIDs: []string{"github", "google", "ldap"}, + }, + { + name: "Empty filter - all connectors returned", + allowedConnectors: []string{}, + wantIDs: []string{"github", "google", "ldap"}, + }, + { + name: "Filter to one connector", + allowedConnectors: []string{"github"}, + wantIDs: []string{"github"}, + }, + { + name: "Filter to two connectors", + allowedConnectors: []string{"github", "ldap"}, + wantIDs: []string{"github", "ldap"}, + }, + { + name: "Filter with non-existent connector ID", + allowedConnectors: []string{"nonexistent"}, + wantIDs: []string{}, + }, + { + name: "Filter with mix of valid and invalid IDs", + allowedConnectors: []string{"google", "nonexistent"}, + wantIDs: []string{"google"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := Filter(connectors, tc.allowedConnectors) + gotIDs := make([]string, len(result)) + for i, c := range result { + gotIDs[i] = c.ID + } + require.Equal(t, tc.wantIDs, gotIDs) + }) + } +} + +func TestIsConnectorAllowed(t *testing.T) { + tests := []struct { + name string + allowedConnectors []string + connectorID string + want bool + }{ + { + name: "No restrictions - all allowed", + allowedConnectors: nil, + connectorID: "any", + want: true, + }, + { + name: "Empty list - all allowed", + allowedConnectors: []string{}, + connectorID: "any", + want: true, + }, + { + name: "Connector in allowed list", + allowedConnectors: []string{"github", "google"}, + connectorID: "github", + want: true, + }, + { + name: "Connector not in allowed list", + allowedConnectors: []string{"github", "google"}, + connectorID: "ldap", + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ConnectorAllowed(tc.allowedConnectors, tc.connectorID) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/server/connectors/password.go b/server/connectors/password.go new file mode 100644 index 0000000000..8b5fb8ca1d --- /dev/null +++ b/server/connectors/password.go @@ -0,0 +1,99 @@ +package connectors + +import ( + "context" + "errors" + "fmt" + + "golang.org/x/crypto/bcrypt" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/passwords" + "github.com/dexidp/dex/storage" +) + +// NewPasswordDB returns the built-in local password connector backed by the +// password store. Resolver uses it for LocalConnector; it is exported so a +// custom ResolveFunc can reuse it. +func NewPasswordDB(s storage.Storage) interface { + connector.Connector + connector.PasswordConnector +} { + return passwordDB{s} +} + +type passwordDB struct { + s storage.Storage +} + +func resolvePasswordName(p storage.Password) string { + if p.Name != "" { + return p.Name + } + return p.Username +} + +func resolvePasswordEmailVerified(p storage.Password) bool { + if p.EmailVerified != nil { + return *p.EmailVerified + } + return true +} + +func (db passwordDB) Login(ctx context.Context, s connector.Scopes, email, password string) (connector.Identity, bool, error) { + p, err := db.s.GetPassword(ctx, email) + if err != nil { + if err != storage.ErrNotFound { + return connector.Identity{}, false, fmt.Errorf("get password: %v", err) + } + return connector.Identity{}, false, nil + } + // This check prevents dex users from logging in using static passwords + // configured with hash costs that are too high or low. + if err := passwords.CheckCost(p.Hash); err != nil { + return connector.Identity{}, false, err + } + if err := bcrypt.CompareHashAndPassword(p.Hash, []byte(password)); err != nil { + return connector.Identity{}, false, nil + } + return connector.Identity{ + UserID: p.UserID, + Username: resolvePasswordName(p), + PreferredUsername: p.PreferredUsername, + Email: p.Email, + EmailVerified: resolvePasswordEmailVerified(p), + Groups: p.Groups, + }, true, nil +} + +func (db passwordDB) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { + // If the user has been deleted, the refresh token will be rejected. + p, err := db.s.GetPassword(ctx, identity.Email) + if err != nil { + if err == storage.ErrNotFound { + return connector.Identity{}, errors.New("user not found") + } + return connector.Identity{}, fmt.Errorf("get password: %v", err) + } + + // User removed but a new user with the same email exists. + if p.UserID != identity.UserID { + return connector.Identity{}, errors.New("user not found") + } + + // If a user has updated their username, that will be reflected in the + // refreshed token. + // + // No other fields are expected to be refreshable as email is effectively used + // as an ID. + identity.Username = resolvePasswordName(p) + identity.PreferredUsername = p.PreferredUsername + identity.EmailVerified = resolvePasswordEmailVerified(p) + identity.Groups = p.Groups + + return identity, nil +} + +func (db passwordDB) Prompt() string { + return "Email Address" +} diff --git a/server/connectors/policy.go b/server/connectors/policy.go new file mode 100644 index 0000000000..e70fcb4c32 --- /dev/null +++ b/server/connectors/policy.go @@ -0,0 +1,32 @@ +package connectors + +import ( + "slices" + + "github.com/dexidp/dex/server/oauth2" +) + +// ConnectorGrantTypes is the set of grant types that can be restricted per connector. +var ConnectorGrantTypes = map[string]bool{ + oauth2.GrantTypeAuthorizationCode: true, + oauth2.GrantTypeRefreshToken: true, + oauth2.GrantTypeImplicit: true, + oauth2.GrantTypePassword: true, + oauth2.GrantTypeDeviceCode: true, + oauth2.GrantTypeTokenExchange: true, +} + +// GrantTypeAllowed reports whether grantType is allowed for a connector with the +// given configured grant types. If none are configured, all are allowed. +func GrantTypeAllowed(configuredTypes []string, grantType string) bool { + return len(configuredTypes) == 0 || slices.Contains(configuredTypes, grantType) +} + +// ConnectorAllowed reports whether connectorID is in a client's allowed +// connectors list. If the list is empty, all connectors are allowed. +func ConnectorAllowed(allowedConnectors []string, connectorID string) bool { + if len(allowedConnectors) == 0 { + return true + } + return slices.Contains(allowedConnectors, connectorID) +} diff --git a/server/connectors/resolve.go b/server/connectors/resolve.go new file mode 100644 index 0000000000..c9ee160fc0 --- /dev/null +++ b/server/connectors/resolve.go @@ -0,0 +1,57 @@ +package connectors + +import ( + "encoding/json" + "fmt" + "log/slog" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/storage" +) + +// LocalConnector is the local passwordDB connector: an internal connector, +// backed by the password store, that is not part of the injected config map. +const LocalConnector = "local" + +// ConnectorConfig is a configuration that can open a connector. +type ConnectorConfig interface { + Open(id string, logger *slog.Logger) (connector.Connector, error) +} + +// Resolver returns a ResolveFunc that builds the underlying implementation for a +// stored connector: the built-in local password DB (backed by storage), or a +// connector from the given config map. The map is injected by the caller so this +// package need not import any connector implementation โ€” a library consumer can +// pass its own set of connectors. +func Resolver(store storage.Storage, logger *slog.Logger, configs map[string]func() ConnectorConfig) ResolveFunc { + return func(conn storage.Connector) (connector.Connector, error) { + if conn.Type == LocalConnector { + return NewPasswordDB(store), nil + } + return openConnector(logger, configs, conn) + } +} + +// openConnector parses the stored config and opens the connector named by its type. +func openConnector(logger *slog.Logger, configs map[string]func() ConnectorConfig, conn storage.Connector) (connector.Connector, error) { + var c connector.Connector + + f, ok := configs[conn.Type] + if !ok { + return c, fmt.Errorf("unknown connector type %q", conn.Type) + } + + connConfig := f() + if len(conn.Config) != 0 { + if err := json.Unmarshal(conn.Config, connConfig); err != nil { + return c, fmt.Errorf("parse connector config: %v", err) + } + } + + c, err := connConfig.Open(conn.ID, logger) + if err != nil { + return c, fmt.Errorf("failed to create connector %s: %v", conn.ID, err) + } + + return c, nil +} diff --git a/server/consent/consent.go b/server/consent/consent.go new file mode 100644 index 0000000000..e69d5da3ec --- /dev/null +++ b/server/consent/consent.go @@ -0,0 +1,145 @@ +package consent + +import ( + "context" + "log/slog" + "net/http" + + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/router" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/templates" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// Handler owns the consent step. The /auth dispatcher decides whether consent is +// needed (via Satisfied) and, if so, routes to the approval screen here; on +// approve it records the granted scopes and returns to the dispatcher with the +// "approved" verifier. It holds no reference to the other flow steps. +type Handler struct { + Storage storage.Storage + Templates *templates.Templates + Logger *slog.Logger + IssuerURL oauth2.IssuerURL + Sessions *session.Manager + SkipApproval bool +} + +// renderError renders a user-facing HTML error page. +func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) { + templates.RenderError(h.Templates, h.Logger, r, w, status, description) +} + +// Mount registers the consent endpoint. +func (h *Handler) Mount(mux router.Mux) { + mux.HandleFunc("/approval", h.handleApproval) +} + +// buildApprovedURL builds the HMAC-protected URL that returns to the authorize +// dispatcher (/auth) with the "approved" verifier, so the dispatcher knows the +// user consented and can issue. +func (h *Handler) buildApprovedURL(authReq storage.AuthRequest) string { + return internal.StepURL(h.IssuerURL.AbsPath("/auth"), authReq, internal.StepApproved, nil) +} + +// Satisfied reports whether the approval screen can be skipped: the client did +// not force it, and either approval is disabled server-wide or the user has +// already consented to the requested scopes for this client. It is a package +// function so the /auth dispatcher can decide consent from state without holding +// the consent Handler. +func Satisfied(ctx context.Context, store storage.Storage, skipApproval bool, authReq *storage.AuthRequest) bool { + if authReq.ForceApprovalPrompt { + return false + } + if skipApproval { + return true + } + ui, err := store.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) + return err == nil && scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes) +} + +func (h *Handler) handleApproval(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + macEncoded := r.FormValue("hmac") + if macEncoded == "" { + h.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") + return + } + authReq, err := h.Storage.GetAuthRequest(ctx, r.FormValue("req")) + if err != nil { + if err == storage.ErrNotFound { + h.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } + h.Logger.ErrorContext(ctx, "failed to get auth request", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + if !authReq.LoggedIn { + h.Logger.ErrorContext(ctx, "auth request does not have an identity for approval") + h.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.") + return + } + + if !internal.VerifyStep(authReq, macEncoded, internal.StepApproval) { + h.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") + return + } + + switch r.Method { + case http.MethodGet: + // The dispatcher routes here only when consent is required, so just show + // the approval screen. + client, err := h.Storage.GetClient(ctx, authReq.ClientID) + if err != nil { + h.Logger.ErrorContext(ctx, "Failed to get client", "client_id", authReq.ClientID, "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve client.") + return + } + if err := h.Templates.Approval(r, w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes); err != nil { + h.Logger.ErrorContext(ctx, "server template error", "err", err) + } + case http.MethodPost: + if r.FormValue("approval") != "approve" { + h.renderError(r, w, http.StatusInternalServerError, "Approval rejected.") + return + } + // Persist the approved scopes so a future request skips consent, then return + // to the dispatcher with the "approved" verifier. + if h.Sessions.Enabled() { + if err := h.Storage.UpdateUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + if old.Consents == nil { + old.Consents = make(map[string][]string) + } + old.Consents[authReq.ClientID] = authReq.Scopes + return old, nil + }); err != nil { + h.Logger.ErrorContext(ctx, "failed to update user identity consents", "err", err) + } + } + http.Redirect(w, r, h.buildApprovedURL(authReq), http.StatusSeeOther) + } +} + +// scopesCoveredByConsent checks whether the approved scopes cover all requested +// scopes. The openid scope is excluded from the comparison as it is a technical +// scope that does not require user consent. +func scopesCoveredByConsent(approved, requested []string) bool { + approvedSet := make(map[string]struct{}, len(approved)) + for _, s := range approved { + approvedSet[s] = struct{}{} + } + + for _, scope := range requested { + if scope == tokens.ScopeOpenID { + continue + } + if _, ok := approvedSet[scope]; !ok { + return false + } + } + + return true +} diff --git a/server/consent/consent_test.go b/server/consent/consent_test.go new file mode 100644 index 0000000000..4ac8a8a542 --- /dev/null +++ b/server/consent/consent_test.go @@ -0,0 +1,80 @@ +package consent + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestScopesCoveredByConsent(t *testing.T) { + tests := []struct { + name string + approved []string + requested []string + want bool + }{ + { + name: "All scopes covered", + approved: []string{"email", "profile"}, + requested: []string{"openid", "email", "profile"}, + want: true, + }, + { + name: "Missing scope", + approved: []string{"email"}, + requested: []string{"openid", "email", "groups"}, + want: false, + }, + { + name: "Only openid scope skipped", + approved: []string{}, + requested: []string{"openid"}, + want: true, + }, + { + name: "offline_access requires consent", + approved: []string{}, + requested: []string{"openid", "offline_access"}, + want: false, + }, + { + name: "offline_access covered by consent", + approved: []string{"offline_access"}, + requested: []string{"openid", "offline_access"}, + want: true, + }, + { + name: "Nil approved", + approved: nil, + requested: []string{"email"}, + want: false, + }, + { + name: "Empty requested", + approved: []string{"email"}, + requested: []string{}, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := scopesCoveredByConsent(tc.approved, tc.requested) + require.Equal(t, tc.want, got) + }) + } +} + +// TestConsentIsolatedBetweenClients verifies that consent given for +// client-A does not satisfy scope check for client-B. +func TestConsentIsolatedBetweenClients(t *testing.T) { + approvedForA := map[string][]string{"client-a": {"openid", "email"}} + + // client-b should not have consent. + require.False(t, scopesCoveredByConsent(approvedForA["client-b"], []string{"openid", "email"}), + "consent for client-a should not cover client-b") + + // client-a should have consent. + require.True(t, scopesCoveredByConsent(approvedForA["client-a"], []string{"openid", "email"}), + "consent for client-a should cover client-a's requested scopes") +} diff --git a/server/consent/doc.go b/server/consent/doc.go new file mode 100644 index 0000000000..94dc27a3bd --- /dev/null +++ b/server/consent/doc.go @@ -0,0 +1,9 @@ +// Package consent owns the approval (consent) step of the authorization flow: +// the /approval endpoint, the consent screen, recording the user's consent, and +// the decision of whether consent can be skipped. +// +// It is one of the shared flow steps (alongside mfa and issue): the browser +// login flow and, conceptually, any other front-channel flow reach it once the +// user is authenticated. When consent is granted (or already covered) it hands +// off to the issue component to complete the authorization response. +package consent diff --git a/server/device/device.go b/server/device/device.go new file mode 100644 index 0000000000..486d3297ae --- /dev/null +++ b/server/device/device.go @@ -0,0 +1,432 @@ +package device + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/grants" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/router" + "github.com/dexidp/dex/server/templates" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// DeviceCodeResponse is the device authorization response (RFC 8628 ยง3.2). +type DeviceCodeResponse struct { + // The unique device code for device authentication + DeviceCode string `json:"device_code"` + // The code the user will exchange via a browser and log in + UserCode string `json:"user_code"` + // The url to verify the user code. + VerificationURI string `json:"verification_uri"` + // The verification uri with the user code appended for pre-filling form + VerificationURIComplete string `json:"verification_uri_complete"` + // The lifetime of the device code + ExpireTime int `json:"expires_in"` + // How often the device is allowed to poll to verify that the user login occurred + PollInterval int `json:"interval"` +} + +// Handler serves the browser side of the device authorization grant. +type Handler struct { + IssuerURL oauth2.IssuerURL + Storage storage.Storage + Templates *templates.Templates + Now func() time.Time + RequestsValidFor time.Duration + Logger *slog.Logger + + // Issuer mints the tokens, and Connectors resolves the connector, for the + // auth-code exchange the device flow shares with the authorization_code grant + // via grants.ExchangeAuthCode. + Issuer *tokens.Issuer + Connectors *connectors.Cache +} + +// Mount registers the device authorization routes. +func (h *Handler) Mount(m router.Mux) { + m.HandleFunc("/device", h.handleDeviceExchange) + m.HandleFunc("/device/auth/verify_code", h.verifyUserCode) + m.HandleFunc("/device/code", h.handleDeviceCode) + m.HandleFunc(oauth2.DeviceCallbackURI, h.handleDeviceCallback) +} + +// deviceFlowError is a failed step in the flow. A non-empty OAuth2 code makes the +// handler write a JSON error response; otherwise the message is rendered as an +// HTML error page. +type deviceFlowError struct { + status int + code string + message string +} + +func (h *Handler) writeFlowError(r *http.Request, w http.ResponseWriter, e *deviceFlowError) { + if e.code != "" { + h.writeError(w, e.code, e.message, e.status) + return + } + h.renderError(r, w, e.status, e.message) +} + +// writeError writes a JSON OAuth2 error response. +func (h *Handler) writeError(w http.ResponseWriter, typ, description string, statusCode int) { + oauth2.WriteErrorResponse(h.Logger, w, typ, description, statusCode) +} + +// renderError renders an HTML error page. +func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) { + templates.RenderError(h.Templates, h.Logger, r, w, status, description) +} + +func (h *Handler) getDeviceVerificationURI() string { + return h.IssuerURL.AbsPath("/device/auth/verify_code") +} + +// handleDeviceExchange serves the /device user-code entry page. +func (h *Handler) handleDeviceExchange(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + return + } + + // If "user_code" is set, pre-populate the user code field. If "invalid" is + // set, show a message that the code was invalid or expired. + userCode := r.URL.Query().Get("user_code") + invalidAttempt, err := strconv.ParseBool(r.URL.Query().Get("invalid")) + if err != nil { + invalidAttempt = false + } + if err := h.Templates.Device(r, w, h.getDeviceVerificationURI(), userCode, invalidAttempt); err != nil { + h.Logger.ErrorContext(r.Context(), "server template error", "err", err) + h.renderError(r, w, http.StatusNotFound, "Page not found") + } +} + +// deviceCodeRequest is a parsed /device/code authorization request. +type deviceCodeRequest struct { + clientID string + clientSecret string + scopes []string + pkce storage.PKCE +} + +// parseDeviceCodeRequest parses and validates the /device/code form. +func (h *Handler) parseDeviceCodeRequest(r *http.Request) (deviceCodeRequest, *deviceFlowError) { + if err := r.ParseForm(); err != nil { + h.Logger.ErrorContext(r.Context(), "could not parse Device Request body", "err", err) + return deviceCodeRequest{}, &deviceFlowError{status: http.StatusNotFound, code: oauth2.InvalidRequest} + } + + method := r.Form.Get("code_challenge_method") + if method == "" { + method = oauth2.PKCEMethodPlain + } + if method != oauth2.PKCEMethodS256 && method != oauth2.PKCEMethodPlain { + return deviceCodeRequest{}, &deviceFlowError{ + status: http.StatusBadRequest, + code: oauth2.InvalidRequest, + message: fmt.Sprintf("Unsupported PKCE challenge method (%q).", method), + } + } + + scopes := strings.Fields(r.Form.Get("scope")) + if len(scopes) == 0 { + // per RFC 8628 ยง3.1 scope is optional, but dex requires at least 'openid'. + scopes = []string{"openid"} + } + + return deviceCodeRequest{ + clientID: r.Form.Get("client_id"), + clientSecret: r.Form.Get("client_secret"), + scopes: scopes, + pkce: storage.PKCE{ + CodeChallenge: r.Form.Get("code_challenge"), + CodeChallengeMethod: method, + }, + }, nil +} + +// createDeviceAuthorization mints and stores the device and user codes and builds +// the authorization response the device polls against. +func (h *Handler) createDeviceAuthorization(ctx context.Context, req deviceCodeRequest) (*DeviceCodeResponse, *deviceFlowError) { + h.Logger.InfoContext(ctx, "received device request", "client_id", req.clientID, "scoped", req.scopes) + + deviceCode := storage.NewDeviceCode() + userCode := storage.NewUserCode() + expireTime := h.Now().Add(h.RequestsValidFor) + + if err := h.Storage.CreateDeviceRequest(ctx, storage.DeviceRequest{ + UserCode: userCode, + DeviceCode: deviceCode, + ClientID: req.clientID, + ClientSecret: req.clientSecret, + Scopes: req.scopes, + Expiry: expireTime, + }); err != nil { + h.Logger.ErrorContext(ctx, "failed to store device request", "err", err) + return nil, &deviceFlowError{status: http.StatusInternalServerError, code: oauth2.InvalidRequest} + } + + if err := h.Storage.CreateDeviceToken(ctx, storage.DeviceToken{ + DeviceCode: deviceCode, + Status: oauth2.DeviceTokenPending, + Expiry: expireTime, + LastRequestTime: h.Now(), + PollIntervalSeconds: 0, + PKCE: req.pkce, + }); err != nil { + h.Logger.ErrorContext(ctx, "failed to store device token", "err", err) + return nil, &deviceFlowError{status: http.StatusInternalServerError, code: oauth2.InvalidRequest} + } + + u := h.IssuerURL + u.Path = path.Join(u.Path, "device") + vURI := u.String() + + q := u.Query() + q.Set("user_code", userCode) + u.RawQuery = q.Encode() + vURIComplete := u.String() + + return &DeviceCodeResponse{ + DeviceCode: deviceCode, + UserCode: userCode, + VerificationURI: vURI, + VerificationURIComplete: vURIComplete, + ExpireTime: int(h.RequestsValidFor.Seconds()), + PollInterval: 5, + }, nil +} + +func (h *Handler) handleDeviceCode(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + h.renderError(r, w, http.StatusBadRequest, "Invalid device code request type") + h.writeError(w, oauth2.InvalidRequest, "", http.StatusBadRequest) + return + } + + req, ferr := h.parseDeviceCodeRequest(r) + if ferr != nil { + h.writeFlowError(r, w, ferr) + return + } + + resp, ferr := h.createDeviceAuthorization(r.Context(), req) + if ferr != nil { + h.writeFlowError(r, w, ferr) + return + } + + writeDeviceCodeResponse(w, resp) +} + +// writeDeviceCodeResponse writes the device authorization response: it can carry +// a cache-control header (RFC 8628 ยง3.2) and is JSON (RFC 6749 ยง5.1). +func writeDeviceCodeResponse(w http.ResponseWriter, resp *DeviceCodeResponse) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (h *Handler) verifyUserCode(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + return + } + ctx := r.Context() + + if err := r.ParseForm(); err != nil { + h.Logger.Warn("could not parse user code verification request body", "err", err) + h.renderError(r, w, http.StatusBadRequest, "") + return + } + + userCode := r.Form.Get("user_code") + if userCode == "" { + h.renderError(r, w, http.StatusBadRequest, "No user code received") + return + } + userCode = strings.ToUpper(userCode) + + // Find the user code among the outstanding requests. + deviceRequest, err := h.Storage.GetDeviceRequest(ctx, userCode) + if err != nil || h.Now().After(deviceRequest.Expiry) { + if err != nil && err != storage.ErrNotFound { + h.Logger.ErrorContext(ctx, "failed to get device request", "err", err) + } + if err := h.Templates.Device(r, w, h.getDeviceVerificationURI(), userCode, true); err != nil { + h.Logger.ErrorContext(ctx, "Server template error", "err", err) + h.renderError(r, w, http.StatusNotFound, "Page not found") + } + return + } + + // Redirect to the dex auth endpoint, which sends the user back to the device + // callback once they authenticate. + u := h.IssuerURL + u.Path = path.Join(u.Path, "/auth") + q := u.Query() + q.Set("client_id", deviceRequest.ClientID) + // Do not put client_secret in this browser redirect: /auth is the + // authorization endpoint and never consumes it, so it would only leak the + // confidential secret into browser history, Referer, and access logs. The + // client is authenticated later in completeDeviceAuthorization against the + // stored device request. + q.Set("state", deviceRequest.UserCode) + q.Set("response_type", "code") + q.Set("redirect_uri", h.IssuerURL.AbsPath(oauth2.DeviceCallbackURI)) + q.Set("scope", strings.Join(deviceRequest.Scopes, " ")) + u.RawQuery = q.Encode() + + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func (h *Handler) handleDeviceCallback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + h.Logger.ErrorContext(r.Context(), "unsupported method in device callback", "method", r.Method) + h.renderError(r, w, http.StatusBadRequest, "Method not allowed.") + return + } + + clientName, ferr := h.completeDeviceAuthorization(w, r) + if ferr != nil { + h.writeFlowError(r, w, ferr) + return + } + + if err := h.Templates.DeviceSuccess(r, w, clientName); err != nil { + h.Logger.ErrorContext(r.Context(), "Server template error", "err", err) + h.renderError(r, w, http.StatusNotFound, "Page not found") + } +} + +// completeDeviceAuthorization handles the browser callback: it exchanges the +// authorization code for tokens and stores them against the device code so the +// polling device_code grant can return them. It returns the client name for the +// success page. +func (h *Handler) completeDeviceAuthorization(w http.ResponseWriter, r *http.Request) (string, *deviceFlowError) { + ctx := r.Context() + + userCode := r.FormValue("state") + code := r.FormValue("code") + if userCode == "" || code == "" { + return "", &deviceFlowError{status: http.StatusBadRequest, message: "Request was missing parameters"} + } + + // Authorization redirect callback from the OAuth2 auth flow. + if errMsg := r.FormValue("error"); errMsg != "" { + // Log the error details but don't expose them to the user. + h.Logger.ErrorContext(ctx, "OAuth2 authorization error", + "error", errMsg, + "error_description", r.FormValue("error_description")) + return "", &deviceFlowError{status: http.StatusBadRequest, message: "Authorization failed. Please try again."} + } + + authCode, err := h.Storage.GetAuthCode(ctx, code) + if err != nil || h.Now().After(authCode.Expiry) { + status := http.StatusBadRequest + if err != nil && err != storage.ErrNotFound { + h.Logger.ErrorContext(ctx, "failed to get auth code", "err", err) + status = http.StatusInternalServerError + } + return "", &deviceFlowError{status: status, message: "Invalid or expired auth code."} + } + + deviceReq, err := h.Storage.GetDeviceRequest(ctx, userCode) + if err != nil || h.Now().After(deviceReq.Expiry) { + status := http.StatusBadRequest + if err != nil && err != storage.ErrNotFound { + h.Logger.ErrorContext(ctx, "failed to get device code", "err", err) + status = http.StatusInternalServerError + } + return "", &deviceFlowError{status: status, message: "Invalid or expired user code."} + } + + // Bind the auth code to this device request: it must have been minted for the + // same client and issued to the device callback redirect. The authorization_code + // grant enforces the same client/redirect binding (see grants/authcode.go); the + // device callback must not skip it, or a code minted for one client could be + // redeemed against another client's device request (cross-client token theft). + // The redirect is matched on its parsed path suffix, mirroring how the auth flow + // recognizes the device callback: the issuer path prefix does not matter, and a + // "/device/callback" in the query string can not spoof it. A value that fails to + // parse is not a valid device redirect. + redirectURL, err := url.Parse(authCode.RedirectURI) + validRedirect := err == nil && strings.HasSuffix(redirectURL.Path, oauth2.DeviceCallbackURI) + if authCode.ClientID != deviceReq.ClientID || !validRedirect { + h.Logger.ErrorContext(ctx, "device callback: auth code does not match the device request", + "auth_code_client_id", authCode.ClientID, "device_client_id", deviceReq.ClientID) + return "", &deviceFlowError{status: http.StatusBadRequest, message: "Invalid or expired auth code."} + } + + client, err := h.Storage.GetClient(ctx, deviceReq.ClientID) + if err != nil { + if err != storage.ErrNotFound { + h.Logger.ErrorContext(ctx, "failed to get client", "err", err) + return "", &deviceFlowError{status: http.StatusInternalServerError, code: oauth2.ServerError} + } + return "", &deviceFlowError{status: http.StatusUnauthorized, code: oauth2.InvalidClient, message: "Invalid client credentials."} + } + // Constant-time comparison of the client secret, matching grants.go's client + // authentication, so the compare does not leak the secret via timing. + if subtle.ConstantTimeCompare([]byte(client.Secret), []byte(deviceReq.ClientSecret)) != 1 { + return "", &deviceFlowError{status: http.StatusUnauthorized, code: oauth2.InvalidClient, message: "Invalid client credentials."} + } + + // ExchangeAuthCode consumes the code (its atomic single-use gate) and returns + // what to issue; the tokens are minted here. + auth, withRefresh, err := grants.ExchangeAuthCode(ctx, h.Storage, h.Connectors, h.Logger, authCode, client) + if err != nil { + h.Logger.ErrorContext(ctx, "could not exchange auth code for client", "client_id", deviceReq.ClientID, "err", err) + return "", &deviceFlowError{status: http.StatusInternalServerError, message: "Failed to exchange auth code."} + } + resp, err := h.Issuer.IssueResponse(ctx, auth, authCode.ID, withRefresh) + if err != nil { + h.Logger.ErrorContext(ctx, "could not issue tokens for device flow", "client_id", deviceReq.ClientID, "err", err) + return "", &deviceFlowError{status: http.StatusInternalServerError, message: "Failed to exchange auth code."} + } + + old, err := h.Storage.GetDeviceToken(ctx, deviceReq.DeviceCode) + if err != nil || h.Now().After(old.Expiry) { + status := http.StatusBadRequest + if err != nil && err != storage.ErrNotFound { + h.Logger.ErrorContext(ctx, "failed to get device token", "err", err) + status = http.StatusInternalServerError + } + return "", &deviceFlowError{status: status, message: "Invalid or expired device code."} + } + + // Store the token against the device code and mark it complete. + updater := func(old storage.DeviceToken) (storage.DeviceToken, error) { + if old.Status == oauth2.DeviceTokenComplete { + return old, errors.New("device token already complete") + } + respStr, err := json.MarshalIndent(resp, "", " ") + if err != nil { + h.Logger.ErrorContext(ctx, "failed to marshal device token response", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "") + return old, err + } + old.Token = string(respStr) + old.Status = oauth2.DeviceTokenComplete + return old, nil + } + if err := h.Storage.UpdateDeviceToken(ctx, deviceReq.DeviceCode, updater); err != nil { + h.Logger.ErrorContext(ctx, "failed to update device token", "err", err) + return "", &deviceFlowError{status: http.StatusBadRequest, message: ""} + } + + return client.Name, nil +} diff --git a/server/device/device_test.go b/server/device/device_test.go new file mode 100644 index 0000000000..cdadbffead --- /dev/null +++ b/server/device/device_test.go @@ -0,0 +1,59 @@ +package device + +import ( + "io" + "log/slog" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/storage/memory" +) + +func TestGetDeviceVerificationURI(t *testing.T) { + u, err := url.Parse("https://dex.example.com/non-root-path") + require.NoError(t, err) + + h := &Handler{IssuerURL: oauth2.IssuerURL{URL: *u}} + require.Equal(t, "/non-root-path/device/auth/verify_code", h.getDeviceVerificationURI()) +} + +// TestCreateDeviceAuthorizationUsesInjectedClock pins the device request and +// token expiry to the handler's clock. Both were minted from a mix of +// time.Now() and h.Now(), which made the two expiries drift apart under a +// fixed test clock. +func TestCreateDeviceAuthorizationUsesInjectedClock(t *testing.T) { + u, err := url.Parse("https://dex.example.com") + require.NoError(t, err) + + fixed := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + h := &Handler{ + IssuerURL: oauth2.IssuerURL{URL: *u}, + Storage: memory.New(logger), + Logger: logger, + Now: func() time.Time { return fixed }, + RequestsValidFor: 5 * time.Minute, + } + + resp, ferr := h.createDeviceAuthorization(t.Context(), deviceCodeRequest{ + clientID: "test", + scopes: []string{"openid"}, + }) + require.Nil(t, ferr) + + want := fixed.Add(5 * time.Minute) + + req, err := h.Storage.GetDeviceRequest(t.Context(), resp.UserCode) + require.NoError(t, err) + require.WithinDuration(t, want, req.Expiry, 0) + + token, err := h.Storage.GetDeviceToken(t.Context(), resp.DeviceCode) + require.NoError(t, err) + require.WithinDuration(t, want, token.Expiry, 0) + require.WithinDuration(t, fixed, token.LastRequestTime, 0) +} diff --git a/server/device/doc.go b/server/device/doc.go new file mode 100644 index 0000000000..54749ff9ac --- /dev/null +++ b/server/device/doc.go @@ -0,0 +1,6 @@ +// Package device implements the browser-facing side of the OAuth2 device +// authorization grant (RFC 8628): the /device user-code entry page, the +// /device/code authorization request, user-code verification, and the callback +// that completes the flow. The device_code token grant that the device polls for +// lives with the token endpoint. +package device diff --git a/server/deviceflowhandlers.go b/server/deviceflowhandlers.go deleted file mode 100644 index 95fed3b3c3..0000000000 --- a/server/deviceflowhandlers.go +++ /dev/null @@ -1,444 +0,0 @@ -package server - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "path" - "strconv" - "strings" - "time" - - "golang.org/x/net/html" - - "github.com/dexidp/dex/pkg/log" - "github.com/dexidp/dex/storage" -) - -type deviceCodeResponse struct { - // The unique device code for device authentication - DeviceCode string `json:"device_code"` - // The code the user will exchange via a browser and log in - UserCode string `json:"user_code"` - // The url to verify the user code. - VerificationURI string `json:"verification_uri"` - // The verification uri with the user code appended for pre-filling form - VerificationURIComplete string `json:"verification_uri_complete"` - // The lifetime of the device code - ExpireTime int `json:"expires_in"` - // How often the device is allowed to poll to verify that the user login occurred - PollInterval int `json:"interval"` -} - -func (s *Server) getDeviceVerificationURI() string { - return path.Join(s.issuerURL.Path, "/device/auth/verify_code") -} - -func (s *Server) handleDeviceExchange(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - // Grab the parameter(s) from the query. - // If "user_code" is set, pre-populate the user code text field. - // If "invalid" is set, set the invalidAttempt boolean, which will display a message to the user that they - // attempted to redeem an invalid or expired user code. - userCode := r.URL.Query().Get("user_code") - invalidAttempt, err := strconv.ParseBool(r.URL.Query().Get("invalid")) - if err != nil { - invalidAttempt = false - } - if err := s.templates.device(r, w, s.getDeviceVerificationURI(), userCode, invalidAttempt); err != nil { - s.logger.Errorf("Server template error: %v", err) - s.renderError(r, w, http.StatusNotFound, "Page not found") - } - default: - s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") - } -} - -func (s *Server) handleDeviceCode(w http.ResponseWriter, r *http.Request) { - pollIntervalSeconds := 5 - - switch r.Method { - case http.MethodPost: - err := r.ParseForm() - if err != nil { - s.logger.Errorf("Could not parse Device Request body: %v", err) - s.tokenErrHelper(w, errInvalidRequest, "", http.StatusNotFound) - return - } - - // Get the client id and scopes from the post - clientID := r.Form.Get("client_id") - clientSecret := r.Form.Get("client_secret") - scopes := strings.Fields(r.Form.Get("scope")) - codeChallenge := r.Form.Get("code_challenge") - codeChallengeMethod := r.Form.Get("code_challenge_method") - - if codeChallengeMethod == "" { - codeChallengeMethod = codeChallengeMethodPlain - } - if codeChallengeMethod != codeChallengeMethodS256 && codeChallengeMethod != codeChallengeMethodPlain { - description := fmt.Sprintf("Unsupported PKCE challenge method (%q).", codeChallengeMethod) - s.tokenErrHelper(w, errInvalidRequest, description, http.StatusBadRequest) - return - } - - s.logger.Infof("Received device request for client %v with scopes %v", clientID, scopes) - - // Make device code - deviceCode := storage.NewDeviceCode() - - // make user code - userCode := storage.NewUserCode() - - // Generate the expire time - expireTime := time.Now().Add(s.deviceRequestsValidFor) - - // Store the Device Request - deviceReq := storage.DeviceRequest{ - UserCode: userCode, - DeviceCode: deviceCode, - ClientID: clientID, - ClientSecret: clientSecret, - Scopes: scopes, - Expiry: expireTime, - } - - if err := s.storage.CreateDeviceRequest(deviceReq); err != nil { - s.logger.Errorf("Failed to store device request; %v", err) - s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError) - return - } - - // Store the device token - deviceToken := storage.DeviceToken{ - DeviceCode: deviceCode, - Status: deviceTokenPending, - Expiry: expireTime, - LastRequestTime: s.now(), - PollIntervalSeconds: 0, - PKCE: storage.PKCE{ - CodeChallenge: codeChallenge, - CodeChallengeMethod: codeChallengeMethod, - }, - } - - if err := s.storage.CreateDeviceToken(deviceToken); err != nil { - s.logger.Errorf("Failed to store device token %v", err) - s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError) - return - } - - u, err := url.Parse(s.issuerURL.String()) - if err != nil { - s.logger.Errorf("Could not parse issuer URL %v", err) - s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError) - return - } - u.Path = path.Join(u.Path, "device") - vURI := u.String() - - q := u.Query() - q.Set("user_code", userCode) - u.RawQuery = q.Encode() - vURIComplete := u.String() - - code := deviceCodeResponse{ - DeviceCode: deviceCode, - UserCode: userCode, - VerificationURI: vURI, - VerificationURIComplete: vURIComplete, - ExpireTime: int(s.deviceRequestsValidFor.Seconds()), - PollInterval: pollIntervalSeconds, - } - - // Device Authorization Response can contain cache control header according to - // https://tools.ietf.org/html/rfc8628#section-3.2 - w.Header().Set("Cache-Control", "no-store") - - // Response type should be application/json according to - // https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 - w.Header().Set("Content-Type", "application/json") - - enc := json.NewEncoder(w) - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - enc.Encode(code) - - default: - s.renderError(r, w, http.StatusBadRequest, "Invalid device code request type") - s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest) - } -} - -func (s *Server) handleDeviceTokenDeprecated(w http.ResponseWriter, r *http.Request) { - log.Deprecated(s.logger, `The /device/token endpoint was called. It will be removed, use /token instead.`) - - w.Header().Set("Content-Type", "application/json") - switch r.Method { - case http.MethodPost: - err := r.ParseForm() - if err != nil { - s.logger.Warnf("Could not parse Device Token Request body: %v", err) - s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest) - return - } - - grantType := r.PostFormValue("grant_type") - if grantType != grantTypeDeviceCode { - s.tokenErrHelper(w, errInvalidGrant, "", http.StatusBadRequest) - return - } - - s.handleDeviceToken(w, r) - default: - s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") - } -} - -func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) { - deviceCode := r.Form.Get("device_code") - if deviceCode == "" { - s.tokenErrHelper(w, errInvalidRequest, "No device code received", http.StatusBadRequest) - return - } - - now := s.now() - - // Grab the device token, check validity - deviceToken, err := s.storage.GetDeviceToken(deviceCode) - if err != nil { - if err != storage.ErrNotFound { - s.logger.Errorf("failed to get device code: %v", err) - } - s.tokenErrHelper(w, errInvalidRequest, "Invalid Device code.", http.StatusBadRequest) - return - } else if now.After(deviceToken.Expiry) { - s.tokenErrHelper(w, deviceTokenExpired, "", http.StatusBadRequest) - return - } - - // Rate Limiting check - slowDown := false - pollInterval := deviceToken.PollIntervalSeconds - minRequestTime := deviceToken.LastRequestTime.Add(time.Second * time.Duration(pollInterval)) - if now.Before(minRequestTime) { - slowDown = true - // Continually increase the poll interval until the user waits the proper time - pollInterval += 5 - } else { - pollInterval = 5 - } - - switch deviceToken.Status { - case deviceTokenPending: - updater := func(old storage.DeviceToken) (storage.DeviceToken, error) { - old.PollIntervalSeconds = pollInterval - old.LastRequestTime = now - return old, nil - } - // Update device token last request time in storage - if err := s.storage.UpdateDeviceToken(deviceCode, updater); err != nil { - s.logger.Errorf("failed to update device token: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "") - return - } - if slowDown { - s.tokenErrHelper(w, deviceTokenSlowDown, "", http.StatusBadRequest) - } else { - s.tokenErrHelper(w, deviceTokenPending, "", http.StatusUnauthorized) - } - case deviceTokenComplete: - codeChallengeFromStorage := deviceToken.PKCE.CodeChallenge - providedCodeVerifier := r.Form.Get("code_verifier") - - switch { - case providedCodeVerifier != "" && codeChallengeFromStorage != "": - calculatedCodeChallenge, err := s.calculateCodeChallenge(providedCodeVerifier, deviceToken.PKCE.CodeChallengeMethod) - if err != nil { - s.logger.Error(err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - if codeChallengeFromStorage != calculatedCodeChallenge { - s.tokenErrHelper(w, errInvalidGrant, "Invalid code_verifier.", http.StatusBadRequest) - return - } - case providedCodeVerifier != "": - // Received no code_challenge on /auth, but a code_verifier on /token - s.tokenErrHelper(w, errInvalidRequest, "No PKCE flow started. Cannot check code_verifier.", http.StatusBadRequest) - return - case codeChallengeFromStorage != "": - // Received PKCE request on /auth, but no code_verifier on /token - s.tokenErrHelper(w, errInvalidGrant, "Expecting parameter code_verifier in PKCE flow.", http.StatusBadRequest) - return - } - w.Write([]byte(deviceToken.Token)) - } -} - -func (s *Server) handleDeviceCallback(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - userCode := r.FormValue("state") - code := r.FormValue("code") - - if userCode == "" || code == "" { - s.renderError(r, w, http.StatusBadRequest, "Request was missing parameters") - return - } - - // Authorization redirect callback from OAuth2 auth flow. - if errMsg := r.FormValue("error"); errMsg != "" { - // escape the message to prevent cross-site scripting - msg := html.EscapeString(errMsg + ": " + r.FormValue("error_description")) - http.Error(w, msg, http.StatusBadRequest) - return - } - - authCode, err := s.storage.GetAuthCode(code) - if err != nil || s.now().After(authCode.Expiry) { - errCode := http.StatusBadRequest - if err != nil && err != storage.ErrNotFound { - s.logger.Errorf("failed to get auth code: %v", err) - errCode = http.StatusInternalServerError - } - s.renderError(r, w, errCode, "Invalid or expired auth code.") - return - } - - // Grab the device request from storage - deviceReq, err := s.storage.GetDeviceRequest(userCode) - if err != nil || s.now().After(deviceReq.Expiry) { - errCode := http.StatusBadRequest - if err != nil && err != storage.ErrNotFound { - s.logger.Errorf("failed to get device code: %v", err) - errCode = http.StatusInternalServerError - } - s.renderError(r, w, errCode, "Invalid or expired user code.") - return - } - - client, err := s.storage.GetClient(deviceReq.ClientID) - if err != nil { - if err != storage.ErrNotFound { - s.logger.Errorf("failed to get client: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - } else { - s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized) - } - return - } - if client.Secret != deviceReq.ClientSecret { - s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized) - return - } - - resp, err := s.exchangeAuthCode(w, authCode, client) - if err != nil { - s.logger.Errorf("Could not exchange auth code for client %q: %v", deviceReq.ClientID, err) - s.renderError(r, w, http.StatusInternalServerError, "Failed to exchange auth code.") - return - } - - // Grab the device token from storage - old, err := s.storage.GetDeviceToken(deviceReq.DeviceCode) - if err != nil || s.now().After(old.Expiry) { - errCode := http.StatusBadRequest - if err != nil && err != storage.ErrNotFound { - s.logger.Errorf("failed to get device token: %v", err) - errCode = http.StatusInternalServerError - } - s.renderError(r, w, errCode, "Invalid or expired device code.") - return - } - - updater := func(old storage.DeviceToken) (storage.DeviceToken, error) { - if old.Status == deviceTokenComplete { - return old, errors.New("device token already complete") - } - respStr, err := json.MarshalIndent(resp, "", " ") - if err != nil { - s.logger.Errorf("failed to marshal device token response: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "") - return old, err - } - - old.Token = string(respStr) - old.Status = deviceTokenComplete - return old, nil - } - - // Update refresh token in the storage, store the token and mark as complete - if err := s.storage.UpdateDeviceToken(deviceReq.DeviceCode, updater); err != nil { - s.logger.Errorf("failed to update device token: %v", err) - s.renderError(r, w, http.StatusBadRequest, "") - return - } - - if err := s.templates.deviceSuccess(r, w, client.Name); err != nil { - s.logger.Errorf("Server template error: %v", err) - s.renderError(r, w, http.StatusNotFound, "Page not found") - } - - default: - http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest) - return - } -} - -func (s *Server) verifyUserCode(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - err := r.ParseForm() - if err != nil { - s.logger.Warnf("Could not parse user code verification request body : %v", err) - s.renderError(r, w, http.StatusBadRequest, "") - return - } - - userCode := r.Form.Get("user_code") - if userCode == "" { - s.renderError(r, w, http.StatusBadRequest, "No user code received") - return - } - - userCode = strings.ToUpper(userCode) - - // Find the user code in the available requests - deviceRequest, err := s.storage.GetDeviceRequest(userCode) - if err != nil || s.now().After(deviceRequest.Expiry) { - if err != nil && err != storage.ErrNotFound { - s.logger.Errorf("failed to get device request: %v", err) - } - if err := s.templates.device(r, w, s.getDeviceVerificationURI(), userCode, true); err != nil { - s.logger.Errorf("Server template error: %v", err) - s.renderError(r, w, http.StatusNotFound, "Page not found") - } - return - } - - // Redirect to Dex Auth Endpoint - authURL := path.Join(s.issuerURL.Path, "/auth") - u, err := url.Parse(authURL) - if err != nil { - s.renderError(r, w, http.StatusInternalServerError, "Invalid auth URI.") - return - } - q := u.Query() - q.Set("client_id", deviceRequest.ClientID) - q.Set("client_secret", deviceRequest.ClientSecret) - q.Set("state", deviceRequest.UserCode) - q.Set("response_type", "code") - q.Set("redirect_uri", "/device/callback") - q.Set("scope", strings.Join(deviceRequest.Scopes, " ")) - u.RawQuery = q.Encode() - - http.Redirect(w, r, u.String(), http.StatusFound) - - default: - s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") - } -} diff --git a/server/deviceflowhandlers_test.go b/server/deviceflowhandlers_test.go deleted file mode 100644 index 9a9f28584e..0000000000 --- a/server/deviceflowhandlers_test.go +++ /dev/null @@ -1,830 +0,0 @@ -package server - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "path" - "strings" - "testing" - "time" - - "github.com/dexidp/dex/storage" -) - -func TestDeviceVerificationURI(t *testing.T) { - t0 := time.Now() - - now := func() time.Time { return t0 } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // Setup a dex server. - httpServer, s := newTestServer(ctx, t, func(c *Config) { - c.Issuer += "/non-root-path" - c.Now = now - }) - defer httpServer.Close() - - u, err := url.Parse(s.issuerURL.String()) - if err != nil { - t.Fatalf("Could not parse issuer URL %v", err) - } - u.Path = path.Join(u.Path, "/device/auth/verify_code") - - uri := s.getDeviceVerificationURI() - if uri != u.Path { - t.Errorf("Invalid verification URI. Expected %v got %v", u.Path, uri) - } -} - -func TestHandleDeviceCode(t *testing.T) { - t0 := time.Now() - - now := func() time.Time { return t0 } - - tests := []struct { - testName string - clientID string - codeChallengeMethod string - requestType string - scopes []string - expectedResponseCode int - expectedContentType string - expectedServerResponse string - }{ - { - testName: "New Code", - clientID: "test", - requestType: "POST", - scopes: []string{"openid", "profile", "email"}, - expectedResponseCode: http.StatusOK, - expectedContentType: "application/json", - }, - { - testName: "Invalid request Type (GET)", - clientID: "test", - requestType: "GET", - scopes: []string{"openid", "profile", "email"}, - expectedResponseCode: http.StatusBadRequest, - expectedContentType: "application/json", - }, - { - testName: "New Code with valid PKCE", - clientID: "test", - requestType: "POST", - scopes: []string{"openid", "profile", "email"}, - codeChallengeMethod: "S256", - expectedResponseCode: http.StatusOK, - expectedContentType: "application/json", - }, - { - testName: "Invalid code challenge method", - clientID: "test", - requestType: "POST", - codeChallengeMethod: "invalid", - scopes: []string{"openid", "profile", "email"}, - expectedResponseCode: http.StatusBadRequest, - expectedContentType: "application/json", - }, - } - for _, tc := range tests { - t.Run(tc.testName, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Setup a dex server. - httpServer, s := newTestServer(ctx, t, func(c *Config) { - c.Issuer += "/non-root-path" - c.Now = now - }) - defer httpServer.Close() - - u, err := url.Parse(s.issuerURL.String()) - if err != nil { - t.Fatalf("Could not parse issuer URL %v", err) - } - u.Path = path.Join(u.Path, "device/code") - - data := url.Values{} - data.Set("client_id", tc.clientID) - data.Set("code_challenge_method", tc.codeChallengeMethod) - for _, scope := range tc.scopes { - data.Add("scope", scope) - } - req, _ := http.NewRequest(tc.requestType, u.String(), bytes.NewBufferString(data.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") - - rr := httptest.NewRecorder() - s.ServeHTTP(rr, req) - if rr.Code != tc.expectedResponseCode { - t.Errorf("Unexpected Response Type. Expected %v got %v", tc.expectedResponseCode, rr.Code) - } - - if rr.Header().Get("content-type") != tc.expectedContentType { - t.Errorf("Unexpected Response Content Type. Expected %v got %v", tc.expectedContentType, rr.Header().Get("content-type")) - } - - body, err := io.ReadAll(rr.Body) - if err != nil { - t.Errorf("Could read token response %v", err) - } - if tc.expectedResponseCode == http.StatusOK { - var resp deviceCodeResponse - if err := json.Unmarshal(body, &resp); err != nil { - t.Errorf("Unexpected Device Code Response Format %v", string(body)) - } - } - }) - } -} - -func TestDeviceCallback(t *testing.T) { - t0 := time.Now() - - now := func() time.Time { return t0 } - - type formValues struct { - state string - code string - error string - } - - // Base "Control" test values - baseFormValues := formValues{ - state: "XXXX-XXXX", - code: "somecode", - } - baseAuthCode := storage.AuthCode{ - ID: "somecode", - ClientID: "testclient", - RedirectURI: deviceCallbackURI, - Nonce: "", - Scopes: []string{"openid", "profile", "email"}, - ConnectorID: "mock", - ConnectorData: nil, - Claims: storage.Claims{}, - Expiry: now().Add(5 * time.Minute), - } - baseDeviceRequest := storage.DeviceRequest{ - UserCode: "XXXX-XXXX", - DeviceCode: "devicecode", - ClientID: "testclient", - ClientSecret: "", - Scopes: []string{"openid", "profile", "email"}, - Expiry: now().Add(5 * time.Minute), - } - baseDeviceToken := storage.DeviceToken{ - DeviceCode: "devicecode", - Status: deviceTokenPending, - Token: "", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - } - - tests := []struct { - testName string - expectedResponseCode int - expectedServerResponse string - values formValues - testAuthCode storage.AuthCode - testDeviceRequest storage.DeviceRequest - testDeviceToken storage.DeviceToken - }{ - { - testName: "Missing State", - values: formValues{ - state: "", - code: "somecode", - error: "", - }, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Missing Code", - values: formValues{ - state: "XXXX-XXXX", - code: "", - error: "", - }, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Error During Authorization", - values: formValues{ - state: "XXXX-XXXX", - code: "somecode", - error: "Error Condition", - }, - expectedResponseCode: http.StatusBadRequest, - expectedServerResponse: "Error Condition: \n", - }, - { - testName: "Expired Auth Code", - values: baseFormValues, - testAuthCode: storage.AuthCode{ - ID: "somecode", - ClientID: "testclient", - RedirectURI: deviceCallbackURI, - Nonce: "", - Scopes: []string{"openid", "profile", "email"}, - ConnectorID: "pic", - ConnectorData: nil, - Claims: storage.Claims{}, - Expiry: now().Add(-5 * time.Minute), - }, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Invalid Auth Code", - values: baseFormValues, - testAuthCode: storage.AuthCode{ - ID: "somecode", - ClientID: "testclient", - RedirectURI: deviceCallbackURI, - Nonce: "", - Scopes: []string{"openid", "profile", "email"}, - ConnectorID: "pic", - ConnectorData: nil, - Claims: storage.Claims{}, - Expiry: now().Add(5 * time.Minute), - }, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Expired Device Request", - values: baseFormValues, - testAuthCode: baseAuthCode, - testDeviceRequest: storage.DeviceRequest{ - UserCode: "XXXX-XXXX", - DeviceCode: "devicecode", - ClientID: "testclient", - Scopes: []string{"openid", "profile", "email"}, - Expiry: now().Add(-5 * time.Minute), - }, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Non-Existent User Code", - values: baseFormValues, - testAuthCode: baseAuthCode, - testDeviceRequest: storage.DeviceRequest{ - UserCode: "ZZZZ-ZZZZ", - DeviceCode: "devicecode", - Scopes: []string{"openid", "profile", "email"}, - Expiry: now().Add(5 * time.Minute), - }, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Bad Device Request Client", - values: baseFormValues, - testAuthCode: baseAuthCode, - testDeviceRequest: storage.DeviceRequest{ - UserCode: "XXXX-XXXX", - DeviceCode: "devicecode", - Scopes: []string{"openid", "profile", "email"}, - Expiry: now().Add(5 * time.Minute), - }, - expectedResponseCode: http.StatusUnauthorized, - }, - { - testName: "Bad Device Request Secret", - values: baseFormValues, - testAuthCode: baseAuthCode, - testDeviceRequest: storage.DeviceRequest{ - UserCode: "XXXX-XXXX", - DeviceCode: "devicecode", - ClientSecret: "foobar", - Scopes: []string{"openid", "profile", "email"}, - Expiry: now().Add(5 * time.Minute), - }, - expectedResponseCode: http.StatusUnauthorized, - }, - { - testName: "Expired Device Token", - values: baseFormValues, - testAuthCode: baseAuthCode, - testDeviceRequest: baseDeviceRequest, - testDeviceToken: storage.DeviceToken{ - DeviceCode: "devicecode", - Status: deviceTokenPending, - Token: "", - Expiry: now().Add(-5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - }, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Device Code Already Redeemed", - values: baseFormValues, - testAuthCode: baseAuthCode, - testDeviceRequest: baseDeviceRequest, - testDeviceToken: storage.DeviceToken{ - DeviceCode: "devicecode", - Status: deviceTokenComplete, - Token: "", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - }, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Successful Exchange", - values: baseFormValues, - testAuthCode: baseAuthCode, - testDeviceRequest: baseDeviceRequest, - testDeviceToken: baseDeviceToken, - expectedResponseCode: http.StatusOK, - }, - { - testName: "Prevent cross-site scripting", - values: formValues{ - state: "XXXX-XXXX", - code: "somecode", - error: "", - }, - expectedResponseCode: http.StatusBadRequest, - expectedServerResponse: "<script>console.log(window);</script>: \n", - }, - } - for _, tc := range tests { - t.Run(tc.testName, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Setup a dex server. - httpServer, s := newTestServer(ctx, t, func(c *Config) { - // c.Issuer = c.Issuer + "/non-root-path" - c.Now = now - }) - defer httpServer.Close() - - if err := s.storage.CreateAuthCode(tc.testAuthCode); err != nil { - t.Fatalf("failed to create auth code: %v", err) - } - - if err := s.storage.CreateDeviceRequest(tc.testDeviceRequest); err != nil { - t.Fatalf("failed to create device request: %v", err) - } - - if err := s.storage.CreateDeviceToken(tc.testDeviceToken); err != nil { - t.Fatalf("failed to create device token: %v", err) - } - - client := storage.Client{ - ID: "testclient", - Secret: "", - RedirectURIs: []string{deviceCallbackURI}, - } - if err := s.storage.CreateClient(client); err != nil { - t.Fatalf("failed to create client: %v", err) - } - - u, err := url.Parse(s.issuerURL.String()) - if err != nil { - t.Fatalf("Could not parse issuer URL %v", err) - } - u.Path = path.Join(u.Path, "device/callback") - q := u.Query() - q.Set("state", tc.values.state) - q.Set("code", tc.values.code) - q.Set("error", tc.values.error) - u.RawQuery = q.Encode() - req, _ := http.NewRequest("GET", u.String(), nil) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") - - rr := httptest.NewRecorder() - s.ServeHTTP(rr, req) - if rr.Code != tc.expectedResponseCode { - t.Errorf("%s: Unexpected Response Type. Expected %v got %v", tc.testName, tc.expectedResponseCode, rr.Code) - } - - if len(tc.expectedServerResponse) > 0 { - result, _ := io.ReadAll(rr.Body) - if string(result) != tc.expectedServerResponse { - t.Errorf("%s: Unexpected Response. Expected %q got %q", tc.testName, tc.expectedServerResponse, result) - } - } - }) - } -} - -func TestDeviceTokenResponse(t *testing.T) { - t0 := time.Now() - - now := func() time.Time { return t0 } - - // Base PKCE values - // base64-urlencoded, sha256 digest of code_verifier - codeChallenge := "L7ZqsT_zNwvrH8E7J0CqPHx1wgBaFiaE-fAZcKUUAbc" - codeChallengeMethod := "S256" - // "random" string between 43 & 128 ASCII characters - codeVerifier := "66114650f56cc45dee7ee03c49f048ddf9aa53cbf5b09985832fa4f790ff2604" - - baseDeviceRequest := storage.DeviceRequest{ - UserCode: "ABCD-WXYZ", - DeviceCode: "foo", - ClientID: "testclient", - Scopes: []string{"openid", "profile", "offline_access"}, - Expiry: now().Add(5 * time.Minute), - } - - tests := []struct { - testName string - testDeviceRequest storage.DeviceRequest - testDeviceToken storage.DeviceToken - testGrantType string - testDeviceCode string - testCodeVerifier string - expectedServerResponse string - expectedResponseCode int - }{ - { - testName: "Valid but pending token", - testDeviceRequest: baseDeviceRequest, - testDeviceToken: storage.DeviceToken{ - DeviceCode: "f00bar", - Status: deviceTokenPending, - Token: "", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - }, - testDeviceCode: "f00bar", - expectedServerResponse: deviceTokenPending, - expectedResponseCode: http.StatusUnauthorized, - }, - { - testName: "Invalid Grant Type", - testDeviceRequest: baseDeviceRequest, - testDeviceToken: storage.DeviceToken{ - DeviceCode: "f00bar", - Status: deviceTokenPending, - Token: "", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - }, - testDeviceCode: "f00bar", - testGrantType: grantTypeAuthorizationCode, - expectedServerResponse: errInvalidGrant, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Test Slow Down State", - testDeviceRequest: baseDeviceRequest, - testDeviceToken: storage.DeviceToken{ - DeviceCode: "f00bar", - Status: deviceTokenPending, - Token: "", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: now(), - PollIntervalSeconds: 10, - }, - testDeviceCode: "f00bar", - expectedServerResponse: deviceTokenSlowDown, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Test Expired Device Token", - testDeviceRequest: baseDeviceRequest, - testDeviceToken: storage.DeviceToken{ - DeviceCode: "f00bar", - Status: deviceTokenPending, - Token: "", - Expiry: now().Add(-5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - }, - testDeviceCode: "f00bar", - expectedServerResponse: deviceTokenExpired, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Test Non-existent Device Code", - testDeviceRequest: baseDeviceRequest, - testDeviceToken: storage.DeviceToken{ - DeviceCode: "foo", - Status: deviceTokenPending, - Token: "", - Expiry: now().Add(-5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - }, - testDeviceCode: "bar", - expectedServerResponse: errInvalidRequest, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Empty Device Code in Request", - testDeviceRequest: baseDeviceRequest, - testDeviceToken: storage.DeviceToken{ - DeviceCode: "bar", - Status: deviceTokenPending, - Token: "", - Expiry: now().Add(-5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - }, - testDeviceCode: "", - expectedServerResponse: errInvalidRequest, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Claim validated token from Device Code", - testDeviceRequest: baseDeviceRequest, - testDeviceToken: storage.DeviceToken{ - DeviceCode: "foo", - Status: deviceTokenComplete, - Token: "{\"access_token\": \"foobar\"}", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - }, - testDeviceCode: "foo", - expectedServerResponse: "{\"access_token\": \"foobar\"}", - expectedResponseCode: http.StatusOK, - }, - { - testName: "Successful Exchange with PKCE", - testDeviceToken: storage.DeviceToken{ - DeviceCode: "foo", - Status: deviceTokenComplete, - Token: "{\"access_token\": \"foobar\"}", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - PKCE: storage.PKCE{ - CodeChallenge: codeChallenge, - CodeChallengeMethod: codeChallengeMethod, - }, - }, - testDeviceCode: "foo", - testCodeVerifier: codeVerifier, - testDeviceRequest: baseDeviceRequest, - expectedServerResponse: "{\"access_token\": \"foobar\"}", - expectedResponseCode: http.StatusOK, - }, - { - testName: "Test Exchange started with PKCE but without verifier provided", - testDeviceToken: storage.DeviceToken{ - DeviceCode: "foo", - Status: deviceTokenComplete, - Token: "{\"access_token\": \"foobar\"}", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - PKCE: storage.PKCE{ - CodeChallenge: codeChallenge, - CodeChallengeMethod: codeChallengeMethod, - }, - }, - testDeviceCode: "foo", - testDeviceRequest: baseDeviceRequest, - expectedServerResponse: errInvalidGrant, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Test Exchange not started with PKCE but verifier provided", - testDeviceToken: storage.DeviceToken{ - DeviceCode: "foo", - Status: deviceTokenComplete, - Token: "{\"access_token\": \"foobar\"}", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - }, - testDeviceCode: "foo", - testCodeVerifier: codeVerifier, - testDeviceRequest: baseDeviceRequest, - expectedServerResponse: errInvalidRequest, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Test with PKCE but incorrect verifier provided", - testDeviceToken: storage.DeviceToken{ - DeviceCode: "foo", - Status: deviceTokenComplete, - Token: "{\"access_token\": \"foobar\"}", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - PKCE: storage.PKCE{ - CodeChallenge: codeChallenge, - CodeChallengeMethod: codeChallengeMethod, - }, - }, - testDeviceCode: "foo", - testCodeVerifier: "invalid", - testDeviceRequest: baseDeviceRequest, - expectedServerResponse: errInvalidGrant, - expectedResponseCode: http.StatusBadRequest, - }, - { - testName: "Test with PKCE but incorrect challenge provided", - testDeviceToken: storage.DeviceToken{ - DeviceCode: "foo", - Status: deviceTokenComplete, - Token: "{\"access_token\": \"foobar\"}", - Expiry: now().Add(5 * time.Minute), - LastRequestTime: time.Time{}, - PollIntervalSeconds: 0, - PKCE: storage.PKCE{ - CodeChallenge: "invalid", - CodeChallengeMethod: codeChallengeMethod, - }, - }, - testDeviceCode: "foo", - testCodeVerifier: codeVerifier, - testDeviceRequest: baseDeviceRequest, - expectedServerResponse: errInvalidGrant, - expectedResponseCode: http.StatusBadRequest, - }, - } - for _, tc := range tests { - t.Run(tc.testName, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Setup a dex server. - httpServer, s := newTestServer(ctx, t, func(c *Config) { - c.Issuer += "/non-root-path" - c.Now = now - }) - defer httpServer.Close() - - if err := s.storage.CreateDeviceRequest(tc.testDeviceRequest); err != nil { - t.Fatalf("Failed to store device token %v", err) - } - - if err := s.storage.CreateDeviceToken(tc.testDeviceToken); err != nil { - t.Fatalf("Failed to store device token %v", err) - } - - u, err := url.Parse(s.issuerURL.String()) - if err != nil { - t.Fatalf("Could not parse issuer URL %v", err) - } - u.Path = path.Join(u.Path, "device/token") - - data := url.Values{} - grantType := grantTypeDeviceCode - if tc.testGrantType != "" { - grantType = tc.testGrantType - } - data.Set("grant_type", grantType) - data.Set("device_code", tc.testDeviceCode) - if tc.testCodeVerifier != "" { - data.Set("code_verifier", tc.testCodeVerifier) - } - req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") - - rr := httptest.NewRecorder() - s.ServeHTTP(rr, req) - if rr.Code != tc.expectedResponseCode { - t.Errorf("Unexpected Response Type. Expected %v got %v", tc.expectedResponseCode, rr.Code) - } - - body, err := io.ReadAll(rr.Body) - if err != nil { - t.Errorf("Could read token response %v", err) - } - if tc.expectedResponseCode == http.StatusBadRequest || tc.expectedResponseCode == http.StatusUnauthorized { - expectJSONErrorResponse(tc.testName, body, tc.expectedServerResponse, t) - } else if string(body) != tc.expectedServerResponse { - t.Errorf("Unexpected Server Response. Expected %v got %v", tc.expectedServerResponse, string(body)) - } - }) - } -} - -func expectJSONErrorResponse(testCase string, body []byte, expectedError string, t *testing.T) { - jsonMap := make(map[string]interface{}) - err := json.Unmarshal(body, &jsonMap) - if err != nil { - t.Errorf("Unexpected error unmarshalling response: %v", err) - } - if jsonMap["error"] != expectedError { - t.Errorf("Test Case %s expected error %v, received %v", testCase, expectedError, jsonMap["error"]) - } -} - -func TestVerifyCodeResponse(t *testing.T) { - t0 := time.Now() - - now := func() time.Time { return t0 } - - tests := []struct { - testName string - testDeviceRequest storage.DeviceRequest - userCode string - expectedResponseCode int - expectedRedirectPath string - }{ - { - testName: "Unknown user code", - testDeviceRequest: storage.DeviceRequest{ - UserCode: "ABCD-WXYZ", - DeviceCode: "f00bar", - ClientID: "testclient", - Scopes: []string{"openid", "profile", "offline_access"}, - Expiry: now().Add(5 * time.Minute), - }, - userCode: "CODE-TEST", - expectedResponseCode: http.StatusBadRequest, - expectedRedirectPath: "", - }, - { - testName: "Expired user code", - testDeviceRequest: storage.DeviceRequest{ - UserCode: "ABCD-WXYZ", - DeviceCode: "f00bar", - ClientID: "testclient", - Scopes: []string{"openid", "profile", "offline_access"}, - Expiry: now().Add(-5 * time.Minute), - }, - userCode: "ABCD-WXYZ", - expectedResponseCode: http.StatusBadRequest, - expectedRedirectPath: "", - }, - { - testName: "No user code", - testDeviceRequest: storage.DeviceRequest{ - UserCode: "ABCD-WXYZ", - DeviceCode: "f00bar", - ClientID: "testclient", - Scopes: []string{"openid", "profile", "offline_access"}, - Expiry: now().Add(-5 * time.Minute), - }, - userCode: "", - expectedResponseCode: http.StatusBadRequest, - expectedRedirectPath: "", - }, - { - testName: "Valid user code, expect redirect to auth endpoint", - testDeviceRequest: storage.DeviceRequest{ - UserCode: "ABCD-WXYZ", - DeviceCode: "f00bar", - ClientID: "testclient", - Scopes: []string{"openid", "profile", "offline_access"}, - Expiry: now().Add(5 * time.Minute), - }, - userCode: "ABCD-WXYZ", - expectedResponseCode: http.StatusFound, - expectedRedirectPath: "/auth", - }, - } - for _, tc := range tests { - t.Run(tc.testName, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Setup a dex server. - httpServer, s := newTestServer(ctx, t, func(c *Config) { - c.Issuer += "/non-root-path" - c.Now = now - }) - defer httpServer.Close() - - if err := s.storage.CreateDeviceRequest(tc.testDeviceRequest); err != nil { - t.Fatalf("Failed to store device token %v", err) - } - - u, err := url.Parse(s.issuerURL.String()) - if err != nil { - t.Fatalf("Could not parse issuer URL %v", err) - } - - u.Path = path.Join(u.Path, "device/auth/verify_code") - data := url.Values{} - data.Set("user_code", tc.userCode) - req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") - - rr := httptest.NewRecorder() - s.ServeHTTP(rr, req) - if rr.Code != tc.expectedResponseCode { - t.Errorf("Unexpected Response Type. Expected %v got %v", tc.expectedResponseCode, rr.Code) - } - - u, err = url.Parse(s.issuerURL.String()) - if err != nil { - t.Errorf("Could not parse issuer URL %v", err) - } - u.Path = path.Join(u.Path, tc.expectedRedirectPath) - - location := rr.Header().Get("Location") - if rr.Code == http.StatusFound && !strings.HasPrefix(location, u.Path) { - t.Errorf("Invalid Redirect. Expected %v got %v", u.Path, location) - } - }) - } -} diff --git a/server/discovery/discovery.go b/server/discovery/discovery.go new file mode 100644 index 0000000000..d9119d4b68 --- /dev/null +++ b/server/discovery/discovery.go @@ -0,0 +1,180 @@ +package discovery + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sort" + "strconv" + "sync" + "time" + + jose "github.com/go-jose/go-jose/v4" + + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/router" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/server/templates" +) + +// Handler serves the discovery document and the JWKS. Like every other domain +// handler it takes the issuer URL and the templates directly, so it can be +// built without a Server. +type Handler struct { + IssuerURL oauth2.IssuerURL + Templates *templates.Templates + Signer signer.Signer + Logger *slog.Logger + ResponseTypes map[string]bool + GrantTypes []string + PKCEMethods []string + SessionsEnabled bool + + docOnce sync.Once + docData []byte + docErr error +} + +// renderError renders a user-facing HTML error page. +func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) { + templates.RenderError(h.Templates, h.Logger, r, w, status, description) +} + +// Mount registers the discovery routes. +func (h *Handler) Mount(m router.Mux) { + m.HandleCORS("/.well-known/openid-configuration", h.serveDocument) + m.HandleCORS("/keys", h.Keys) +} + +// Document is the OIDC discovery document. +type Document struct { + Issuer string `json:"issuer"` + Auth string `json:"authorization_endpoint"` + Token string `json:"token_endpoint"` + Keys string `json:"jwks_uri"` + UserInfo string `json:"userinfo_endpoint"` + DeviceEndpoint string `json:"device_authorization_endpoint"` + Introspect string `json:"introspection_endpoint"` + EndSession string `json:"end_session_endpoint,omitempty"` + // BackchannelLogout and BackchannelLogoutSession advertise OIDC Back-Channel + // Logout 1.0. Both are omitted rather than sent as false when sessions are off, + // matching how end_session_endpoint disappears with them. + BackchannelLogout bool `json:"backchannel_logout_supported,omitempty"` + BackchannelLogoutSession bool `json:"backchannel_logout_session_supported,omitempty"` + GrantTypes []string `json:"grant_types_supported"` + ResponseTypes []string `json:"response_types_supported"` + Subjects []string `json:"subject_types_supported"` + IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"` + CodeChallengeAlgs []string `json:"code_challenge_methods_supported"` + Scopes []string `json:"scopes_supported"` + AuthMethods []string `json:"token_endpoint_auth_methods_supported"` + Claims []string `json:"claims_supported"` +} + +// Keys serves the JSON Web Key Set. +func (h *Handler) Keys(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // TODO(ericchiang): Cache this. + keys, err := h.Signer.ValidationKeys(ctx) + if err != nil { + h.Logger.ErrorContext(ctx, "failed to get keys", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + if len(keys) == 0 { + h.Logger.ErrorContext(ctx, "no public keys found.") + h.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + jwks := jose.JSONWebKeySet{ + Keys: make([]jose.JSONWebKey, len(keys)), + } + for i, key := range keys { + jwks.Keys[i] = *key + } + + data, err := json.MarshalIndent(jwks, "", " ") + if err != nil { + h.Logger.ErrorContext(ctx, "failed to marshal discovery data", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + // We don't have NextRotation info from Signer interface easily, + // so we'll just set a reasonable default cache time. + maxAge := time.Minute * 10 + + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, must-revalidate", int(maxAge.Seconds()))) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.Write(data) +} + +// serveDocument serves the discovery document, marshaling it once on first use. +func (h *Handler) serveDocument(w http.ResponseWriter, r *http.Request) { + h.docOnce.Do(func() { + h.docData, h.docErr = json.MarshalIndent(h.Construct(r.Context()), "", " ") + }) + if h.docErr != nil { + h.Logger.ErrorContext(r.Context(), "failed to marshal discovery data", "err", h.docErr) + h.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(h.docData))) + w.Write(h.docData) +} + +// Construct builds the discovery document from the current configuration. +func (h *Handler) Construct(ctx context.Context) Document { + d := Document{ + Issuer: h.IssuerURL.String(), + Auth: h.IssuerURL.AbsURL("/auth"), + Token: h.IssuerURL.AbsURL("/token"), + Keys: h.IssuerURL.AbsURL("/keys"), + UserInfo: h.IssuerURL.AbsURL("/userinfo"), + DeviceEndpoint: h.IssuerURL.AbsURL("/device/code"), + Introspect: h.IssuerURL.AbsURL("/token/introspect"), + Subjects: []string{"public"}, + IDTokenAlgs: []string{string(jose.RS256)}, + CodeChallengeAlgs: h.PKCEMethods, + Scopes: []string{"openid", "email", "groups", "profile", "offline_access"}, + AuthMethods: []string{"client_secret_basic", "client_secret_post"}, + Claims: []string{ + "iss", "sub", "aud", "iat", "exp", "email", "email_verified", + "locale", "name", "preferred_username", "at_hash", "groups", + "federated_claims", + }, + } + + // Determine signing algorithm from signer. + signingAlg, err := h.Signer.Algorithm(ctx) + if err != nil { + h.Logger.Error("failed to get signing algorithm", "err", err) + } else { + d.IDTokenAlgs = []string{string(signingAlg)} + } + + for responseType := range h.ResponseTypes { + d.ResponseTypes = append(d.ResponseTypes, responseType) + } + sort.Strings(d.ResponseTypes) + + d.GrantTypes = h.GrantTypes + + if h.SessionsEnabled { + d.EndSession = h.IssuerURL.AbsURL("/logout") + d.BackchannelLogout = true + // Dex always puts a sid in its logout tokens, so clients never need to set + // backchannel_logout_session_required to get one. + d.BackchannelLogoutSession = true + d.Claims = append(d.Claims, "sid") + } + + return d +} diff --git a/server/discovery/discovery_test.go b/server/discovery/discovery_test.go new file mode 100644 index 0000000000..720acee18e --- /dev/null +++ b/server/discovery/discovery_test.go @@ -0,0 +1,59 @@ +package discovery + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "log/slog" + "net/url" + "testing" + + jose "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/signer" +) + +func testHandler(t *testing.T, sessionsEnabled bool) *Handler { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + sig, err := signer.NewMockSigner(key) + require.NoError(t, err) + + u, err := url.Parse("https://dex.example.com") + require.NoError(t, err) + + return &Handler{ + IssuerURL: oauth2.IssuerURL{URL: *u}, + Signer: sig, + Logger: slog.New(slog.DiscardHandler), + ResponseTypes: map[string]bool{"id_token": true, "code": true}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + PKCEMethods: []string{"S256", "plain"}, + SessionsEnabled: sessionsEnabled, + } +} + +func TestConstruct(t *testing.T) { + doc := testHandler(t, true).Construct(context.Background()) + + require.Equal(t, "https://dex.example.com", doc.Issuer) + require.Equal(t, "https://dex.example.com/auth", doc.Auth) + require.Equal(t, "https://dex.example.com/token", doc.Token) + require.Equal(t, "https://dex.example.com/keys", doc.Keys) + require.Equal(t, "https://dex.example.com/token/introspect", doc.Introspect) + // Response types are sorted. + require.Equal(t, []string{"code", "id_token"}, doc.ResponseTypes) + require.Equal(t, []string{"authorization_code", "refresh_token"}, doc.GrantTypes) + require.Equal(t, []string{"S256", "plain"}, doc.CodeChallengeAlgs) + require.Equal(t, []string{string(jose.RS256)}, doc.IDTokenAlgs) + // end_session_endpoint is present only when sessions are enabled. + require.Equal(t, "https://dex.example.com/logout", doc.EndSession) +} + +func TestConstructNoSessions(t *testing.T) { + doc := testHandler(t, false).Construct(context.Background()) + require.Empty(t, doc.EndSession) +} diff --git a/server/discovery/doc.go b/server/discovery/doc.go new file mode 100644 index 0000000000..2b4f2751b2 --- /dev/null +++ b/server/discovery/doc.go @@ -0,0 +1,3 @@ +// Package discovery serves the OIDC discovery document +// (/.well-known/openid-configuration) and the JWKS endpoint (/keys). +package discovery diff --git a/server/grants/authcode.go b/server/grants/authcode.go new file mode 100644 index 0000000000..d75716c3e1 --- /dev/null +++ b/server/grants/authcode.go @@ -0,0 +1,122 @@ +package grants + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// authorizationCode serves the authorization_code grant: the client redeems a +// code minted at the /auth endpoint for tokens. +type authorizationCode struct { + issuer *tokens.Issuer + storage storage.Storage + connectors *connectors.Cache + now func() time.Time + logger *slog.Logger +} + +func (g *authorizationCode) GrantType() string { + return oauth2.GrantTypeAuthorizationCode +} + +func (g *authorizationCode) RequiresClientAuth() bool { + return true +} + +// Scopes are passed through: they were validated at /auth and stored on the code. +func (g *authorizationCode) ScopePolicy() ScopePolicy { + return ScopePolicy{} +} + +// ConnectorID is empty: the connector is recorded on the stored auth code and +// was already authorized at /auth. The grant resolves it (without re-running the +// invariant) only to decide on a refresh token, inside ExchangeAuthCode. +func (g *authorizationCode) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) { + return "", nil +} + +// handle an access token request https://tools.ietf.org/html/rfc6749#section-4.1.3 +func (g *authorizationCode) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) { + if req.Code == "" { + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Required param: code.", Status: http.StatusBadRequest} + } + + authCode, err := g.storage.GetAuthCode(ctx, req.Code) + if err != nil || g.now().After(authCode.Expiry) || authCode.ClientID != client.ID { + if err != nil && err != storage.ErrNotFound { + g.logger.ErrorContext(ctx, "failed to get auth code", "err", err) + return nil, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError} + } + return nil, &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Invalid or expired code parameter.", Status: http.StatusBadRequest} + } + + if oerr := verifyPKCE(req.CodeVerifier, authCode.PKCE); oerr != nil { + return nil, oerr + } + + if authCode.RedirectURI != req.RedirectURI { + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "redirect_uri did not match URI from initial request.", Status: http.StatusBadRequest} + } + + auth, withRefresh, err := ExchangeAuthCode(ctx, g.storage, g.connectors, g.logger, authCode, client) + if err != nil { + return nil, err + } + return issueTokens(ctx, g.logger, g.issuer, auth, authCode.ID, withRefresh) +} + +// ExchangeAuthCode consumes a validated authorization code and returns the +// authorization to issue tokens for and whether a refresh token is warranted. The +// caller then mints the tokens, binding authCode.ID into c_hash. It is shared by +// the authorization_code grant and the device flow, which both redeem an auth +// code for tokens. +// +// DeleteAuthCode is the atomic single-use gate: it serializes concurrent +// redemptions of the same code, so a second request finds the code already gone +// and is rejected โ€” a code yields tokens at most once. Consuming it before +// minting means a signing failure afterwards leaves the code spent, which is the +// right trade: replay safety over a retry on a rare signer outage. +func ExchangeAuthCode(ctx context.Context, s storage.Storage, conns *connectors.Cache, logger *slog.Logger, authCode storage.AuthCode, client storage.Client) (tokens.Authorization, bool, error) { + if err := s.DeleteAuthCode(ctx, authCode.ID); err != nil { + if err == storage.ErrNotFound { + return tokens.Authorization{}, false, &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Invalid or expired code parameter.", Status: http.StatusBadRequest} + } + logger.ErrorContext(ctx, "failed to delete auth code", "err", err) + return tokens.Authorization{}, false, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError} + } + + auth := tokens.Authorization{ + Client: client, + Claims: authCode.Claims, + Scopes: authCode.Scopes, + ConnectorID: authCode.ConnectorID, + Nonce: authCode.Nonce, + AuthTime: authCode.AuthTime, + ConnectorData: authCode.ConnectorData, + + // Stamped on the code while the browser was still here: the token endpoint + // has no cookie to consult, and resolving the session from the user would + // hand this token whichever session that user has open elsewhere. A code + // redeemed after its session ended still names it, which is what the sid + // means โ€” where the token came from. Whether that token is good for + // anything is the session check in introspection and refresh. + SessionID: authCode.SessionID, + } + + // A refresh token is only issued when the connector supports it, the grant + // type is allowed and offline_access was requested (RFC 6749 ยง1.5). + conn, err := conns.Get(ctx, authCode.ConnectorID) + if err != nil { + logger.ErrorContext(ctx, "connector not found", "connector_id", authCode.ConnectorID, "err", err) + return tokens.Authorization{}, false, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError} + } + + return auth, shouldIssueRefreshToken(conn, authCode.Scopes), nil +} diff --git a/server/grants/clientcredentials.go b/server/grants/clientcredentials.go new file mode 100644 index 0000000000..fbdca60b13 --- /dev/null +++ b/server/grants/clientcredentials.go @@ -0,0 +1,82 @@ +package grants + +import ( + "context" + "log/slog" + "net/http" + + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// clientCredentials serves the client_credentials grant: a confidential client +// obtains tokens for itself, with no user involved. +type clientCredentials struct { + issuer *tokens.Issuer + logger *slog.Logger +} + +func (g *clientCredentials) GrantType() string { + return oauth2.GrantTypeClientCredentials +} + +func (g *clientCredentials) RequiresClientAuth() bool { + return true +} + +var clientCredentialsScopePolicy = ScopePolicy{ + Standard: map[string]bool{ + tokens.ScopeOpenID: true, + tokens.ScopeEmail: true, + tokens.ScopeProfile: true, + tokens.ScopeGroups: true, + }, + Rejected: map[string]string{ + tokens.ScopeOfflineAccess: "client_credentials grant does not support offline_access scope.", + tokens.ScopeFederatedID: "client_credentials grant does not support federated:id scope.", + }, + ErrorType: oauth2.InvalidScope, +} + +func (g *clientCredentials) ScopePolicy() ScopePolicy { + return clientCredentialsScopePolicy +} + +// ConnectorID is empty: client_credentials involves no connector. +func (g *clientCredentials) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) { + return "", nil +} + +func (g *clientCredentials) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) { + // client_credentials requires a confidential client. + if client.Public { + return nil, &oauth2.Error{Type: oauth2.UnauthorizedClient, Description: "Public clients cannot use client_credentials grant.", Status: http.StatusBadRequest} + } + + // Build claims from the client itself โ€” no user involved. + claims := storage.Claims{UserID: client.ID} + for _, scope := range req.Scopes { + switch scope { + case tokens.ScopeProfile: + claims.Username = client.Name + claims.PreferredUsername = client.Name + case tokens.ScopeGroups: + if client.ClientCredentialsClaims != nil { + claims.Groups = client.ClientCredentialsClaims.Groups + } + } + } + + auth := tokens.Authorization{ + Client: client, + Claims: claims, + Scopes: req.Scopes, + // Empty connector ID is unique for client credentials grant. Creating + // connectors with an empty ID via the config and API is prohibited. + ConnectorID: "", + Nonce: req.Nonce, + } + return issueTokens(ctx, g.logger, g.issuer, auth, "", false) +} diff --git a/server/grants/devicecode.go b/server/grants/devicecode.go new file mode 100644 index 0000000000..81a5ef446f --- /dev/null +++ b/server/grants/devicecode.go @@ -0,0 +1,104 @@ +package grants + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/storage" +) + +// deviceCode serves the RFC 8628 device_code grant: the device polls for the +// token minted and stored by the browser callback once the user authorizes it. +// It issues nothing itself โ€” a Minter returning the stored token โ€” and drives the +// authorization_pending / slow_down polling protocol. +type deviceCode struct { + storage storage.Storage + now func() time.Time + logger *slog.Logger +} + +func (g *deviceCode) GrantType() string { + return oauth2.GrantTypeDeviceCode +} + +// RequiresClientAuth is false: the device is identified by the device code, not +// client credentials. +func (g *deviceCode) RequiresClientAuth() bool { + return false +} + +func (g *deviceCode) ScopePolicy() ScopePolicy { + return ScopePolicy{} +} + +func (g *deviceCode) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) { + return "", nil +} + +func (g *deviceCode) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) { + if req.DeviceCode == "" { + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "No device code received", Status: http.StatusBadRequest} + } + + now := g.now() + deviceToken, err := g.storage.GetDeviceToken(ctx, req.DeviceCode) + if err != nil { + if err != storage.ErrNotFound { + g.logger.ErrorContext(ctx, "failed to get device code", "err", err) + } + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Invalid Device code.", Status: http.StatusBadRequest} + } + if now.After(deviceToken.Expiry) { + return nil, &oauth2.Error{Type: oauth2.DeviceTokenExpired, Status: http.StatusBadRequest} + } + + // Rate limiting: increase the poll interval until the device waits long enough. + slowDown := false + pollInterval := deviceToken.PollIntervalSeconds + if now.Before(deviceToken.LastRequestTime.Add(time.Second * time.Duration(pollInterval))) { + slowDown = true + pollInterval += 5 + } else { + pollInterval = 5 + } + + switch deviceToken.Status { + case oauth2.DeviceTokenPending: + updater := func(old storage.DeviceToken) (storage.DeviceToken, error) { + old.PollIntervalSeconds = pollInterval + old.LastRequestTime = now + return old, nil + } + if err := g.storage.UpdateDeviceToken(ctx, req.DeviceCode, updater); err != nil { + g.logger.ErrorContext(ctx, "failed to update device token", "err", err) + return nil, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError} + } + if slowDown { + return nil, &oauth2.Error{Type: oauth2.DeviceTokenSlowDown, Status: http.StatusBadRequest} + } + return nil, &oauth2.Error{Type: oauth2.DeviceTokenPending, Status: http.StatusBadRequest} + + case oauth2.DeviceTokenComplete: + if oerr := verifyPKCE(req.CodeVerifier, deviceToken.PKCE); oerr != nil { + return nil, oerr + } + // The token was minted and stored by the browser callback; relay it verbatim. + return storedResponse(deviceToken.Token), nil + + default: + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Invalid Device code.", Status: http.StatusBadRequest} + } +} + +// storedResponse writes an already-serialized token response verbatim. +type storedResponse string + +func (s storedResponse) Write(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(s)) + return err +} diff --git a/server/grants/doc.go b/server/grants/doc.go new file mode 100644 index 0000000000..e73e6cb199 --- /dev/null +++ b/server/grants/doc.go @@ -0,0 +1,5 @@ +// Package grants implements the OAuth2 token endpoint (/token). It defines a +// Grant abstraction โ€” one handler per grant_type โ€” and a Handler that +// dispatches a token request to the grant registered for its grant_type, +// authenticating the client first when the grant requires it. +package grants diff --git a/server/grants/grants.go b/server/grants/grants.go new file mode 100644 index 0000000000..04eba65cf5 --- /dev/null +++ b/server/grants/grants.go @@ -0,0 +1,404 @@ +package grants + +import ( + "context" + "crypto/subtle" + "errors" + "log/slog" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/router" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// Request is the parsed token-endpoint request. Every field the grants read is +// parsed once, here, so a grant never reaches into the raw form. +type Request struct { + ClientID string + ClientSecret string + Scopes []string + Nonce string + ConnectorID string + + // authorization_code + Code string + RedirectURI string + CodeVerifier string + + // refresh_token + RefreshToken string + + // device_code + DeviceCode string + + // password + Username string + Password string + + // token exchange (RFC 8693) + SubjectToken string + SubjectTokenType string + RequestedTokenType string + + // refresh holds the refresh token the refresh grant looks up while resolving + // the connector, so it is fetched and decoded once and reused in Authorize. + refresh *storage.RefreshToken + refreshID *internal.RefreshToken +} + +// parseRequest reads the whole token request form once. Client credentials come +// from the Authorization header when present, otherwise from the form. +func parseRequest(r *http.Request) (*Request, *oauth2.Error) { + if err := r.ParseForm(); err != nil { + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusBadRequest} + } + + req := &Request{ + Scopes: strings.Fields(r.PostFormValue("scope")), + Nonce: r.PostFormValue("nonce"), + ConnectorID: r.PostFormValue("connector_id"), + DeviceCode: r.PostFormValue("device_code"), + Code: r.PostFormValue("code"), + RedirectURI: r.PostFormValue("redirect_uri"), + CodeVerifier: r.PostFormValue("code_verifier"), + RefreshToken: r.PostFormValue("refresh_token"), + Username: r.PostFormValue("username"), + Password: r.PostFormValue("password"), + SubjectToken: r.PostFormValue("subject_token"), + SubjectTokenType: r.PostFormValue("subject_token_type"), + RequestedTokenType: r.PostFormValue("requested_token_type"), + } + + if id, secret, ok := r.BasicAuth(); ok { + var err error + if req.ClientID, err = url.QueryUnescape(id); err != nil { + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "client_id improperly encoded", Status: http.StatusBadRequest} + } + if req.ClientSecret, err = url.QueryUnescape(secret); err != nil { + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "client_secret improperly encoded", Status: http.StatusBadRequest} + } + } else { + req.ClientID = r.PostFormValue("client_id") + req.ClientSecret = r.PostFormValue("client_secret") + } + + return req, nil +} + +// Responder writes the token endpoint's HTTP response body. tokens.Response is +// the usual one; a grant that returns an already-serialized body (device_code +// relays the token stored by the browser callback) returns its own. +type Responder interface { + Write(w http.ResponseWriter) error +} + +// Grant serves one OAuth2 grant type at the token endpoint. It is a set of hooks +// the Handler calls in order โ€” the shared phases (client auth, scope validation, +// connector resolution, response writing) live on the Handler, so a grant only +// fills in the parts unique to it and cannot forget a shared step. +type Grant interface { + // GrantType is the grant_type value this grant serves. + GrantType() string + // RequiresClientAuth reports whether the endpoint must authenticate the + // client before the request is processed. + RequiresClientAuth() bool + // ScopePolicy reports how the endpoint validates the requested scopes for + // this grant. + ScopePolicy() ScopePolicy + // ConnectorID is the connector this grant authenticates against; the endpoint + // resolves it and enforces the connector-authorization invariant (client + // allows it, connector allows the grant type) before Authorize. The grant may + // read it from the request or look it up in storage; returning an error + // rejects the request. Returning "" skips the step โ€” for a grant that uses no + // connector (client_credentials, device_code), or one already authorized + // elsewhere (authorization_code was gated at /auth and resolves its connector + // inside Authorize only to decide on a refresh token). + ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) + // Authorize proves the identity against conn (the zero Connector when + // ConnectorID is "") and produces the response to write. Standard grants build + // it with the shared issueTokens helper; a grant with a non-standard response + // builds its own. Returning an *oauth2.Error makes the endpoint write it. + Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) +} + +// ScopePolicy configures the shared scope-validation phase for a grant. It is +// the single place scope rules are enforced, so no grant re-implements the +// cross-client trust check or scope filtering. +type ScopePolicy struct { + // Standard is the set of standard (non cross-client) scopes the grant + // accepts. When nil, scopes are passed through unvalidated (token exchange). + Standard map[string]bool + // RequireOpenID rejects the request when the openid scope is absent. + RequireOpenID bool + // Rejected maps an explicitly refused scope to its rejection message. + Rejected map[string]string + // ErrorType is the OAuth2 error code returned for scope violations. + ErrorType string +} + +// Handler is the /token endpoint. It owns the phases shared by every grant โ€” +// dispatch by grant_type, client authentication, scope validation, connector +// resolution and writing the response or error โ€” while each grant carries only +// its own narrow dependencies. It mounts its own routes (router.Handler). +type Handler struct { + Issuer *tokens.Issuer + Storage storage.Storage + Connectors *connectors.Cache + Now func() time.Time + Logger *slog.Logger + PasswordConnector string + RefreshPolicy *tokens.RefreshStrategy + Sessions *session.Manager + SessionsEnabled bool + SupportedGrantTypes []string + + grants map[string]Grant +} + +func (h *Handler) register(supported []string, gs ...Grant) { + for _, g := range gs { + if slices.Contains(supported, g.GrantType()) { + h.grants[g.GrantType()] = g + } + } +} + +// Mount wires the endpoint's grants and registers the token route. Only grants +// whose type is in SupportedGrantTypes are registered, so a grant type disabled +// by config is simply not served. +func (h *Handler) Mount(m router.Mux) { + h.grants = map[string]Grant{} + h.register(h.SupportedGrantTypes, + &clientCredentials{issuer: h.Issuer, logger: h.Logger}, + &password{issuer: h.Issuer, logger: h.Logger, connectorID: h.PasswordConnector}, + &tokenExchange{issuer: h.Issuer, logger: h.Logger}, + &authorizationCode{issuer: h.Issuer, storage: h.Storage, connectors: h.Connectors, now: h.Now, logger: h.Logger}, + &refresh{storage: h.Storage, issuer: h.Issuer, policy: h.RefreshPolicy, sessions: h.Sessions, sessionsEnabled: h.SessionsEnabled, now: h.Now, logger: h.Logger}, + &deviceCode{storage: h.Storage, now: h.Now, logger: h.Logger}, + ) + m.HandleCORS("/token", h.handleToken) +} + +// handleToken serves /token: it validates the request shape and dispatches to the +// grant for its grant_type. +func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + h.writeError(ctx, w, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "method not allowed", Status: http.StatusBadRequest}) + return + } + if err := r.ParseForm(); err != nil { + h.Logger.ErrorContext(ctx, "could not parse request body", "err", err) + h.writeError(ctx, w, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusBadRequest}) + return + } + + grantType := r.PostFormValue("grant_type") + if !h.dispatch(w, r, grantType) { + h.Logger.ErrorContext(ctx, "unsupported grant type", "grant_type", grantType) + h.writeError(ctx, w, &oauth2.Error{Type: oauth2.UnsupportedGrantType, Status: http.StatusBadRequest}) + } +} + +// dispatch runs the token-endpoint pipeline for the grant registered for +// grantType. It reports whether a grant handled the request, so the caller can +// fall back (e.g. the implicit grant, which is not a token-endpoint grant). +func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request, grantType string) bool { + grant, ok := h.grants[grantType] + if !ok { + return false + } + + ctx := r.Context() + req, oerr := parseRequest(r) + if oerr != nil { + h.writeError(ctx, w, oerr) + return true + } + + // 1. Authenticate the client. + client := storage.Client{} + if grant.RequiresClientAuth() { + client, ok = h.authenticateClient(ctx, w, req) + if !ok { + return true + } + } + + // 2. Validate the requested scopes. + if oerr := h.validateScopes(ctx, client, req, grant.ScopePolicy()); oerr != nil { + h.writeError(ctx, w, oerr) + return true + } + + // 3. Resolve the grant's connector and enforce the connector-authorization + // invariant. A grant that uses no connector resolves to the zero Connector. + connID, oerr := grant.ConnectorID(ctx, req, client) + if oerr != nil { + h.writeError(ctx, w, oerr) + return true + } + conn, oerr := h.resolveConnector(ctx, connID, client, grant.GrantType()) + if oerr != nil { + h.writeError(ctx, w, oerr) + return true + } + + // 4. Let the grant prove the identity and produce the response. + resp, err := grant.Authorize(ctx, req, client, conn) + if err != nil { + h.writeError(ctx, w, err) + return true + } + + // 5. Write the response. + if err := resp.Write(w); err != nil { + h.Logger.ErrorContext(ctx, "failed to write token response", "err", err) + } + return true +} + +// validateScopes validates the requested scopes per the grant's policy: it +// rejects refused scopes, filters unknown ones, enforces openid when required, +// and verifies cross-client trust โ€” the security-sensitive check that must run +// for every grant. A nil policy set passes scopes through unvalidated. +func (h *Handler) validateScopes(ctx context.Context, client storage.Client, req *Request, p ScopePolicy) *oauth2.Error { + if p.Standard == nil { + return nil + } + + var unrecognized, invalid []string + for _, scope := range req.Scopes { + if msg, refused := p.Rejected[scope]; refused { + return &oauth2.Error{Type: p.ErrorType, Description: msg, Status: http.StatusBadRequest} + } + if p.Standard[scope] { + continue + } + + peerID, ok := tokens.ParseCrossClientScope(scope) + if !ok { + unrecognized = append(unrecognized, scope) + continue + } + trusted, err := tokens.CrossClientTrusted(ctx, h.Storage, client.ID, peerID) + if err != nil { + h.Logger.ErrorContext(ctx, "error validating cross client trust", "client_id", client.ID, "peer_id", peerID, "err", err) + return &oauth2.Error{Type: oauth2.InvalidClient, Description: "Error validating cross client trust.", Status: http.StatusBadRequest} + } + if !trusted { + invalid = append(invalid, scope) + } + } + + if p.RequireOpenID && !tokens.HasOpenID(req.Scopes) { + return &oauth2.Error{Type: p.ErrorType, Description: `Missing required scope(s) ["openid"].`, Status: http.StatusBadRequest} + } + if len(unrecognized) > 0 { + return oauth2.Errorf(p.ErrorType, http.StatusBadRequest, "Unrecognized scope(s) %q", unrecognized) + } + if len(invalid) > 0 { + return oauth2.Errorf(p.ErrorType, http.StatusBadRequest, "Client can't request scope(s) %q", invalid) + } + return nil +} + +// resolveConnector enforces the connector-authorization invariant and returns the +// opened connector: the client must allow the connector, and the connector must +// permit the grant type. connID == "" (a grant that uses no connector) resolves +// to the zero Connector. Running here, before Authorize, means no grant can +// forget the check. +func (h *Handler) resolveConnector(ctx context.Context, connID string, client storage.Client, grantType string) (connectors.Connector, *oauth2.Error) { + if connID == "" { + return connectors.Connector{}, nil + } + + if !connectors.ConnectorAllowed(client.AllowedConnectors, connID) { + h.Logger.WarnContext(ctx, "connector not allowed for client", "client_id", client.ID, "connector_id", connID) + return connectors.Connector{}, &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Connector not allowed for this client.", Status: http.StatusBadRequest} + } + conn, err := h.Connectors.Get(ctx, connID) + if err != nil { + h.Logger.ErrorContext(ctx, "failed to get connector", "connector_id", connID, "err", err) + return connectors.Connector{}, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Requested connector does not exist.", Status: http.StatusBadRequest} + } + if !connectors.GrantTypeAllowed(conn.GrantTypes, grantType) { + h.Logger.ErrorContext(ctx, "connector does not allow grant", "connector_id", connID, "grant_type", grantType) + return connectors.Connector{}, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Requested connector does not support this grant type.", Status: http.StatusBadRequest} + } + return conn, nil +} + +// authenticateClient resolves the client from the parsed credentials. On failure +// it writes the error response and returns ok=false. +func (h *Handler) authenticateClient(ctx context.Context, w http.ResponseWriter, req *Request) (storage.Client, bool) { + client, err := h.Storage.GetClient(ctx, req.ClientID) + if err != nil { + if err != storage.ErrNotFound { + h.Logger.ErrorContext(ctx, "failed to get client", "err", err) + h.writeError(ctx, w, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError}) + } else { + h.writeError(ctx, w, &oauth2.Error{Type: oauth2.InvalidClient, Description: "Invalid client credentials.", Status: http.StatusUnauthorized}) + } + return storage.Client{}, false + } + + if subtle.ConstantTimeCompare([]byte(client.Secret), []byte(req.ClientSecret)) != 1 { + if req.ClientSecret == "" { + h.Logger.InfoContext(ctx, "missing client_secret on token request", "client_id", client.ID) + } else { + h.Logger.InfoContext(ctx, "invalid client_secret on token request", "client_id", client.ID) + } + h.writeError(ctx, w, &oauth2.Error{Type: oauth2.InvalidClient, Description: "Invalid client credentials.", Status: http.StatusUnauthorized}) + return storage.Client{}, false + } + + return client, true +} + +// writeError writes err as an OAuth2 error response. An *oauth2.Error carries its +// own type/description/status; anything else is reported as a server error. +func (h *Handler) writeError(ctx context.Context, w http.ResponseWriter, err error) { + var oerr *oauth2.Error + if !errors.As(err, &oerr) || oerr == nil { + h.Logger.ErrorContext(ctx, "token request failed", "err", err) + oerr = &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError} + } + oauth2.WriteErrorResponse(h.Logger, w, oerr.Type, oerr.Description, oerr.Status) +} + +// issue mints the standard token response โ€” the single mint every standard grant +// shares โ€” logging and mapping a signing failure to a server error. code is the +// authorization code bound into the ID token's c_hash, empty when there is none. +func issueTokens(ctx context.Context, logger *slog.Logger, issuer *tokens.Issuer, auth tokens.Authorization, code string, withRefresh bool) (Responder, error) { + resp, err := issuer.IssueResponse(ctx, auth, code, withRefresh) + if err != nil { + logger.ErrorContext(ctx, "failed to issue tokens", "err", err) + return nil, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError} + } + return resp, nil +} + +// shouldIssueRefreshToken reports whether a refresh token should be issued: the +// connector supports refresh, the connector permits the refresh_token grant, and +// offline_access was requested. A refresh token is never mandatory (RFC 6749 ยง1.5). +func shouldIssueRefreshToken(conn connectors.Connector, scopes []string) bool { + if _, ok := conn.Connector.(connector.RefreshConnector); !ok { + return false + } + if !connectors.GrantTypeAllowed(conn.GrantTypes, oauth2.GrantTypeRefreshToken) { + return false + } + return slices.Contains(scopes, tokens.ScopeOfflineAccess) +} diff --git a/server/grants/password.go b/server/grants/password.go new file mode 100644 index 0000000000..1442f86684 --- /dev/null +++ b/server/grants/password.go @@ -0,0 +1,77 @@ +package grants + +import ( + "context" + "log/slog" + "net/http" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// password serves the Resource Owner Password Credentials grant: the client +// exchanges a username and password for tokens via a password-capable connector. +type password struct { + issuer *tokens.Issuer + logger *slog.Logger + connectorID string +} + +func (g *password) GrantType() string { + return oauth2.GrantTypePassword +} + +func (g *password) RequiresClientAuth() bool { + return true +} + +var passwordScopePolicy = ScopePolicy{ + Standard: map[string]bool{ + tokens.ScopeOpenID: true, + tokens.ScopeOfflineAccess: true, + tokens.ScopeEmail: true, + tokens.ScopeProfile: true, + tokens.ScopeGroups: true, + tokens.ScopeFederatedID: true, + }, + RequireOpenID: true, + ErrorType: oauth2.InvalidRequest, +} + +func (g *password) ScopePolicy() ScopePolicy { + return passwordScopePolicy +} + +// ConnectorID is the connector the password grant is configured to use. +func (g *password) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) { + return g.connectorID, nil +} + +func (g *password) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) { + passwordConnector, ok := conn.Connector.(connector.PasswordConnector) + if !ok { + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Requested password connector does not correct type.", Status: http.StatusBadRequest} + } + + identity, ok, err := passwordConnector.Login(ctx, tokens.ParseScopes(req.Scopes), req.Username, req.Password) + if err != nil { + g.logger.ErrorContext(ctx, "failed to login user", "err", err) + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Could not login user", Status: http.StatusBadRequest} + } + if !ok { + return nil, &oauth2.Error{Type: oauth2.AccessDenied, Description: "Invalid username or password", Status: http.StatusUnauthorized} + } + + auth := tokens.Authorization{ + Client: client, + Claims: tokens.ClaimsFromIdentity(identity), + Scopes: req.Scopes, + ConnectorID: g.connectorID, + Nonce: req.Nonce, + ConnectorData: identity.ConnectorData, + } + return issueTokens(ctx, g.logger, g.issuer, auth, "", shouldIssueRefreshToken(conn, req.Scopes)) +} diff --git a/server/grants/pkce.go b/server/grants/pkce.go new file mode 100644 index 0000000000..a505809a38 --- /dev/null +++ b/server/grants/pkce.go @@ -0,0 +1,31 @@ +package grants + +import ( + "net/http" + + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/storage" +) + +// verifyPKCE checks a code_verifier against a stored PKCE challenge (RFC 7636). +// It is the single PKCE check, shared by the authorization_code and device_code +// grants, which redeem a code challenge stored at /auth. +func verifyPKCE(codeVerifier string, pkce storage.PKCE) *oauth2.Error { + switch { + case codeVerifier != "" && pkce.CodeChallenge != "": + calculated, err := oauth2.CalculateCodeChallenge(codeVerifier, pkce.CodeChallengeMethod) + if err != nil { + return &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError} + } + if pkce.CodeChallenge != calculated { + return &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Invalid code_verifier.", Status: http.StatusBadRequest} + } + case codeVerifier != "": + // No code_challenge on /auth, but a code_verifier on /token. + return &oauth2.Error{Type: oauth2.InvalidRequest, Description: "No PKCE flow started. Cannot check code_verifier.", Status: http.StatusBadRequest} + case pkce.CodeChallenge != "": + // PKCE started on /auth, but no code_verifier on /token. + return &oauth2.Error{Type: oauth2.InvalidGrant, Description: "Expecting parameter code_verifier in PKCE flow.", Status: http.StatusBadRequest} + } + return nil +} diff --git a/server/grants/refresh.go b/server/grants/refresh.go new file mode 100644 index 0000000000..300d93d5f8 --- /dev/null +++ b/server/grants/refresh.go @@ -0,0 +1,307 @@ +package grants + +import ( + "context" + "errors" + "log/slog" + "net/http" + "slices" + "time" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// refresh serves the refresh_token grant: it validates and rotates a refresh +// token, re-reads the identity (from the session or the upstream connector) and +// issues a fresh token set. Its response reuses the rotated refresh token rather +// than minting a new one, so it mints its own instead of the standard Issue. +type refresh struct { + sessions *session.Manager + storage storage.Storage + issuer *tokens.Issuer + policy *tokens.RefreshStrategy + sessionsEnabled bool + now func() time.Time + logger *slog.Logger +} + +func (g *refresh) GrantType() string { + return oauth2.GrantTypeRefreshToken +} + +func (g *refresh) RequiresClientAuth() bool { + return true +} + +// Scopes are validated against the token's originally authorized scopes in +// Authorize, not against a fixed set, so the shared phase passes them through. +func (g *refresh) ScopePolicy() ScopePolicy { + return ScopePolicy{} +} + +// ConnectorID validates the refresh token and reports the connector recorded on +// it, so the endpoint resolves and re-checks that connector on every refresh: a +// client's allowed connectors, or a connector's grant types, may have been +// tightened after the token was issued. The looked-up and decoded token is +// stashed on the request so Authorize reuses it without a second lookup or parse. +func (g *refresh) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) { + token, oerr := parseRefreshToken(req.RefreshToken) + if oerr != nil { + return "", oerr + } + + refreshToken, err := tokens.LookupRefreshToken(ctx, g.storage, g.policy, g.logger, &client.ID, token) + if err != nil { + return "", refreshLookupError(err) + } + + req.refresh, req.refreshID = refreshToken, token + return refreshToken.ConnectorID, nil +} + +// Authorize rotates the refresh token, re-reads the identity against the resolved +// connector, and returns the token set โ€” reusing the rotated refresh token, so it +// mints its own response rather than the standard set (which would mint a second +// refresh token). +func (g *refresh) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) { + refreshToken := req.refresh + + scopes, oerr := g.refreshScopes(req, refreshToken) + if oerr != nil { + return nil, oerr + } + + // Resolved before anything is rotated or read from the connector: a token whose + // session has ended is not going to produce a token set. Skipped outright when + // sessions are off โ€” nothing was ever bound to one, so the read would only + // confirm that, and refusing a refresh over it would be indefensible. + var sessionID string + if g.sessionsEnabled { + var oerr *oauth2.Error + if sessionID, oerr = g.sessionID(ctx, refreshToken, client); oerr != nil { + return nil, oerr + } + } + + var userIdent *storage.UserIdentity + if g.sessionsEnabled { + ui, err := g.storage.GetUserIdentity(ctx, refreshToken.Claims.UserID, refreshToken.ConnectorID) + if err != nil { + g.logger.ErrorContext(ctx, "failed to get user identity", "err", err) + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError} + } + userIdent = &ui + } + + authTime := time.Time{} + if userIdent != nil { + authTime = userIdent.LastLogin + } + + // When sessions are enabled, downstream refresh is disconnected from the + // upstream provider: use the claims cached in UserIdentity at the last login + // instead of contacting the connector (which may fail if the upstream token + // has expired). Otherwise re-read the identity from the connector. + freshIdentity := func(ctx context.Context) (connector.Identity, error) { + if userIdent != nil { + return tokens.IdentityFromClaims(userIdent.Claims), nil + } + connectorData, err := g.refreshConnectorData(ctx, refreshToken) + if err != nil { + return connector.Identity{}, err + } + return g.refreshWithConnector(ctx, conn, connectorData, scopes, tokens.IdentityFromClaims(refreshToken.Claims)) + } + + rawNewToken, ident, err := g.issuer.Refresh.Rotate(ctx, refreshToken, req.refreshID, g.policy, freshIdentity) + if err != nil { + g.logger.ErrorContext(ctx, "failed to rotate refresh token", "err", err) + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError} + } + + auth := tokens.Authorization{ + Client: client, + Claims: tokens.ClaimsFromIdentity(ident), + Scopes: scopes, + ConnectorID: refreshToken.ConnectorID, + Nonce: refreshToken.Nonce, + AuthTime: authTime, + SessionID: sessionID, + } + + accessToken, _, err := g.issuer.SignAccessToken(ctx, auth) + if err != nil { + g.logger.ErrorContext(ctx, "failed to create new access token", "err", err) + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError} + } + idToken, expiry, err := g.issuer.SignIDToken(ctx, auth, accessToken, "") + if err != nil { + g.logger.ErrorContext(ctx, "failed to create ID token", "err", err) + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError} + } + + ts := tokens.TokenSet{AccessToken: accessToken, IDToken: idToken, RefreshToken: rawNewToken, Expiry: expiry} + return ts.Response(g.now()), nil +} + +// sessionID returns the sid for the refreshed tokens, and refuses the refresh when +// the session has ended and the client asked its tokens to end with it. +// +// The sid names where a token came from and is carried across refreshes unchanged, +// dead session or not; stripping it would make the token active again at the next +// refresh, undoing what introspection just reported. Whether the token is still good +// for anything is the client's RefreshTokenLifetime, read the same way here and in +// introspection (see sessionAlive in server/introspection). +// +// Origin comes from the stored reference and nowhere else: a token minted outside a +// browser flow has none and must not acquire one from whatever session its user +// happens to have open. +func (g *refresh) sessionID(ctx context.Context, refreshToken *storage.RefreshToken, client storage.Client) (string, *oauth2.Error) { + bound := client.RefreshBoundToSession() + + offlineSessions, err := g.storage.GetOfflineSessions(ctx, refreshToken.Claims.UserID, refreshToken.ConnectorID) + if err != nil { + if !errors.Is(err, storage.ErrNotFound) { + g.logger.ErrorContext(ctx, "refresh: failed to read offline session for sid", "err", err) + } + if bound { + // Nothing to check the token against. For a standalone token that costs + // only its sid, which can make it look less bound than it is but never + // more; for a bound one it would mean handing out a token whose whole + // validity rests on a session nobody could read. + return "", sessionEndedError() + } + return "", nil + } + + ref, ok := offlineSessions.Refresh[refreshToken.ClientID] + if !ok || ref.SessionID == "" { + // Issued outside a browser flow โ€” the password grant, or before sessions were + // turned on. There is no session to be bound to, so there is none to end. + return "", nil + } + + if !bound { + return ref.SessionID, nil + } + + if !g.sessions.Alive(ctx, ref.SessionID) { + // Through the store, not storage.DeleteRefresh: the token and the offline + // session's reference to it have to go together, or the admin API lists a + // token that no longer exists and fails trying to revoke it. + g.issuer.Refresh.RevokeClients(ctx, refreshToken.Claims.UserID, refreshToken.ConnectorID, + []string{refreshToken.ClientID}) + + g.logger.InfoContext(ctx, "refresh: refused, session ended", + "client_id", refreshToken.ClientID, "user_id", refreshToken.Claims.UserID) + return "", sessionEndedError() + } + return ref.SessionID, nil +} + +// sessionEndedError reports a refused refresh as invalid_grant, the one code RFC +// 6749 ยง5.2 has for a refresh token that is no longer good for anything. The client +// owns the session in question, so the description names the reason. +func sessionEndedError() *oauth2.Error { + return &oauth2.Error{ + Type: oauth2.InvalidGrant, + Description: "The session this refresh token belongs to has ended.", + Status: http.StatusBadRequest, + } +} + +// refreshScopes resolves the scopes for this refresh. Per RFC 6749 ยง6 the client +// may omit them (defaulting to the originally authorized scopes) but may not +// widen them. +func (g *refresh) refreshScopes(req *Request, refreshToken *storage.RefreshToken) ([]string, *oauth2.Error) { + if len(req.Scopes) == 0 { + return refreshToken.Scopes, nil + } + + var unauthorized []string + for _, scope := range req.Scopes { + if !slices.Contains(refreshToken.Scopes, scope) { + unauthorized = append(unauthorized, scope) + } + } + if len(unauthorized) > 0 { + return nil, oauth2.Errorf(oauth2.InvalidRequest, http.StatusBadRequest, "Requested scopes contain unauthorized scope(s): %q.", unauthorized) + } + return req.Scopes, nil +} + +// refreshConnectorData returns the connector data for the upstream refresh: the +// token's own data for legacy tokens that still carry it, otherwise the value on +// the user's offline session. +func (g *refresh) refreshConnectorData(ctx context.Context, refreshToken *storage.RefreshToken) ([]byte, error) { + if len(refreshToken.ConnectorData) > 0 { + return refreshToken.ConnectorData, nil + } + + session, err := g.storage.GetOfflineSessions(ctx, refreshToken.Claims.UserID, refreshToken.ConnectorID) + if err != nil { + if err != storage.ErrNotFound { + g.logger.ErrorContext(ctx, "failed to get offline session", "err", err) + return nil, err + } + return nil, nil + } + return session.ConnectorData, nil +} + +// refreshWithConnector re-reads the identity from the upstream connector when it +// supports refreshing. +func (g *refresh) refreshWithConnector(ctx context.Context, conn connectors.Connector, connectorData []byte, scopes []string, ident connector.Identity) (connector.Identity, error) { + refreshConn, ok := conn.Connector.(connector.RefreshConnector) + if !ok { + return ident, nil + } + + ident.ConnectorData = connectorData + g.logger.Debug("connector data before refresh", "connector_data", ident.ConnectorData) + + newIdent, err := refreshConn.Refresh(ctx, tokens.ParseScopes(scopes), ident) + if err != nil { + g.logger.ErrorContext(ctx, "failed to refresh identity", "err", err) + return ident, err + } + return newIdent, nil +} + +// parseRefreshToken decodes the refresh_token parameter, tolerating the legacy +// raw-ID form for backward compatibility. +func parseRefreshToken(code string) (*internal.RefreshToken, *oauth2.Error) { + if code == "" { + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "No refresh token is found in request.", Status: http.StatusBadRequest} + } + + token := new(internal.RefreshToken) + if err := internal.Unmarshal(code, token); err != nil { + // Assume a raw refresh token ID generated by an older server that has no + // Token value. Reuse is still rejected because Token stays empty. + token = &internal.RefreshToken{RefreshId: code, Token: ""} + } + return token, nil +} + +// refreshLookupError maps a tokens.LookupRefreshToken sentinel to the grant's +// OAuth2 error response. +func refreshLookupError(err error) *oauth2.Error { + const claimedDesc = "Refresh token is invalid or has already been claimed by another client." + switch { + case errors.Is(err, tokens.ErrRefreshTokenInvalid): + return &oauth2.Error{Type: oauth2.InvalidRequest, Description: claimedDesc, Status: http.StatusBadRequest} + case errors.Is(err, tokens.ErrRefreshTokenClaimedByOtherClient): + return &oauth2.Error{Type: oauth2.InvalidGrant, Description: claimedDesc, Status: http.StatusBadRequest} + case errors.Is(err, tokens.ErrRefreshTokenExpired): + return &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Refresh token expired.", Status: http.StatusBadRequest} + default: + return &oauth2.Error{Type: oauth2.InvalidRequest, Status: http.StatusInternalServerError} + } +} diff --git a/server/grants/tokenexchange.go b/server/grants/tokenexchange.go new file mode 100644 index 0000000000..256fcfb654 --- /dev/null +++ b/server/grants/tokenexchange.go @@ -0,0 +1,119 @@ +package grants + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" +) + +// tokenExchange serves the RFC 8693 token-exchange grant: a subject token +// (ID or access token) verified by a connector is exchanged for a new token. +// Its response carries a single requested token plus issued_token_type, so it +// builds its own response from the issuer primitives instead of the standard +// Issue mint. +type tokenExchange struct { + issuer *tokens.Issuer + logger *slog.Logger +} + +func (g *tokenExchange) GrantType() string { + return oauth2.GrantTypeTokenExchange +} + +func (g *tokenExchange) RequiresClientAuth() bool { + return true +} + +// Scopes are passed through: for token exchange the requested scope maps to the +// issued token's scope and is not validated against a fixed set. +func (g *tokenExchange) ScopePolicy() ScopePolicy { + return ScopePolicy{} +} + +// ConnectorID reads the required connector_id parameter (an RFC 8693 extension). +func (g *tokenExchange) ConnectorID(ctx context.Context, req *Request, client storage.Client) (string, *oauth2.Error) { + return req.ConnectorID, nil +} + +func (g *tokenExchange) Authorize(ctx context.Context, req *Request, client storage.Client, conn connectors.Connector) (Responder, error) { + switch req.SubjectTokenType { + case oauth2.TokenTypeID, oauth2.TokenTypeAccess: // ok, continue + default: + return nil, &oauth2.Error{Type: oauth2.RequestNotSupported, Description: "Invalid subject_token_type.", Status: http.StatusBadRequest} + } + if req.SubjectToken == "" { + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Missing subject_token", Status: http.StatusBadRequest} + } + + teConn, ok := conn.Connector.(connector.TokenIdentityConnector) + if !ok { + g.logger.ErrorContext(ctx, "connector doesn't implement token exchange", "connector_id", req.ConnectorID) + return nil, &oauth2.Error{Type: oauth2.InvalidRequest, Description: "Requested connector does not exist.", Status: http.StatusBadRequest} + } + identity, err := teConn.TokenIdentity(ctx, req.SubjectTokenType, req.SubjectToken) + if err != nil { + g.logger.ErrorContext(ctx, "failed to verify subject token", "err", err) + return nil, &oauth2.Error{Type: oauth2.AccessDenied, Status: http.StatusUnauthorized} + } + + email := identity.Email + if !identity.EmailVerified { + email += " (unverified)" + } + reqType := requestedTokenType(req) + g.logger.InfoContext(ctx, "token exchange successful", + "connector_id", req.ConnectorID, "client_id", client.ID, + "user_id", identity.UserID, + "username", identity.Username, "preferred_username", identity.PreferredUsername, + "email", email, "groups", identity.Groups, + "subject_token_type", req.SubjectTokenType, "requested_token_type", reqType) + + auth := tokens.Authorization{ + Client: client, + Claims: tokens.ClaimsFromIdentity(identity), + Scopes: req.Scopes, + ConnectorID: req.ConnectorID, + } + + // RFC 8693 returns a single requested token plus issued_token_type, not the + // standard access+id+refresh set, so it signs from the issuer primitives. + var ( + token string + expiry time.Time + ) + switch reqType { + case oauth2.TokenTypeID: + token, expiry, err = g.issuer.SignIDToken(ctx, auth, "", "") + case oauth2.TokenTypeAccess: + token, expiry, err = g.issuer.SignAccessToken(ctx, auth) + default: + return nil, &oauth2.Error{Type: oauth2.RequestNotSupported, Description: "Invalid requested_token_type.", Status: http.StatusBadRequest} + } + if err != nil { + g.logger.ErrorContext(ctx, "token exchange failed to create new token", "requested_token_type", reqType, "err", err) + return nil, &oauth2.Error{Type: oauth2.ServerError, Status: http.StatusInternalServerError} + } + + return tokens.Response{ + AccessToken: token, + IssuedTokenType: reqType, + TokenType: "bearer", + ExpiresIn: int(time.Until(expiry).Seconds()), + }, nil +} + +// requestedTokenType is the requested_token_type param, defaulting to an access +// token (RFC 8693 ยง2.1). +func requestedTokenType(req *Request) string { + if req.RequestedTokenType != "" { + return req.RequestedTokenType + } + return oauth2.TokenTypeAccess +} diff --git a/server/handlers.go b/server/handlers.go deleted file mode 100755 index 5f8caf11af..0000000000 --- a/server/handlers.go +++ /dev/null @@ -1,1317 +0,0 @@ -package server - -import ( - "crypto/sha256" - "crypto/subtle" - "encoding/base64" - "encoding/json" - "fmt" - "html/template" - "net/http" - "net/url" - "path" - "sort" - "strconv" - "strings" - "time" - - "github.com/coreos/go-oidc/v3/oidc" - "github.com/gorilla/mux" - jose "gopkg.in/square/go-jose.v2" - - "github.com/dexidp/dex/connector" - "github.com/dexidp/dex/server/internal" - "github.com/dexidp/dex/storage" -) - -const ( - codeChallengeMethodPlain = "plain" - codeChallengeMethodS256 = "S256" -) - -func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) { - // TODO(ericchiang): Cache this. - keys, err := s.storage.GetKeys() - if err != nil { - s.logger.Errorf("failed to get keys: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") - return - } - - if keys.SigningKeyPub == nil { - s.logger.Errorf("No public keys found.") - s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") - return - } - - jwks := jose.JSONWebKeySet{ - Keys: make([]jose.JSONWebKey, len(keys.VerificationKeys)+1), - } - jwks.Keys[0] = *keys.SigningKeyPub - for i, verificationKey := range keys.VerificationKeys { - jwks.Keys[i+1] = *verificationKey.PublicKey - } - - data, err := json.MarshalIndent(jwks, "", " ") - if err != nil { - s.logger.Errorf("failed to marshal discovery data: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") - return - } - maxAge := keys.NextRotation.Sub(s.now()) - if maxAge < (time.Minute * 2) { - maxAge = time.Minute * 2 - } - - w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, must-revalidate", int(maxAge.Seconds()))) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Content-Length", strconv.Itoa(len(data))) - w.Write(data) -} - -type discovery struct { - Issuer string `json:"issuer"` - Auth string `json:"authorization_endpoint"` - Token string `json:"token_endpoint"` - Keys string `json:"jwks_uri"` - UserInfo string `json:"userinfo_endpoint"` - DeviceEndpoint string `json:"device_authorization_endpoint"` - GrantTypes []string `json:"grant_types_supported"` - ResponseTypes []string `json:"response_types_supported"` - Subjects []string `json:"subject_types_supported"` - IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"` - CodeChallengeAlgs []string `json:"code_challenge_methods_supported"` - Scopes []string `json:"scopes_supported"` - AuthMethods []string `json:"token_endpoint_auth_methods_supported"` - Claims []string `json:"claims_supported"` -} - -func (s *Server) discoveryHandler() (http.HandlerFunc, error) { - d := discovery{ - Issuer: s.issuerURL.String(), - Auth: s.absURL("/auth"), - Token: s.absURL("/token"), - Keys: s.absURL("/keys"), - UserInfo: s.absURL("/userinfo"), - DeviceEndpoint: s.absURL("/device/code"), - Subjects: []string{"public"}, - IDTokenAlgs: []string{string(jose.RS256)}, - CodeChallengeAlgs: []string{codeChallengeMethodS256, codeChallengeMethodPlain}, - Scopes: []string{"openid", "email", "groups", "profile", "offline_access"}, - AuthMethods: []string{"client_secret_basic", "client_secret_post"}, - Claims: []string{ - "iss", "sub", "aud", "iat", "exp", "email", "email_verified", - "locale", "name", "preferred_username", "at_hash", - }, - } - - for responseType := range s.supportedResponseTypes { - d.ResponseTypes = append(d.ResponseTypes, responseType) - } - sort.Strings(d.ResponseTypes) - - d.GrantTypes = s.supportedGrantTypes - - data, err := json.MarshalIndent(d, "", " ") - if err != nil { - return nil, fmt.Errorf("failed to marshal discovery data: %v", err) - } - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Content-Length", strconv.Itoa(len(data))) - w.Write(data) - }), nil -} - -// handleAuthorization handles the OAuth2 auth endpoint. -func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { - // Extract the arguments - if err := r.ParseForm(); err != nil { - s.logger.Errorf("Failed to parse arguments: %v", err) - - s.renderError(r, w, http.StatusBadRequest, err.Error()) - return - } - - connectorID := r.Form.Get("connector_id") - - connectors, err := s.storage.ListConnectors() - if err != nil { - s.logger.Errorf("Failed to get list of connectors: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve connector list.") - return - } - - // We don't need connector_id any more - r.Form.Del("connector_id") - - // Construct a URL with all of the arguments in its query - connURL := url.URL{ - RawQuery: r.Form.Encode(), - } - - // Redirect if a client chooses a specific connector_id - if connectorID != "" { - for _, c := range connectors { - if c.ID == connectorID { - connURL.Path = s.absPath("/auth", url.PathEscape(c.ID)) - http.Redirect(w, r, connURL.String(), http.StatusFound) - return - } - } - s.renderError(r, w, http.StatusBadRequest, "Connector ID does not match a valid Connector") - return - } - - if len(connectors) == 1 && !s.alwaysShowLogin { - connURL.Path = s.absPath("/auth", url.PathEscape(connectors[0].ID)) - http.Redirect(w, r, connURL.String(), http.StatusFound) - } - - connectorInfos := make([]connectorInfo, len(connectors)) - for index, conn := range connectors { - connURL.Path = s.absPath("/auth", url.PathEscape(conn.ID)) - connectorInfos[index] = connectorInfo{ - ID: conn.ID, - Name: conn.Name, - Type: conn.Type, - URL: template.URL(connURL.String()), - } - } - - if err := s.templates.login(r, w, connectorInfos); err != nil { - s.logger.Errorf("Server template error: %v", err) - } -} - -func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { - authReq, err := s.parseAuthorizationRequest(r) - if err != nil { - s.logger.Errorf("Failed to parse authorization request: %v", err) - - switch authErr := err.(type) { - case *redirectedAuthErr: - authErr.Handler().ServeHTTP(w, r) - case *displayedAuthErr: - s.renderError(r, w, authErr.Status, err.Error()) - default: - panic("unsupported error type") - } - - return - } - - connID, err := url.PathUnescape(mux.Vars(r)["connector"]) - if err != nil { - s.logger.Errorf("Failed to parse connector: %v", err) - s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist") - return - } - - conn, err := s.getConnector(connID) - if err != nil { - s.logger.Errorf("Failed to get connector: %v", err) - s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist") - return - } - - // Set the connector being used for the login. - if authReq.ConnectorID != "" && authReq.ConnectorID != connID { - s.logger.Errorf("Mismatched connector ID in auth request: %s vs %s", - authReq.ConnectorID, connID) - s.renderError(r, w, http.StatusBadRequest, "Bad connector ID") - return - } - - authReq.ConnectorID = connID - - // Actually create the auth request - authReq.Expiry = s.now().Add(s.authRequestsValidFor) - if err := s.storage.CreateAuthRequest(*authReq); err != nil { - s.logger.Errorf("Failed to create authorization request: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Failed to connect to the database.") - return - } - - scopes := parseScopes(authReq.Scopes) - - // Work out where the "Select another login method" link should go. - backLink := "" - if len(s.connectors) > 1 { - backLinkURL := url.URL{ - Path: s.absPath("/auth"), - RawQuery: r.Form.Encode(), - } - backLink = backLinkURL.String() - } - - switch r.Method { - case http.MethodGet: - switch conn := conn.Connector.(type) { - case connector.CallbackConnector: - // Use the auth request ID as the "state" token. - // - // TODO(ericchiang): Is this appropriate or should we also be using a nonce? - callbackURL, err := conn.LoginURL(scopes, s.absURL("/callback"), authReq.ID) - if err != nil { - s.logger.Errorf("Connector %q returned error when creating callback: %v", connID, err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } - http.Redirect(w, r, callbackURL, http.StatusFound) - case connector.PasswordConnector: - loginURL := url.URL{ - Path: s.absPath("/auth", connID, "login"), - } - q := loginURL.Query() - q.Set("state", authReq.ID) - q.Set("back", backLink) - loginURL.RawQuery = q.Encode() - - http.Redirect(w, r, loginURL.String(), http.StatusFound) - case connector.SAMLConnector: - action, value, err := conn.POSTData(scopes, authReq.ID) - if err != nil { - s.logger.Errorf("Creating SAML data: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Connector Login Error") - return - } - - // TODO(ericchiang): Don't inline this. - fmt.Fprintf(w, ` - - - - SAML login - - -
- - -
- - - `, action, value, authReq.ID) - default: - s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") - } - default: - s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") - } -} - -func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { - authID := r.URL.Query().Get("state") - if authID == "" { - s.renderError(r, w, http.StatusBadRequest, "User session error.") - return - } - - backLink := r.URL.Query().Get("back") - - authReq, err := s.storage.GetAuthRequest(authID) - if err != nil { - if err == storage.ErrNotFound { - s.logger.Errorf("Invalid 'state' parameter provided: %v", err) - s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") - return - } - s.logger.Errorf("Failed to get auth request: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Database error.") - return - } - - connID, err := url.PathUnescape(mux.Vars(r)["connector"]) - if err != nil { - s.logger.Errorf("Failed to parse connector: %v", err) - s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist") - return - } else if connID != "" && connID != authReq.ConnectorID { - s.logger.Errorf("Connector mismatch: authentication started with id %q, but password login for id %q was triggered", authReq.ConnectorID, connID) - s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") - return - } - - conn, err := s.getConnector(authReq.ConnectorID) - if err != nil { - s.logger.Errorf("Failed to get connector with id %q : %v", authReq.ConnectorID, err) - s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") - return - } - - pwConn, ok := conn.Connector.(connector.PasswordConnector) - if !ok { - s.logger.Errorf("Expected password connector in handlePasswordLogin(), but got %v", pwConn) - s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") - return - } - - switch r.Method { - case http.MethodGet: - if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink); err != nil { - s.logger.Errorf("Server template error: %v", err) - } - case http.MethodPost: - username := r.FormValue("login") - password := r.FormValue("password") - scopes := parseScopes(authReq.Scopes) - - identity, ok, err := pwConn.Login(r.Context(), scopes, username, password) - if err != nil { - s.logger.Errorf("Failed to login user: %v", err) - s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Login error: %v", err)) - return - } - if !ok { - if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink); err != nil { - s.logger.Errorf("Server template error: %v", err) - } - return - } - redirectURL, err := s.finalizeLogin(identity, authReq, conn.Connector) - if err != nil { - s.logger.Errorf("Failed to finalize login: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } - - http.Redirect(w, r, redirectURL, http.StatusSeeOther) - default: - s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") - } -} - -func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) { - var authID string - switch r.Method { - case http.MethodGet: // OAuth2 callback - if authID = r.URL.Query().Get("state"); authID == "" { - s.renderError(r, w, http.StatusBadRequest, "User session error.") - return - } - case http.MethodPost: // SAML POST binding - if authID = r.PostFormValue("RelayState"); authID == "" { - s.renderError(r, w, http.StatusBadRequest, "User session error.") - return - } - default: - s.renderError(r, w, http.StatusBadRequest, "Method not supported") - return - } - - authReq, err := s.storage.GetAuthRequest(authID) - if err != nil { - if err == storage.ErrNotFound { - s.logger.Errorf("Invalid 'state' parameter provided: %v", err) - s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") - return - } - s.logger.Errorf("Failed to get auth request: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Database error.") - return - } - - connID, err := url.PathUnescape(mux.Vars(r)["connector"]) - if err != nil { - s.logger.Errorf("Failed to get connector with id %q : %v", authReq.ConnectorID, err) - s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") - return - } else if connID != "" && connID != authReq.ConnectorID { - s.logger.Errorf("Connector mismatch: authentication started with id %q, but callback for id %q was triggered", authReq.ConnectorID, connID) - s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") - return - } - - conn, err := s.getConnector(authReq.ConnectorID) - if err != nil { - s.logger.Errorf("Failed to get connector with id %q : %v", authReq.ConnectorID, err) - s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") - return - } - - var identity connector.Identity - switch conn := conn.Connector.(type) { - case connector.CallbackConnector: - if r.Method != http.MethodGet { - s.logger.Errorf("SAML request mapped to OAuth2 connector") - s.renderError(r, w, http.StatusBadRequest, "Invalid request") - return - } - identity, err = conn.HandleCallback(parseScopes(authReq.Scopes), r) - case connector.SAMLConnector: - if r.Method != http.MethodPost { - s.logger.Errorf("OAuth2 request mapped to SAML connector") - s.renderError(r, w, http.StatusBadRequest, "Invalid request") - return - } - identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"), authReq.ID) - default: - s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") - return - } - - if err != nil { - s.logger.Errorf("Failed to authenticate: %v", err) - s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Failed to authenticate: %v", err)) - return - } - - redirectURL, err := s.finalizeLogin(identity, authReq, conn.Connector) - if err != nil { - s.logger.Errorf("Failed to finalize login: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } - - http.Redirect(w, r, redirectURL, http.StatusSeeOther) -} - -// finalizeLogin associates the user's identity with the current AuthRequest, then returns -// the approval page's path. -func (s *Server) finalizeLogin(identity connector.Identity, authReq storage.AuthRequest, conn connector.Connector) (string, error) { - claims := storage.Claims{ - UserID: identity.UserID, - Username: identity.Username, - PreferredUsername: identity.PreferredUsername, - Email: identity.Email, - EmailVerified: identity.EmailVerified, - Groups: identity.Groups, - } - - updater := func(a storage.AuthRequest) (storage.AuthRequest, error) { - a.LoggedIn = true - a.Claims = claims - a.ConnectorData = identity.ConnectorData - return a, nil - } - if err := s.storage.UpdateAuthRequest(authReq.ID, updater); err != nil { - return "", fmt.Errorf("failed to update auth request: %v", err) - } - - email := claims.Email - if !claims.EmailVerified { - email += " (unverified)" - } - - s.logger.Infof("login successful: connector %q, username=%q, preferred_username=%q, email=%q, groups=%q", - authReq.ConnectorID, claims.Username, claims.PreferredUsername, email, claims.Groups) - - returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID - _, ok := conn.(connector.RefreshConnector) - if !ok { - return returnURL, nil - } - - // Try to retrieve an existing OfflineSession object for the corresponding user. - session, err := s.storage.GetOfflineSessions(identity.UserID, authReq.ConnectorID) - if err != nil { - if err != storage.ErrNotFound { - s.logger.Errorf("failed to get offline session: %v", err) - return "", err - } - offlineSessions := storage.OfflineSessions{ - UserID: identity.UserID, - ConnID: authReq.ConnectorID, - Refresh: make(map[string]*storage.RefreshTokenRef), - ConnectorData: identity.ConnectorData, - } - - // Create a new OfflineSession object for the user and add a reference object for - // the newly received refreshtoken. - if err := s.storage.CreateOfflineSessions(offlineSessions); err != nil { - s.logger.Errorf("failed to create offline session: %v", err) - return "", err - } - - return returnURL, nil - } - - // Update existing OfflineSession obj with new RefreshTokenRef. - if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { - if len(identity.ConnectorData) > 0 { - old.ConnectorData = identity.ConnectorData - } - return old, nil - }); err != nil { - s.logger.Errorf("failed to update offline session: %v", err) - return "", err - } - - return returnURL, nil -} - -func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { - authReq, err := s.storage.GetAuthRequest(r.FormValue("req")) - if err != nil { - s.logger.Errorf("Failed to get auth request: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Database error.") - return - } - if !authReq.LoggedIn { - s.logger.Errorf("Auth request does not have an identity for approval") - s.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.") - return - } - - switch r.Method { - case http.MethodGet: - if s.skipApproval { - s.sendCodeResponse(w, r, authReq) - return - } - client, err := s.storage.GetClient(authReq.ClientID) - if err != nil { - s.logger.Errorf("Failed to get client %q: %v", authReq.ClientID, err) - s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve client.") - return - } - if err := s.templates.approval(r, w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes); err != nil { - s.logger.Errorf("Server template error: %v", err) - } - case http.MethodPost: - if r.FormValue("approval") != "approve" { - s.renderError(r, w, http.StatusInternalServerError, "Approval rejected.") - return - } - s.sendCodeResponse(w, r, authReq) - } -} - -func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) { - if s.now().After(authReq.Expiry) { - s.renderError(r, w, http.StatusBadRequest, "User session has expired.") - return - } - - if err := s.storage.DeleteAuthRequest(authReq.ID); err != nil { - if err != storage.ErrNotFound { - s.logger.Errorf("Failed to delete authorization request: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") - } else { - s.renderError(r, w, http.StatusBadRequest, "User session error.") - } - return - } - u, err := url.Parse(authReq.RedirectURI) - if err != nil { - s.renderError(r, w, http.StatusInternalServerError, "Invalid redirect URI.") - return - } - - var ( - // Was the initial request using the implicit or hybrid flow instead of - // the "normal" code flow? - implicitOrHybrid = false - - // Only present in hybrid or code flow. code.ID == "" if this is not set. - code storage.AuthCode - - // ID token returned immediately if the response_type includes "id_token". - // Only valid for implicit and hybrid flows. - idToken string - idTokenExpiry time.Time - - // Access token - accessToken string - ) - - for _, responseType := range authReq.ResponseTypes { - switch responseType { - case responseTypeCode: - code = storage.AuthCode{ - ID: storage.NewID(), - ClientID: authReq.ClientID, - ConnectorID: authReq.ConnectorID, - Nonce: authReq.Nonce, - Scopes: authReq.Scopes, - Claims: authReq.Claims, - Expiry: s.now().Add(time.Minute * 30), - RedirectURI: authReq.RedirectURI, - ConnectorData: authReq.ConnectorData, - PKCE: authReq.PKCE, - } - if err := s.storage.CreateAuthCode(code); err != nil { - s.logger.Errorf("Failed to create auth code: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") - return - } - - // Implicit and hybrid flows that try to use the OOB redirect URI are - // rejected earlier. If we got here we're using the code flow. - if authReq.RedirectURI == redirectURIOOB { - if err := s.templates.oob(r, w, code.ID); err != nil { - s.logger.Errorf("Server template error: %v", err) - } - return - } - case responseTypeToken: - implicitOrHybrid = true - case responseTypeIDToken: - implicitOrHybrid = true - var err error - - accessToken, err = s.newAccessToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, authReq.ConnectorID) - if err != nil { - s.logger.Errorf("failed to create new access token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - - idToken, idTokenExpiry, err = s.newIDToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, accessToken, code.ID, authReq.ConnectorID) - if err != nil { - s.logger.Errorf("failed to create ID token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - } - } - - if implicitOrHybrid { - v := url.Values{} - v.Set("access_token", accessToken) - v.Set("token_type", "bearer") - v.Set("state", authReq.State) - if idToken != "" { - v.Set("id_token", idToken) - // The hybrid flow with only "code token" or "code id_token" doesn't return an - // "expires_in" value. If "code" wasn't provided, indicating the implicit flow, - // don't add it. - // - // https://openid.net/specs/openid-connect-core-1_0.html#HybridAuthResponse - if code.ID == "" { - v.Set("expires_in", strconv.Itoa(int(idTokenExpiry.Sub(s.now()).Seconds()))) - } - } - if code.ID != "" { - v.Set("code", code.ID) - } - - // Implicit and hybrid flows return their values as part of the fragment. - // - // HTTP/1.1 303 See Other - // Location: https://client.example.org/cb# - // access_token=SlAV32hkKG - // &token_type=bearer - // &id_token=eyJ0 ... NiJ9.eyJ1c ... I6IjIifX0.DeWt4Qu ... ZXso - // &expires_in=3600 - // &state=af0ifjsldkj - // - u.Fragment = v.Encode() - } else { - // The code flow add values to the URL query. - // - // HTTP/1.1 303 See Other - // Location: https://client.example.org/cb? - // code=SplxlOBeZQQYbYS6WxSbIA - // &state=af0ifjsldkj - // - q := u.Query() - q.Set("code", code.ID) - q.Set("state", authReq.State) - u.RawQuery = q.Encode() - } - - http.Redirect(w, r, u.String(), http.StatusSeeOther) -} - -func (s *Server) withClientFromStorage(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request, storage.Client)) { - clientID, clientSecret, ok := r.BasicAuth() - if ok { - var err error - if clientID, err = url.QueryUnescape(clientID); err != nil { - s.tokenErrHelper(w, errInvalidRequest, "client_id improperly encoded", http.StatusBadRequest) - return - } - if clientSecret, err = url.QueryUnescape(clientSecret); err != nil { - s.tokenErrHelper(w, errInvalidRequest, "client_secret improperly encoded", http.StatusBadRequest) - return - } - } else { - clientID = r.PostFormValue("client_id") - clientSecret = r.PostFormValue("client_secret") - } - - client, err := s.storage.GetClient(clientID) - if err != nil { - if err != storage.ErrNotFound { - s.logger.Errorf("failed to get client: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - } else { - s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized) - } - return - } - - if subtle.ConstantTimeCompare([]byte(client.Secret), []byte(clientSecret)) != 1 { - if clientSecret == "" { - s.logger.Infof("missing client_secret on token request for client: %s", client.ID) - } else { - s.logger.Infof("invalid client_secret on token request for client: %s", client.ID) - } - s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized) - return - } - - handler(w, r, client) -} - -func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - s.tokenErrHelper(w, errInvalidRequest, "method not allowed", http.StatusBadRequest) - return - } - - err := r.ParseForm() - if err != nil { - s.logger.Errorf("Could not parse request body: %v", err) - s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest) - return - } - - grantType := r.PostFormValue("grant_type") - switch grantType { - case grantTypeDeviceCode: - s.handleDeviceToken(w, r) - case grantTypeAuthorizationCode: - s.withClientFromStorage(w, r, s.handleAuthCode) - case grantTypeRefreshToken: - s.withClientFromStorage(w, r, s.handleRefreshToken) - case grantTypePassword: - s.withClientFromStorage(w, r, s.handlePasswordGrant) - default: - s.tokenErrHelper(w, errUnsupportedGrantType, "", http.StatusBadRequest) - } -} - -func (s *Server) calculateCodeChallenge(codeVerifier, codeChallengeMethod string) (string, error) { - switch codeChallengeMethod { - case codeChallengeMethodPlain: - return codeVerifier, nil - case codeChallengeMethodS256: - shaSum := sha256.Sum256([]byte(codeVerifier)) - return base64.RawURLEncoding.EncodeToString(shaSum[:]), nil - default: - return "", fmt.Errorf("unknown challenge method (%v)", codeChallengeMethod) - } -} - -// handle an access token request https://tools.ietf.org/html/rfc6749#section-4.1.3 -func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client storage.Client) { - code := r.PostFormValue("code") - redirectURI := r.PostFormValue("redirect_uri") - - if code == "" { - s.tokenErrHelper(w, errInvalidRequest, `Required param: code.`, http.StatusBadRequest) - return - } - - authCode, err := s.storage.GetAuthCode(code) - if err != nil || s.now().After(authCode.Expiry) || authCode.ClientID != client.ID { - if err != storage.ErrNotFound { - s.logger.Errorf("failed to get auth code: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - } else { - s.tokenErrHelper(w, errInvalidGrant, "Invalid or expired code parameter.", http.StatusBadRequest) - } - return - } - - // RFC 7636 (PKCE) - codeChallengeFromStorage := authCode.PKCE.CodeChallenge - providedCodeVerifier := r.PostFormValue("code_verifier") - - switch { - case providedCodeVerifier != "" && codeChallengeFromStorage != "": - calculatedCodeChallenge, err := s.calculateCodeChallenge(providedCodeVerifier, authCode.PKCE.CodeChallengeMethod) - if err != nil { - s.logger.Error(err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - if codeChallengeFromStorage != calculatedCodeChallenge { - s.tokenErrHelper(w, errInvalidGrant, "Invalid code_verifier.", http.StatusBadRequest) - return - } - case providedCodeVerifier != "": - // Received no code_challenge on /auth, but a code_verifier on /token - s.tokenErrHelper(w, errInvalidRequest, "No PKCE flow started. Cannot check code_verifier.", http.StatusBadRequest) - return - case codeChallengeFromStorage != "": - // Received PKCE request on /auth, but no code_verifier on /token - s.tokenErrHelper(w, errInvalidGrant, "Expecting parameter code_verifier in PKCE flow.", http.StatusBadRequest) - return - } - - if authCode.RedirectURI != redirectURI { - s.tokenErrHelper(w, errInvalidRequest, "redirect_uri did not match URI from initial request.", http.StatusBadRequest) - return - } - - tokenResponse, err := s.exchangeAuthCode(w, authCode, client) - if err != nil { - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - s.writeAccessToken(w, tokenResponse) -} - -func (s *Server) exchangeAuthCode(w http.ResponseWriter, authCode storage.AuthCode, client storage.Client) (*accessTokenResponse, error) { - accessToken, err := s.newAccessToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID) - if err != nil { - s.logger.Errorf("failed to create new access token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return nil, err - } - - idToken, expiry, err := s.newIDToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, accessToken, authCode.ID, authCode.ConnectorID) - if err != nil { - s.logger.Errorf("failed to create ID token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return nil, err - } - - if err := s.storage.DeleteAuthCode(authCode.ID); err != nil { - s.logger.Errorf("failed to delete auth code: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return nil, err - } - - reqRefresh := func() bool { - // Ensure the connector supports refresh tokens. - // - // Connectors like `saml` do not implement RefreshConnector. - conn, err := s.getConnector(authCode.ConnectorID) - if err != nil { - s.logger.Errorf("connector with ID %q not found: %v", authCode.ConnectorID, err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return false - } - - _, ok := conn.Connector.(connector.RefreshConnector) - if !ok { - return false - } - - for _, scope := range authCode.Scopes { - if scope == scopeOfflineAccess { - return true - } - } - return false - }() - var refreshToken string - if reqRefresh { - refresh := storage.RefreshToken{ - ID: storage.NewID(), - Token: storage.NewID(), - ClientID: authCode.ClientID, - ConnectorID: authCode.ConnectorID, - Scopes: authCode.Scopes, - Claims: authCode.Claims, - Nonce: authCode.Nonce, - ConnectorData: authCode.ConnectorData, - CreatedAt: s.now(), - LastUsed: s.now(), - } - token := &internal.RefreshToken{ - RefreshId: refresh.ID, - Token: refresh.Token, - } - if refreshToken, err = internal.Marshal(token); err != nil { - s.logger.Errorf("failed to marshal refresh token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return nil, err - } - - if err := s.storage.CreateRefresh(refresh); err != nil { - s.logger.Errorf("failed to create refresh token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return nil, err - } - - // deleteToken determines if we need to delete the newly created refresh token - // due to a failure in updating/creating the OfflineSession object for the - // corresponding user. - var deleteToken bool - defer func() { - if deleteToken { - // Delete newly created refresh token from storage. - if err := s.storage.DeleteRefresh(refresh.ID); err != nil { - s.logger.Errorf("failed to delete refresh token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - } - }() - - tokenRef := storage.RefreshTokenRef{ - ID: refresh.ID, - ClientID: refresh.ClientID, - CreatedAt: refresh.CreatedAt, - LastUsed: refresh.LastUsed, - } - - // Try to retrieve an existing OfflineSession object for the corresponding user. - if session, err := s.storage.GetOfflineSessions(refresh.Claims.UserID, refresh.ConnectorID); err != nil { - if err != storage.ErrNotFound { - s.logger.Errorf("failed to get offline session: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - deleteToken = true - return nil, err - } - offlineSessions := storage.OfflineSessions{ - UserID: refresh.Claims.UserID, - ConnID: refresh.ConnectorID, - Refresh: make(map[string]*storage.RefreshTokenRef), - } - offlineSessions.Refresh[tokenRef.ClientID] = &tokenRef - - // Create a new OfflineSession object for the user and add a reference object for - // the newly received refreshtoken. - if err := s.storage.CreateOfflineSessions(offlineSessions); err != nil { - s.logger.Errorf("failed to create offline session: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - deleteToken = true - return nil, err - } - } else { - if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok { - // Delete old refresh token from storage. - if err := s.storage.DeleteRefresh(oldTokenRef.ID); err != nil && err != storage.ErrNotFound { - s.logger.Errorf("failed to delete refresh token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - deleteToken = true - return nil, err - } - } - - // Update existing OfflineSession obj with new RefreshTokenRef. - if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { - old.Refresh[tokenRef.ClientID] = &tokenRef - return old, nil - }); err != nil { - s.logger.Errorf("failed to update offline session: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - deleteToken = true - return nil, err - } - } - } - return s.toAccessTokenResponse(idToken, accessToken, refreshToken, expiry), nil -} - -func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) { - const prefix = "Bearer " - - auth := r.Header.Get("authorization") - if len(auth) < len(prefix) || !strings.EqualFold(prefix, auth[:len(prefix)]) { - w.Header().Set("WWW-Authenticate", "Bearer") - s.tokenErrHelper(w, errAccessDenied, "Invalid bearer token.", http.StatusUnauthorized) - return - } - rawIDToken := auth[len(prefix):] - - verifier := oidc.NewVerifier(s.issuerURL.String(), &storageKeySet{s.storage}, &oidc.Config{SkipClientIDCheck: true}) - idToken, err := verifier.Verify(r.Context(), rawIDToken) - if err != nil { - s.tokenErrHelper(w, errAccessDenied, err.Error(), http.StatusForbidden) - return - } - - var claims json.RawMessage - if err := idToken.Claims(&claims); err != nil { - s.tokenErrHelper(w, errServerError, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(claims) -} - -func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, client storage.Client) { - // Parse the fields - if err := r.ParseForm(); err != nil { - s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest) - return - } - q := r.Form - - nonce := q.Get("nonce") - // Some clients, like the old go-oidc, provide extra whitespace. Tolerate this. - scopes := strings.Fields(q.Get("scope")) - - // Parse the scopes if they are passed - var ( - unrecognized []string - invalidScopes []string - ) - hasOpenIDScope := false - for _, scope := range scopes { - switch scope { - case scopeOpenID: - hasOpenIDScope = true - case scopeOfflineAccess, scopeEmail, scopeProfile, scopeGroups, scopeFederatedID: - default: - peerID, ok := parseCrossClientScope(scope) - if !ok { - unrecognized = append(unrecognized, scope) - continue - } - - isTrusted, err := s.validateCrossClientTrust(client.ID, peerID) - if err != nil { - s.tokenErrHelper(w, errInvalidClient, fmt.Sprintf("Error validating cross client trust %v.", err), http.StatusBadRequest) - return - } - if !isTrusted { - invalidScopes = append(invalidScopes, scope) - } - } - } - if !hasOpenIDScope { - s.tokenErrHelper(w, errInvalidRequest, `Missing required scope(s) ["openid"].`, http.StatusBadRequest) - return - } - if len(unrecognized) > 0 { - s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest) - return - } - if len(invalidScopes) > 0 { - s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest) - return - } - - // Which connector - connID := s.passwordConnector - conn, err := s.getConnector(connID) - if err != nil { - s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest) - return - } - - passwordConnector, ok := conn.Connector.(connector.PasswordConnector) - if !ok { - s.tokenErrHelper(w, errInvalidRequest, "Requested password connector does not correct type.", http.StatusBadRequest) - return - } - - // Login - username := q.Get("username") - password := q.Get("password") - identity, ok, err := passwordConnector.Login(r.Context(), parseScopes(scopes), username, password) - if err != nil { - s.logger.Errorf("Failed to login user: %v", err) - s.tokenErrHelper(w, errInvalidRequest, "Could not login user", http.StatusBadRequest) - return - } - if !ok { - s.tokenErrHelper(w, errAccessDenied, "Invalid username or password", http.StatusUnauthorized) - return - } - - // Build the claims to send the id token - claims := storage.Claims{ - UserID: identity.UserID, - Username: identity.Username, - PreferredUsername: identity.PreferredUsername, - Email: identity.Email, - EmailVerified: identity.EmailVerified, - Groups: identity.Groups, - } - - accessToken, err := s.newAccessToken(client.ID, claims, scopes, nonce, connID) - if err != nil { - s.logger.Errorf("password grant failed to create new access token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - - idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, nonce, accessToken, "", connID) - if err != nil { - s.logger.Errorf("password grant failed to create new ID token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - - reqRefresh := func() bool { - // Ensure the connector supports refresh tokens. - // - // Connectors like `saml` do not implement RefreshConnector. - _, ok := conn.Connector.(connector.RefreshConnector) - if !ok { - return false - } - - for _, scope := range scopes { - if scope == scopeOfflineAccess { - return true - } - } - return false - }() - var refreshToken string - if reqRefresh { - refresh := storage.RefreshToken{ - ID: storage.NewID(), - Token: storage.NewID(), - ClientID: client.ID, - ConnectorID: connID, - Scopes: scopes, - Claims: claims, - Nonce: nonce, - // ConnectorData: authCode.ConnectorData, - CreatedAt: s.now(), - LastUsed: s.now(), - } - token := &internal.RefreshToken{ - RefreshId: refresh.ID, - Token: refresh.Token, - } - if refreshToken, err = internal.Marshal(token); err != nil { - s.logger.Errorf("failed to marshal refresh token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - - if err := s.storage.CreateRefresh(refresh); err != nil { - s.logger.Errorf("failed to create refresh token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - - // deleteToken determines if we need to delete the newly created refresh token - // due to a failure in updating/creating the OfflineSession object for the - // corresponding user. - var deleteToken bool - defer func() { - if deleteToken { - // Delete newly created refresh token from storage. - if err := s.storage.DeleteRefresh(refresh.ID); err != nil { - s.logger.Errorf("failed to delete refresh token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - } - }() - - tokenRef := storage.RefreshTokenRef{ - ID: refresh.ID, - ClientID: refresh.ClientID, - CreatedAt: refresh.CreatedAt, - LastUsed: refresh.LastUsed, - } - - // Try to retrieve an existing OfflineSession object for the corresponding user. - if session, err := s.storage.GetOfflineSessions(refresh.Claims.UserID, refresh.ConnectorID); err != nil { - if err != storage.ErrNotFound { - s.logger.Errorf("failed to get offline session: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - deleteToken = true - return - } - offlineSessions := storage.OfflineSessions{ - UserID: refresh.Claims.UserID, - ConnID: refresh.ConnectorID, - Refresh: make(map[string]*storage.RefreshTokenRef), - ConnectorData: identity.ConnectorData, - } - offlineSessions.Refresh[tokenRef.ClientID] = &tokenRef - - // Create a new OfflineSession object for the user and add a reference object for - // the newly received refreshtoken. - if err := s.storage.CreateOfflineSessions(offlineSessions); err != nil { - s.logger.Errorf("failed to create offline session: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - deleteToken = true - return - } - } else { - if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok { - // Delete old refresh token from storage. - if err := s.storage.DeleteRefresh(oldTokenRef.ID); err != nil { - if err == storage.ErrNotFound { - s.logger.Warnf("database inconsistent, refresh token missing: %v", oldTokenRef.ID) - } else { - s.logger.Errorf("failed to delete refresh token: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - deleteToken = true - return - } - } - } - - // Update existing OfflineSession obj with new RefreshTokenRef. - if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { - old.Refresh[tokenRef.ClientID] = &tokenRef - old.ConnectorData = identity.ConnectorData - return old, nil - }); err != nil { - s.logger.Errorf("failed to update offline session: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - deleteToken = true - return - } - } - } - - resp := s.toAccessTokenResponse(idToken, accessToken, refreshToken, expiry) - s.writeAccessToken(w, resp) -} - -type accessTokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - RefreshToken string `json:"refresh_token,omitempty"` - IDToken string `json:"id_token"` -} - -func (s *Server) toAccessTokenResponse(idToken, accessToken, refreshToken string, expiry time.Time) *accessTokenResponse { - return &accessTokenResponse{ - accessToken, - "bearer", - int(expiry.Sub(s.now()).Seconds()), - refreshToken, - idToken, - } -} - -func (s *Server) writeAccessToken(w http.ResponseWriter, resp *accessTokenResponse) { - data, err := json.Marshal(resp) - if err != nil { - s.logger.Errorf("failed to marshal access token response: %v", err) - s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Content-Length", strconv.Itoa(len(data))) - - // Token response must include cache headers https://tools.ietf.org/html/rfc6749#section-5.1 - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Pragma", "no-cache") - w.Write(data) -} - -func (s *Server) renderError(r *http.Request, w http.ResponseWriter, status int, description string) { - if err := s.templates.err(r, w, status, description); err != nil { - s.logger.Errorf("Server template error: %v", err) - } -} - -func (s *Server) tokenErrHelper(w http.ResponseWriter, typ string, description string, statusCode int) { - if err := tokenErr(w, typ, description, statusCode); err != nil { - s.logger.Errorf("token error response: %v", err) - } -} - -// Check for username prompt override from connector. Defaults to "Username". -func usernamePrompt(conn connector.PasswordConnector) string { - if attr := conn.Prompt(); attr != "" { - return attr - } - return "Username" -} diff --git a/server/handlers_test.go b/server/handlers_test.go deleted file mode 100644 index fb1a05064f..0000000000 --- a/server/handlers_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package server - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "path" - "testing" - "time" - - gosundheit "github.com/AppsFlyer/go-sundheit" - "github.com/AppsFlyer/go-sundheit/checks" - "github.com/coreos/go-oidc/v3/oidc" - "github.com/stretchr/testify/require" - "golang.org/x/oauth2" - - "github.com/dexidp/dex/storage" -) - -func TestHandleHealth(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - httpServer, server := newTestServer(ctx, t, nil) - defer httpServer.Close() - - rr := httptest.NewRecorder() - server.ServeHTTP(rr, httptest.NewRequest("GET", "/healthz", nil)) - if rr.Code != http.StatusOK { - t.Errorf("expected 200 got %d", rr.Code) - } -} - -func TestHandleHealthFailure(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - httpServer, server := newTestServer(ctx, t, func(c *Config) { - c.HealthChecker = gosundheit.New() - - c.HealthChecker.RegisterCheck( - &checks.CustomCheck{ - CheckName: "fail", - CheckFunc: func(_ context.Context) (details interface{}, err error) { - return nil, errors.New("error") - }, - }, - gosundheit.InitiallyPassing(false), - gosundheit.ExecutionPeriod(1*time.Second), - ) - }) - defer httpServer.Close() - - rr := httptest.NewRecorder() - server.ServeHTTP(rr, httptest.NewRequest("GET", "/healthz", nil)) - if rr.Code != http.StatusInternalServerError { - t.Errorf("expected 500 got %d", rr.Code) - } -} - -type emptyStorage struct { - storage.Storage -} - -func (*emptyStorage) GetAuthRequest(string) (storage.AuthRequest, error) { - return storage.AuthRequest{}, storage.ErrNotFound -} - -func TestHandleInvalidOAuth2Callbacks(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - httpServer, server := newTestServer(ctx, t, func(c *Config) { - c.Storage = &emptyStorage{c.Storage} - }) - defer httpServer.Close() - - tests := []struct { - TargetURI string - ExpectedCode int - }{ - {"/callback", http.StatusBadRequest}, - {"/callback?code=&state=", http.StatusBadRequest}, - {"/callback?code=AAAAAAA&state=BBBBBBB", http.StatusBadRequest}, - } - - rr := httptest.NewRecorder() - - for i, r := range tests { - server.ServeHTTP(rr, httptest.NewRequest("GET", r.TargetURI, nil)) - if rr.Code != r.ExpectedCode { - t.Fatalf("test %d expected %d, got %d", i, r.ExpectedCode, rr.Code) - } - } -} - -func TestHandleInvalidSAMLCallbacks(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - httpServer, server := newTestServer(ctx, t, func(c *Config) { - c.Storage = &emptyStorage{c.Storage} - }) - defer httpServer.Close() - - type requestForm struct { - RelayState string - } - tests := []struct { - RequestForm requestForm - ExpectedCode int - }{ - {requestForm{}, http.StatusBadRequest}, - {requestForm{RelayState: "AAAAAAA"}, http.StatusBadRequest}, - } - - rr := httptest.NewRecorder() - - for i, r := range tests { - jsonValue, err := json.Marshal(r.RequestForm) - if err != nil { - t.Fatal(err.Error()) - } - server.ServeHTTP(rr, httptest.NewRequest("POST", "/callback", bytes.NewBuffer(jsonValue))) - if rr.Code != r.ExpectedCode { - t.Fatalf("test %d expected %d, got %d", i, r.ExpectedCode, rr.Code) - } - } -} - -// TestHandleAuthCode checks that it is forbidden to use same code twice -func TestHandleAuthCode(t *testing.T) { - tests := []struct { - name string - handleCode func(*testing.T, context.Context, *oauth2.Config, string) - }{ - { - name: "Code Reuse should return invalid_grant", - handleCode: func(t *testing.T, ctx context.Context, oauth2Config *oauth2.Config, code string) { - _, err := oauth2Config.Exchange(ctx, code) - require.NoError(t, err) - - _, err = oauth2Config.Exchange(ctx, code) - require.Error(t, err) - - oauth2Err, ok := err.(*oauth2.RetrieveError) - require.True(t, ok) - - var errResponse struct{ Error string } - err = json.Unmarshal(oauth2Err.Body, &errResponse) - require.NoError(t, err) - - // invalid_grant must be returned for invalid values - // https://tools.ietf.org/html/rfc6749#section-5.2 - require.Equal(t, errInvalidGrant, errResponse.Error) - }, - }, - { - name: "No Code should return invalid_request", - handleCode: func(t *testing.T, ctx context.Context, oauth2Config *oauth2.Config, _ string) { - _, err := oauth2Config.Exchange(ctx, "") - require.Error(t, err) - - oauth2Err, ok := err.(*oauth2.RetrieveError) - require.True(t, ok) - - var errResponse struct{ Error string } - err = json.Unmarshal(oauth2Err.Body, &errResponse) - require.NoError(t, err) - - require.Equal(t, errInvalidRequest, errResponse.Error) - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - httpServer, s := newTestServer(ctx, t, func(c *Config) { c.Issuer += "/non-root-path" }) - defer httpServer.Close() - - p, err := oidc.NewProvider(ctx, httpServer.URL) - require.NoError(t, err) - - var oauth2Client oauth2Client - oauth2Client.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/callback" { - http.Redirect(w, r, oauth2Client.config.AuthCodeURL(""), http.StatusSeeOther) - return - } - - q := r.URL.Query() - require.Equal(t, q.Get("error"), "", q.Get("error_description")) - - code := q.Get("code") - tc.handleCode(t, ctx, oauth2Client.config, code) - - w.WriteHeader(http.StatusOK) - })) - defer oauth2Client.server.Close() - - redirectURL := oauth2Client.server.URL + "/callback" - client := storage.Client{ - ID: "testclient", - Secret: "testclientsecret", - RedirectURIs: []string{redirectURL}, - } - err = s.storage.CreateClient(client) - require.NoError(t, err) - - oauth2Client.config = &oauth2.Config{ - ClientID: client.ID, - ClientSecret: client.Secret, - Endpoint: p.Endpoint(), - Scopes: []string{oidc.ScopeOpenID, "email", "offline_access"}, - RedirectURL: redirectURL, - } - - resp, err := http.Get(oauth2Client.server.URL + "/login") - require.NoError(t, err) - - resp.Body.Close() - }) - } -} - -func mockConnectorDataTestStorage(t *testing.T, s storage.Storage) { - c := storage.Client{ - ID: "test", - Secret: "barfoo", - RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"}, - Name: "dex client", - LogoURL: "https://goo.gl/JIyzIC", - } - - err := s.CreateClient(c) - require.NoError(t, err) - - c1 := storage.Connector{ - ID: "test", - Type: "mockPassword", - Name: "mockPassword", - Config: []byte(`{ -"username": "test", -"password": "test" -}`), - } - - err = s.CreateConnector(c1) - require.NoError(t, err) - - c2 := storage.Connector{ - ID: "http://any.valid.url/", - Type: "mock", - Name: "mockURLID", - } - - err = s.CreateConnector(c2) - require.NoError(t, err) -} - -func TestPasswordConnectorDataNotEmpty(t *testing.T) { - t0 := time.Now() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Setup a dex server. - httpServer, s := newTestServer(ctx, t, func(c *Config) { - c.PasswordConnector = "test" - c.Now = func() time.Time { return t0 } - }) - defer httpServer.Close() - - mockConnectorDataTestStorage(t, s.storage) - - u, err := url.Parse(s.issuerURL.String()) - require.NoError(t, err) - - u.Path = path.Join(u.Path, "/token") - v := url.Values{} - v.Add("scope", "openid offline_access email") - v.Add("grant_type", "password") - v.Add("username", "test") - v.Add("password", "test") - - req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(v.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") - req.SetBasicAuth("test", "barfoo") - - rr := httptest.NewRecorder() - s.ServeHTTP(rr, req) - - require.Equal(t, 200, rr.Code) - - // Check that we received expected refresh token - var ref struct { - Token string `json:"refresh_token"` - } - err = json.Unmarshal(rr.Body.Bytes(), &ref) - require.NoError(t, err) - - newSess, err := s.storage.GetOfflineSessions("0-385-28089-0", "test") - require.NoError(t, err) - require.Equal(t, `{"test": "true"}`, string(newSess.ConnectorData)) -} diff --git a/server/helpers_test.go b/server/helpers_test.go new file mode 100644 index 0000000000..e3448e690e --- /dev/null +++ b/server/helpers_test.go @@ -0,0 +1,330 @@ +package server + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + gosundheit "github.com/AppsFlyer/go-sundheit" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/pkg/featureflags" + "github.com/dexidp/dex/server/connectors" + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/server/tokens" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" +) + +func newLogger(t *testing.T) *slog.Logger { + return slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +func boolPtr(v bool) *bool { + return &v +} + +// isFlowPath reports whether a redirect target is an internal step of the login +// chain (the /auth dispatcher, MFA factors, consent) rather than the client +// redirect. +func isFlowPath(p string) bool { + if strings.HasSuffix(p, "/auth") { // dispatcher re-entry + return true + } + for _, step := range []string{"/mfa/", "/approval"} { + if strings.Contains(p, step) { + return true + } + } + return false +} + +// followFlow walks the internal redirects of the login chain starting from rr +// (each hop is HMAC-protected and followed with a GET) and returns the path the +// flow comes to rest at: the client redirect_uri when the request is issued, or +// the flow step that renders a page (e.g. /approval). It leaves rr on the final +// response so callers can still inspect status, body or the Location query. +func followFlow(t *testing.T, s *Server, rr *httptest.ResponseRecorder) (*httptest.ResponseRecorder, string) { + t.Helper() + for range 10 { + if rr.Code != http.StatusFound && rr.Code != http.StatusSeeOther { + return rr, "" // rendered a page with no redirect + } + loc := rr.Header().Get("Location") + u, err := url.Parse(loc) + require.NoError(t, err) + if !isFlowPath(u.Path) { + return rr, u.Path // left the flow โ€” this is the client redirect + } + next := httptest.NewRecorder() + s.ServeHTTP(next, httptest.NewRequest(http.MethodGet, loc, nil)) + if next.Code != http.StatusFound && next.Code != http.StatusSeeOther { + return next, u.Path // this flow step rendered (e.g. the consent screen) + } + rr = next + } + t.Fatal("followFlow: redirect loop did not settle") + return rr, "" +} + +type emptyStorage struct { + storage.Storage +} + +func (*emptyStorage) GetAuthRequest(context.Context, string) (storage.AuthRequest, error) { + return storage.AuthRequest{}, storage.ErrNotFound +} + +func mockConnectorDataTestStorage(t *testing.T, s storage.Storage) { + ctx := t.Context() + c := storage.Client{ + ID: "test", + Secret: "barfoo", + RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"}, + Name: "dex client", + LogoURL: "https://goo.gl/JIyzIC", + } + + err := s.CreateClient(ctx, c) + require.NoError(t, err) + + c1 := storage.Connector{ + ID: "test", + Type: "mockPassword", + Name: "mockPassword", + Config: []byte(`{ +"username": "test", +"password": "test" +}`), + } + + err = s.CreateConnector(ctx, c1) + require.NoError(t, err) + + c2 := storage.Connector{ + ID: "http://any.valid.url/", + Type: "mock", + Name: "mockURLID", + } + + err = s.CreateConnector(ctx, c2) + require.NoError(t, err) +} + +func setSessionsEnabled(t *testing.T, enabled bool) { + t.Helper() + if enabled { + t.Setenv("DEX_SESSIONS_ENABLED", "true") + } else { + t.Setenv("DEX_SESSIONS_ENABLED", "false") + } +} + +// spnegoShortCircuit implements connector.PasswordConnector and connector.SPNEGOAware +// to simulate successful SPNEGO authentication on GET. +type spnegoShortCircuit struct{ Identity connector.Identity } + +func (s spnegoShortCircuit) Close() error { return nil } + +func (s spnegoShortCircuit) Prompt() string { return "" } + +func (s spnegoShortCircuit) Login(ctx context.Context, sc connector.Scopes, u, p string) (connector.Identity, bool, error) { + return connector.Identity{}, false, nil +} + +func (s spnegoShortCircuit) TrySPNEGO(ctx context.Context, sc connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) { + id := s.Identity + return &id, true, nil +} + +// spnegoError implements connector.PasswordConnector and connector.SPNEGOAware +// to simulate SPNEGO authentication that fails with an error (e.g., LDAP lookup failed). +type spnegoError struct{ Err error } + +func (s spnegoError) Close() error { return nil } + +func (s spnegoError) Prompt() string { return "" } + +func (s spnegoError) Login(ctx context.Context, sc connector.Scopes, u, p string) (connector.Identity, bool, error) { + return connector.Identity{}, false, nil +} + +func (s spnegoError) TrySPNEGO(ctx context.Context, sc connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) { + return nil, true, s.Err +} + +func setNonEmpty(vals url.Values, key, value string) { + if value != "" { + vals.Set(key, value) + } +} + +// registerTestConnector creates a connector in storage and registers it in the server's connectors map. +func registerTestConnector(t *testing.T, s *Server, connID string, c connector.Connector) { + t.Helper() + ctx := t.Context() + + storageConn := storage.Connector{ + ID: connID, + Type: "saml", + Name: "Test SAML", + ResourceVersion: "1", + } + if err := s.storage.CreateConnector(ctx, storageConn); err != nil { + t.Fatalf("failed to create connector in storage: %v", err) + } + + s.connectors.Set(connID, connectors.Connector{ + ResourceVersion: "1", + Connector: c, + }) +} + +// mockSAMLRefreshConnector implements SAMLConnector + RefreshConnector for testing. +type mockSAMLRefreshConnector struct { + refreshIdentity connector.Identity +} + +func (m *mockSAMLRefreshConnector) POSTData(s connector.Scopes, requestID string) (ssoURL, samlRequest string, err error) { + return "", "", nil +} + +func (m *mockSAMLRefreshConnector) HandlePOST(s connector.Scopes, samlResponse, inResponseTo string) (connector.Identity, error) { + return connector.Identity{}, nil +} + +func (m *mockSAMLRefreshConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { + return m.refreshIdentity, nil +} + +// testSessionKey is the AES key the test servers encrypt session cookies with. +// Tests that forge a session cookie sign it with this key rather than reading +// the key back off the server they are exercising. +var testSessionKey = []byte("0123456789abcdef0123456789abcdef") + +// mockConnector is the connector every test server serves. +func mockConnector(id string) storage.Connector { + return storage.Connector{ + ID: id, + Type: "mockCallback", + Name: "Mock", + ResourceVersion: "1", + } +} + +// testSessionConfig is the session config the test servers run with. +func testSessionConfig() *session.Config { + return &session.Config{ + CookieName: "dex_session", + CookieEncryptionKey: testSessionKey, + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: time.Hour, + } +} + +// newTestServerWith builds a server serving conns, behind an httptest.Server +// that dispatches to it. updateConfig adjusts the shared default config before +// the server is built; the caller-facing constructors below are thin wrappers +// that differ only in the connectors and the grant types they enable. +func newTestServerWith(t *testing.T, conns []storage.Connector, updateConfig func(c *Config)) (*httptest.Server, *Server) { + t.Helper() + + var server *Server + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server.ServeHTTP(w, r) + })) + + logger := newLogger(t) + ctx := t.Context() + + sig, err := signer.NewMockSigner(testKey) + require.NoError(t, err, "failed to create mock signer") + + config := Config{ + Issuer: s.URL, + Storage: memory.New(logger), + Web: WebConfig{ + Dir: "../web", + }, + Logger: logger, + PrometheusRegistry: prometheus.NewRegistry(), + HealthChecker: gosundheit.New(), + SkipApprovalScreen: true, // Don't prompt for approval, just immediately redirect with code. + Signer: sig, + } + if updateConfig != nil { + updateConfig(&config) + } + s.URL = config.Issuer + + // Default rotation policy, set before the server is built so the token + // endpoint captures it. + if config.RefreshTokenPolicy == nil { + config.RefreshTokenPolicy = tokens.NewRefreshStrategy(true, 0, 0, 0, config.Now) + } + + // Mirror cmd: the session config is present iff the sessions feature flag is on. + if featureflags.SessionsEnabled.Enabled() && config.SessionConfig == nil { + config.SessionConfig = testSessionConfig() + } + + for _, conn := range conns { + require.NoError(t, config.Storage.CreateConnector(ctx, conn), "create connector") + } + + server, err = newServer(ctx, config) + require.NoError(t, err) + + return s, server +} + +// newTestServer serves one mock connector with every implemented grant enabled. +func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) { + return newTestServerWith(t, []storage.Connector{mockConnector("mock")}, func(c *Config) { + c.AllowedGrantTypes = []string{ // all implemented types + oauth2.GrantTypeDeviceCode, + oauth2.GrantTypeAuthorizationCode, + oauth2.GrantTypeClientCredentials, + oauth2.GrantTypeRefreshToken, + oauth2.GrantTypeTokenExchange, + oauth2.GrantTypeImplicit, + oauth2.GrantTypePassword, + } + if updateConfig != nil { + updateConfig(c) + } + }) +} + +// newTestServerMultipleConnectors serves two mock connectors, for the paths that +// depend on the connector selection screen. +func newTestServerMultipleConnectors(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) { + return newTestServerWith(t, []storage.Connector{mockConnector("mock"), mockConnector("mock2")}, updateConfig) +} + +// newTestServerWithSessions serves one mock connector with sessions always on, +// regardless of the feature flag. +func newTestServerWithSessions(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) { + return newTestServerWith(t, []storage.Connector{mockConnector("mock")}, func(c *Config) { + c.AllowedGrantTypes = []string{ + oauth2.GrantTypeAuthorizationCode, + oauth2.GrantTypeClientCredentials, + oauth2.GrantTypeRefreshToken, + oauth2.GrantTypeTokenExchange, + oauth2.GrantTypeDeviceCode, + } + c.SessionConfig = testSessionConfig() + if updateConfig != nil { + updateConfig(c) + } + }) +} diff --git a/server/home/doc.go b/server/home/doc.go new file mode 100644 index 0000000000..d718d63fe2 --- /dev/null +++ b/server/home/doc.go @@ -0,0 +1,2 @@ +// Package home serves the dex landing page at "/". +package home diff --git a/server/home/home.go b/server/home/home.go new file mode 100644 index 0000000000..6a65486292 --- /dev/null +++ b/server/home/home.go @@ -0,0 +1,137 @@ +package home + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/dexidp/dex/server/oauth2" + "github.com/dexidp/dex/server/router" + "github.com/dexidp/dex/server/session" + "github.com/dexidp/dex/server/templates" + "github.com/dexidp/dex/storage" +) + +// Handler serves the landing page. When sessions are enabled and a home template +// is available it renders the rich page (with logged-in details); otherwise it +// falls back to a minimal inline page. +type Handler struct { + IssuerURL oauth2.IssuerURL + Storage storage.Storage + Templates *templates.Templates + Logger *slog.Logger + // Sessions is the shared session manager; nil (or with a nil Config) when + // sessions are disabled. + Sessions *session.Manager +} + +// Mount registers the landing-page route. +func (h *Handler) Mount(m router.Mux) { + m.HandleCORS("/", h.handle) +} + +func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) { + templates.RenderError(h.Templates, h.Logger, r, w, status, description) +} + +func (h *Handler) handle(w http.ResponseWriter, r *http.Request) { + if h.Sessions == nil || h.Sessions.Config == nil || !h.Templates.HasHome() { + h.handleInline(w, r) + return + } + + ctx := r.Context() + + data := templates.HomeData{ + DiscoveryURL: h.IssuerURL.JoinPath(".well-known", "openid-configuration").String(), + LogoutURL: h.IssuerURL.AbsURL("/logout"), + } + + // ValidSession enforces the nonce AND absolute/idle expiry (clearing an + // expired session), so an expired-but-not-yet-purged cookie no longer renders + // a logged-in page. + if session := h.Sessions.ValidSession(ctx, w, r); session != nil { + data.LoggedIn = true + data.IPAddress = session.IPAddress + data.UserAgent = session.UserAgent + data.SignedInISO, data.SignedInText = timeFields(session.CreatedAt) + expiry, idle := sessionExpiry(session) + data.SessionExpiresISO, data.SessionExpiresText = timeFields(expiry) + data.SessionExpiryIsIdle = idle + h.populateData(ctx, &data, session.UserID, session.ConnectorID) + } + + if err := h.Templates.Home(r, w, data); err != nil { + h.Logger.ErrorContext(ctx, "failed to render home template", "err", err) + h.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + } +} + +// timeFields renders a timestamp for the page: an ISO 8601 string for the