From c13246cf4708ed5031f6bb22bf0dfb4558fecef1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:29:37 +0000 Subject: [PATCH 001/222] build(deps): bump github.com/coreos/go-oidc/v3 in /examples Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.16.0 to 3.17.0. - [Release notes](https://github.com/coreos/go-oidc/releases) - [Commits](https://github.com/coreos/go-oidc/compare/v3.16.0...v3.17.0) --- updated-dependencies: - dependency-name: github.com/coreos/go-oidc/v3 dependency-version: 3.17.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- examples/go.mod | 2 +- examples/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index de09f42c..53558851 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,7 +3,7 @@ module github.com/dexidp/dex/examples go 1.24.0 require ( - github.com/coreos/go-oidc/v3 v3.16.0 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/dexidp/dex/api/v2 v2.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/oauth2 v0.34.0 diff --git a/examples/go.sum b/examples/go.sum index fadded37..fab2976e 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,5 +1,5 @@ -github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= -github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dexidp/dex/api/v2 v2.4.0 h1:gNba7n6BKVp8X4Jp24cxYn5rIIGhM6kDOXcZoL6tr9A= github.com/dexidp/dex/api/v2 v2.4.0/go.mod h1:/p550ADvFFh7K95VmhUD+jgm15VdaNnab9td8DHOpyI= From 2da2a227d54339d025cd145b51dc597296b823ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:01:27 +0000 Subject: [PATCH 002/222] build(deps): bump actions/setup-go from 6.0.0 to 6.1.0 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.0.0 to 6.1.0. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/44694675825211faa026b3c33043df3e48a5fa00...4dc6199c7b1a012772edbd06daecab0f50c9053c) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 201ca364..dab1f784 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -69,7 +69,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: "1.25" @@ -140,7 +140,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: "1.25" From 4d1d54cbe79ea3e2608b97ed0f76594a2769682f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:01:32 +0000 Subject: [PATCH 003/222] build(deps): bump docker/metadata-action from 5.9.0 to 5.10.0 Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.9.0 to 5.10.0. - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/318604b99e75e41977312d83839a89be02ca4893...c299e40c65443455700f0fdfc63efafe5b349051) --- updated-dependencies: - dependency-name: docker/metadata-action dependency-version: 5.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index a464cd17..89a18fbd 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -71,7 +71,7 @@ jobs: - name: Gather build metadata id: meta - uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: | ${{ steps.image-name.outputs.value }} From 3dea4ba5d9e3d6646c76d37f4d038c8a01f94b4d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:01:37 +0000 Subject: [PATCH 004/222] build(deps): bump anchore/sbom-action from 0.20.9 to 0.20.11 Bumps [anchore/sbom-action](https://github.com/anchore/sbom-action) from 0.20.9 to 0.20.11. - [Release notes](https://github.com/anchore/sbom-action/releases) - [Changelog](https://github.com/anchore/sbom-action/blob/main/RELEASE.md) - [Commits](https://github.com/anchore/sbom-action/compare/8e94d75ddd33f69f691467e42275782e4bfefe84...43a17d6e7add2b5535efe4dcae9952337c479a93) --- updated-dependencies: - dependency-name: anchore/sbom-action dependency-version: 0.20.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index a464cd17..8823ac2a 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -60,7 +60,7 @@ jobs: uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Set up Syft - uses: anchore/sbom-action/download-syft@8e94d75ddd33f69f691467e42275782e4bfefe84 # v0.20.9 + uses: anchore/sbom-action/download-syft@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 - name: Install cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 From 71b893e58a90e6749429f87d48ebdd562ae8eedb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:01:44 +0000 Subject: [PATCH 005/222] build(deps): bump actions/checkout from 5.0.0 to 6.0.1 Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 6.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/08c6903cd8c0fde910a37f88322edcfb5dd907a8...8e8c483db84b4bee98b60c0593521ed34d9990e8) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/analysis-scorecard.yaml | 2 +- .github/workflows/artifacts.yaml | 2 +- .github/workflows/ci.yaml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index 5031899e..43402bf0 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index a464cd17..f99fc45f 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -51,7 +51,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 201ca364..70121315 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,7 +66,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 @@ -137,7 +137,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 @@ -172,7 +172,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Dependency Review uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 From 95bf3d09810e507966db07c3b54f5514d3558689 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:01:47 +0000 Subject: [PATCH 006/222] build(deps): bump golang.org/x/net from 0.47.0 to 0.48.0 Bumps [golang.org/x/net](https://github.com/golang/net) from 0.47.0 to 0.48.0. - [Commits](https://github.com/golang/net/compare/v0.47.0...v0.48.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.48.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 08770b50..af02326e 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( go.etcd.io/etcd/client/v3 v3.6.5 golang.org/x/crypto v0.46.0 golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 - golang.org/x/net v0.47.0 + golang.org/x/net v0.48.0 golang.org/x/oauth2 v0.34.0 google.golang.org/api v0.257.0 google.golang.org/grpc v1.77.0 diff --git a/go.sum b/go.sum index 29c01de7..d22a3f1f 100644 --- a/go.sum +++ b/go.sum @@ -242,8 +242,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From ab8306c071c006c710674a5ca6ba63913162c779 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:01:50 +0000 Subject: [PATCH 007/222] build(deps): bump github.com/spf13/cobra from 1.10.1 to 1.10.2 Bumps [github.com/spf13/cobra](https://github.com/spf13/cobra) from 1.10.1 to 1.10.2. - [Release notes](https://github.com/spf13/cobra/releases) - [Commits](https://github.com/spf13/cobra/compare/v1.10.1...v1.10.2) --- updated-dependencies: - dependency-name: github.com/spf13/cobra dependency-version: 1.10.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 08770b50..983fe1c6 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/russellhaering/goxmldsig v1.5.0 - github.com/spf13/cobra v1.10.1 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 go.etcd.io/etcd/client/pkg/v3 v3.6.5 go.etcd.io/etcd/client/v3 v3.6.5 diff --git a/go.sum b/go.sum index 29c01de7..8611900e 100644 --- a/go.sum +++ b/go.sum @@ -179,8 +179,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp 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.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +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= @@ -227,6 +227,7 @@ 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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= From 78363ecb56681d0834e505fc98bb74f237bfff66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:01:56 +0000 Subject: [PATCH 008/222] build(deps): bump github/codeql-action from 4.31.3 to 4.31.7 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.31.3 to 4.31.7. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/014f16e7ab1402f30e7c3329d33797e7948572db...cf1bb45a277cb3c205638b2cd5c984db1c46a412) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.31.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/analysis-scorecard.yaml | 2 +- .github/workflows/artifacts.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index 5031899e..87660a0d 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -42,6 +42,6 @@ jobs: retention-days: 5 - name: Upload results to GitHub Security tab - uses: github/codeql-action/upload-sarif@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5 + uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index a464cd17..a0c134ff 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -233,6 +233,6 @@ jobs: overwrite: true - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5 + uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v3.29.5 with: sarif_file: trivy-results.sarif From 8be9fc37ff15c1d13fde605648552a8d8e88b9fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:02:01 +0000 Subject: [PATCH 009/222] build(deps): bump github.com/coreos/go-oidc/v3 from 3.14.1 to 3.17.0 Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.14.1 to 3.17.0. - [Release notes](https://github.com/coreos/go-oidc/releases) - [Commits](https://github.com/coreos/go-oidc/compare/v3.14.1...v3.17.0) --- updated-dependencies: - dependency-name: github.com/coreos/go-oidc/v3 dependency-version: 3.17.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 08770b50..f0db1921 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/Masterminds/semver v1.5.0 github.com/Masterminds/sprig/v3 v3.3.0 github.com/beevik/etree v1.6.0 - github.com/coreos/go-oidc/v3 v3.14.1 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/dexidp/dex/api/v2 v2.3.0 github.com/fsnotify/fsnotify v1.9.0 github.com/ghodss/yaml v1.0.0 diff --git a/go.sum b/go.sum index 29c01de7..d587a60c 100644 --- a/go.sum +++ b/go.sum @@ -40,8 +40,8 @@ github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQ github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= 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.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= -github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= 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= From a023784701751d6c36f32be98482f72ea4608fa5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:01:44 +0000 Subject: [PATCH 010/222] build(deps): bump google.golang.org/protobuf from 1.36.10 to 1.36.11 Bumps google.golang.org/protobuf from 1.36.10 to 1.36.11. --- updated-dependencies: - dependency-name: google.golang.org/protobuf dependency-version: 1.36.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 08770b50..46c9f1a9 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( golang.org/x/oauth2 v0.34.0 google.golang.org/api v0.257.0 google.golang.org/grpc v1.77.0 - google.golang.org/protobuf v1.36.10 + google.golang.org/protobuf v1.36.11 ) require ( diff --git a/go.sum b/go.sum index 29c01de7..54a88448 100644 --- a/go.sum +++ b/go.sum @@ -288,8 +288,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= From 2c5f06e9d1d740c3dbed0ec9b5250865c919bee7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 04:03:59 +0000 Subject: [PATCH 011/222] build(deps): bump google.golang.org/grpc in /examples Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.77.0 to 1.78.0. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.77.0...v1.78.0) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.78.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- examples/go.mod | 10 +++++----- examples/go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index de09f42c..d3dd0e28 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -7,16 +7,16 @@ require ( github.com/dexidp/dex/api/v2 v2.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/oauth2 v0.34.0 - google.golang.org/grpc v1.77.0 + google.golang.org/grpc v1.78.0 ) require ( github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/examples/go.sum b/examples/go.sum index fadded37..e9afb42e 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -35,20 +35,20 @@ go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689Cbtr go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= -golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 895a74879bd735f2d63a12eea34ec6b84f2ac033 Mon Sep 17 00:00:00 2001 From: Appu Date: Tue, 13 Jan 2026 02:24:02 -0500 Subject: [PATCH 012/222] Update distroless base image to debian13 (#4453) Signed-off-by: Appu Goundan --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 90376dc7..1dc7722a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,7 @@ RUN wget -O /usr/local/bin/gomplate \ # For Dependabot to detect base image versions FROM alpine:3.23.0@sha256:51183f2cfa6320055da30872f211093f9ff1d3cf06f39a0bdb212314c5dc7375 AS alpine -FROM gcr.io/distroless/static-debian12:nonroot@sha256:2b7c93f6d6648c11f0e80a48558c8f77885eb0445213b8e69a6a0d7c89fc6ae4 AS distroless +FROM gcr.io/distroless/static-debian13:nonroot@sha256:b5b9fd04c8dcf72a173183c0b7dee47e053e002246b308a59f3441db7b8b9cc4 AS distroless FROM $BASE_IMAGE From dcbb7bbe58b73b1ab15bf2407376073515d76f68 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Tue, 13 Jan 2026 01:39:41 -0600 Subject: [PATCH 013/222] fix: device callback URL needs to handle a / (#4448) If the issuer path ends with a / the URL will be built wrong so we should instead use the helper function to ensure the path is built correctly. fixes #4242. Signed-off-by: Doug Goldstein --- server/oauth2.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/oauth2.go b/server/oauth2.go index 18cc3dd4..bd0b0530 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -491,7 +491,7 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques return nil, newDisplayedErr(http.StatusBadRequest, "Unregistered redirect_uri (%q).", redirectURI) } if redirectURI == deviceCallbackURI && client.Public { - redirectURI = s.issuerURL.Path + deviceCallbackURI + redirectURI = s.absPath(deviceCallbackURI) } // From here on out, we want to redirect back to the client with an error. From 2d7ecd30a2c97aa44700018327d1a13579dc7cf8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 08:40:08 +0100 Subject: [PATCH 014/222] build(deps): bump alpine from 3.23.0 to 3.23.2 (#4455) Bumps alpine from 3.23.0 to 3.23.2. --- updated-dependencies: - dependency-name: alpine dependency-version: 3.23.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1dc7722a..7f0559c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,13 +35,13 @@ RUN make release-binary RUN xx-verify /go/bin/dex && xx-verify /go/bin/docker-entrypoint -FROM alpine:3.23.0@sha256:51183f2cfa6320055da30872f211093f9ff1d3cf06f39a0bdb212314c5dc7375 AS stager +FROM alpine:3.23.2@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62 AS stager RUN mkdir -p /var/dex RUN mkdir -p /etc/dex COPY config.docker.yaml /etc/dex/ -FROM alpine:3.23.0@sha256:51183f2cfa6320055da30872f211093f9ff1d3cf06f39a0bdb212314c5dc7375 AS gomplate +FROM alpine:3.23.2@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62 AS gomplate ARG TARGETOS ARG TARGETARCH @@ -54,7 +54,7 @@ RUN wget -O /usr/local/bin/gomplate \ && chmod +x /usr/local/bin/gomplate # For Dependabot to detect base image versions -FROM alpine:3.23.0@sha256:51183f2cfa6320055da30872f211093f9ff1d3cf06f39a0bdb212314c5dc7375 AS alpine +FROM alpine:3.23.2@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62 AS alpine FROM gcr.io/distroless/static-debian13:nonroot@sha256:b5b9fd04c8dcf72a173183c0b7dee47e053e002246b308a59f3441db7b8b9cc4 AS distroless FROM $BASE_IMAGE From d1b2722e396c5fa1abeec9ba7704e8d69e440223 Mon Sep 17 00:00:00 2001 From: Ivan Zviagintsev Date: Tue, 13 Jan 2026 01:07:21 -0800 Subject: [PATCH 015/222] feat: support groups and preferred_username for staticPasswords (#4456) Signed-off-by: Ivan Zvyagintsev --- cmd/dex/config.go | 32 ++--- cmd/dex/config_test.go | 17 ++- config.dev.yaml | 4 + config.yaml.dist | 13 ++- examples/config-dev.yaml | 4 + examples/k8s/dex.yaml | 4 + server/handlers_test.go | 80 +++++++++++++ server/server.go | 14 ++- server/server_test.go | 20 ++-- storage/conformance/conformance.go | 20 ++-- storage/ent/client/password.go | 4 + storage/ent/client/types.go | 10 +- storage/ent/db/migrate/schema.go | 2 + storage/ent/db/mutation.go | 173 +++++++++++++++++++++++++--- storage/ent/db/password.go | 31 ++++- storage/ent/db/password/password.go | 13 +++ storage/ent/db/password/where.go | 80 +++++++++++++ storage/ent/db/password_create.go | 41 +++++++ storage/ent/db/password_update.go | 93 +++++++++++++++ storage/ent/db/runtime.go | 6 +- storage/ent/schema/password.go | 5 + storage/kubernetes/storage.go | 8 +- storage/kubernetes/types.go | 28 +++-- storage/sql/crud.go | 18 +-- storage/sql/migrate.go | 40 +++++++ storage/storage.go | 6 + 26 files changed, 678 insertions(+), 88 deletions(-) diff --git a/cmd/dex/config.go b/cmd/dex/config.go index aa49a181..c76ff030 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -95,19 +95,23 @@ 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"` + PreferredUsername string `json:"preferredUsername"` + UserID string `json:"userID"` + Hash string `json:"hash"` + HashFromEnv string `json:"hashFromEnv"` + Groups []string `json:"groups"` } if err := json.Unmarshal(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, + PreferredUsername: data.PreferredUsername, + UserID: data.UserID, + Groups: data.Groups, }) if len(data.Hash) == 0 && len(data.HashFromEnv) > 0 { data.Hash = os.Getenv(data.HashFromEnv) @@ -275,12 +279,12 @@ var ( _ StorageConfig = (*ent.MySQL)(nil) ) -func getORMBasedSQLStorage(normal, entBased StorageConfig) func() StorageConfig { +func getORMBasedSQLStorage(normal, entBased func() StorageConfig) func() StorageConfig { return func() StorageConfig { if featureflags.EntEnabled.Enabled() { - return entBased + return entBased() } - return normal + return normal() } } @@ -309,9 +313,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{}), + "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 diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go index 68abe1f7..30d50c6c 100644 --- a/cmd/dex/config_test.go +++ b/cmd/dex/config_test.go @@ -116,6 +116,10 @@ staticPasswords: # bcrypt hash of the string "password" hash: "$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy" username: "admin" + 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 @@ -206,10 +210,15 @@ additionalFeatures: [ 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", + PreferredUsername: "admin-public", + UserID: "08a8684b-db88-4b73-90a9-3cd1661f5466", + Groups: []string{ + "team-a", + "team-a/admins", + }, }, { Email: "foo@example.com", diff --git a/config.dev.yaml b/config.dev.yaml index dda65e08..b0dc959c 100644 --- a/config.dev.yaml +++ b/config.dev.yaml @@ -32,4 +32,8 @@ staticPasswords: - email: "admin@example.com" hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: "admin" + preferredUsername: "admin" + groups: + - "team-a" + - "team-a/admins" userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" diff --git a/config.yaml.dist b/config.yaml.dist index b7e1410f..c187ca3c 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -135,4 +135,15 @@ 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 "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/examples/config-dev.yaml b/examples/config-dev.yaml index 147597a2..0fdf350c 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -164,4 +164,8 @@ staticPasswords: # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: "admin" + preferredUsername: "admin" + groups: + - "team-a" + - "team-a/admins" userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" diff --git a/examples/k8s/dex.yaml b/examples/k8s/dex.yaml index c8d83944..41156af0 100644 --- a/examples/k8s/dex.yaml +++ b/examples/k8s/dex.yaml @@ -106,6 +106,10 @@ data: # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: "admin" + preferredUsername: "admin" + groups: + - "team-a" + - "team-a/admins" userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" --- apiVersion: v1 diff --git a/server/handlers_test.go b/server/handlers_test.go index 114712ba..184080c8 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -18,6 +18,7 @@ import ( "github.com/AppsFlyer/go-sundheit/checks" "github.com/coreos/go-oidc/v3/oidc" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "github.com/dexidp/dex/storage" @@ -402,6 +403,85 @@ func TestHandlePassword(t *testing.T) { } } +func TestHandlePassword_LocalPasswordDBClaims(t *testing.T) { + ctx := t.Context() + + // Setup a dex server. + httpServer, s := newTestServer(t, func(c *Config) { + c.PasswordConnector = "local" + }) + defer httpServer.Close() + + // Client credentials for password grant. + client := storage.Client{ + ID: "test", + Secret: "barfoo", + RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"}, + } + require.NoError(t, s.storage.CreateClient(ctx, client)) + + // Enable local connector. + localConn := storage.Connector{ + ID: "local", + Type: LocalConnector, + Name: "Email", + ResourceVersion: "1", + } + require.NoError(t, s.storage.CreateConnector(ctx, localConn)) + _, err := s.OpenConnector(localConn) + require.NoError(t, err) + + // Create a user in the password DB with groups and preferred_username. + pw := "secret" + hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + require.NoError(t, err) + require.NoError(t, s.storage.CreatePassword(ctx, storage.Password{ + Email: "user@example.com", + Username: "user-login", + PreferredUsername: "user-public", + UserID: "user-id", + Groups: []string{"team-a", "team-a/admins"}, + Hash: hash, + })) + + 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 profile email groups") + v.Add("grant_type", "password") + v.Add("username", "user@example.com") + v.Add("password", pw) + + req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(v.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("test", "barfoo") + + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + var tokenResponse struct { + IDToken string `json:"id_token"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokenResponse)) + require.NotEmpty(t, tokenResponse.IDToken) + + p, err := oidc.NewProvider(ctx, httpServer.URL) + require.NoError(t, err) + idToken, err := p.Verifier(&oidc.Config{SkipClientIDCheck: true}).Verify(ctx, tokenResponse.IDToken) + require.NoError(t, err) + + var claims struct { + PreferredUsername string `json:"preferred_username"` + Groups []string `json:"groups"` + } + require.NoError(t, idToken.Claims(&claims)) + require.Equal(t, "user-public", claims.PreferredUsername) + require.Equal(t, []string{"team-a", "team-a/admins"}, claims.Groups) +} + func TestHandlePasswordLoginWithSkipApproval(t *testing.T) { ctx := t.Context() diff --git a/server/server.go b/server/server.go index 70e8ae75..d81a0f71 100644 --- a/server/server.go +++ b/server/server.go @@ -565,10 +565,12 @@ func (db passwordDB) Login(ctx context.Context, s connector.Scopes, email, passw return connector.Identity{}, false, nil } return connector.Identity{ - UserID: p.UserID, - Username: p.Username, - Email: p.Email, - EmailVerified: true, + UserID: p.UserID, + Username: p.Username, + PreferredUsername: p.PreferredUsername, + Email: p.Email, + EmailVerified: true, + Groups: p.Groups, }, true, nil } @@ -591,8 +593,10 @@ func (db passwordDB) Refresh(ctx context.Context, s connector.Scopes, identity c // refreshed token. // // No other fields are expected to be refreshable as email is effectively used - // as an ID and this implementation doesn't deal with groups. + // as an ID. identity.Username = p.Username + identity.PreferredUsername = p.PreferredUsername + identity.Groups = p.Groups return identity, nil } diff --git a/server/server_test.go b/server/server_test.go index a922aa75..312c0de9 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1279,10 +1279,12 @@ func TestPasswordDB(t *testing.T) { } s.CreatePassword(ctx, storage.Password{ - Email: "jane@example.com", - Username: "jane", - UserID: "foobar", - Hash: h, + Email: "jane@example.com", + Username: "jane", + PreferredUsername: "jane-public", + UserID: "foobar", + Groups: []string{"team-a", "team-a/admins"}, + Hash: h, }) tests := []struct { @@ -1298,10 +1300,12 @@ func TestPasswordDB(t *testing.T) { username: "jane@example.com", password: pw, wantIdentity: connector.Identity{ - Email: "jane@example.com", - Username: "jane", - UserID: "foobar", - EmailVerified: true, + Email: "jane@example.com", + Username: "jane", + PreferredUsername: "jane-public", + UserID: "foobar", + EmailVerified: true, + Groups: []string{"team-a", "team-a/admins"}, }, }, { diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index f9d21961..333561d9 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -456,10 +456,12 @@ func testPasswordCRUD(t *testing.T, s storage.Storage) { } password1 := storage.Password{ - Email: "jane@example.com", - Hash: passwordHash1, - Username: "jane", - UserID: "foobar", + Email: "jane@example.com", + Hash: passwordHash1, + Username: "jane", + PreferredUsername: "jane-public", + UserID: "foobar", + Groups: []string{"team-a", "team-a/admins"}, } if err := s.CreatePassword(ctx, password1); err != nil { t.Fatalf("create password token: %v", err) @@ -475,10 +477,12 @@ func testPasswordCRUD(t *testing.T, s storage.Storage) { } password2 := storage.Password{ - Email: "john@example.com", - Hash: passwordHash2, - Username: "john", - UserID: "barfoo", + Email: "john@example.com", + Hash: passwordHash2, + Username: "john", + PreferredUsername: "john-public", + UserID: "barfoo", + Groups: []string{"team-b"}, } if err := s.CreatePassword(ctx, password2); err != nil { t.Fatalf("create password token: %v", err) diff --git a/storage/ent/client/password.go b/storage/ent/client/password.go index 2845fa8f..c2dc07ca 100644 --- a/storage/ent/client/password.go +++ b/storage/ent/client/password.go @@ -14,7 +14,9 @@ func (d *Database) CreatePassword(ctx context.Context, password storage.Password SetEmail(password.Email). SetHash(password.Hash). SetUsername(password.Username). + SetPreferredUsername(password.PreferredUsername). SetUserID(password.UserID). + SetGroups(password.Groups). Save(ctx) if err != nil { return convertDBError("create password: %w", err) @@ -86,7 +88,9 @@ func (d *Database) UpdatePassword(ctx context.Context, email string, updater fun SetEmail(newPassword.Email). SetHash(newPassword.Hash). SetUsername(newPassword.Username). + SetPreferredUsername(newPassword.PreferredUsername). SetUserID(newPassword.UserID). + SetGroups(newPassword.Groups). Save(ctx) if err != nil { return rollback(tx, "update password uploading: %w", err) diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 397d4d30..27f20cfa 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -139,10 +139,12 @@ func toStorageRefreshToken(r *db.RefreshToken) storage.RefreshToken { func toStoragePassword(p *db.Password) storage.Password { return storage.Password{ - Email: p.Email, - Hash: p.Hash, - Username: p.Username, - UserID: p.UserID, + Email: p.Email, + Hash: p.Hash, + Username: p.Username, + PreferredUsername: p.PreferredUsername, + UserID: p.UserID, + Groups: p.Groups, } } diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index d3295a0c..3fee7bd5 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -161,7 +161,9 @@ var ( {Name: "email", Type: field.TypeString, Unique: true, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "hash", Type: field.TypeBytes}, {Name: "username", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "preferred_username", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "user_id", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "groups", Type: field.TypeJSON, Nullable: true}, } // PasswordsTable holds the schema information for the "passwords" table. PasswordsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 71203574..e0c1cc48 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -6314,17 +6314,20 @@ func (m *OfflineSessionMutation) ResetEdge(name string) error { // PasswordMutation represents an operation that mutates the Password nodes in the graph. type PasswordMutation struct { config - op Op - typ string - id *int - email *string - hash *[]byte - username *string - user_id *string - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*Password, error) - predicates []predicate.Password + op Op + typ string + id *int + email *string + hash *[]byte + username *string + preferred_username *string + user_id *string + groups *[]string + appendgroups []string + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*Password, error) + predicates []predicate.Password } var _ ent.Mutation = (*PasswordMutation)(nil) @@ -6533,6 +6536,42 @@ func (m *PasswordMutation) ResetUsername() { m.username = nil } +// SetPreferredUsername sets the "preferred_username" field. +func (m *PasswordMutation) SetPreferredUsername(s string) { + m.preferred_username = &s +} + +// PreferredUsername returns the value of the "preferred_username" field in the mutation. +func (m *PasswordMutation) PreferredUsername() (r string, exists bool) { + v := m.preferred_username + if v == nil { + return + } + return *v, true +} + +// OldPreferredUsername returns the old "preferred_username" field's value of the Password entity. +// If the Password object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *PasswordMutation) OldPreferredUsername(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPreferredUsername is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPreferredUsername requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPreferredUsername: %w", err) + } + return oldValue.PreferredUsername, nil +} + +// ResetPreferredUsername resets all changes to the "preferred_username" field. +func (m *PasswordMutation) ResetPreferredUsername() { + m.preferred_username = nil +} + // SetUserID sets the "user_id" field. func (m *PasswordMutation) SetUserID(s string) { m.user_id = &s @@ -6569,6 +6608,71 @@ func (m *PasswordMutation) ResetUserID() { m.user_id = nil } +// SetGroups sets the "groups" field. +func (m *PasswordMutation) SetGroups(s []string) { + m.groups = &s + m.appendgroups = nil +} + +// Groups returns the value of the "groups" field in the mutation. +func (m *PasswordMutation) Groups() (r []string, exists bool) { + v := m.groups + if v == nil { + return + } + return *v, true +} + +// OldGroups returns the old "groups" field's value of the Password entity. +// If the Password object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *PasswordMutation) OldGroups(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldGroups is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldGroups requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldGroups: %w", err) + } + return oldValue.Groups, nil +} + +// AppendGroups adds s to the "groups" field. +func (m *PasswordMutation) AppendGroups(s []string) { + m.appendgroups = append(m.appendgroups, s...) +} + +// AppendedGroups returns the list of values that were appended to the "groups" field in this mutation. +func (m *PasswordMutation) AppendedGroups() ([]string, bool) { + if len(m.appendgroups) == 0 { + return nil, false + } + return m.appendgroups, true +} + +// ClearGroups clears the value of the "groups" field. +func (m *PasswordMutation) ClearGroups() { + m.groups = nil + m.appendgroups = nil + m.clearedFields[password.FieldGroups] = struct{}{} +} + +// GroupsCleared returns if the "groups" field was cleared in this mutation. +func (m *PasswordMutation) GroupsCleared() bool { + _, ok := m.clearedFields[password.FieldGroups] + return ok +} + +// ResetGroups resets all changes to the "groups" field. +func (m *PasswordMutation) ResetGroups() { + m.groups = nil + m.appendgroups = nil + delete(m.clearedFields, password.FieldGroups) +} + // Where appends a list predicates to the PasswordMutation builder. func (m *PasswordMutation) Where(ps ...predicate.Password) { m.predicates = append(m.predicates, ps...) @@ -6603,7 +6707,7 @@ func (m *PasswordMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *PasswordMutation) Fields() []string { - fields := make([]string, 0, 4) + fields := make([]string, 0, 6) if m.email != nil { fields = append(fields, password.FieldEmail) } @@ -6613,9 +6717,15 @@ func (m *PasswordMutation) Fields() []string { if m.username != nil { fields = append(fields, password.FieldUsername) } + if m.preferred_username != nil { + fields = append(fields, password.FieldPreferredUsername) + } if m.user_id != nil { fields = append(fields, password.FieldUserID) } + if m.groups != nil { + fields = append(fields, password.FieldGroups) + } return fields } @@ -6630,8 +6740,12 @@ func (m *PasswordMutation) Field(name string) (ent.Value, bool) { return m.Hash() case password.FieldUsername: return m.Username() + case password.FieldPreferredUsername: + return m.PreferredUsername() case password.FieldUserID: return m.UserID() + case password.FieldGroups: + return m.Groups() } return nil, false } @@ -6647,8 +6761,12 @@ func (m *PasswordMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldHash(ctx) case password.FieldUsername: return m.OldUsername(ctx) + case password.FieldPreferredUsername: + return m.OldPreferredUsername(ctx) case password.FieldUserID: return m.OldUserID(ctx) + case password.FieldGroups: + return m.OldGroups(ctx) } return nil, fmt.Errorf("unknown Password field %s", name) } @@ -6679,6 +6797,13 @@ func (m *PasswordMutation) SetField(name string, value ent.Value) error { } m.SetUsername(v) return nil + case password.FieldPreferredUsername: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPreferredUsername(v) + return nil case password.FieldUserID: v, ok := value.(string) if !ok { @@ -6686,6 +6811,13 @@ func (m *PasswordMutation) SetField(name string, value ent.Value) error { } m.SetUserID(v) return nil + case password.FieldGroups: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetGroups(v) + return nil } return fmt.Errorf("unknown Password field %s", name) } @@ -6715,7 +6847,11 @@ func (m *PasswordMutation) AddField(name string, value ent.Value) error { // ClearedFields returns all nullable fields that were cleared during this // mutation. func (m *PasswordMutation) ClearedFields() []string { - return nil + var fields []string + if m.FieldCleared(password.FieldGroups) { + fields = append(fields, password.FieldGroups) + } + return fields } // FieldCleared returns a boolean indicating if a field with the given name was @@ -6728,6 +6864,11 @@ func (m *PasswordMutation) FieldCleared(name string) bool { // ClearField clears the value of the field with the given name. It returns an // error if the field is not defined in the schema. func (m *PasswordMutation) ClearField(name string) error { + switch name { + case password.FieldGroups: + m.ClearGroups() + return nil + } return fmt.Errorf("unknown Password nullable field %s", name) } @@ -6744,9 +6885,15 @@ func (m *PasswordMutation) ResetField(name string) error { case password.FieldUsername: m.ResetUsername() return nil + case password.FieldPreferredUsername: + m.ResetPreferredUsername() + return nil case password.FieldUserID: m.ResetUserID() return nil + case password.FieldGroups: + m.ResetGroups() + return nil } return fmt.Errorf("unknown Password field %s", name) } diff --git a/storage/ent/db/password.go b/storage/ent/db/password.go index e2ceec8f..26f4089b 100644 --- a/storage/ent/db/password.go +++ b/storage/ent/db/password.go @@ -3,6 +3,7 @@ package db import ( + "encoding/json" "fmt" "strings" @@ -22,8 +23,12 @@ type Password struct { Hash []byte `json:"hash,omitempty"` // Username holds the value of the "username" field. Username string `json:"username,omitempty"` + // PreferredUsername holds the value of the "preferred_username" field. + PreferredUsername string `json:"preferred_username,omitempty"` // UserID holds the value of the "user_id" field. - UserID string `json:"user_id,omitempty"` + UserID string `json:"user_id,omitempty"` + // Groups holds the value of the "groups" field. + Groups []string `json:"groups,omitempty"` selectValues sql.SelectValues } @@ -32,11 +37,11 @@ func (*Password) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case password.FieldHash: + case password.FieldHash, password.FieldGroups: values[i] = new([]byte) case password.FieldID: values[i] = new(sql.NullInt64) - case password.FieldEmail, password.FieldUsername, password.FieldUserID: + case password.FieldEmail, password.FieldUsername, password.FieldPreferredUsername, password.FieldUserID: values[i] = new(sql.NullString) default: values[i] = new(sql.UnknownType) @@ -77,12 +82,26 @@ func (_m *Password) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Username = value.String } + case password.FieldPreferredUsername: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field preferred_username", values[i]) + } else if value.Valid { + _m.PreferredUsername = value.String + } case password.FieldUserID: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field user_id", values[i]) } else if value.Valid { _m.UserID = value.String } + case password.FieldGroups: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field groups", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.Groups); err != nil { + return fmt.Errorf("unmarshal field groups: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -128,8 +147,14 @@ func (_m *Password) String() string { builder.WriteString("username=") builder.WriteString(_m.Username) builder.WriteString(", ") + builder.WriteString("preferred_username=") + builder.WriteString(_m.PreferredUsername) + builder.WriteString(", ") builder.WriteString("user_id=") builder.WriteString(_m.UserID) + builder.WriteString(", ") + builder.WriteString("groups=") + builder.WriteString(fmt.Sprintf("%v", _m.Groups)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/password/password.go b/storage/ent/db/password/password.go index 37ab1e49..4502db30 100644 --- a/storage/ent/db/password/password.go +++ b/storage/ent/db/password/password.go @@ -17,8 +17,12 @@ const ( FieldHash = "hash" // FieldUsername holds the string denoting the username field in the database. FieldUsername = "username" + // FieldPreferredUsername holds the string denoting the preferred_username field in the database. + FieldPreferredUsername = "preferred_username" // FieldUserID holds the string denoting the user_id field in the database. FieldUserID = "user_id" + // FieldGroups holds the string denoting the groups field in the database. + FieldGroups = "groups" // Table holds the table name of the password in the database. Table = "passwords" ) @@ -29,7 +33,9 @@ var Columns = []string{ FieldEmail, FieldHash, FieldUsername, + FieldPreferredUsername, FieldUserID, + FieldGroups, } // ValidColumn reports if the column name is valid (part of the table columns). @@ -47,6 +53,8 @@ var ( EmailValidator func(string) error // UsernameValidator is a validator for the "username" field. It is called by the builders before save. UsernameValidator func(string) error + // DefaultPreferredUsername holds the default value on creation for the "preferred_username" field. + DefaultPreferredUsername string // UserIDValidator is a validator for the "user_id" field. It is called by the builders before save. UserIDValidator func(string) error ) @@ -69,6 +77,11 @@ func ByUsername(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUsername, opts...).ToFunc() } +// ByPreferredUsername orders the results by the preferred_username field. +func ByPreferredUsername(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPreferredUsername, opts...).ToFunc() +} + // ByUserID orders the results by the user_id field. func ByUserID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUserID, opts...).ToFunc() diff --git a/storage/ent/db/password/where.go b/storage/ent/db/password/where.go index 105a8d4f..f710e248 100644 --- a/storage/ent/db/password/where.go +++ b/storage/ent/db/password/where.go @@ -67,6 +67,11 @@ func Username(v string) predicate.Password { return predicate.Password(sql.FieldEQ(FieldUsername, v)) } +// PreferredUsername applies equality check predicate on the "preferred_username" field. It's identical to PreferredUsernameEQ. +func PreferredUsername(v string) predicate.Password { + return predicate.Password(sql.FieldEQ(FieldPreferredUsername, v)) +} + // UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. func UserID(v string) predicate.Password { return predicate.Password(sql.FieldEQ(FieldUserID, v)) @@ -242,6 +247,71 @@ func UsernameContainsFold(v string) predicate.Password { return predicate.Password(sql.FieldContainsFold(FieldUsername, v)) } +// PreferredUsernameEQ applies the EQ predicate on the "preferred_username" field. +func PreferredUsernameEQ(v string) predicate.Password { + return predicate.Password(sql.FieldEQ(FieldPreferredUsername, v)) +} + +// PreferredUsernameNEQ applies the NEQ predicate on the "preferred_username" field. +func PreferredUsernameNEQ(v string) predicate.Password { + return predicate.Password(sql.FieldNEQ(FieldPreferredUsername, v)) +} + +// PreferredUsernameIn applies the In predicate on the "preferred_username" field. +func PreferredUsernameIn(vs ...string) predicate.Password { + return predicate.Password(sql.FieldIn(FieldPreferredUsername, vs...)) +} + +// PreferredUsernameNotIn applies the NotIn predicate on the "preferred_username" field. +func PreferredUsernameNotIn(vs ...string) predicate.Password { + return predicate.Password(sql.FieldNotIn(FieldPreferredUsername, vs...)) +} + +// PreferredUsernameGT applies the GT predicate on the "preferred_username" field. +func PreferredUsernameGT(v string) predicate.Password { + return predicate.Password(sql.FieldGT(FieldPreferredUsername, v)) +} + +// PreferredUsernameGTE applies the GTE predicate on the "preferred_username" field. +func PreferredUsernameGTE(v string) predicate.Password { + return predicate.Password(sql.FieldGTE(FieldPreferredUsername, v)) +} + +// PreferredUsernameLT applies the LT predicate on the "preferred_username" field. +func PreferredUsernameLT(v string) predicate.Password { + return predicate.Password(sql.FieldLT(FieldPreferredUsername, v)) +} + +// PreferredUsernameLTE applies the LTE predicate on the "preferred_username" field. +func PreferredUsernameLTE(v string) predicate.Password { + return predicate.Password(sql.FieldLTE(FieldPreferredUsername, v)) +} + +// PreferredUsernameContains applies the Contains predicate on the "preferred_username" field. +func PreferredUsernameContains(v string) predicate.Password { + return predicate.Password(sql.FieldContains(FieldPreferredUsername, v)) +} + +// PreferredUsernameHasPrefix applies the HasPrefix predicate on the "preferred_username" field. +func PreferredUsernameHasPrefix(v string) predicate.Password { + return predicate.Password(sql.FieldHasPrefix(FieldPreferredUsername, v)) +} + +// PreferredUsernameHasSuffix applies the HasSuffix predicate on the "preferred_username" field. +func PreferredUsernameHasSuffix(v string) predicate.Password { + return predicate.Password(sql.FieldHasSuffix(FieldPreferredUsername, v)) +} + +// PreferredUsernameEqualFold applies the EqualFold predicate on the "preferred_username" field. +func PreferredUsernameEqualFold(v string) predicate.Password { + return predicate.Password(sql.FieldEqualFold(FieldPreferredUsername, v)) +} + +// PreferredUsernameContainsFold applies the ContainsFold predicate on the "preferred_username" field. +func PreferredUsernameContainsFold(v string) predicate.Password { + return predicate.Password(sql.FieldContainsFold(FieldPreferredUsername, v)) +} + // UserIDEQ applies the EQ predicate on the "user_id" field. func UserIDEQ(v string) predicate.Password { return predicate.Password(sql.FieldEQ(FieldUserID, v)) @@ -307,6 +377,16 @@ func UserIDContainsFold(v string) predicate.Password { return predicate.Password(sql.FieldContainsFold(FieldUserID, v)) } +// GroupsIsNil applies the IsNil predicate on the "groups" field. +func GroupsIsNil() predicate.Password { + return predicate.Password(sql.FieldIsNull(FieldGroups)) +} + +// GroupsNotNil applies the NotNil predicate on the "groups" field. +func GroupsNotNil() predicate.Password { + return predicate.Password(sql.FieldNotNull(FieldGroups)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.Password) predicate.Password { return predicate.Password(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/password_create.go b/storage/ent/db/password_create.go index 4d68de8f..b5cbf325 100644 --- a/storage/ent/db/password_create.go +++ b/storage/ent/db/password_create.go @@ -37,12 +37,32 @@ func (_c *PasswordCreate) SetUsername(v string) *PasswordCreate { return _c } +// SetPreferredUsername sets the "preferred_username" field. +func (_c *PasswordCreate) SetPreferredUsername(v string) *PasswordCreate { + _c.mutation.SetPreferredUsername(v) + return _c +} + +// SetNillablePreferredUsername sets the "preferred_username" field if the given value is not nil. +func (_c *PasswordCreate) SetNillablePreferredUsername(v *string) *PasswordCreate { + if v != nil { + _c.SetPreferredUsername(*v) + } + return _c +} + // SetUserID sets the "user_id" field. func (_c *PasswordCreate) SetUserID(v string) *PasswordCreate { _c.mutation.SetUserID(v) return _c } +// SetGroups sets the "groups" field. +func (_c *PasswordCreate) SetGroups(v []string) *PasswordCreate { + _c.mutation.SetGroups(v) + return _c +} + // Mutation returns the PasswordMutation object of the builder. func (_c *PasswordCreate) Mutation() *PasswordMutation { return _c.mutation @@ -50,6 +70,7 @@ func (_c *PasswordCreate) Mutation() *PasswordMutation { // Save creates the Password in the database. func (_c *PasswordCreate) Save(ctx context.Context) (*Password, error) { + _c.defaults() return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) } @@ -75,6 +96,14 @@ func (_c *PasswordCreate) ExecX(ctx context.Context) { } } +// defaults sets the default values of the builder before save. +func (_c *PasswordCreate) defaults() { + if _, ok := _c.mutation.PreferredUsername(); !ok { + v := password.DefaultPreferredUsername + _c.mutation.SetPreferredUsername(v) + } +} + // check runs all checks and user-defined validators on the builder. func (_c *PasswordCreate) check() error { if _, ok := _c.mutation.Email(); !ok { @@ -96,6 +125,9 @@ func (_c *PasswordCreate) check() error { return &ValidationError{Name: "username", err: fmt.Errorf(`db: validator failed for field "Password.username": %w`, err)} } } + if _, ok := _c.mutation.PreferredUsername(); !ok { + return &ValidationError{Name: "preferred_username", err: errors.New(`db: missing required field "Password.preferred_username"`)} + } if _, ok := _c.mutation.UserID(); !ok { return &ValidationError{Name: "user_id", err: errors.New(`db: missing required field "Password.user_id"`)} } @@ -142,10 +174,18 @@ func (_c *PasswordCreate) createSpec() (*Password, *sqlgraph.CreateSpec) { _spec.SetField(password.FieldUsername, field.TypeString, value) _node.Username = value } + if value, ok := _c.mutation.PreferredUsername(); ok { + _spec.SetField(password.FieldPreferredUsername, field.TypeString, value) + _node.PreferredUsername = value + } if value, ok := _c.mutation.UserID(); ok { _spec.SetField(password.FieldUserID, field.TypeString, value) _node.UserID = value } + if value, ok := _c.mutation.Groups(); ok { + _spec.SetField(password.FieldGroups, field.TypeJSON, value) + _node.Groups = value + } return _node, _spec } @@ -167,6 +207,7 @@ func (_c *PasswordCreateBulk) Save(ctx context.Context) ([]*Password, error) { for i := range _c.builders { func(i int, root context.Context) { builder := _c.builders[i] + builder.defaults() var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { mutation, ok := m.(*PasswordMutation) if !ok { diff --git a/storage/ent/db/password_update.go b/storage/ent/db/password_update.go index 75394a78..3c5f7f05 100644 --- a/storage/ent/db/password_update.go +++ b/storage/ent/db/password_update.go @@ -9,6 +9,7 @@ import ( "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/dialect/sql/sqljson" "entgo.io/ent/schema/field" "github.com/dexidp/dex/storage/ent/db/password" "github.com/dexidp/dex/storage/ent/db/predicate" @@ -61,6 +62,20 @@ func (_u *PasswordUpdate) SetNillableUsername(v *string) *PasswordUpdate { return _u } +// SetPreferredUsername sets the "preferred_username" field. +func (_u *PasswordUpdate) SetPreferredUsername(v string) *PasswordUpdate { + _u.mutation.SetPreferredUsername(v) + return _u +} + +// SetNillablePreferredUsername sets the "preferred_username" field if the given value is not nil. +func (_u *PasswordUpdate) SetNillablePreferredUsername(v *string) *PasswordUpdate { + if v != nil { + _u.SetPreferredUsername(*v) + } + return _u +} + // SetUserID sets the "user_id" field. func (_u *PasswordUpdate) SetUserID(v string) *PasswordUpdate { _u.mutation.SetUserID(v) @@ -75,6 +90,24 @@ func (_u *PasswordUpdate) SetNillableUserID(v *string) *PasswordUpdate { return _u } +// SetGroups sets the "groups" field. +func (_u *PasswordUpdate) SetGroups(v []string) *PasswordUpdate { + _u.mutation.SetGroups(v) + return _u +} + +// AppendGroups appends value to the "groups" field. +func (_u *PasswordUpdate) AppendGroups(v []string) *PasswordUpdate { + _u.mutation.AppendGroups(v) + return _u +} + +// ClearGroups clears the value of the "groups" field. +func (_u *PasswordUpdate) ClearGroups() *PasswordUpdate { + _u.mutation.ClearGroups() + return _u +} + // Mutation returns the PasswordMutation object of the builder. func (_u *PasswordUpdate) Mutation() *PasswordMutation { return _u.mutation @@ -148,9 +181,23 @@ func (_u *PasswordUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.Username(); ok { _spec.SetField(password.FieldUsername, field.TypeString, value) } + if value, ok := _u.mutation.PreferredUsername(); ok { + _spec.SetField(password.FieldPreferredUsername, field.TypeString, value) + } if value, ok := _u.mutation.UserID(); ok { _spec.SetField(password.FieldUserID, field.TypeString, value) } + if value, ok := _u.mutation.Groups(); ok { + _spec.SetField(password.FieldGroups, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedGroups(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, password.FieldGroups, value) + }) + } + if _u.mutation.GroupsCleared() { + _spec.ClearField(password.FieldGroups, field.TypeJSON) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{password.Label} @@ -205,6 +252,20 @@ func (_u *PasswordUpdateOne) SetNillableUsername(v *string) *PasswordUpdateOne { return _u } +// SetPreferredUsername sets the "preferred_username" field. +func (_u *PasswordUpdateOne) SetPreferredUsername(v string) *PasswordUpdateOne { + _u.mutation.SetPreferredUsername(v) + return _u +} + +// SetNillablePreferredUsername sets the "preferred_username" field if the given value is not nil. +func (_u *PasswordUpdateOne) SetNillablePreferredUsername(v *string) *PasswordUpdateOne { + if v != nil { + _u.SetPreferredUsername(*v) + } + return _u +} + // SetUserID sets the "user_id" field. func (_u *PasswordUpdateOne) SetUserID(v string) *PasswordUpdateOne { _u.mutation.SetUserID(v) @@ -219,6 +280,24 @@ func (_u *PasswordUpdateOne) SetNillableUserID(v *string) *PasswordUpdateOne { return _u } +// SetGroups sets the "groups" field. +func (_u *PasswordUpdateOne) SetGroups(v []string) *PasswordUpdateOne { + _u.mutation.SetGroups(v) + return _u +} + +// AppendGroups appends value to the "groups" field. +func (_u *PasswordUpdateOne) AppendGroups(v []string) *PasswordUpdateOne { + _u.mutation.AppendGroups(v) + return _u +} + +// ClearGroups clears the value of the "groups" field. +func (_u *PasswordUpdateOne) ClearGroups() *PasswordUpdateOne { + _u.mutation.ClearGroups() + return _u +} + // Mutation returns the PasswordMutation object of the builder. func (_u *PasswordUpdateOne) Mutation() *PasswordMutation { return _u.mutation @@ -322,9 +401,23 @@ func (_u *PasswordUpdateOne) sqlSave(ctx context.Context) (_node *Password, err if value, ok := _u.mutation.Username(); ok { _spec.SetField(password.FieldUsername, field.TypeString, value) } + if value, ok := _u.mutation.PreferredUsername(); ok { + _spec.SetField(password.FieldPreferredUsername, field.TypeString, value) + } if value, ok := _u.mutation.UserID(); ok { _spec.SetField(password.FieldUserID, field.TypeString, value) } + if value, ok := _u.mutation.Groups(); ok { + _spec.SetField(password.FieldGroups, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedGroups(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, password.FieldGroups, value) + }) + } + if _u.mutation.GroupsCleared() { + _spec.ClearField(password.FieldGroups, field.TypeJSON) + } _node = &Password{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/db/runtime.go b/storage/ent/db/runtime.go index 797c9761..055a7217 100644 --- a/storage/ent/db/runtime.go +++ b/storage/ent/db/runtime.go @@ -212,8 +212,12 @@ func init() { passwordDescUsername := passwordFields[2].Descriptor() // password.UsernameValidator is a validator for the "username" field. It is called by the builders before save. password.UsernameValidator = passwordDescUsername.Validators[0].(func(string) error) + // passwordDescPreferredUsername is the schema descriptor for preferred_username field. + passwordDescPreferredUsername := passwordFields[3].Descriptor() + // password.DefaultPreferredUsername holds the default value on creation for the preferred_username field. + password.DefaultPreferredUsername = passwordDescPreferredUsername.Default.(string) // passwordDescUserID is the schema descriptor for user_id field. - passwordDescUserID := passwordFields[3].Descriptor() + passwordDescUserID := passwordFields[4].Descriptor() // password.UserIDValidator is a validator for the "user_id" field. It is called by the builders before save. password.UserIDValidator = passwordDescUserID.Validators[0].(func(string) error) refreshtokenFields := schema.RefreshToken{}.Fields() diff --git a/storage/ent/schema/password.go b/storage/ent/schema/password.go index cbc72fc5..af7513c1 100644 --- a/storage/ent/schema/password.go +++ b/storage/ent/schema/password.go @@ -32,9 +32,14 @@ func (Password) Fields() []ent.Field { field.Text("username"). SchemaType(textSchema). NotEmpty(), + field.Text("preferred_username"). + SchemaType(textSchema). + Default(""), field.Text("user_id"). SchemaType(textSchema). NotEmpty(), + field.JSON("groups", []string{}). + Optional(), } } diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go index eae5b7a6..a53e2554 100644 --- a/storage/kubernetes/storage.go +++ b/storage/kubernetes/storage.go @@ -383,13 +383,7 @@ func (cli *client) ListPasswords(ctx context.Context) (passwords []storage.Passw } for _, password := range passwordList.Passwords { - p := storage.Password{ - Email: password.Email, - Hash: password.Hash, - Username: password.Username, - UserID: password.UserID, - } - passwords = append(passwords, p) + passwords = append(passwords, toStoragePassword(password)) } return diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index c126ddc0..a9806add 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -431,9 +431,11 @@ type Password struct { // This field is IMMUTABLE. Do not change. Email string `json:"email,omitempty"` - Hash []byte `json:"hash,omitempty"` - Username string `json:"username,omitempty"` - UserID string `json:"userID,omitempty"` + Hash []byte `json:"hash,omitempty"` + Username string `json:"username,omitempty"` + PreferredUsername string `json:"preferredUsername,omitempty"` + UserID string `json:"userID,omitempty"` + Groups []string `json:"groups,omitempty"` } // PasswordList is a list of Passwords. @@ -454,19 +456,23 @@ func (cli *client) fromStoragePassword(p storage.Password) Password { Name: cli.idToName(email), Namespace: cli.namespace, }, - Email: email, - Hash: p.Hash, - Username: p.Username, - UserID: p.UserID, + Email: email, + Hash: p.Hash, + Username: p.Username, + PreferredUsername: p.PreferredUsername, + UserID: p.UserID, + Groups: p.Groups, } } func toStoragePassword(p Password) storage.Password { return storage.Password{ - Email: p.Email, - Hash: p.Hash, - Username: p.Username, - UserID: p.UserID, + Email: p.Email, + Hash: p.Hash, + Username: p.Username, + PreferredUsername: p.PreferredUsername, + UserID: p.UserID, + Groups: p.Groups, } } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index a9ca3816..b7b32650 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -598,13 +598,13 @@ func (c *conn) CreatePassword(ctx context.Context, p storage.Password) error { p.Email = strings.ToLower(p.Email) _, err := c.Exec(` insert into password ( - email, hash, username, user_id + email, hash, username, preferred_username, user_id, groups ) values ( - $1, $2, $3, $4 + $1, $2, $3, $4, $5, $6 ); `, - p.Email, p.Hash, p.Username, p.UserID, + p.Email, p.Hash, p.Username, p.PreferredUsername, p.UserID, encoder(p.Groups), ) if err != nil { if c.alreadyExistsCheck(err) { @@ -629,10 +629,10 @@ func (c *conn) UpdatePassword(ctx context.Context, email string, updater func(p _, err = tx.Exec(` update password set - hash = $1, username = $2, user_id = $3 - where email = $4; + hash = $1, username = $2, preferred_username = $3, user_id = $4, groups = $5 + where email = $6; `, - np.Hash, np.Username, np.UserID, p.Email, + np.Hash, np.Username, np.PreferredUsername, np.UserID, encoder(np.Groups), p.Email, ) if err != nil { return fmt.Errorf("update password: %v", err) @@ -648,7 +648,7 @@ func (c *conn) GetPassword(ctx context.Context, email string) (storage.Password, func getPassword(ctx context.Context, q querier, email string) (p storage.Password, err error) { return scanPassword(q.QueryRow(` select - email, hash, username, user_id + email, hash, username, preferred_username, user_id, groups from password where email = $1; `, strings.ToLower(email))) } @@ -656,7 +656,7 @@ func getPassword(ctx context.Context, q querier, email string) (p storage.Passwo func (c *conn) ListPasswords(ctx context.Context) ([]storage.Password, error) { rows, err := c.Query(` select - email, hash, username, user_id + email, hash, username, preferred_username, user_id, groups from password; `) if err != nil { @@ -680,7 +680,7 @@ func (c *conn) ListPasswords(ctx context.Context) ([]storage.Password, error) { func scanPassword(s scanner) (p storage.Password, err error) { err = s.Scan( - &p.Email, &p.Hash, &p.Username, &p.UserID, + &p.Email, &p.Hash, &p.Username, &p.PreferredUsername, &p.UserID, decoder(&p.Groups), ) if err != nil { if err == sql.ErrNoRows { diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 83e9c20d..dc8eee05 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -298,4 +298,44 @@ var migrations = []migration{ add column hmac_key bytea;`, }, }, + { + stmts: []string{ + ` + alter table password + add column preferred_username text not null default '';`, + ` + alter table password + add column groups bytea not null default convert_to('[]', 'UTF8');`, + }, + flavor: &flavorPostgres, + }, + { + stmts: []string{ + ` + alter table password + add column preferred_username text not null default '';`, + ` + alter table password + add column groups bytea not null default '[]';`, + }, + flavor: &flavorSQLite3, + }, + { + stmts: []string{ + ` + alter table password + add column preferred_username text not null default '';`, + ` + alter table password + add column groups bytea;`, + ` + update password + set groups = '[]' + where groups is null;`, + ` + alter table password + modify column groups bytea not null;`, + }, + flavor: &flavorMySQL, + }, } diff --git a/storage/storage.go b/storage/storage.go index 574b0a5a..79a2fca3 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -352,8 +352,14 @@ type Password struct { // Optional username to display. NOT used during login. Username string `json:"username"` + // Optional preferred username for OIDC "preferred_username" claim. + PreferredUsername string `json:"preferredUsername"` + // Randomly generated user ID. This is NOT the primary ID of the Password object. UserID string `json:"userID"` + + // Groups assigned to the user + Groups []string `json:"groups"` } // Connector is an object that contains the metadata about connectors used to login to Dex. From debcb5c8f9112fd524995013ed04770009e6d245 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 23 Dec 2025 11:42:47 +0300 Subject: [PATCH 016/222] fix: hide internal server error details from users Signed-off-by: Ivan Zvyagintsev --- server/deviceflowhandlers.go | 13 +- server/errors.go | 26 +++ server/errors_test.go | 300 +++++++++++++++++++++++++++++++++ server/handlers.go | 15 +- server/introspectionhandler.go | 4 +- server/oauth2.go | 6 +- 6 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 server/errors.go create mode 100644 server/errors_test.go diff --git a/server/deviceflowhandlers.go b/server/deviceflowhandlers.go index 3cbfbf16..ec5fb52b 100644 --- a/server/deviceflowhandlers.go +++ b/server/deviceflowhandlers.go @@ -11,8 +11,6 @@ import ( "strings" "time" - "golang.org/x/net/html" - "github.com/dexidp/dex/storage" ) @@ -300,9 +298,11 @@ func (s *Server) handleDeviceCallback(w http.ResponseWriter, r *http.Request) { // 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) + // Log the error details but don't expose them to the user + s.logger.ErrorContext(r.Context(), "OAuth2 authorization error", + "error", errMsg, + "error_description", r.FormValue("error_description")) + s.renderError(r, w, http.StatusBadRequest, "Authorization failed. Please try again.") return } @@ -392,7 +392,8 @@ func (s *Server) handleDeviceCallback(w http.ResponseWriter, r *http.Request) { } default: - http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest) + s.logger.ErrorContext(r.Context(), "unsupported method in device callback", "method", r.Method) + s.renderError(r, w, http.StatusBadRequest, ErrMsgMethodNotAllowed) return } } diff --git a/server/errors.go b/server/errors.go new file mode 100644 index 00000000..c0b9d425 --- /dev/null +++ b/server/errors.go @@ -0,0 +1,26 @@ +package server + +// 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." +) diff --git a/server/errors_test.go b/server/errors_test.go new file mode 100644 index 00000000..17d2b1f5 --- /dev/null +++ b/server/errors_test.go @@ -0,0 +1,300 @@ +package server + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestErrorMessagesDoNotLeakInternalDetails verifies that error responses +// do not contain internal error details that could be exploited by attackers. +func TestErrorMessagesDoNotLeakInternalDetails(t *testing.T) { + // List of sensitive patterns that should never appear in user-facing errors + sensitivePatterns := []string{ + "panic", + "runtime error", + "nil pointer", + "stack trace", + "goroutine", + ".go:", // file paths like "server.go:123" + "sql:", // SQL errors + "connection", // Connection errors + "timeout", // Unless it's a user-friendly timeout message + "ECONNREFUSED", + "EOF", + "broken pipe", + } + + tests := []struct { + name string + path string + method string + body string + contentType string + setupFunc func(t *testing.T, s *Server) + checkFunc func(t *testing.T, resp *http.Response, body string) + }{ + { + name: "Invalid authorization request parse error", + path: "/auth", + method: "POST", + body: "invalid%body", + contentType: "application/x-www-form-urlencoded", + checkFunc: func(t *testing.T, resp *http.Response, body string) { + // Should return a safe error message, not the parse error details + for _, pattern := range sensitivePatterns { + require.NotContains(t, body, pattern, + "Response should not contain sensitive pattern: %s", pattern) + } + }, + }, + { + name: "Invalid callback state", + path: "/callback?state=invalid_state", + method: "GET", + checkFunc: func(t *testing.T, resp *http.Response, body string) { + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + // Should not leak storage error details + require.NotContains(t, body, "storage") + require.NotContains(t, body, "not found") + }, + }, + { + name: "Invalid token request", + path: "/token", + method: "POST", + body: "grant_type=authorization_code&code=invalid", + contentType: "application/x-www-form-urlencoded", + checkFunc: func(t *testing.T, resp *http.Response, body string) { + // Token endpoint returns JSON errors which is correct OAuth2 behavior + // Just verify no internal details leak + for _, pattern := range sensitivePatterns { + require.NotContains(t, body, pattern, + "Response should not contain sensitive pattern: %s", pattern) + } + }, + }, + { + name: "Invalid introspection request - no token", + path: "/token/introspect", + method: "POST", + body: "", + contentType: "application/x-www-form-urlencoded", + checkFunc: func(t *testing.T, resp *http.Response, body string) { + for _, pattern := range sensitivePatterns { + require.NotContains(t, body, pattern, + "Response should not contain sensitive pattern: %s", pattern) + } + }, + }, + { + name: "Device flow invalid user code", + path: "/device/auth/verify_code", + method: "POST", + body: "user_code=INVALID", + checkFunc: func(t *testing.T, resp *http.Response, body string) { + for _, pattern := range sensitivePatterns { + require.NotContains(t, body, pattern, + "Response should not contain sensitive pattern: %s", pattern) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + httpServer, s := newTestServer(t, nil) + defer httpServer.Close() + + if tc.setupFunc != nil { + tc.setupFunc(t, s) + } + + var reqBody io.Reader + if tc.body != "" { + reqBody = strings.NewReader(tc.body) + } + + req := httptest.NewRequest(tc.method, tc.path, reqBody) + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + resp := rr.Result() + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + body := string(bodyBytes) + + if tc.checkFunc != nil { + tc.checkFunc(t, resp, body) + } + }) + } +} + +// TestLoginErrorMessageIsSafe verifies that the login error page +// shows a safe, user-friendly message. +func TestLoginErrorMessageIsSafe(t *testing.T) { + httpServer, s := newTestServer(t, nil) + defer httpServer.Close() + + // Create a request that will trigger a login error + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/auth/nonexistent/login?state=test", nil) + s.ServeHTTP(rr, req) + + resp := rr.Result() + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + // Should not contain error stack traces or internal details + require.NotContains(t, bodyStr, "panic") + require.NotContains(t, bodyStr, ".go:") + require.NotContains(t, bodyStr, "goroutine") +} + +// TestCallbackErrorMessageIsSafe verifies that callback errors +// do not leak internal details. +func TestCallbackErrorMessageIsSafe(t *testing.T) { + httpServer, s := newTestServer(t, nil) + defer httpServer.Close() + + // Test OAuth2 callback with invalid state + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/callback?code=test&state=invalid", nil) + s.ServeHTTP(rr, req) + + resp := rr.Result() + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + // Should not contain storage error details + require.NotContains(t, bodyStr, "storage.ErrNotFound") + require.NotContains(t, bodyStr, "database") +} + +// TestDeviceCallbackMethodError verifies that unsupported methods +// return safe error messages. +func TestDeviceCallbackMethodError(t *testing.T) { + httpServer, s := newTestServer(t, nil) + defer httpServer.Close() + + // Test with unsupported method + rr := httptest.NewRecorder() + req := httptest.NewRequest("PUT", "/device/callback", nil) + s.ServeHTTP(rr, req) + + resp := rr.Result() + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + // Should not expose the method name in error + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.NotContains(t, bodyStr, "PUT") + require.NotContains(t, bodyStr, "method not implemented") +} + +// 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) { + httpServer, s := newTestServer(t, nil) + defer httpServer.Close() + + 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) + } + }) + } +} + +// TestTokenErrorDoesNotLeakDetails tests that token errors don't leak internal details +func TestTokenErrorDoesNotLeakDetails(t *testing.T) { + httpServer, s := newTestServer(t, nil) + defer httpServer.Close() + + // Create a token request with invalid credentials + body := bytes.NewBufferString("grant_type=authorization_code&code=invalid_code") + req := httptest.NewRequest("POST", "/token", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("invalid_client", "invalid_secret") + + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + resp := rr.Result() + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + bodyStr := string(respBody) + + // Should not contain internal error details + require.NotContains(t, bodyStr, "storage") + require.NotContains(t, bodyStr, "not found") + require.NotContains(t, bodyStr, ".go:") +} diff --git a/server/handlers.go b/server/handlers.go index f8d0ed64..e46c7b8f 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -141,7 +141,7 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.logger.ErrorContext(r.Context(), "failed to parse arguments", "err", err) - s.renderError(r, w, http.StatusBadRequest, err.Error()) + s.renderError(r, w, http.StatusBadRequest, ErrMsgInvalidRequest) return } @@ -374,7 +374,7 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { identity, ok, err := pwConn.Login(r.Context(), scopes, username, password) if err != nil { s.logger.ErrorContext(r.Context(), "failed to login user", "err", err) - s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Login error: %v", err)) + s.renderError(r, w, http.StatusInternalServerError, ErrMsgLoginError) return } if !ok { @@ -480,7 +480,7 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) if err != nil { s.logger.ErrorContext(r.Context(), "failed to authenticate", "err", err) - s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Failed to authenticate: %v", err)) + s.renderError(r, w, http.StatusInternalServerError, ErrMsgAuthenticationFailed) return } @@ -1102,13 +1102,15 @@ func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) { verifier := oidc.NewVerifier(s.issuerURL.String(), &storageKeySet{s.storage}, &oidc.Config{SkipClientIDCheck: true}) idToken, err := verifier.Verify(ctx, rawIDToken) if err != nil { - s.tokenErrHelper(w, errAccessDenied, err.Error(), http.StatusForbidden) + s.logger.ErrorContext(r.Context(), "failed to verify ID token", "err", err) + s.tokenErrHelper(w, errAccessDenied, "Invalid bearer token.", http.StatusForbidden) return } var claims json.RawMessage if err := idToken.Claims(&claims); err != nil { - s.tokenErrHelper(w, errServerError, err.Error(), http.StatusInternalServerError) + s.logger.ErrorContext(r.Context(), "failed to decode ID token claims", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) return } @@ -1149,7 +1151,8 @@ func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, cli isTrusted, err := s.validateCrossClientTrust(ctx, client.ID, peerID) if err != nil { - s.tokenErrHelper(w, errInvalidClient, fmt.Sprintf("Error validating cross client trust %v.", err), http.StatusBadRequest) + s.logger.ErrorContext(r.Context(), "error validating cross client trust", "client_id", client.ID, "peer_id", peerID, "err", err) + s.tokenErrHelper(w, errInvalidClient, "Error validating cross client trust.", http.StatusBadRequest) return } if !isTrusted { diff --git a/server/introspectionhandler.go b/server/introspectionhandler.go index 42ad1b3c..4b0073db 100644 --- a/server/introspectionhandler.go +++ b/server/introspectionhandler.go @@ -318,7 +318,9 @@ func (s *Server) handleIntrospect(w http.ResponseWriter, r *http.Request) { rawJSON, jsonErr := json.Marshal(introspect) if jsonErr != nil { - s.introspectErrHelper(w, errServerError, jsonErr.Error(), 500) + s.logger.ErrorContext(r.Context(), "failed to marshal introspection response", "err", jsonErr) + s.introspectErrHelper(w, errServerError, "", http.StatusInternalServerError) + return } w.Header().Set("Content-Type", "application/json") diff --git a/server/oauth2.go b/server/oauth2.go index bd0b0530..7268bcfd 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -481,14 +481,16 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques client, err := s.storage.GetClient(ctx, clientID) if err != nil { if err == storage.ErrNotFound { - return nil, newDisplayedErr(http.StatusNotFound, "Invalid client_id (%q).", clientID) + s.logger.ErrorContext(r.Context(), "invalid client_id provided", "client_id", clientID) + return nil, newDisplayedErr(http.StatusNotFound, "Invalid client_id.") } s.logger.ErrorContext(r.Context(), "failed to get client", "err", err) return nil, newDisplayedErr(http.StatusInternalServerError, "Database error.") } if !validateRedirectURI(client, redirectURI) { - return nil, newDisplayedErr(http.StatusBadRequest, "Unregistered redirect_uri (%q).", redirectURI) + s.logger.ErrorContext(r.Context(), "unregistered redirect_uri", "redirect_uri", redirectURI, "client_id", clientID) + return nil, newDisplayedErr(http.StatusBadRequest, "Unregistered redirect_uri.") } if redirectURI == deviceCallbackURI && client.Public { redirectURI = s.absPath(deviceCallbackURI) From b0a6ee904567c0931ae53e9d7977eeede3e4698c Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 13 Jan 2026 13:21:35 +0300 Subject: [PATCH 017/222] fix: hide internal server error details from users Signed-off-by: Ivan Zvyagintsev --- server/deviceflowhandlers_test.go | 33 +++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/server/deviceflowhandlers_test.go b/server/deviceflowhandlers_test.go index 3f3ea81e..ec7bf29d 100644 --- a/server/deviceflowhandlers_test.go +++ b/server/deviceflowhandlers_test.go @@ -222,8 +222,9 @@ func TestDeviceCallback(t *testing.T) { code: "somecode", error: "Error Condition", }, - expectedResponseCode: http.StatusBadRequest, - expectedServerResponse: "Error Condition: \n", + expectedResponseCode: http.StatusBadRequest, + // Note: Error details should NOT be displayed to user anymore. + // Instead, a safe generic message is shown. }, { testName: "Expired Auth Code", @@ -352,8 +353,9 @@ func TestDeviceCallback(t *testing.T) { code: "somecode", error: "", }, - expectedResponseCode: http.StatusBadRequest, - expectedServerResponse: "<script>console.log(window);</script>: \n", + expectedResponseCode: http.StatusBadRequest, + // Note: XSS data should NOT be displayed to user anymore. + // Instead, a safe generic message is shown. }, } for _, tc := range tests { @@ -413,6 +415,29 @@ func TestDeviceCallback(t *testing.T) { t.Errorf("%s: Unexpected Response. Expected %q got %q", tc.testName, tc.expectedServerResponse, result) } } + + // Special check for error message safety tests + if tc.testName == "Prevent cross-site scripting" || tc.testName == "Error During Authorization" { + result, _ := io.ReadAll(rr.Body) + responseBody := string(result) + + // Error details should NOT be present in the response (for security) + if tc.testName == "Prevent cross-site scripting" { + if strings.Contains(responseBody, " - - -`)) + pubKey := &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: eInt, + } -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, + pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + return "" + } + + pubKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubKeyBytes, }) + + return string(pubKeyPEM) +} + +func renderToken(w http.ResponseWriter, ctx context.Context, provider *oidc.Provider, redirectURL, idToken, accessToken, refreshToken, claims string) { + data := tokenTmplData{ + IDToken: idToken, + IDTokenJWTLink: generateJWTIOLink(idToken, provider, ctx), + AccessToken: accessToken, + AccessTokenJWTLink: generateJWTIOLink(accessToken, provider, ctx), + RefreshToken: refreshToken, + RedirectURL: redirectURL, + Claims: claims, + PublicKeyPEM: getPublicKeyPEM(provider), + } + renderTemplate(w, tokenTmpl, data) } func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data interface{}) { @@ -371,13 +163,9 @@ func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data interfa 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. + // An error with the underlying write, such as the connection being dropped. Ignore for now. } } diff --git a/examples/example-app/templates/index.html b/examples/example-app/templates/index.html new file mode 100644 index 00000000..494920d6 --- /dev/null +++ b/examples/example-app/templates/index.html @@ -0,0 +1,62 @@ + + + + + + Example App - Login + + + +
+ +
+
+ This is an example application for Dex OpenID Connect provider.
+ Learn more in the documentation. +
+
+ +
+
+ Advanced options +
+ + Select OpenID Connect scopes to request. Standard scopes are pre-selected. +
+ {{range .ScopesSupported}} +
+ + +
+ {{end}} +
+ {{if eq (len .ScopesSupported) 0}} +
No scopes from discovery - add custom scopes below.
+ {{end}} +
+ + +
+
+
+ + Each client is sent as audience:server:client_id scope. +
+
+ + +
+
+
+ + Specify a connector ID to bypass the connector selection screen. + +
+
+
+
+ + + + + diff --git a/examples/example-app/templates/token.html b/examples/example-app/templates/token.html new file mode 100644 index 00000000..f830e16b --- /dev/null +++ b/examples/example-app/templates/token.html @@ -0,0 +1,64 @@ + + + + + + Tokens + + + + {{ if .IDToken }} +
+
+ ID Token: + Decode on jwt.io +
+
{{ .IDToken }}
+
+ {{ end }} + + {{ if .AccessToken }} +
+
+ Access Token: + Decode on jwt.io +
+
{{ .AccessToken }}
+
+ {{ end }} + + {{ if .Claims }} +
+
Claims:
+
{{ .Claims }}
+
+ {{ end }} + + {{ if .RefreshToken }} +
+
Refresh Token:
+
{{ .RefreshToken }}
+
+ + +
+
+ {{ end }} + + {{ if .PublicKeyPEM }} +
+
+ Public Key (for JWT verification): + +
+
{{ .PublicKeyPEM }}
+
Copy this key and paste it into jwt.io's "Verify Signature" section to validate the token signature.
+
+ {{ end }} + + Back to Home + + + + + From 25591eeaf41a5bd8039e5421cca7a794e6c9f112 Mon Sep 17 00:00:00 2001 From: Giovanni Vella Date: Sun, 22 Feb 2026 22:37:10 +0100 Subject: [PATCH 095/222] Add support to PKCE in OIDC connector (#3777) Signed-off-by: johnvan7 Signed-off-by: Giovanni Vella --- connector/bitbucketcloud/bitbucketcloud.go | 8 +- .../bitbucketcloud/bitbucketcloud_test.go | 2 +- connector/connector.go | 4 +- connector/gitea/gitea.go | 8 +- connector/gitea/gitea_test.go | 4 +- connector/github/github.go | 8 +- connector/github/github_test.go | 6 +- connector/gitlab/gitlab.go | 8 +- connector/gitlab/gitlab_test.go | 14 +-- connector/google/google.go | 8 +- connector/google/google_test.go | 2 +- connector/linkedin/linkedin.go | 8 +- connector/microsoft/microsoft.go | 8 +- connector/microsoft/microsoft_test.go | 8 +- connector/mock/connectortest.go | 8 +- connector/oidc/oidc.go | 101 +++++++++++++++++- connector/oidc/oidc_test.go | 42 +++++++- connector/openshift/openshift.go | 7 +- connector/openshift/openshift_test.go | 2 +- server/handlers.go | 16 ++- 20 files changed, 208 insertions(+), 64 deletions(-) diff --git a/connector/bitbucketcloud/bitbucketcloud.go b/connector/bitbucketcloud/bitbucketcloud.go index 5f802e34..d7fb64ca 100644 --- a/connector/bitbucketcloud/bitbucketcloud.go +++ b/connector/bitbucketcloud/bitbucketcloud.go @@ -111,12 +111,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 +131,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")} diff --git a/connector/bitbucketcloud/bitbucketcloud_test.go b/connector/bitbucketcloud/bitbucketcloud_test.go index 9545ff09..67a74dab 100644 --- a/connector/bitbucketcloud/bitbucketcloud_test.go +++ b/connector/bitbucketcloud/bitbucketcloud_test.go @@ -102,7 +102,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 d812390f..b1e069c3 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -63,10 +63,10 @@ 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. diff --git a/connector/gitea/gitea.go b/connector/gitea/gitea.go index 62523185..059c8617 100644 --- a/connector/gitea/gitea.go +++ b/connector/gitea/gitea.go @@ -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 a71d7995..4fe77689 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 18a56628..eb19f778 100644 --- a/connector/github/github.go +++ b/connector/github/github.go @@ -194,12 +194,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 { @@ -214,7 +214,7 @@ func (e *oauth2Error) Error() string { return e.error + ": " + e.errorDescription } -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")} diff --git a/connector/github/github_test.go b/connector/github/github_test.go index a2222cc1..543cfc89 100644 --- a/connector/github/github_test.go +++ b/connector/github/github_test.go @@ -152,7 +152,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") @@ -160,7 +160,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") @@ -193,7 +193,7 @@ 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") diff --git a/connector/gitlab/gitlab.go b/connector/gitlab/gitlab.go index fce6271a..a4ccf738 100644 --- a/connector/gitlab/gitlab.go +++ b/connector/gitlab/gitlab.go @@ -122,11 +122,11 @@ func (c *gitlabConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { } } -func (c *gitlabConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { +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 { @@ -141,7 +141,7 @@ 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) { +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")} diff --git a/connector/gitlab/gitlab_test.go b/connector/gitlab/gitlab_test.go index 2ecede1f..06adc5d3 100644 --- a/connector/gitlab/gitlab_test.go +++ b/connector/gitlab/gitlab_test.go @@ -247,7 +247,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") @@ -255,7 +255,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") @@ -283,7 +283,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") @@ -310,7 +310,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") @@ -337,7 +337,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") @@ -371,7 +371,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") @@ -435,7 +435,7 @@ func TestGroupsWithPermission(t *testing.T) { expectNil(t, err) c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), getGroupsPermission: 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.Groups, []string{ diff --git a/connector/google/google.go b/connector/google/google.go index e17ec5bd..4a8599c0 100644 --- a/connector/google/google.go +++ b/connector/google/google.go @@ -168,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 @@ -186,7 +186,7 @@ func (c *googleConnector) LoginURL(s connector.Scopes, callbackURL, state string 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 { @@ -201,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")} diff --git a/connector/google/google_test.go b/connector/google/google_test.go index 8cc79739..ce0e017c 100644 --- a/connector/google/google_test.go +++ b/connector/google/google_test.go @@ -439,7 +439,7 @@ func TestPromptTypeConfig(t *testing.T) { assert.Nil(t, err) assert.Equal(t, test.expectedPromptTypeValue, conn.promptType) - loginURL, err := conn.LoginURL(connector.Scopes{OfflineAccess: true}, ts.URL+"/callback", "state") + loginURL, _, err := conn.LoginURL(connector.Scopes{OfflineAccess: true}, ts.URL+"/callback", "state") assert.Nil(t, err) urlp, err := url.Parse(loginURL) diff --git a/connector/linkedin/linkedin.go b/connector/linkedin/linkedin.go index f17d17cc..0c24ff47 100644 --- a/connector/linkedin/linkedin.go +++ b/connector/linkedin/linkedin.go @@ -62,17 +62,17 @@ var ( ) // 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/microsoft.go b/connector/microsoft/microsoft.go index 2fcf6a75..1db16942 100644 --- a/connector/microsoft/microsoft.go +++ b/connector/microsoft/microsoft.go @@ -175,9 +175,9 @@ func (c *microsoftConnector) oauth2Config(scopes connector.Scopes) *oauth2.Confi } } -func (c *microsoftConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { +func (c *microsoftConnector) 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) } var options []oauth2.AuthCodeOption @@ -188,10 +188,10 @@ func (c *microsoftConnector) LoginURL(scopes connector.Scopes, callbackURL, stat options = append(options, oauth2.SetAuthURLParam("domain_hint", c.domainHint)) } - return c.oauth2Config(scopes).AuthCodeURL(state, options...), nil + return c.oauth2Config(scopes).AuthCodeURL(state, options...), nil, nil } -func (c *microsoftConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { +func (c *microsoftConnector) 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/microsoft_test.go b/connector/microsoft/microsoft_test.go index 67be660f..1fa2f3dc 100644 --- a/connector/microsoft/microsoft_test.go +++ b/connector/microsoft/microsoft_test.go @@ -39,7 +39,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 +70,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() @@ -91,7 +91,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") @@ -114,7 +114,7 @@ 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"}) } diff --git a/connector/mock/connectortest.go b/connector/mock/connectortest.go index 7e5979a9..be44bfd1 100644 --- a/connector/mock/connectortest.go +++ b/connector/mock/connectortest.go @@ -43,21 +43,21 @@ type Callback struct { } // 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 } diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index 1cf2b62a..879d82d7 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -21,6 +21,20 @@ import ( "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"` @@ -84,6 +98,10 @@ type Config struct { // 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. // This setting allows you to override the default behavior of Dex and enforce the mappings defined in `claimMapping`. @@ -224,6 +242,25 @@ 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 *slog.Logger) (conn connector.Connector, err error) { @@ -282,6 +319,27 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, } } + // Obtain CodeChallengeMethodsSupported from the provider + var metadata struct { + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + } + 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") + } + } + clientID := c.ClientID return &oidcConnector{ provider: provider, @@ -316,6 +374,7 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, groupsFilter: groupsFilter, groupsPrefix: c.ClaimMutations.ModifyGroupNames.Prefix, groupsSuffix: c.ClaimMutations.ModifyGroupNames.Suffix, + pkceChallenge: c.PKCEChallenge, }, nil } @@ -348,6 +407,7 @@ type oidcConnector struct { groupsFilter *regexp.Regexp groupsPrefix string groupsSuffix string + pkceChallenge string } func (c *oidcConnector) Close() error { @@ -355,12 +415,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, " ") @@ -370,7 +431,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 { @@ -393,7 +472,7 @@ const ( 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")} @@ -401,7 +480,19 @@ func (c *oidcConnector) HandleCallback(s connector.Scopes, r *http.Request) (ide ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) - token, err := c.oauth2Config.Exchange(ctx, q.Get("code")) + 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) } diff --git a/connector/oidc/oidc_test.go b/connector/oidc/oidc_test.go index 9a3d7126..71a30b6e 100644 --- a/connector/oidc/oidc_test.go +++ b/connector/oidc/oidc_test.go @@ -67,6 +67,7 @@ func TestHandleCallback(t *testing.T) { newGroupFromClaims []NewGroupFromClaims groupsPrefix string groupsSuffix string + pkceChallenge string }{ { name: "simpleCase", @@ -484,6 +485,40 @@ func TestHandleCallback(t *testing.T) { "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 { @@ -515,6 +550,7 @@ 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 @@ -534,7 +570,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) } diff --git a/connector/openshift/openshift.go b/connector/openshift/openshift.go index 4519a85b..3d4408c5 100644 --- a/connector/openshift/openshift.go +++ b/connector/openshift/openshift.go @@ -138,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 { @@ -160,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() diff --git a/connector/openshift/openshift_test.go b/connector/openshift/openshift_test.go index ef86e593..bdddfc83 100644 --- a/connector/openshift/openshift_test.go +++ b/connector/openshift/openshift_test.go @@ -175,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") diff --git a/server/handlers.go b/server/handlers.go index 1292b8ee..b8e11ce4 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -271,12 +271,24 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { // 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) + callbackURL, connData, err := conn.LoginURL(scopes, s.absURL("/callback"), authReq.ID) if err != nil { s.logger.ErrorContext(r.Context(), "connector returned error when creating callback", "connector_id", connID, "err", err) s.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 := s.storage.UpdateAuthRequest(ctx, authReq.ID, updater) + if err != nil { + s.logger.ErrorContext(r.Context(), "Failed to set connector data on auth request", "connector_id", connID, "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + } http.Redirect(w, r, callbackURL, http.StatusFound) case connector.PasswordConnector: loginURL := url.URL{ @@ -472,7 +484,7 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) s.renderError(r, w, http.StatusBadRequest, "Invalid request") return } - identity, err = conn.HandleCallback(parseScopes(authReq.Scopes), r) + identity, err = conn.HandleCallback(parseScopes(authReq.Scopes), authReq.ConnectorData, r) case connector.SAMLConnector: if r.Method != http.MethodPost { s.logger.ErrorContext(r.Context(), "OAuth2 request mapped to SAML connector") From 83697b06a680ade01bde2a063347dbbc04d0a2ec Mon Sep 17 00:00:00 2001 From: Aljoscha Bollmann Date: Sun, 22 Feb 2026 22:41:35 +0100 Subject: [PATCH 096/222] fix(server): respond with forbidden if failed to authenticate (#4200) Signed-off-by: Aljoscha Bollmann --- connector/connector.go | 13 ++++++++++ connector/microsoft/microsoft.go | 6 ++--- connector/microsoft/microsoft_test.go | 34 +++++++++++++++++++++++++++ server/errors.go | 4 ++++ server/handlers.go | 8 ++++++- 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/connector/connector.go b/connector/connector.go index b1e069c3..1e554789 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 diff --git a/connector/microsoft/microsoft.go b/connector/microsoft/microsoft.go index 1db16942..ca6e025d 100644 --- a/connector/microsoft/microsoft.go +++ b/connector/microsoft/microsoft.go @@ -227,7 +227,7 @@ func (c *microsoftConnector) HandleCallback(s connector.Scopes, connData []byte, if c.groupsRequired(s.Groups) { groups, err := c.getGroups(ctx, client, user.ID) if err != nil { - return identity, fmt.Errorf("microsoft: get groups: %v", err) + return identity, fmt.Errorf("microsoft: get groups: %w", err) } identity.Groups = groups } @@ -318,7 +318,7 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id if c.groupsRequired(s.Groups) { groups, err := c.getGroups(ctx, client, user.ID) if err != nil { - return identity, fmt.Errorf("microsoft: get groups: %v", err) + return identity, fmt.Errorf("microsoft: get groups: %w", err) } identity.Groups = groups } @@ -404,7 +404,7 @@ func (c *microsoftConnector) getGroups(ctx context.Context, client *http.Client, // ensure that the user is in at least one required group filteredGroups := groups_pkg.Filter(userGroups, c.groups) if len(c.groups) > 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 } diff --git a/connector/microsoft/microsoft_test.go b/connector/microsoft/microsoft_test.go index 1fa2f3dc..e7443bf4 100644 --- a/connector/microsoft/microsoft_test.go +++ b/connector/microsoft/microsoft_test.go @@ -2,6 +2,7 @@ package microsoft import ( "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -119,6 +120,39 @@ func TestUserGroupsFromGraphAPI(t *testing.T) { expectEquals(t, identity.Groups, []string{"a", "b"}) } +func TestUserNotInRequiredGroupFromGraphAPI(t *testing.T) { + s := newTestServer(map[string]testResponse{ + "/v1.0/me?$select=id,displayName,userPrincipalName": { + 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}, 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) + } +} + 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/server/errors.go b/server/errors.go index c0b9d425..ec9146e0 100644 --- a/server/errors.go +++ b/server/errors.go @@ -23,4 +23,8 @@ const ( // 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/handlers.go b/server/handlers.go index b8e11ce4..62f1650c 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -7,6 +7,7 @@ import ( "crypto/subtle" "encoding/base64" "encoding/json" + "errors" "fmt" "html/template" "net/http" @@ -499,7 +500,12 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) if err != nil { s.logger.ErrorContext(r.Context(), "failed to authenticate", "err", err) - s.renderError(r, w, http.StatusInternalServerError, ErrMsgAuthenticationFailed) + var groupsErr *connector.UserNotInRequiredGroupsError + if errors.As(err, &groupsErr) { + s.renderError(r, w, http.StatusForbidden, ErrMsgNotInRequiredGroups) + } else { + s.renderError(r, w, http.StatusInternalServerError, ErrMsgAuthenticationFailed) + } return } From cf17fc68c8e627e9a2c316d19af21c6f49683251 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Sun, 22 Feb 2026 22:55:17 +0100 Subject: [PATCH 097/222] test: update HandleCallback after merging OIDC PKCE (#4572) Signed-off-by: maksim.nabokikh --- connector/gitlab/gitlab_test.go | 4 ++-- connector/microsoft/microsoft_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/connector/gitlab/gitlab_test.go b/connector/gitlab/gitlab_test.go index 06adc5d3..28ff2643 100644 --- a/connector/gitlab/gitlab_test.go +++ b/connector/gitlab/gitlab_test.go @@ -127,7 +127,7 @@ func TestHandleCallbackCustomRootCADataEnablesTLSRequests(t *testing.T) { req, err := http.NewRequest("GET", hostURL.String()+"?code=testcode", nil) expectNil(t, err) - identity, err := conn.(connector.CallbackConnector).HandleCallback(connector.Scopes{Groups: false}, req) + identity, err := conn.(connector.CallbackConnector).HandleCallback(connector.Scopes{Groups: false}, nil, req) if err != nil { t.Fatalf("HandleCallback() error: %v", err) } @@ -170,7 +170,7 @@ func TestHandleCallbackWithoutRootCADataFailsTLS(t *testing.T) { req, err := http.NewRequest("GET", hostURL.String()+"?code=testcode", nil) expectNil(t, err) - _, err = conn.(connector.CallbackConnector).HandleCallback(connector.Scopes{Groups: false}, req) + _, err = conn.(connector.CallbackConnector).HandleCallback(connector.Scopes{Groups: false}, nil, req) if err == nil { t.Fatalf("expected TLS error, got nil") } diff --git a/connector/microsoft/microsoft_test.go b/connector/microsoft/microsoft_test.go index e7443bf4..f0dcd96d 100644 --- a/connector/microsoft/microsoft_test.go +++ b/connector/microsoft/microsoft_test.go @@ -142,7 +142,7 @@ func TestUserNotInRequiredGroupFromGraphAPI(t *testing.T) { tenant: tenant, groups: []string{"a"}, } - _, err := c.HandleCallback(connector.Scopes{Groups: true}, req) + _, 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") } From 8db7699e0f5fbcd552fd6db671c54006c77ea8ce Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Sun, 22 Feb 2026 23:06:03 +0100 Subject: [PATCH 098/222] feat: implement device code flow in example-app (#4570) This is a KubeCon 2026 preparation: 1. Add device flow to the example-app 2. Add userinfo checker 3. Refactor the structure Signed-off-by: maksim.nabokikh --- examples/config-dev.yaml | 1 + examples/example-app/handlers.go | 141 +++++++++++ examples/example-app/handlers_device.go | 273 +++++++++++++++++++++ examples/example-app/handlers_userinfo.go | 68 +++++ examples/example-app/main.go | 253 ++----------------- examples/example-app/static/app.js | 48 ++++ examples/example-app/static/device.js | 110 +++++++++ examples/example-app/static/style.css | 232 +++++++++++++++++ examples/example-app/static/token.js | 70 ++++++ examples/example-app/templates.go | 19 ++ examples/example-app/templates/device.html | 61 +++++ examples/example-app/templates/index.html | 9 +- examples/example-app/templates/token.html | 22 +- examples/example-app/utils.go | 154 ++++++++++++ 14 files changed, 1222 insertions(+), 239 deletions(-) create mode 100644 examples/example-app/handlers.go create mode 100644 examples/example-app/handlers_device.go create mode 100644 examples/example-app/handlers_userinfo.go create mode 100644 examples/example-app/static/device.js create mode 100644 examples/example-app/templates/device.html create mode 100644 examples/example-app/utils.go diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 7bb0f2eb..94a40bff 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -129,6 +129,7 @@ staticClients: - id: example-app redirectURIs: - 'http://127.0.0.1:5555/callback' + - '/dex/device/callback' name: 'Example App' secret: ZXhhbXBsZS1hcHAtc2VjcmV0 diff --git a/examples/example-app/handlers.go b/examples/example-app/handlers.go new file mode 100644 index 00000000..fce65d77 --- /dev/null +++ b/examples/example-app/handlers.go @@ -0,0 +1,141 @@ +package main + +import ( + "fmt" + "net/http" + "net/url" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) { + renderIndex(w, indexPageData{ + ScopesSupported: a.scopesSupported, + LogoURI: dexLogoDataURI, + }) +} + +func (a *app) 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 + } + + // Only use scopes that are checked in the form + scopes := r.Form["extra_scopes"] + crossClients := r.Form["cross_client"] + + // Build complete scope list with audience scopes + scopes = buildScopes(scopes, crossClients) + + connectorID := "" + if id := r.FormValue("connector_id"); id != "" { + connectorID = id + } + + authCodeURL := "" + + var authCodeOptions []oauth2.AuthCodeOption + + if a.pkce { + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_challenge", codeChallenge)) + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_challenge_method", "S256")) + } + + // Check if offline_access scope is present to determine offline access mode + hasOfflineAccess := false + for _, scope := range scopes { + if scope == "offline_access" { + hasOfflineAccess = true + break + } + } + + if hasOfflineAccess && !a.offlineAsScope { + // Provider uses access_type=offline instead of offline_access scope + authCodeOptions = append(authCodeOptions, oauth2.AccessTypeOffline) + // Remove offline_access from scopes as it's not supported + filteredScopes := make([]string, 0, len(scopes)) + for _, scope := range scopes { + if scope != "offline_access" { + filteredScopes = append(filteredScopes, scope) + } + } + scopes = filteredScopes + } + + authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState, authCodeOptions...) + + // Parse the auth code URL and safely add connector_id parameter if provided + u, err := url.Parse(authCodeURL) + if err != nil { + http.Error(w, "Failed to parse auth URL", http.StatusInternalServerError) + return + } + + if connectorID != "" { + query := u.Query() + query.Set("connector_id", connectorID) + u.RawQuery = query.Encode() + } + + http.Redirect(w, r, u.String(), 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 + } + + var authCodeOptions []oauth2.AuthCodeOption + if a.pkce { + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) + } + + token, err = oauth2Config.Exchange(ctx, code, authCodeOptions...) + 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 + } + + parseAndRenderToken(w, r, a, token) +} diff --git a/examples/example-app/handlers_device.go b/examples/example-app/handlers_device.go new file mode 100644 index 00000000..40209ca2 --- /dev/null +++ b/examples/example-app/handlers_device.go @@ -0,0 +1,273 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "golang.org/x/oauth2" +) + +func (a *app) handleDeviceLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse request body to get options + 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 + } + + // Build complete scope list with audience scopes (same as handleLogin) + scopes := buildScopes(reqBody.Scopes, reqBody.CrossClients) + + // Build scope string + scopeStr := strings.Join(scopes, " ") + + // Get device authorization endpoint + // Properly construct the device code endpoint URL + authURL := a.provider.Endpoint().AuthURL + deviceAuthURL := strings.TrimSuffix(authURL, "/auth") + "/device/code" + + // Request device code + data := url.Values{} + data.Set("client_id", a.clientID) + data.Set("client_secret", a.clientSecret) + data.Set("scope", scopeStr) + + // Add connector_id if specified + if reqBody.ConnectorID != "" { + data.Set("connector_id", reqBody.ConnectorID) + } + + resp, err := a.client.PostForm(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"` + VerificationURIComplete string `json:"verification_uri_complete"` + 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 + } + + // Store device flow data with new session + sessionID := generateSessionID() + + a.deviceFlowMutex.Lock() + a.deviceFlowData.sessionID = sessionID + a.deviceFlowData.deviceCode = deviceResp.DeviceCode + a.deviceFlowData.userCode = deviceResp.UserCode + a.deviceFlowData.verificationURI = deviceResp.VerificationURI + a.deviceFlowData.pollInterval = deviceResp.Interval + if a.deviceFlowData.pollInterval == 0 { + a.deviceFlowData.pollInterval = 5 + } + a.deviceFlowData.token = nil + a.deviceFlowMutex.Unlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "ok", + "session_id": sessionID, + }) +} + +func (a *app) handleDevicePage(w http.ResponseWriter, r *http.Request) { + a.deviceFlowMutex.Lock() + data := devicePageData{ + SessionID: a.deviceFlowData.sessionID, + DeviceCode: a.deviceFlowData.deviceCode, + UserCode: a.deviceFlowData.userCode, + VerificationURI: a.deviceFlowData.verificationURI, + PollInterval: a.deviceFlowData.pollInterval, + LogoURI: dexLogoDataURI, + } + a.deviceFlowMutex.Unlock() + + if data.DeviceCode == "" { + http.Error(w, "No device flow in progress", http.StatusBadRequest) + return + } + + renderDevice(w, data) +} + +func (a *app) handleDevicePoll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + DeviceCode string `json:"device_code"` + SessionID string `json:"session_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + a.deviceFlowMutex.Lock() + storedSessionID := a.deviceFlowData.sessionID + storedDeviceCode := a.deviceFlowData.deviceCode + existingToken := a.deviceFlowData.token + a.deviceFlowMutex.Unlock() + + // Check if this session has been superseded by a new one + if req.SessionID != storedSessionID { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusGone) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "session_expired", + "error_description": "This device flow session has been superseded by a new one", + }) + return + } + + if req.DeviceCode != storedDeviceCode { + http.Error(w, "Invalid device code", http.StatusBadRequest) + return + } + + // If we already have a token, return success + if existingToken != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "complete", + }) + return + } + + // Poll the token endpoint + tokenURL := a.provider.Endpoint().TokenURL + + data := url.Values{} + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + data.Set("device_code", req.DeviceCode) + data.Set("client_id", a.clientID) + data.Set("client_secret", a.clientSecret) + + tokenResp, err := a.client.PostForm(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 { + // Success! We got the token + // Parse the full response including id_token + 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 + } + + // Create oauth2.Token with all fields + token := &oauth2.Token{ + AccessToken: tokenData.AccessToken, + TokenType: tokenData.TokenType, + RefreshToken: tokenData.RefreshToken, + } + + // Add id_token to Extra + token = token.WithExtra(map[string]interface{}{ + "id_token": tokenData.IDToken, + }) + + // Store the token + a.deviceFlowMutex.Lock() + a.deviceFlowData.token = token + a.deviceFlowMutex.Unlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "complete", + }) + return + } + + // Check for errors + 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 + } + + // Other errors + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tokenResp.StatusCode) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": errorResp.Error, + "error_description": errorResp.ErrorDescription, + }) + return + } + + // Unknown response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "pending", + }) +} + +func (a *app) handleDeviceResult(w http.ResponseWriter, r *http.Request) { + a.deviceFlowMutex.Lock() + token := a.deviceFlowData.token + a.deviceFlowMutex.Unlock() + + if token == nil { + http.Error(w, "No token available", http.StatusBadRequest) + return + } + + parseAndRenderToken(w, r, a, token) +} diff --git a/examples/example-app/handlers_userinfo.go b/examples/example-app/handlers_userinfo.go new file mode 100644 index 00000000..36bab851 --- /dev/null +++ b/examples/example-app/handlers_userinfo.go @@ -0,0 +1,68 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +func (a *app) handleUserInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse form to get access token + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("Failed to parse form: %v", err), http.StatusBadRequest) + return + } + + accessToken := r.FormValue("access_token") + if accessToken == "" { + http.Error(w, "access_token is required", http.StatusBadRequest) + return + } + + // Get UserInfo endpoint from provider + userInfoEndpoint := a.provider.Endpoint().AuthURL + if len(userInfoEndpoint) > 5 { + // Replace /auth with /userinfo + userInfoEndpoint = userInfoEndpoint[:len(userInfoEndpoint)-5] + "/userinfo" + } + + // Create request to UserInfo endpoint + req, err := http.NewRequestWithContext(r.Context(), "GET", userInfoEndpoint, nil) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to create request: %v", err), http.StatusInternalServerError) + return + } + + // Add Authorization header with access token + req.Header.Set("Authorization", "Bearer "+accessToken) + + // Make the request + resp, err := a.client.Do(req) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to fetch userinfo: %v", err), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + http.Error(w, fmt.Sprintf("UserInfo request failed: %s", string(body)), resp.StatusCode) + return + } + + // Parse and return the userinfo + var userInfo map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { + http.Error(w, fmt.Sprintf("Failed to decode userinfo: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(userInfo) +} diff --git a/examples/example-app/main.go b/examples/example-app/main.go index c1b0e968..389d2ff0 100644 --- a/examples/example-app/main.go +++ b/examples/example-app/main.go @@ -1,21 +1,14 @@ package main import ( - "bytes" "context" - "crypto/tls" - "crypto/x509" - "encoding/json" "errors" "fmt" "log" - "net" "net/http" - "net/http/httputil" "net/url" "os" - "slices" - "time" + "sync" "github.com/coreos/go-oidc/v3/oidc" "github.com/spf13/cobra" @@ -49,55 +42,19 @@ type app struct { 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) + // Device flow state + // Only one session is possible at a time + // Since it is an example, we don't bother locking', this is a simplicity tradeoff + deviceFlowMutex sync.Mutex + deviceFlowData struct { + sessionID string // Unique ID for current flow session + deviceCode string + userCode string + verificationURI string + pollInterval int + token *oauth2.Token } - 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) - - 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 } func cmd() *cobra.Command { @@ -191,6 +148,11 @@ func cmd() *cobra.Command { http.Handle("/static/", http.StripPrefix("/static/", staticHandler)) http.HandleFunc("/", a.handleIndex) http.HandleFunc("/login", a.handleLogin) + http.HandleFunc("/device/login", a.handleDeviceLogin) + http.HandleFunc("/device", a.handleDevicePage) + http.HandleFunc("/device/poll", a.handleDevicePoll) + http.HandleFunc("/device/result", a.handleDeviceResult) + http.HandleFunc("/userinfo", a.handleUserInfo) http.HandleFunc(u.Path, a.handleCallback) switch listenURL.Scheme { @@ -224,184 +186,3 @@ func main() { os.Exit(2) } } - -func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) { - renderIndex(w, indexPageData{ - ScopesSupported: a.scopesSupported, - LogoURI: dexLogoDataURI, - }) -} - -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) { - if err := r.ParseForm(); err != nil { - http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest) - return - } - - // Only use scopes that are checked in the form - scopes := r.Form["extra_scopes"] - - clients := r.Form["cross_client"] - for _, client := range clients { - if client == "" { - continue - } - scopes = append(scopes, "audience:server:client_id:"+client) - } - connectorID := "" - if id := r.FormValue("connector_id"); id != "" { - connectorID = id - } - - authCodeURL := "" - scopes = uniqueStrings(scopes) - - var authCodeOptions []oauth2.AuthCodeOption - - if a.pkce { - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_challenge", codeChallenge)) - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_challenge_method", "S256")) - } - - // Check if offline_access scope is present to determine offline access mode - hasOfflineAccess := false - for _, scope := range scopes { - if scope == "offline_access" { - hasOfflineAccess = true - break - } - } - - if hasOfflineAccess && !a.offlineAsScope { - // Provider uses access_type=offline instead of offline_access scope - authCodeOptions = append(authCodeOptions, oauth2.AccessTypeOffline) - // Remove offline_access from scopes as it's not supported - filteredScopes := make([]string, 0, len(scopes)) - for _, scope := range scopes { - if scope != "offline_access" { - filteredScopes = append(filteredScopes, scope) - } - } - scopes = filteredScopes - } - - authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState, authCodeOptions...) - - // Parse the auth code URL and safely add connector_id parameter if provided - u, err := url.Parse(authCodeURL) - if err != nil { - http.Error(w, "Failed to parse auth URL", http.StatusInternalServerError) - return - } - - if connectorID != "" { - query := u.Query() - query.Set("connector_id", connectorID) - u.RawQuery = query.Encode() - } - - http.Redirect(w, r, u.String(), 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 - } - - var authCodeOptions []oauth2.AuthCodeOption - if a.pkce { - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) - } - - token, err = oauth2Config.Exchange(ctx, code, authCodeOptions...) - 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, claims, "", " "); err != nil { - http.Error(w, fmt.Sprintf("error indenting ID token claims: %v", err), http.StatusInternalServerError) - return - } - - renderToken(w, r.Context(), a.provider, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buff.String()) -} - -func uniqueStrings(values []string) []string { - slices.Sort(values) - values = slices.Compact(values) - - return values -} diff --git a/examples/example-app/static/app.js b/examples/example-app/static/app.js index fc2c350d..14a8a80a 100644 --- a/examples/example-app/static/app.js +++ b/examples/example-app/static/app.js @@ -102,5 +102,53 @@ customScopeInput.value = ""; } }); + + // 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 Flow"; + } + }); })(); diff --git a/examples/example-app/static/device.js b/examples/example-app/static/device.js new file mode 100644 index 00000000..5896682a --- /dev/null +++ b/examples/example-app/static/device.js @@ -0,0 +1,110 @@ +(function() { + const sessionID = document.getElementById("session-id")?.value; + 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; + + document.querySelectorAll(".copy-btn").forEach(btn => { + btn.addEventListener("click", async function() { + const targetId = this.getAttribute("data-copy"); + const targetElement = document.getElementById(targetId); + + if (targetElement) { + const textToCopy = targetElement.textContent; + + try { + await navigator.clipboard.writeText(textToCopy); + const originalText = this.textContent; + this.textContent = "✓"; + setTimeout(() => { + this.textContent = originalText; + }, 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + } + }); + }); + + 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({ + session_id: sessionID, + 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 === 'session_expired') { + showError('This session has been superseded by a new device flow. Please start over.'); + 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/static/style.css b/examples/example-app/static/style.css index def84224..fafca567 100644 --- a/examples/example-app/static/style.css +++ b/examples/example-app/static/style.css @@ -355,3 +355,235 @@ pre .number { color: #00f; } +/* Login Buttons Styles */ +.login-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.login-button { + width: 100%; + padding: 14px 24px; + font-size: 16px; + border-radius: 4px; + cursor: pointer; + transition: all 0.3s ease; + font-weight: 600; + border: 2px solid #3F9FD8; +} + +.login-button.primary { + background-color: #3F9FD8; + color: #fff; +} + +.login-button.primary:hover { + background-color: #357FAA; + border-color: #357FAA; +} + +.login-button.secondary { + background-color: #fff; + color: #3F9FD8; +} + +.login-button.secondary:hover { + background-color: #f0f8ff; +} + +/* Device Flow Page Styles */ +.device-flow-container { + background-color: #fff; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + width: 100%; +} + +.device-instructions h2 { + margin-top: 0; + color: #333; + text-align: center; +} + +.instruction-text { + text-align: center; + color: #666; + margin-bottom: 25px; +} + +.verification-info { + display: flex; + flex-direction: column; + gap: 20px; + margin-bottom: 25px; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 8px; +} + +.info-item label { + font-weight: 600; + color: #333; + font-size: 14px; +} + +.code-display { + display: flex; + align-items: center; + gap: 10px; + background-color: #f5f5f5; + padding: 12px; + border-radius: 4px; + border: 1px solid #ddd; +} + +.code-display.large { + padding: 20px; +} + +.code-display code { + flex: 1; + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + word-break: break-all; +} + +.code-display code.user-code { + font-size: 24px; + font-weight: bold; + letter-spacing: 2px; + color: #3F9FD8; +} + +.copy-btn { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + padding: 5px 10px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.copy-btn:hover { + background-color: #e0e0e0; +} + +.copy-btn:active { + background-color: #d0d0d0; +} + +.actions { + text-align: center; +} + +.primary-button { + padding: 12px 32px; + font-size: 16px; + background-color: #3F9FD8; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + transition: background-color 0.3s; +} + +.primary-button:hover { + background-color: #357FAA; +} + +.polling-status { + margin-top: 30px; + padding-top: 30px; + border-top: 1px solid #eee; +} + +.status-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + color: #666; +} + +.spinner { + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #3F9FD8; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.error-message { + margin-top: 15px; + padding: 12px; + background-color: #fee; + border: 1px solid #fcc; + border-radius: 4px; + color: #c00; + text-align: center; +} + +.device-data { + display: none; +} + +/* UserInfo Styles */ +#userinfo-section { + margin-top: 10px; +} + +.fetch-userinfo-btn { + padding: 10px 20px; + background-color: #3F9FD8; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background-color 0.2s; +} + +.fetch-userinfo-btn:hover { + background-color: #357FAA; +} + +.userinfo-loading { + display: flex; + align-items: center; + gap: 10px; + color: #666; + margin-top: 10px; +} + +.userinfo-loading .spinner { + width: 16px; + height: 16px; + border: 2px solid #f3f3f3; + border-top: 2px solid #3F9FD8; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +#userinfo-claims { + margin-top: 15px; +} + +#userinfo-error { + margin-top: 10px; +} + diff --git a/examples/example-app/static/token.js b/examples/example-app/static/token.js index c147ce32..6d410a85 100644 --- a/examples/example-app/static/token.js +++ b/examples/example-app/static/token.js @@ -80,3 +80,73 @@ function showCopyFeedback(message) { }, 2000); } +// UserInfo functionality +document.addEventListener("DOMContentLoaded", function() { + const form = document.getElementById("userinfo-form"); + if (form) { + form.addEventListener("submit", fetchUserInfo); + } +}); + +async function fetchUserInfo(event) { + event.preventDefault(); + + const form = event.target; + const loading = document.getElementById("userinfo-loading"); + const error = document.getElementById("userinfo-error"); + const claimsElement = document.getElementById("userinfo-claims"); + const submitButton = form.querySelector('button[type="submit"]'); + + // Hide error and claims from previous attempts + error.style.display = "none"; + claimsElement.style.display = "none"; + + // Show loading, hide button + submitButton.style.display = "none"; + loading.style.display = "flex"; + + try { + const formData = new FormData(form); + + // Convert FormData to URL-encoded string + const urlEncodedData = new URLSearchParams(formData).toString(); + + const response = await fetch("/userinfo", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: urlEncodedData + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `HTTP ${response.status}`); + } + + const userinfo = await response.json(); + + // Display the userinfo claims + const code = claimsElement.querySelector("code"); + const formattedJson = JSON.stringify(userinfo, null, 2); + code.textContent = formattedJson; + + // Apply syntax highlighting + try { + code.innerHTML = syntaxHighlight(userinfo); + } catch (e) { + console.error("Failed to highlight JSON:", e); + } + + claimsElement.style.display = "block"; + + } catch (err) { + console.error("Failed to fetch userinfo:", err); + error.textContent = "Failed to fetch UserInfo: " + err.message; + error.style.display = "block"; + submitButton.style.display = "inline-block"; + } finally { + loading.style.display = "none"; + } +} + diff --git a/examples/example-app/templates.go b/examples/example-app/templates.go index 67ff709d..b3ae7396 100644 --- a/examples/example-app/templates.go +++ b/examples/example-app/templates.go @@ -29,6 +29,7 @@ const dexLogoDataURI = "/static/dex-glyph-color.svg" var ( indexTmpl *template.Template tokenTmpl *template.Template + deviceTmpl *template.Template staticHandler http.Handler ) @@ -44,6 +45,11 @@ func init() { log.Fatalf("failed to parse token template: %v", err) } + deviceTmpl, err = template.ParseFS(templatesFS, "templates/device.html") + if err != nil { + log.Fatalf("failed to parse device template: %v", err) + } + // Create handler for static files staticSubFS, err := fs.Sub(staticFS, "static") if err != nil { @@ -56,11 +62,24 @@ func renderIndex(w http.ResponseWriter, data indexPageData) { renderTemplate(w, indexTmpl, data) } +func renderDevice(w http.ResponseWriter, data devicePageData) { + renderTemplate(w, deviceTmpl, data) +} + type indexPageData struct { ScopesSupported []string LogoURI string } +type devicePageData struct { + SessionID string + DeviceCode string + UserCode string + VerificationURI string + PollInterval int + LogoURI string +} + type tokenTmplData struct { IDToken string IDTokenJWTLink string diff --git a/examples/example-app/templates/device.html b/examples/example-app/templates/device.html new file mode 100644 index 00000000..092ec371 --- /dev/null +++ b/examples/example-app/templates/device.html @@ -0,0 +1,61 @@ + + + + + + Device Login - Example App + + + +
+ +
+
+

Device Login

+

Please authenticate on your device:

+ +
+
+ +
+ {{.VerificationURI}} + +
+
+ +
+ +
+ {{.UserCode}} + +
+
+
+ +
+ +
+
+ +
+
+
+ Waiting for authentication... +
+ +
+ + +
+
+ + + + + diff --git a/examples/example-app/templates/index.html b/examples/example-app/templates/index.html index 494920d6..063b154f 100644 --- a/examples/example-app/templates/index.html +++ b/examples/example-app/templates/index.html @@ -14,8 +14,13 @@ This is an example application for Dex OpenID Connect provider.
Learn more in the documentation. -
- +
Advanced options diff --git a/examples/example-app/templates/token.html b/examples/example-app/templates/token.html index f830e16b..b003deeb 100644 --- a/examples/example-app/templates/token.html +++ b/examples/example-app/templates/token.html @@ -29,11 +29,31 @@ {{ if .Claims }}
-
Claims:
+
ID Token Claims:
{{ .Claims }}
{{ end }} + {{ if .AccessToken }} +
+
UserInfo:
+
+
+ + +
+ + + +
+
+ {{ end }} + {{ if .RefreshToken }}
Refresh Token:
diff --git a/examples/example-app/utils.go b/examples/example-app/utils.go new file mode 100644 index 00000000..099c1062 --- /dev/null +++ b/examples/example-app/utils.go @@ -0,0 +1,154 @@ +package main + +import ( + "bytes" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "os" + "slices" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// generateSessionID creates a random session identifier +func generateSessionID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback to timestamp if random fails + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +// buildScopes constructs a scope list from base scopes and cross-client IDs +func buildScopes(baseScopes []string, crossClients []string) []string { + scopes := make([]string, len(baseScopes)) + copy(scopes, baseScopes) + + // Add audience scopes for cross-client authorization + for _, client := range crossClients { + if client != "" { + scopes = append(scopes, "audience:server:client_id:"+client) + } + } + + return uniqueStrings(scopes) +} + +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 uniqueStrings(values []string) []string { + slices.Sort(values) + values = slices.Compact(values) + return values +} + +// 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) + + 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 +} + +func encodeToken(idToken *oidc.IDToken) (string, error) { + var claims json.RawMessage + if err := idToken.Claims(&claims); err != nil { + return "", fmt.Errorf("error decoding ID token claims: %v", err) + } + + buff := new(bytes.Buffer) + if err := json.Indent(buff, claims, "", " "); err != nil { + return "", fmt.Errorf("error indenting ID token claims: %v", err) + } + return buff.String(), nil +} + +func parseAndRenderToken(w http.ResponseWriter, r *http.Request, a *app, token *oauth2.Token) { + 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 { + accessToken = token.AccessToken + if accessToken == "" { + http.Error(w, "no access_token in token response", http.StatusInternalServerError) + return + } + } + + buf, err := encodeToken(idToken) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + renderToken(w, r.Context(), a.provider, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buf) +} From 51c66d252391c901cc931b9e437f8a685467b6bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 06:47:30 +0100 Subject: [PATCH 099/222] build(deps): bump aquasecurity/trivy-action from 0.34.0 to 0.34.1 (#4574) --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 835737a7..d8af9b6f 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -233,7 +233,7 @@ jobs: restore-keys: trivy-cache- - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284 # 0.34.0 + uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1 with: input: docker-image format: sarif From ec26e19e7943de816f61b49a71a8bd126e38e705 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 06:47:59 +0100 Subject: [PATCH 100/222] build(deps): bump github/codeql-action from 4.32.3 to 4.32.4 (#4573) --- .github/workflows/analysis-scorecard.yaml | 2 +- .github/workflows/artifacts.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index a212e17a..2bef8332 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -42,6 +42,6 @@ jobs: retention-days: 5 - name: Upload results to GitHub Security tab - uses: github/codeql-action/upload-sarif@9e907b5e64f6b83e7804b09294d44122997950d6 # v3.29.5 + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index d8af9b6f..f518d4d0 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -264,6 +264,6 @@ jobs: overwrite: true - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@9e907b5e64f6b83e7804b09294d44122997950d6 # v3.29.5 + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5 with: sarif_file: trivy-results.sarif From bcc2283694018a59e9b84c43c6c50ce996e9409b Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Mon, 23 Feb 2026 15:34:51 +0100 Subject: [PATCH 101/222] feat: enhance test commands to support GitHub Actions formatting (#4575) With the number of tests growing, it is harder and harder to find errors in the test output. Gotestsum is a well-known runner for tests helping to format the output fot both local runs and GitHub Actions. Signed-off-by: maksim.nabokikh --- Makefile | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 26d3b3aa..2722be7d 100644 --- a/Makefile +++ b/Makefile @@ -127,12 +127,28 @@ verify-go-mod: go-mod-tidy ## Check that go.mod and go.sum formatted according t deps: bin/gotestsum bin/golangci-lint bin/protoc bin/protoc-gen-go bin/protoc-gen-go-grpc bin/kind ## Install dev dependencies. -.PHONY: test testrace testall -test: ## Test go code. - @go test -v ./... +# Detect if we're running in GitHub Actions +ifdef GITHUB_ACTIONS +GOTESTSUM_FORMAT = github-actions +else +GOTESTSUM_FORMAT = testname +GOTESTSUM_FORMAT_ICONS = hivis +endif -testrace: ## Test go code and check for possible race conditions. - @go test -v --race ./... +.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. From 0963bbe780611669cd3a0ddad09e5217b682e3ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:34:55 +0100 Subject: [PATCH 102/222] build(deps): bump google.golang.org/api from 0.267.0 to 0.268.0 (#4577) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ee55dd4e..e8d40376 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 golang.org/x/net v0.50.0 golang.org/x/oauth2 v0.35.0 - google.golang.org/api v0.267.0 + google.golang.org/api v0.268.0 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index ac41652e..615fbe08 100644 --- a/go.sum +++ b/go.sum @@ -316,8 +316,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= -google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/api v0.268.0 h1:hgA3aS4lt9rpF5RCCkX0Q2l7DvHgvlb53y4T4u6iKkA= +google.golang.org/api v0.268.0/go.mod h1:HXMyMH496wz+dAJwD/GkAPLd3ZL33Kh0zEG32eNvy9w= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= From a6962a8ba4868f74558fe031b1ae8b7c9feb6a5e Mon Sep 17 00:00:00 2001 From: Michiel De Backker Date: Tue, 24 Feb 2026 13:56:32 +0100 Subject: [PATCH 103/222] fix(mysql): quote `groups` reserved word in query replacer (#4580) `groups` is a reserved word in MySQL >= 8.0.2, causing migration 13 to fail with a syntax error on `ALTER TABLE password ADD COLUMN groups`. Fixes #4579 Signed-off-by: Michiel De Backker --- storage/sql/sql.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage/sql/sql.go b/storage/sql/sql.go index d671021f..1965f5ff 100644 --- a/storage/sql/sql.go +++ b/storage/sql/sql.go @@ -95,7 +95,7 @@ var ( // For compound indexes (with two keys) even less. {matchLiteral("text"), "varchar(384)"}, // Quote keywords and reserved words used as identifiers. - {regexp.MustCompile(`\b(keys)\b`), "`$1`"}, + {regexp.MustCompile(`\b(keys|groups)\b`), "`$1`"}, // Change default timestamp to fit datetime. {regexp.MustCompile(`0001-01-01 00:00:00 UTC`), "1000-01-01 00:00:00"}, }, From 2ecf64e8b89063da3d15a3dff6df1a2ca5197c19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 07:00:31 +0100 Subject: [PATCH 104/222] build(deps): bump google.golang.org/api from 0.268.0 to 0.269.0 (#4582) --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index e8d40376..19306d16 100644 --- a/go.mod +++ b/go.mod @@ -37,14 +37,14 @@ require ( golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 golang.org/x/net v0.50.0 golang.org/x/oauth2 v0.35.0 - google.golang.org/api v0.268.0 + google.golang.org/api v0.269.0 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 ) require ( ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect - cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.1 // indirect @@ -70,7 +70,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -119,7 +119,7 @@ require ( golang.org/x/tools v0.41.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 615fbe08..900a3e87 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc= ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= -cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= -cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= 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= @@ -95,8 +95,8 @@ 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.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= -github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= @@ -316,14 +316,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.268.0 h1:hgA3aS4lt9rpF5RCCkX0Q2l7DvHgvlb53y4T4u6iKkA= -google.golang.org/api v0.268.0/go.mod h1:HXMyMH496wz+dAJwD/GkAPLd3ZL33Kh0zEG32eNvy9w= +google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= +google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 4c3dffdb944022a44f805f2fcb5917a3d5c049da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:08:55 +0100 Subject: [PATCH 105/222] build(deps): bump actions/setup-go from 6.2.0 to 6.3.0 (#4584) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.2.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5...4b73464bb391d4059bd26b0524d20df3927bd417) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0d11a12c..c9ec9e27 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -87,7 +87,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "1.25" @@ -163,7 +163,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "1.25" From 9cd6668f406fc3909a99d8e4727c73f0c934c5c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:09:12 +0100 Subject: [PATCH 106/222] build(deps): bump anchore/sbom-action from 0.22.2 to 0.23.0 (#4587) Bumps [anchore/sbom-action](https://github.com/anchore/sbom-action) from 0.22.2 to 0.23.0. - [Release notes](https://github.com/anchore/sbom-action/releases) - [Changelog](https://github.com/anchore/sbom-action/blob/main/RELEASE.md) - [Commits](https://github.com/anchore/sbom-action/compare/28d71544de8eaf1b958d335707167c5f783590ad...17ae1740179002c89186b61233e0f892c3118b11) --- updated-dependencies: - dependency-name: anchore/sbom-action dependency-version: 0.23.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index f518d4d0..d56df892 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -62,7 +62,7 @@ jobs: uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Set up Syft - uses: anchore/sbom-action/download-syft@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + uses: anchore/sbom-action/download-syft@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0 - name: Install cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 From c0daa71ec7e2babc16412ebdc55a1c92feda78d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:09:31 +0100 Subject: [PATCH 107/222] build(deps): bump golang.org/x/net from 0.50.0 to 0.51.0 (#4586) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.50.0 to 0.51.0. - [Commits](https://github.com/golang/net/compare/v0.50.0...v0.51.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.51.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 19306d16..3dfb3e45 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( go.etcd.io/etcd/client/v3 v3.6.8 golang.org/x/crypto v0.48.0 golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 - golang.org/x/net v0.50.0 + golang.org/x/net v0.51.0 golang.org/x/oauth2 v0.35.0 google.golang.org/api v0.269.0 google.golang.org/grpc v1.79.1 diff --git a/go.sum b/go.sum index 900a3e87..82ea6848 100644 --- a/go.sum +++ b/go.sum @@ -280,8 +280,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From 3295c7206635f633b9e5dfedf42e7930d1c89935 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:09:54 +0100 Subject: [PATCH 108/222] build(deps): bump actions/attest-build-provenance from 3.2.0 to 4.0.0 (#4585) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 3.2.0 to 4.0.0. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/96278af6caaf10aea03fd8d33a09a777ca52d62f...e4d4f7c39adfa4c260fb5c147f0622000aa14b99) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index d56df892..8e87e19b 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -197,14 +197,14 @@ jobs: # 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@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + # uses: actions/attest-build-provenance@e4d4f7c39adfa4c260fb5c147f0622000aa14b99 # v4.0.0 # 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@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + uses: actions/attest-build-provenance@e4d4f7c39adfa4c260fb5c147f0622000aa14b99 # v4.0.0 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.build.outputs.digest }} From 49dcb4d86380930000ccd104ccea847311765da9 Mon Sep 17 00:00:00 2001 From: Andy Lo-A-Foe Date: Thu, 26 Feb 2026 13:19:11 +0100 Subject: [PATCH 109/222] fix: clean up in-memory connector before create (#4529) Signed-off-by: Andy Lo-A-Foe Co-authored-by: Maksim Nabokikh --- server/api.go | 6 ++ server/api_cache_test.go | 133 +++++++++++++++++++++++++++++++++++++++ server/server.go | 7 +++ 3 files changed, 146 insertions(+) create mode 100644 server/api_cache_test.go diff --git a/server/api.go b/server/api.go index 724c4807..202fef36 100644 --- a/server/api.go +++ b/server/api.go @@ -470,6 +470,11 @@ func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq return nil, fmt.Errorf("create connector: %v", err) } + // Make sure we don't reuse stale entries in the cache + if d.server != nil { + d.server.CloseConnector(req.Connector.Id) + } + return &api.CreateConnectorResp{}, nil } @@ -538,6 +543,7 @@ func (d dexAPI) DeleteConnector(ctx context.Context, req *api.DeleteConnectorReq d.logger.Error("api: failed to delete connector", "err", err) return nil, fmt.Errorf("delete connector: %v", err) } + return &api.DeleteConnectorResp{}, nil } diff --git a/server/api_cache_test.go b/server/api_cache_test.go new file mode 100644 index 00000000..64564c46 --- /dev/null +++ b/server/api_cache_test.go @@ -0,0 +1,133 @@ +package server + +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/storage/memory" +) + +func TestConnectorCacheInvalidation(t *testing.T) { + t.Setenv("DEX_API_CONNECTORS_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + serv := &Server{ + storage: s, + logger: logger, + connectors: make(map[string]Connector), + } + + apiServer := NewAPI(s, logger, "test", serv) + 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 := serv.getConnector(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 := serv.getConnector(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 := serv.getConnector(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/server.go b/server/server.go index e923e3e0..ea87aa1c 100644 --- a/server/server.go +++ b/server/server.go @@ -745,6 +745,13 @@ func (s *Server) OpenConnector(conn storage.Connector) (Connector, error) { return connector, nil } +// CloseConnector removes the connector from the server's in-memory map. +func (s *Server) CloseConnector(id string) { + s.mu.Lock() + delete(s.connectors, id) + s.mu.Unlock() +} + // getConnector retrieves the connector object with the given id from the storage // and updates the connector list for server if necessary. func (s *Server) getConnector(ctx context.Context, id string) (Connector, error) { From d78d74446833a6d39d7d9091c6c1bb954ee0fd97 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Thu, 26 Feb 2026 13:35:50 +0100 Subject: [PATCH 110/222] feat: Disallow unknown config fields (#4531) Signed-off-by: maksim.nabokikh --- cmd/dex/config.go | 24 +++++++++++++++++------- cmd/dex/serve.go | 8 +++++++- pkg/featureflags/set.go | 3 +++ storage/etcd/config.go | 18 +++++++++--------- storage/sql/config.go | 4 ++-- storage/storage.go | 18 +++++++++--------- 6 files changed, 47 insertions(+), 28 deletions(-) diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 8861ddef..3f51d95e 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/base64" "encoding/json" "fmt" @@ -23,6 +24,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"` @@ -109,7 +119,7 @@ func (p *password) UnmarshalJSON(b []byte) error { 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{ @@ -333,7 +343,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] @@ -346,7 +356,7 @@ func (s *Storage) UnmarshalJSON(b []byte) error { data := []byte(store.Config) if featureflags.ExpandEnv.Enabled() { var rawMap map[string]interface{} - if err := json.Unmarshal(store.Config, &rawMap); err != nil { + if err := configUnmarshaller(store.Config, &rawMap); err != nil { return fmt.Errorf("unmarshal config for env expansion: %v", err) } @@ -363,7 +373,7 @@ func (s *Storage) UnmarshalJSON(b []byte) error { 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) } } @@ -462,7 +472,7 @@ func (c *Connector) UnmarshalJSON(b []byte) error { Config json.RawMessage `json:"config"` } - 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] @@ -475,7 +485,7 @@ func (c *Connector) UnmarshalJSON(b []byte) error { data := []byte(conn.Config) if featureflags.ExpandEnv.Enabled() { var rawMap map[string]interface{} - if err := json.Unmarshal(conn.Config, &rawMap); err != nil { + if err := configUnmarshaller(conn.Config, &rawMap); err != nil { return fmt.Errorf("unmarshal config for env expansion: %v", err) } @@ -492,7 +502,7 @@ func (c *Connector) UnmarshalJSON(b []byte) error { 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) } } diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 54d150af..43cb8999 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -97,10 +97,16 @@ 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) diff --git a/pkg/featureflags/set.go b/pkg/featureflags/set.go index d4cdf4d8..65a04d8a 100644 --- a/pkg/featureflags/set.go +++ b/pkg/featureflags/set.go @@ -14,4 +14,7 @@ var ( // 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) ) diff --git a/storage/etcd/config.go b/storage/etcd/config.go index a8aee39a..b4850f3a 100644 --- a/storage/etcd/config.go +++ b/storage/etcd/config.go @@ -15,10 +15,10 @@ var defaultDialTimeout = 2 * time.Second // SSL represents SSL options for etcd databases. type SSL struct { - ServerName string `json:"serverName" yaml:"serverName"` - CAFile string `json:"caFile" yaml:"caFile"` - KeyFile string `json:"keyFile" yaml:"keyFile"` - CertFile string `json:"certFile" yaml:"certFile"` + ServerName string `json:"serverName"` + CAFile string `json:"caFile"` + KeyFile string `json:"keyFile"` + CertFile string `json:"certFile"` } // Etcd options for connecting to etcd databases. @@ -26,11 +26,11 @@ type SSL struct { // configure an etcd namespace either via Namespace field or using `etcd grpc-proxy // --namespace=` type Etcd struct { - Endpoints []string `json:"endpoints" yaml:"endpoints"` - Namespace string `json:"namespace" yaml:"namespace"` - Username string `json:"username" yaml:"username"` - Password string `json:"password" yaml:"password"` - SSL SSL `json:"ssl" yaml:"ssl"` + Endpoints []string `json:"endpoints"` + Namespace string `json:"namespace"` + Username string `json:"username"` + Password string `json:"password"` + SSL SSL `json:"ssl"` } // Open creates a new storage implementation backed by Etcd diff --git a/storage/sql/config.go b/storage/sql/config.go index 222b263a..602e7f8a 100644 --- a/storage/sql/config.go +++ b/storage/sql/config.go @@ -78,7 +78,7 @@ type SSL struct { type Postgres struct { NetworkDB - SSL SSL `json:"ssl" yaml:"ssl"` + SSL SSL `json:"ssl"` } // Open creates a new storage implementation backed by Postgres. @@ -206,7 +206,7 @@ func (p *Postgres) open(logger *slog.Logger) (*conn, error) { type MySQL struct { NetworkDB - SSL SSL `json:"ssl" yaml:"ssl"` + SSL SSL `json:"ssl"` // TODO(pborzenkov): used by tests to reduce lock wait timeout. Should // we make it exported and allow users to provide arbitrary params? diff --git a/storage/storage.go b/storage/storage.go index b49e0fd9..b6e4d285 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -149,28 +149,28 @@ type Storage interface { // - Public clients: https://developers.google.com/api-client-library/python/auth/installed-app type Client struct { // Client ID and secret used to identify the client. - ID string `json:"id" yaml:"id"` - IDEnv string `json:"idEnv" yaml:"idEnv"` - Secret string `json:"secret" yaml:"secret"` - SecretEnv string `json:"secretEnv" yaml:"secretEnv"` + ID string `json:"id"` + IDEnv string `json:"idEnv"` + Secret string `json:"secret"` + SecretEnv string `json:"secretEnv"` // A registered set of redirect URIs. When redirecting from dex to the client, the URI // requested to redirect to MUST match one of these values, unless the client is "public". - RedirectURIs []string `json:"redirectURIs" yaml:"redirectURIs"` + RedirectURIs []string `json:"redirectURIs"` // TrustedPeers are a list of peers which can issue tokens on this client's behalf using // the dynamic "oauth2:server:client_id:(client_id)" scope. If a peer makes such a request, // this client's ID will appear as the ID Token's audience. // // Clients inherently trust themselves. - TrustedPeers []string `json:"trustedPeers" yaml:"trustedPeers"` + TrustedPeers []string `json:"trustedPeers"` // Public clients must use either use a redirectURL 127.0.0.1:X or "urn:ietf:wg:oauth:2.0:oob" - Public bool `json:"public" yaml:"public"` + Public bool `json:"public"` // Name and LogoURL used when displaying this client to the end user. - Name string `json:"name" yaml:"name"` - LogoURL string `json:"logoURL" yaml:"logoURL"` + Name string `json:"name"` + LogoURL string `json:"logoURL"` } // Claims represents the ID Token claims supported by the server. From 43119318815fb6293c4a76f2b6a973692eb1bb37 Mon Sep 17 00:00:00 2001 From: Ivan Zviagintsev Date: Thu, 26 Feb 2026 17:27:47 +0300 Subject: [PATCH 111/222] feat: saml support refresh tokens (#4565) Signed-off-by: Ivan Zvyagintsev --- connector/saml/saml.go | 68 +++++++- connector/saml/saml_test.go | 327 ++++++++++++++++++++++++++++++++++++ server/handlers.go | 10 +- server/handlers_test.go | 116 +++++++++++++ 4 files changed, 516 insertions(+), 5 deletions(-) diff --git a/connector/saml/saml.go b/connector/saml/saml.go index 3e44b477..b2e7d9b4 100644 --- a/connector/saml/saml.go +++ b/connector/saml/saml.go @@ -3,8 +3,10 @@ package saml import ( "bytes" + "context" "crypto/x509" "encoding/base64" + "encoding/json" "encoding/pem" "encoding/xml" "fmt" @@ -255,6 +257,39 @@ type provider struct { logger *slog.Logger } +// Compile-time check that provider implements RefreshConnector +var _ connector.RefreshConnector = (*provider)(nil) + +// 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) { r := &authnRequest{ ProtocolBinding: bindingPOST, @@ -405,7 +440,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 == "") { @@ -431,7 +466,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 @@ -447,6 +482,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 } diff --git a/connector/saml/saml_test.go b/connector/saml/saml_test.go index 03e891fe..3eba5cf8 100644 --- a/connector/saml/saml_test.go +++ b/connector/saml/saml_test.go @@ -1,8 +1,10 @@ package saml import ( + "context" "crypto/x509" "encoding/base64" + "encoding/json" "encoding/pem" "errors" "log/slog" @@ -448,6 +450,24 @@ 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) } @@ -589,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/server/handlers.go b/server/handlers.go index 62f1650c..b724b8d8 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -1072,9 +1072,10 @@ func (s *Server) exchangeAuthCode(ctx context.Context, w http.ResponseWriter, au return nil, err } offlineSessions := storage.OfflineSessions{ - UserID: refresh.Claims.UserID, - ConnID: refresh.ConnectorID, - Refresh: make(map[string]*storage.RefreshTokenRef), + UserID: refresh.Claims.UserID, + ConnID: refresh.ConnectorID, + Refresh: make(map[string]*storage.RefreshTokenRef), + ConnectorData: refresh.ConnectorData, } offlineSessions.Refresh[tokenRef.ClientID] = &tokenRef @@ -1100,6 +1101,9 @@ func (s *Server) exchangeAuthCode(ctx context.Context, w http.ResponseWriter, au // Update existing OfflineSession obj with new RefreshTokenRef. if err := s.storage.UpdateOfflineSessions(ctx, session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { old.Refresh[tokenRef.ClientID] = &tokenRef + if len(refresh.ConnectorData) > 0 { + old.ConnectorData = refresh.ConnectorData + } return old, nil }); err != nil { s.logger.ErrorContext(ctx, "failed to update offline session", "err", err) diff --git a/server/handlers_test.go b/server/handlers_test.go index 0514d85c..6ec9b962 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -21,6 +21,7 @@ import ( "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" + "github.com/dexidp/dex/connector" "github.com/dexidp/dex/storage" ) @@ -892,3 +893,118 @@ func setNonEmpty(vals url.Values, key, value string) { 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.mu.Lock() + s.connectors[connID] = Connector{ + ResourceVersion: "1", + Connector: c, + } + s.mu.Unlock() +} + +func TestConnectorDataPersistence(t *testing.T) { + // Test that ConnectorData is correctly stored in refresh token + // and can be used for subsequent refresh operations. + httpServer, server := newTestServer(t, func(c *Config) { + c.RefreshTokenPolicy = &RefreshTokenPolicy{rotateRefreshTokens: true} + }) + defer httpServer.Close() + + ctx := t.Context() + connID := "saml-conndata" + + // Create a mock SAML connector that also implements RefreshConnector + mockConn := &mockSAMLRefreshConnector{ + refreshIdentity: connector.Identity{ + UserID: "refreshed-user", + Username: "refreshed-name", + Email: "refreshed@example.com", + EmailVerified: true, + Groups: []string{"refreshed-group"}, + }, + } + registerTestConnector(t, server, connID, mockConn) + + // Create client + client := storage.Client{ + ID: "conndata-client", + Secret: "conndata-secret", + RedirectURIs: []string{"https://example.com/callback"}, + Name: "ConnData Test Client", + } + require.NoError(t, server.storage.CreateClient(ctx, client)) + + // Create refresh token with ConnectorData (simulating what HandlePOST would store) + connectorData := []byte(`{"userID":"user-123","username":"testuser","email":"test@example.com","emailVerified":true,"groups":["admin","dev"]}`) + refreshToken := storage.RefreshToken{ + ID: "conndata-refresh", + Token: "conndata-token", + CreatedAt: time.Now(), + LastUsed: time.Now(), + ClientID: client.ID, + ConnectorID: connID, + Scopes: []string{"openid", "email", "offline_access"}, + Claims: storage.Claims{ + UserID: "user-123", + Username: "testuser", + Email: "test@example.com", + EmailVerified: true, + Groups: []string{"admin", "dev"}, + }, + ConnectorData: connectorData, + Nonce: "conndata-nonce", + } + require.NoError(t, server.storage.CreateRefresh(ctx, refreshToken)) + + offlineSession := storage.OfflineSessions{ + UserID: "user-123", + ConnID: connID, + Refresh: map[string]*storage.RefreshTokenRef{client.ID: {ID: refreshToken.ID, ClientID: client.ID}}, + ConnectorData: connectorData, + } + require.NoError(t, server.storage.CreateOfflineSessions(ctx, offlineSession)) + + // Verify ConnectorData is stored correctly + storedToken, err := server.storage.GetRefresh(ctx, refreshToken.ID) + require.NoError(t, err) + require.Equal(t, connectorData, storedToken.ConnectorData, + "ConnectorData should be persisted in refresh token storage") + + // Verify ConnectorData is stored in offline session + storedSession, err := server.storage.GetOfflineSessions(ctx, "user-123", connID) + require.NoError(t, err) + require.Equal(t, connectorData, storedSession.ConnectorData, + "ConnectorData should be persisted in offline session storage") +} + +// 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 +} From 44e27490be1857f19e212b2abaa0acd60fc0ae27 Mon Sep 17 00:00:00 2001 From: Mathias Gebbe Date: Thu, 26 Feb 2026 16:09:07 +0100 Subject: [PATCH 112/222] fix(connector): update authproxy and oauth to match CallbackConnector interface (#4589) The PKCE support added in v2.45.0 changed the CallbackConnector interface signatures but missed updating the authproxy and oauth connectors. This caused a type assertion failure in handleConnectorLogin(), resulting in "Requested resource does not exist" errors when using these connectors. Update LoginURL to return (string, []byte, error) and HandleCallback to accept a []byte connData parameter for both connectors and their tests. Signed-off-by: Mathias Gebbe --- connector/authproxy/authproxy.go | 8 ++++---- connector/authproxy/authproxy_test.go | 12 ++++++------ connector/oauth/oauth.go | 8 ++++---- connector/oauth/oauth_test.go | 10 +++++----- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/connector/authproxy/authproxy.go b/connector/authproxy/authproxy.go index f3d87fcb..3fe451b2 100644 --- a/connector/authproxy/authproxy.go +++ b/connector/authproxy/authproxy.go @@ -83,20 +83,20 @@ type callback struct { } // 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) diff --git a/connector/authproxy/authproxy_test.go b/connector/authproxy/authproxy_test.go index fbdd2a5d..bd8b4f36 100644 --- a/connector/authproxy/authproxy_test.go +++ b/connector/authproxy/authproxy_test.go @@ -36,7 +36,7 @@ func TestUser(t *testing.T) { "X-Remote-User": {testUsername}, } - ident, err := callback.HandleCallback(connector.Scopes{OfflineAccess: true, Groups: true}, req) + 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 @@ -62,7 +62,7 @@ func TestExtraHeaders(t *testing.T) { "X-Remote-User-Email": {testEmail}, } - ident, err := callback.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, testUserID) @@ -85,7 +85,7 @@ func TestSingleGroup(t *testing.T) { "X-Remote-Group": {testGroup1}, } - ident, err := callback.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) @@ -106,7 +106,7 @@ func TestMultipleGroup(t *testing.T) { "X-Remote-Group": {testGroup1 + ", " + testGroup2 + ", " + testGroup3 + ", " + testGroup4}, } - ident, err := callback.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) @@ -132,7 +132,7 @@ func TestMultipleGroupWithCustomSeparator(t *testing.T) { "X-Remote-Group": {testGroup1 + ";" + testGroup2 + ";" + testGroup3 + ";" + testGroup4}, } - ident, err := callback.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) @@ -158,7 +158,7 @@ func TestStaticGroup(t *testing.T) { "X-Remote-Group": {testGroup1 + ", " + testGroup2 + ", " + testGroup3 + ", " + testGroup4}, } - ident, err := callback.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/oauth/oauth.go b/connector/oauth/oauth.go index 413a813a..7661a9f8 100644 --- a/connector/oauth/oauth.go +++ b/connector/oauth/oauth.go @@ -116,9 +116,9 @@ func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, erro return oauthConn, err } -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{ @@ -129,10 +129,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 2f6b0b95..cdd2d3c6 100644 --- a/connector/oauth/oauth_test.go +++ b/connector/oauth/oauth_test.go @@ -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") From 47e84dba695684d4a9c106793a749d543d820028 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Thu, 26 Feb 2026 16:43:17 +0100 Subject: [PATCH 113/222] feat(connector): add compile-time checks for connector interfaces (#4591) Signed-off-by: maksim.nabokikh --- connector/atlassiancrowd/atlassiancrowd.go | 10 +++++----- connector/authproxy/authproxy.go | 2 ++ connector/keystone/keystone.go | 10 +++++----- connector/ldap/ldap.go | 10 +++++----- connector/linkedin/linkedin.go | 12 +++++------- connector/mock/connectortest.go | 12 ++++++++---- connector/oauth/oauth.go | 2 ++ connector/oidc/oidc.go | 5 +++-- connector/saml/saml.go | 8 +++++--- 9 files changed, 40 insertions(+), 31 deletions(-) diff --git a/connector/atlassiancrowd/atlassiancrowd.go b/connector/atlassiancrowd/atlassiancrowd.go index d3683284..ca922147 100644 --- a/connector/atlassiancrowd/atlassiancrowd.go +++ b/connector/atlassiancrowd/atlassiancrowd.go @@ -87,16 +87,16 @@ func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, erro return &crowdConnector{Config: *c, logger: logger.With(slog.Group("connector", "type", "atlassiancrowd", "id", id))}, nil } -type crowdConnector struct { - Config - logger *slog.Logger -} - var ( _ connector.PasswordConnector = (*crowdConnector)(nil) _ connector.RefreshConnector = (*crowdConnector)(nil) ) +type crowdConnector struct { + Config + logger *slog.Logger +} + type refreshData struct { Username string `json:"username"` } diff --git a/connector/authproxy/authproxy.go b/connector/authproxy/authproxy.go index 3fe451b2..5756a0d4 100644 --- a/connector/authproxy/authproxy.go +++ b/connector/authproxy/authproxy.go @@ -68,6 +68,8 @@ func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, erro }, 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 { diff --git a/connector/keystone/keystone.go b/connector/keystone/keystone.go index cdfdb558..7d3084b2 100644 --- a/connector/keystone/keystone.go +++ b/connector/keystone/keystone.go @@ -15,6 +15,11 @@ import ( "github.com/dexidp/dex/connector" ) +var ( + _ connector.PasswordConnector = (*conn)(nil) + _ connector.RefreshConnector = (*conn)(nil) +) + type conn struct { Domain domainKeystone Host string @@ -103,11 +108,6 @@ 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 *slog.Logger) (connector.Connector, error) { _, err := uuid.Parse(c.Domain) diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go index 9ac993c4..4cb7180e 100644 --- a/connector/ldap/ldap.go +++ b/connector/ldap/ldap.go @@ -301,6 +301,11 @@ func (c *Config) openConnector(logger *slog.Logger) (*ldapConnector, error) { return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig, logger}, nil } +var ( + _ connector.PasswordConnector = (*ldapConnector)(nil) + _ connector.RefreshConnector = (*ldapConnector)(nil) +) + type ldapConnector struct { Config @@ -312,11 +317,6 @@ type ldapConnector struct { logger *slog.Logger } -var ( - _ connector.PasswordConnector = (*ldapConnector)(nil) - _ connector.RefreshConnector = (*ldapConnector)(nil) -) - // do initializes a connection to the LDAP directory and passes it to the // provided function. It then performs appropriate teardown or reuse before // returning. diff --git a/connector/linkedin/linkedin.go b/connector/linkedin/linkedin.go index 0c24ff47..32e33aea 100644 --- a/connector/linkedin/linkedin.go +++ b/connector/linkedin/linkedin.go @@ -49,18 +49,16 @@ type connectorData struct { AccessToken string `json:"accessToken"` } -type linkedInConnector struct { - oauth2Config *oauth2.Config - logger *slog.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, []byte, error) { if c.oauth2Config.RedirectURL != callbackURL { diff --git a/connector/mock/connectortest.go b/connector/mock/connectortest.go index be44bfd1..4d9e9e27 100644 --- a/connector/mock/connectortest.go +++ b/connector/mock/connectortest.go @@ -29,10 +29,9 @@ func NewCallbackConnector(logger *slog.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. @@ -97,6 +96,11 @@ func (c *PasswordConfig) Open(id string, logger *slog.Logger) (connector.Connect return &passwordConnector{c.Username, c.Password, logger}, nil } +var ( + _ connector.PasswordConnector = passwordConnector{} + _ connector.RefreshConnector = passwordConnector{} +) + type passwordConnector struct { username string password string diff --git a/connector/oauth/oauth.go b/connector/oauth/oauth.go index 7661a9f8..2ae13a69 100644 --- a/connector/oauth/oauth.go +++ b/connector/oauth/oauth.go @@ -16,6 +16,8 @@ import ( "github.com/dexidp/dex/pkg/httpclient" ) +var _ connector.CallbackConnector = (*oauthConnector)(nil) + type oauthConnector struct { clientID string clientSecret string diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index 879d82d7..8e1fe724 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -379,8 +379,9 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, } var ( - _ connector.CallbackConnector = (*oidcConnector)(nil) - _ connector.RefreshConnector = (*oidcConnector)(nil) + _ connector.CallbackConnector = (*oidcConnector)(nil) + _ connector.RefreshConnector = (*oidcConnector)(nil) + _ connector.TokenIdentityConnector = (*oidcConnector)(nil) ) type oidcConnector struct { diff --git a/connector/saml/saml.go b/connector/saml/saml.go index b2e7d9b4..8ef434b6 100644 --- a/connector/saml/saml.go +++ b/connector/saml/saml.go @@ -232,6 +232,11 @@ func (c *Config) openConnector(logger *slog.Logger) (*provider, error) { return p, nil } +var ( + _ connector.SAMLConnector = (*provider)(nil) + _ connector.RefreshConnector = (*provider)(nil) +) + type provider struct { entityIssuer string ssoIssuer string @@ -257,9 +262,6 @@ type provider struct { logger *slog.Logger } -// Compile-time check that provider implements RefreshConnector -var _ connector.RefreshConnector = (*provider)(nil) - // 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. From 8ab16cfe02a8b957111ad6b4967fe580eb82c913 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:06:59 +0100 Subject: [PATCH 114/222] build(deps): bump actions/attest-build-provenance from 4.0.0 to 4.1.0 (#4595) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/e4d4f7c39adfa4c260fb5c147f0622000aa14b99...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 8e87e19b..a9c0980d 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -197,14 +197,14 @@ jobs: # 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@e4d4f7c39adfa4c260fb5c147f0622000aa14b99 # v4.0.0 + # uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 # 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@e4d4f7c39adfa4c260fb5c147f0622000aa14b99 # v4.0.0 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.build.outputs.digest }} From e5e64c64c06b61692a3f5bee4c4539898564ced0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:13:41 +0100 Subject: [PATCH 115/222] build(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#4594) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/analysis-scorecard.yaml | 2 +- .github/workflows/artifacts.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index 2bef8332..9db29eeb 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -35,7 +35,7 @@ jobs: publish_results: true - name: Upload results as artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: OpenSSF Scorecard results path: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index a9c0980d..5207ee49 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -256,7 +256,7 @@ jobs: run: cat trivy-results.sarif - name: Upload Trivy scan results as artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: "[${{ github.job }}] Trivy scan results" path: trivy-results.sarif From e5c14f1c7c7ec20c0c555397f96270e4f2063c74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:13:58 +0100 Subject: [PATCH 116/222] build(deps): bump distroless/static-debian13 from `01e550f` to `f512d81` (#4593) Bumps distroless/static-debian13 from `01e550f` to `f512d81`. --- updated-dependencies: - dependency-name: distroless/static-debian13 dependency-version: nonroot dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 447a431b..a3127b1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,7 @@ FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f 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:01e550fdb7ab79ee7be5ff440a563a58f1fd000ad9e0c532e65c3d23f917f1c5 AS distroless +FROM gcr.io/distroless/static-debian13:nonroot@sha256:f512d819b8f109f2375e8b51d8cfd8aafe81034bc3e319740128b7d7f70d5036 AS distroless FROM $BASE_IMAGE From e1d6c38ca2e2169215773c569dcfd32b0992cac9 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 27 Feb 2026 09:04:29 +0100 Subject: [PATCH 117/222] fix: Invert condition for unknown fields in config unmarshaller (#4596) Signed-off-by: Maksim Nabokikh --- cmd/dex/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 3f51d95e..8f78eea2 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -25,7 +25,7 @@ import ( ) func configUnmarshaller(b []byte, v interface{}) error { - if featureflags.ConfigDisallowUnknownFields.Enabled() { + if !featureflags.ConfigDisallowUnknownFields.Enabled() { return json.Unmarshal(b, v) } dec := json.NewDecoder(bytes.NewReader(b)) From 99c423364e411c5b9de04acf27913046e0fab813 Mon Sep 17 00:00:00 2001 From: kt <45947799+kanywst@users.noreply.github.com> Date: Sat, 28 Feb 2026 23:20:33 +0900 Subject: [PATCH 118/222] fix: fix typo in grpc listener error message (#4598) Signed-off-by: kanywst --- cmd/dex/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 43cb8999..e175442d 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -562,7 +562,7 @@ func runServe(options serveOptions) error { 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...) From 91e985edea9fe13837ffaf09143eb7b08f7979a2 Mon Sep 17 00:00:00 2001 From: kt <45947799+kanywst@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:48:39 +0900 Subject: [PATCH 119/222] fix: correct error message for device request expiry (#4599) Signed-off-by: kanywst --- cmd/dex/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index e175442d..2ac9957f 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -388,7 +388,7 @@ func runServe(options serveOptions) error { 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.Info("config device requests", "valid_for", deviceRequests) serverConfig.DeviceRequestsValidFor = deviceRequests From a70f592589128b3ce0b8a6f57ce0c7ff90aa857a Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Mon, 2 Mar 2026 08:53:13 +0100 Subject: [PATCH 120/222] fix(deviceflow): update redirect URIs to use absolute paths for non-root URLs (#4597) Signed-off-by: maksim.nabokikh --- server/deviceflowhandlers.go | 4 ++-- server/deviceflowhandlers_test.go | 38 +++++++++++++++++++------------ server/server_test.go | 4 ++-- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/server/deviceflowhandlers.go b/server/deviceflowhandlers.go index ec5fb52b..b9fb652a 100644 --- a/server/deviceflowhandlers.go +++ b/server/deviceflowhandlers.go @@ -431,7 +431,7 @@ func (s *Server) verifyUserCode(w http.ResponseWriter, r *http.Request) { } // Redirect to Dex Auth Endpoint - authURL := path.Join(s.issuerURL.Path, "/auth") + authURL := s.absURL("/auth") u, err := url.Parse(authURL) if err != nil { s.renderError(r, w, http.StatusInternalServerError, "Invalid auth URI.") @@ -442,7 +442,7 @@ func (s *Server) verifyUserCode(w http.ResponseWriter, r *http.Request) { q.Set("client_secret", deviceRequest.ClientSecret) q.Set("state", deviceRequest.UserCode) q.Set("response_type", "code") - q.Set("redirect_uri", "/device/callback") + q.Set("redirect_uri", s.absPath(deviceCallbackURI)) q.Set("scope", strings.Join(deviceRequest.Scopes, " ")) u.RawQuery = q.Encode() diff --git a/server/deviceflowhandlers_test.go b/server/deviceflowhandlers_test.go index ec7bf29d..1cbd60f7 100644 --- a/server/deviceflowhandlers_test.go +++ b/server/deviceflowhandlers_test.go @@ -364,7 +364,7 @@ func TestDeviceCallback(t *testing.T) { // Setup a dex server. httpServer, s := newTestServer(t, func(c *Config) { - // c.Issuer = c.Issuer + "/non-root-path" + c.Issuer = c.Issuer + "/non-root-path" c.Now = now }) defer httpServer.Close() @@ -752,7 +752,8 @@ func TestVerifyCodeResponse(t *testing.T) { testDeviceRequest storage.DeviceRequest userCode string expectedResponseCode int - expectedRedirectPath string + expectedAuthPath string + shouldRedirectToAuth bool }{ { testName: "Unknown user code", @@ -765,7 +766,6 @@ func TestVerifyCodeResponse(t *testing.T) { }, userCode: "CODE-TEST", expectedResponseCode: http.StatusBadRequest, - expectedRedirectPath: "", }, { testName: "Expired user code", @@ -778,7 +778,6 @@ func TestVerifyCodeResponse(t *testing.T) { }, userCode: "ABCD-WXYZ", expectedResponseCode: http.StatusBadRequest, - expectedRedirectPath: "", }, { testName: "No user code", @@ -791,10 +790,9 @@ func TestVerifyCodeResponse(t *testing.T) { }, userCode: "", expectedResponseCode: http.StatusBadRequest, - expectedRedirectPath: "", }, { - testName: "Valid user code, expect redirect to auth endpoint", + testName: "Valid user code, expect redirect to auth endpoint with device callback", testDeviceRequest: storage.DeviceRequest{ UserCode: "ABCD-WXYZ", DeviceCode: "f00bar", @@ -804,7 +802,8 @@ func TestVerifyCodeResponse(t *testing.T) { }, userCode: "ABCD-WXYZ", expectedResponseCode: http.StatusFound, - expectedRedirectPath: "/auth", + expectedAuthPath: "/auth", + shouldRedirectToAuth: true, }, } for _, tc := range tests { @@ -839,15 +838,24 @@ func TestVerifyCodeResponse(t *testing.T) { 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) + if rr.Code == http.StatusFound && tc.shouldRedirectToAuth { + // Parse the redirect location + redirectURL, err := url.Parse(location) + if err != nil { + t.Errorf("Could not parse redirect URL: %v", err) + return + } + + // Check that the redirect path contains /auth + if !strings.Contains(redirectURL.Path, tc.expectedAuthPath) { + t.Errorf("Invalid Redirect Path. Expected to contain %q got %q", tc.expectedAuthPath, redirectURL.Path) + } + + // Check that redirect_uri parameter contains /device/callback + if !strings.Contains(location, "redirect_uri=%2Fnon-root-path%2Fdevice%2Fcallback") { + t.Errorf("Invalid redirect_uri parameter. Expected to contain /device/callback (URL encoded), got %v", location) + } } }) } diff --git a/server/server_test.go b/server/server_test.go index 5a735f1d..e61e21ab 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1640,7 +1640,7 @@ func TestOAuth2DeviceFlow(t *testing.T) { // Add the Clients to the test server client := storage.Client{ ID: clientID, - RedirectURIs: []string{deviceCallbackURI}, + RedirectURIs: []string{s.absPath(deviceCallbackURI)}, Public: true, } if err := s.storage.CreateClient(ctx, client); err != nil { @@ -1751,7 +1751,7 @@ func TestOAuth2DeviceFlow(t *testing.T) { ClientSecret: client.Secret, Endpoint: p.Endpoint(), Scopes: requestedScopes, - RedirectURL: deviceCallbackURI, + RedirectURL: s.absURL(deviceCallbackURI), } if len(tc.scopes) != 0 { oauth2Config.Scopes = tc.scopes From 044dcd57a2c38feadc06dd4b7a9c560ecc7fea06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 06:26:02 +0100 Subject: [PATCH 121/222] build(deps): bump aquasecurity/trivy-action from 0.34.1 to 0.34.2 (#4602) --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 5207ee49..a531399f 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -233,7 +233,7 @@ jobs: restore-keys: trivy-cache- - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1 + uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2 with: input: docker-image format: sarif From e79638db5293bc593b8450233efb7388e68f647d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 06:26:30 +0100 Subject: [PATCH 122/222] build(deps): bump github/codeql-action from 4.32.4 to 4.32.5 (#4603) --- .github/workflows/analysis-scorecard.yaml | 2 +- .github/workflows/artifacts.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index 9db29eeb..e9b6cbcb 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -42,6 +42,6 @@ jobs: retention-days: 5 - name: Upload results to GitHub Security tab - uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5 + uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index a531399f..3aa779bd 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -264,6 +264,6 @@ jobs: overwrite: true - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5 + uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v3.29.5 with: sarif_file: trivy-results.sarif From fec4f53203f681345356a5a0a27ea4b1c9573cf9 Mon Sep 17 00:00:00 2001 From: Mathias Gebbe Date: Tue, 3 Mar 2026 12:29:04 +0100 Subject: [PATCH 123/222] feat(oauth2): add client credentials flow with opt-in config flag (#4583) Implement the OAuth2 client_credentials grant type for machine-to-machine authentication. The grant is gated behind a new clientCredentialsEnabled config flag (defaults to false), following the same pattern as passwordConnector for the password grant. --------- Signed-off-by: Mathias Gebbe Signed-off-by: Maksim Nabokikh Signed-off-by: Maksim Nabokikh Co-authored-by: Maksim Nabokikh Co-authored-by: Maksim Nabokikh --- cmd/dex/serve.go | 3 + examples/config-dev.yaml | 3 +- pkg/featureflags/set.go | 4 + server/handlers.go | 104 +++++++++++++++++++++++ server/handlers_test.go | 172 +++++++++++++++++++++++++++++++++++++++ server/oauth2.go | 1 + server/server.go | 2 + server/server_test.go | 33 ++++++-- 8 files changed, 313 insertions(+), 9 deletions(-) diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 2ac9957f..545419dc 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -622,6 +622,9 @@ func applyConfigOverrides(options serveOptions, config *Config) { "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") + } } } diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 94a40bff..9c25b735 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -100,10 +100,11 @@ telemetry: # format: "text" # can also be "json" # Default values shown below -# oauth2: +#oauth2: # grantTypes determines the allowed set of authorization flows. # grantTypes: # - "authorization_code" +# - "client_credentials" # - "refresh_token" # - "implicit" # - "password" diff --git a/pkg/featureflags/set.go b/pkg/featureflags/set.go index 65a04d8a..dcea1ca7 100644 --- a/pkg/featureflags/set.go +++ b/pkg/featureflags/set.go @@ -17,4 +17,8 @@ var ( // 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) ) diff --git a/server/handlers.go b/server/handlers.go index b724b8d8..8a20438c 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -889,6 +889,8 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { s.withClientFromStorage(w, r, s.handlePasswordGrant) case grantTypeTokenExchange: s.withClientFromStorage(w, r, s.handleTokenExchange) + case grantTypeClientCredentials: + s.withClientFromStorage(w, r, s.handleClientCredentialsGrant) default: s.tokenErrHelper(w, errUnsupportedGrantType, "", http.StatusBadRequest) } @@ -1465,6 +1467,108 @@ func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request, cli json.NewEncoder(w).Encode(resp) } +func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request, client storage.Client) { + ctx := r.Context() + + // client_credentials requires a confidential client. + if client.Public { + s.tokenErrHelper(w, errUnauthorizedClient, "Public clients cannot use client_credentials grant.", http.StatusBadRequest) + return + } + + // Parse scopes from request. + if err := r.ParseForm(); err != nil { + s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest) + return + } + scopes := strings.Fields(r.Form.Get("scope")) + + // Validate scopes. + var ( + unrecognized []string + invalidScopes []string + ) + hasOpenIDScope := false + for _, scope := range scopes { + switch scope { + case scopeOpenID: + hasOpenIDScope = true + case scopeEmail, scopeProfile, scopeGroups: + // allowed + case scopeOfflineAccess: + s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support offline_access scope.", http.StatusBadRequest) + return + case scopeFederatedID: + s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support federated:id scope.", http.StatusBadRequest) + return + default: + peerID, ok := parseCrossClientScope(scope) + if !ok { + unrecognized = append(unrecognized, scope) + continue + } + + isTrusted, err := s.validateCrossClientTrust(ctx, client.ID, peerID) + if err != nil { + s.logger.ErrorContext(ctx, "error validating cross client trust", "client_id", client.ID, "peer_id", peerID, "err", err) + s.tokenErrHelper(w, errInvalidClient, "Error validating cross client trust.", http.StatusBadRequest) + return + } + if !isTrusted { + invalidScopes = append(invalidScopes, scope) + } + } + } + if len(unrecognized) > 0 { + s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest) + return + } + if len(invalidScopes) > 0 { + s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest) + return + } + + // Build claims from the client itself — no user involved. + claims := storage.Claims{ + UserID: client.ID, + } + + // Only populate Username/PreferredUsername when the profile scope is requested. + for _, scope := range scopes { + if scope == scopeProfile { + claims.Username = client.Name + claims.PreferredUsername = client.Name + break + } + } + + nonce := r.Form.Get("nonce") + + // Empty connector ID is unique for cluster credentials grant + // Creating connectors with an empty ID with the config and API is prohibited + connID := "" + + accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID) + if err != nil { + s.logger.ErrorContext(ctx, "client_credentials grant failed to create new access token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + var idToken string + if hasOpenIDScope { + idToken, expiry, err = s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID) + if err != nil { + s.logger.ErrorContext(ctx, "client_credentials grant failed to create new ID token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + } + + resp := s.toAccessTokenResponse(idToken, accessToken, "", expiry) + s.writeAccessToken(w, resp) +} + type accessTokenResponse struct { AccessToken string `json:"access_token"` IssuedTokenType string `json:"issued_token_type,omitempty"` diff --git a/server/handlers_test.go b/server/handlers_test.go index 6ec9b962..14bb3da9 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -22,6 +22,7 @@ import ( "golang.org/x/oauth2" "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/internal" "github.com/dexidp/dex/storage" ) @@ -63,6 +64,7 @@ func TestHandleDiscovery(t *testing.T) { Introspect: fmt.Sprintf("%s/token/introspect", httpServer.URL), GrantTypes: []string{ "authorization_code", + "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange", @@ -646,6 +648,176 @@ func TestHandlePasswordLoginWithSkipApproval(t *testing.T) { } } +func TestHandleClientCredentials(t *testing.T) { + tests := []struct { + name string + clientID string + clientSecret string + scopes string + wantCode int + wantAccessTok bool + wantIDToken bool + wantUsername string + }{ + { + name: "Basic grant, no scopes", + clientID: "test", + clientSecret: "barfoo", + scopes: "", + wantCode: 200, + wantAccessTok: true, + wantIDToken: false, + }, + { + name: "With openid scope", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid", + wantCode: 200, + wantAccessTok: true, + wantIDToken: true, + }, + { + name: "With openid and profile scope includes username", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid profile", + wantCode: 200, + wantAccessTok: true, + wantIDToken: true, + wantUsername: "Test Client", + }, + { + name: "With openid email profile groups", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid email profile groups", + wantCode: 200, + wantAccessTok: true, + wantIDToken: true, + wantUsername: "Test Client", + }, + { + name: "Invalid client secret", + clientID: "test", + clientSecret: "wrong", + scopes: "", + wantCode: 401, + }, + { + name: "Unknown client", + clientID: "nonexistent", + clientSecret: "secret", + scopes: "", + wantCode: 401, + }, + { + name: "offline_access scope rejected", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid offline_access", + wantCode: 400, + }, + { + name: "Unrecognized scope", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid bogus", + wantCode: 400, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + + httpServer, s := newTestServer(t, func(c *Config) { + c.Now = time.Now + }) + defer httpServer.Close() + + // Create a confidential client for testing. + err := s.storage.CreateClient(ctx, storage.Client{ + ID: "test", + Secret: "barfoo", + RedirectURIs: []string{"https://example.com/callback"}, + Name: "Test Client", + }) + require.NoError(t, err) + + u, err := url.Parse(s.issuerURL.String()) + require.NoError(t, err) + u.Path = path.Join(u.Path, "/token") + + v := url.Values{} + v.Add("grant_type", "client_credentials") + if tc.scopes != "" { + v.Add("scope", tc.scopes) + } + + req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(v.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(tc.clientID, tc.clientSecret) + + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + require.Equal(t, tc.wantCode, rr.Code) + + if tc.wantCode == 200 { + var resp struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + } + err := json.Unmarshal(rr.Body.Bytes(), &resp) + require.NoError(t, err) + + if tc.wantAccessTok { + require.NotEmpty(t, resp.AccessToken) + require.Equal(t, "bearer", resp.TokenType) + require.Greater(t, resp.ExpiresIn, 0) + } + if tc.wantIDToken { + require.NotEmpty(t, resp.IDToken) + + // Verify the ID token claims. + provider, err := oidc.NewProvider(ctx, httpServer.URL) + require.NoError(t, err) + verifier := provider.Verifier(&oidc.Config{ClientID: tc.clientID}) + idToken, err := verifier.Verify(ctx, resp.IDToken) + require.NoError(t, err) + + // Decode the subject to verify the connector ID. + var sub internal.IDTokenSubject + require.NoError(t, internal.Unmarshal(idToken.Subject, &sub)) + require.Equal(t, "", sub.ConnId) + require.Equal(t, tc.clientID, sub.UserId) + + var claims struct { + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + } + require.NoError(t, idToken.Claims(&claims)) + + if tc.wantUsername != "" { + require.Equal(t, tc.wantUsername, claims.Name) + require.Equal(t, tc.wantUsername, claims.PreferredUsername) + } else { + require.Empty(t, claims.Name) + require.Empty(t, claims.PreferredUsername) + } + } else { + require.Empty(t, resp.IDToken) + } + // client_credentials must never return a refresh token. + require.Empty(t, resp.RefreshToken) + } + }) + } +} + func TestHandleConnectorCallbackWithSkipApproval(t *testing.T) { ctx := t.Context() diff --git a/server/oauth2.go b/server/oauth2.go index 5821f0ff..8fe75475 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -143,6 +143,7 @@ const ( grantTypePassword = "password" grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" + grantTypeClientCredentials = "client_credentials" ) const ( diff --git a/server/server.go b/server/server.go index ea87aa1c..b7c08c64 100644 --- a/server/server.go +++ b/server/server.go @@ -254,6 +254,8 @@ func newServer(ctx context.Context, c Config) (*Server, error) { allSupportedGrants[grantTypePassword] = true } + allSupportedGrants[grantTypeClientCredentials] = true + var supportedGrants []string if len(c.AllowedGrantTypes) > 0 { for _, grant := range c.AllowedGrantTypes { diff --git a/server/server_test.go b/server/server_test.go index e61e21ab..db8f12ce 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -103,6 +103,7 @@ func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server AllowedGrantTypes: []string{ // all implemented types grantTypeDeviceCode, grantTypeAuthorizationCode, + grantTypeClientCredentials, grantTypeRefreshToken, grantTypeTokenExchange, grantTypeImplicit, @@ -1774,7 +1775,7 @@ func TestServerSupportedGrants(t *testing.T) { { name: "Simple", config: func(c *Config) {}, - resGrants: []string{grantTypeAuthorizationCode, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, { name: "Minimal", @@ -1782,14 +1783,30 @@ func TestServerSupportedGrants(t *testing.T) { resGrants: []string{grantTypeTokenExchange}, }, { - name: "With password connector", - config: func(c *Config) { c.PasswordConnector = "local" }, - resGrants: []string{grantTypeAuthorizationCode, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + name: "With password connector", + config: func(c *Config) { + c.PasswordConnector = "local" + }, + resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, { - name: "With token response", - config: func(c *Config) { c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) }, - resGrants: []string{grantTypeAuthorizationCode, grantTypeImplicit, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + name: "Without client credentials", + config: func(c *Config) { + c.AllowedGrantTypes = []string{ + grantTypeAuthorizationCode, + grantTypeRefreshToken, + grantTypeDeviceCode, + grantTypeTokenExchange, + } + }, + resGrants: []string{grantTypeAuthorizationCode, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + }, + { + name: "With token response", + config: func(c *Config) { + c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) + }, + resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeImplicit, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, { name: "All", @@ -1797,7 +1814,7 @@ func TestServerSupportedGrants(t *testing.T) { c.PasswordConnector = "local" c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) }, - resGrants: []string{grantTypeAuthorizationCode, grantTypeImplicit, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeImplicit, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, } From 57a601f11edfe3e07c80a25f85943d62055c9c8f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 06:33:20 +0100 Subject: [PATCH 124/222] build(deps): bump actions/dependency-review-action from 4.8.3 to 4.9.0 (#4605) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c9ec9e27..18fce3e7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -198,4 +198,4 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Dependency Review - uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 From fb570557eecb2cda6f78e4d50365a983ca185e35 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:35:52 +0100 Subject: [PATCH 125/222] build(deps): bump docker/setup-qemu-action from 3.7.0 to 4.0.0 (#4608) Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.7.0 to 4.0.0. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/c7c53464625b32c7a7e944ae62b3e17d2b600130...ce360397dd3f832beb865e1373c09c0e9f86d70a) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 3aa779bd..62c53048 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -56,7 +56,7 @@ jobs: fetch-tags: true - name: Set up QEMU - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 From 3ab094771c1866a452722ae86c1286fdd0d39f61 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:36:17 +0100 Subject: [PATCH 126/222] build(deps): bump docker/login-action from 3.7.0 to 4.0.0 (#4609) Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.0.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...b45d80f862d83dbcd57f89517bcf500b2ab88fb2) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 62c53048..566b6c57 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -109,7 +109,7 @@ jobs: if_false: type=oci,dest=image.tar - name: Login to GitHub Container Registry - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -117,7 +117,7 @@ jobs: if: inputs.publish - name: Login to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} From a11b3cd2ef3ff4aa773646ff40481897d5314756 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Thu, 5 Mar 2026 13:29:49 +0100 Subject: [PATCH 127/222] feat(gitlab): implement TokenIdentity method (#4606) Signed-off-by: maksim.nabokikh --- connector/gitlab/gitlab.go | 33 ++++++++++++- connector/gitlab/gitlab_test.go | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/connector/gitlab/gitlab.go b/connector/gitlab/gitlab.go index a4ccf738..b9fb3bec 100644 --- a/connector/gitlab/gitlab.go +++ b/connector/gitlab/gitlab.go @@ -87,8 +87,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 { @@ -243,6 +244,34 @@ 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) +} + func (c *gitlabConnector) groupsRequired(groupScope bool) bool { return len(c.groups) > 0 || groupScope } diff --git a/connector/gitlab/gitlab_test.go b/connector/gitlab/gitlab_test.go index 28ff2643..92614643 100644 --- a/connector/gitlab/gitlab_test.go +++ b/connector/gitlab/gitlab_test.go @@ -485,3 +485,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) + }) + } +} From 787087179cab15f0f47b04940b5bdd282a5fe3e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:02:40 +0100 Subject: [PATCH 128/222] build(deps): bump golang from 1.26.0-alpine3.22 to 1.26.1-alpine3.22 (#4612) Bumps golang from 1.26.0-alpine3.22 to 1.26.1-alpine3.22. --- updated-dependencies: - dependency-name: golang dependency-version: 1.26.1-alpine3.22 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a3127b1d..cae465b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG BASE_IMAGE=alpine FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx -FROM --platform=$BUILDPLATFORM golang:1.26.0-alpine3.22@sha256:169d3991a4f795124a88b33c73549955a3d856e26e8504b5530c30bd245f9f1b AS builder +FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine3.22@sha256:07e91d24f6330432729082bb580983181809e0a48f0f38ecde26868d4568c6ac AS builder COPY --from=xx / / From 91bf627b3982dd16463199b68576df7057f255bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:02:54 +0100 Subject: [PATCH 129/222] build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.0.0 (#4616) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.0.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 566b6c57..c6f1a1b8 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -59,7 +59,7 @@ jobs: uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Set up Syft uses: anchore/sbom-action/download-syft@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0 From 8dce952b178eabd056907786f3088a7d103e7543 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:03:10 +0100 Subject: [PATCH 130/222] build(deps): bump docker/build-push-action from 6.19.2 to 7.0.0 (#4613) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.0.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...d08e5c354a6adb9ed34480a06d141179aa583294) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index c6f1a1b8..03b0c0d9 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -125,7 +125,7 @@ jobs: - name: Build and push image id: build - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x From 976e45e83c64ba221ad563e82e26b2f8ef8abfbe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:03:27 +0100 Subject: [PATCH 131/222] build(deps): bump docker/metadata-action from 5.10.0 to 6.0.0 (#4614) Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.10.0 to 6.0.0. - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/c299e40c65443455700f0fdfc63efafe5b349051...030e881283bb7a6894de51c315a6bfe6a94e05cf) --- updated-dependencies: - dependency-name: docker/metadata-action dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 03b0c0d9..c3e8135f 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -73,7 +73,7 @@ jobs: - name: Gather build metadata id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: | ${{ steps.image-name.outputs.value }} From f4c3102b3a2497f2934e4a1846f2e51b3a5e5e2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:03:44 +0100 Subject: [PATCH 132/222] build(deps): bump github/codeql-action from 4.32.5 to 4.32.6 (#4615) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.32.5 to 4.32.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/c793b717bc78562f491db7b0e93a3a178b099162...0d579ffd059c29b07949a3cce3983f0780820c98) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.32.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/analysis-scorecard.yaml | 2 +- .github/workflows/artifacts.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index e9b6cbcb..56306a64 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -42,6 +42,6 @@ jobs: retention-days: 5 - name: Upload results to GitHub Security tab - uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v3.29.5 + uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index c3e8135f..6d5f1fdd 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -264,6 +264,6 @@ jobs: overwrite: true - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v3.29.5 + uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5 with: sarif_file: trivy-results.sarif From 591a201c8855b99142cb753318889458251db78f Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 6 Mar 2026 08:48:50 +0100 Subject: [PATCH 133/222] feat(tests): add MySQL 8 support in CI and tests (#4617) Signed-off-by: maksim.nabokikh --- .github/workflows/ci.yaml | 30 +++++++++++++++++ docker-compose.override.yaml.dist | 4 +++ docker-compose.yaml | 9 +++++ storage/ent/mysql_test.go | 56 +++++++++++++++++++++++++++++++ storage/sql/config_test.go | 37 ++++++++++++++++++++ 5 files changed, 136 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 18fce3e7..5224e1b3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -48,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: @@ -124,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 diff --git a/docker-compose.override.yaml.dist b/docker-compose.override.yaml.dist index b9eefac5..30591add 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.yaml b/docker-compose.yaml index cfaf739c..6c5a052a 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: diff --git a/storage/ent/mysql_test.go b/storage/ent/mysql_test.go index a1baffb3..b602e4a5 100644 --- a/storage/ent/mysql_test.go +++ b/storage/ent/mysql_test.go @@ -18,6 +18,12 @@ const ( MySQLEntDatabaseEnv = "DEX_MYSQL_ENT_DATABASE" MySQLEntUserEnv = "DEX_MYSQL_ENT_USER" MySQLEntPasswordEnv = "DEX_MYSQL_ENT_PASSWORD" + + MySQL8EntHostEnv = "DEX_MYSQL8_ENT_HOST" + MySQL8EntPortEnv = "DEX_MYSQL8_ENT_PORT" + MySQL8EntDatabaseEnv = "DEX_MYSQL8_ENT_DATABASE" + MySQL8EntUserEnv = "DEX_MYSQL8_ENT_USER" + MySQL8EntPasswordEnv = "DEX_MYSQL8_ENT_PASSWORD" ) func mysqlTestConfig(host string, port uint64) *MySQL { @@ -40,6 +46,35 @@ func mysqlTestConfig(host string, port uint64) *MySQL { } } +func mysql8TestConfig(host string, port uint64) *MySQL { + return &MySQL{ + NetworkDB: NetworkDB{ + Database: getenv(MySQL8EntDatabaseEnv, "mysql"), + User: getenv(MySQL8EntUserEnv, "mysql"), + Password: getenv(MySQL8EntPasswordEnv, "mysql"), + Host: host, + Port: uint16(port), + }, + SSL: SSL{ + Mode: mysqlSSLFalse, + }, + params: map[string]string{ + "innodb_lock_wait_timeout": "1", + }, + } +} + +func newMySQL8Storage(t *testing.T, host string, port uint64) storage.Storage { + logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) + + cfg := mysql8TestConfig(host, port) + s, err := cfg.Open(logger) + if err != nil { + panic(err) + } + return s +} + func newMySQLStorage(t *testing.T, host string, port uint64) storage.Storage { logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) @@ -72,6 +107,27 @@ func TestMySQL(t *testing.T) { conformance.RunTransactionTests(t, newStorage) } +func TestMySQL8(t *testing.T) { + host := os.Getenv(MySQL8EntHostEnv) + if host == "" { + t.Skipf("test environment variable %s not set, skipping", MySQL8EntHostEnv) + } + + port := uint64(3306) + if rawPort := os.Getenv(MySQL8EntPortEnv); rawPort != "" { + var err error + + port, err = strconv.ParseUint(rawPort, 10, 32) + require.NoError(t, err, "invalid mysql port %q: %s", rawPort, err) + } + + newStorage := func(t *testing.T) storage.Storage { + return newMySQL8Storage(t, host, port) + } + conformance.RunTests(t, newStorage) + conformance.RunTransactionTests(t, newStorage) +} + func TestMySQLDSN(t *testing.T) { tests := []struct { name string diff --git a/storage/sql/config_test.go b/storage/sql/config_test.go index 93a593ea..606c95a8 100644 --- a/storage/sql/config_test.go +++ b/storage/sql/config_test.go @@ -275,3 +275,40 @@ func TestMySQL(t *testing.T) { } testDB(t, s, true) } + +const testMySQL8Env = "DEX_MYSQL8_HOST" + +func TestMySQL8(t *testing.T) { + host := os.Getenv(testMySQL8Env) + if host == "" { + t.Skipf("test environment variable %q not set, skipping", testMySQL8Env) + } + + port := uint64(3306) + if rawPort := os.Getenv("DEX_MYSQL8_PORT"); rawPort != "" { + var err error + + port, err = strconv.ParseUint(rawPort, 10, 32) + if err != nil { + t.Fatalf("invalid mysql port %q: %s", rawPort, err) + } + } + + s := &MySQL{ + NetworkDB: NetworkDB{ + Database: getenv("DEX_MYSQL8_DATABASE", "mysql"), + User: getenv("DEX_MYSQL8_USER", "mysql"), + Password: getenv("DEX_MYSQL8_PASSWORD", "mysql"), + Host: host, + Port: uint16(port), + ConnectionTimeout: 5, + }, + SSL: SSL{ + Mode: mysqlSSLFalse, + }, + params: map[string]string{ + "innodb_lock_wait_timeout": "3", + }, + } + testDB(t, s, true) +} From c03a68746511087baf666675fddc7290510f408a Mon Sep 17 00:00:00 2001 From: Mark Liu Date: Mon, 9 Mar 2026 07:04:01 +1100 Subject: [PATCH 134/222] fix(server): handle double-submit on approval endpoint (#4620) When GetAuthRequest returns ErrNotFound in handleApproval, render a 400 "User session error." instead of logging + rendering a 500 "Database error.". Covers the double-submit race where sendCodeResponse deletes the auth request on first approval and the second request finds nothing. --- Signed-off-by: Mark Liu Signed-off-by: mark-liu --- server/handlers.go | 4 ++ server/handlers_approval_test.go | 117 +++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 server/handlers_approval_test.go diff --git a/server/handlers.go b/server/handlers.go index 8a20438c..10c6688d 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -635,6 +635,10 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { authReq, err := s.storage.GetAuthRequest(ctx, r.FormValue("req")) if err != nil { + if err == storage.ErrNotFound { + s.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } s.logger.ErrorContext(r.Context(), "failed to get auth request", "err", err) s.renderError(r, w, http.StatusInternalServerError, "Database error.") return diff --git a/server/handlers_approval_test.go b/server/handlers_approval_test.go new file mode 100644 index 00000000..5ab80fc5 --- /dev/null +++ b/server/handlers_approval_test.go @@ -0,0 +1,117 @@ +package server + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/storage" +) + +type getAuthRequestErrorStorage struct { + storage.Storage + err error +} + +func (s *getAuthRequestErrorStorage) GetAuthRequest(context.Context, string) (storage.AuthRequest, error) { + return storage.AuthRequest{}, s.err +} + +func TestHandleApprovalGetAuthRequestErrorGET(t *testing.T) { + httpServer, server := newTestServer(t, func(c *Config) { + c.Storage = &getAuthRequestErrorStorage{Storage: c.Storage, err: errors.New("storage unavailable")} + }) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/approval?req=any&hmac=AQ", nil) + + server.ServeHTTP(rr, req) + + require.Equal(t, http.StatusInternalServerError, rr.Code) + require.Contains(t, rr.Body.String(), "Database error.") +} + +func TestHandleApprovalGetAuthRequestNotFoundGET(t *testing.T) { + httpServer, server := newTestServer(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/approval?req=does-not-exist&hmac=AQ", nil) + + server.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "User session error.") + require.NotContains(t, rr.Body.String(), "Database error.") +} + +func TestHandleApprovalGetAuthRequestNotFoundPOST(t *testing.T) { + httpServer, server := newTestServer(t, nil) + defer httpServer.Close() + + body := strings.NewReader("approval=approve&req=does-not-exist&hmac=AQ") + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/approval", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + server.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "User session error.") + require.NotContains(t, rr.Body.String(), "Database error.") +} + +func TestHandleApprovalDoubleSubmitPOST(t *testing.T) { + ctx := t.Context() + httpServer, server := newTestServer(t, nil) + defer httpServer.Close() + + authReq := storage.AuthRequest{ + ID: "approval-double-submit", + ClientID: "test", + ResponseTypes: []string{responseTypeCode}, + RedirectURI: "https://client.example/callback", + Expiry: time.Now().Add(time.Minute), + LoggedIn: true, + HMACKey: []byte("approval-double-submit-key"), + } + require.NoError(t, server.storage.CreateAuthRequest(ctx, authReq)) + + h := hmac.New(sha256.New, authReq.HMACKey) + h.Write([]byte(authReq.ID)) + mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + form := url.Values{ + "approval": {"approve"}, + "req": {authReq.ID}, + "hmac": {mac}, + } + + firstRR := httptest.NewRecorder() + firstReq := httptest.NewRequest(http.MethodPost, "/approval", strings.NewReader(form.Encode())) + firstReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + server.ServeHTTP(firstRR, firstReq) + + require.Equal(t, http.StatusSeeOther, firstRR.Code) + require.Contains(t, firstRR.Header().Get("Location"), "https://client.example/callback") + + secondRR := httptest.NewRecorder() + secondReq := httptest.NewRequest(http.MethodPost, "/approval", strings.NewReader(form.Encode())) + secondReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + server.ServeHTTP(secondRR, secondReq) + + require.Equal(t, http.StatusBadRequest, secondRR.Code) + require.Contains(t, secondRR.Body.String(), "User session error.") + require.NotContains(t, secondRR.Body.String(), "Database error.") +} From e2462a25ce791b193a796cb677eb7adc65203c2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:24:21 +0100 Subject: [PATCH 135/222] build(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 in /examples (#4627) Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0. - [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0) --- updated-dependencies: - dependency-name: golang.org/x/oauth2 dependency-version: 0.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/go.mod | 4 ++-- examples/go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 39e20ea4..39d5b20e 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,12 +1,12 @@ module github.com/dexidp/dex/examples -go 1.24.0 +go 1.25.0 require ( github.com/coreos/go-oidc/v3 v3.17.0 github.com/dexidp/dex/api/v2 v2.4.0 github.com/spf13/cobra v1.10.2 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 google.golang.org/grpc v1.79.1 ) diff --git a/examples/go.sum b/examples/go.sum index 1221aafd..9c77be9a 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -39,8 +39,8 @@ go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4Etq go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= From 9ba3c3f930916940ad5dcd950f4f45fe5d1fdcb1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:24:43 +0100 Subject: [PATCH 136/222] build(deps): bump aquasecurity/trivy-action from 0.34.2 to 0.35.0 (#4622) Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.34.2 to 0.35.0. - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/97e0b3872f55f89b95b2f65b3dbab56962816478...57a97c7e7821a5776cebc9bb87c984fa69cba8f1) --- updated-dependencies: - dependency-name: aquasecurity/trivy-action dependency-version: 0.35.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 6d5f1fdd..8da0e462 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -233,7 +233,7 @@ jobs: restore-keys: trivy-cache- - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0 with: input: docker-image format: sarif From 74dd7eeb4ca8c4de275c2045e81a09b674a0837f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:25:01 +0100 Subject: [PATCH 137/222] build(deps): bump google.golang.org/grpc from 1.79.1 to 1.79.2 (#4623) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.1 to 1.79.2. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.1...v1.79.2) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3dfb3e45..a26769f0 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( golang.org/x/net v0.51.0 golang.org/x/oauth2 v0.35.0 google.golang.org/api v0.269.0 - google.golang.org/grpc v1.79.1 + google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 82ea6848..8b39b275 100644 --- a/go.sum +++ b/go.sum @@ -324,8 +324,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= 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= From e67c47c614ace6386885526a1b839bff9a56cd6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:25:45 +0100 Subject: [PATCH 138/222] build(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 (#4624) Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0. - [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0) --- updated-dependencies: - dependency-name: golang.org/x/oauth2 dependency-version: 0.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a26769f0..702d8ffc 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( golang.org/x/crypto v0.48.0 golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 golang.org/x/net v0.51.0 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.269.0 google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 diff --git a/go.sum b/go.sum index 8b39b275..dfa54411 100644 --- a/go.sum +++ b/go.sum @@ -282,8 +282,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From 01b6822bcbeefe6629a1bc1fd9777acac5d5f13a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:35:13 +0100 Subject: [PATCH 139/222] build(deps): bump google.golang.org/grpc in /examples (#4626) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.1 to 1.79.2. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.1...v1.79.2) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/go.mod | 2 +- examples/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 39d5b20e..1a6756f6 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -7,7 +7,7 @@ require ( github.com/dexidp/dex/api/v2 v2.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/oauth2 v0.36.0 - google.golang.org/grpc v1.79.1 + google.golang.org/grpc v1.79.2 ) require ( diff --git a/examples/go.sum b/examples/go.sum index 9c77be9a..c0aa9ceb 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -49,8 +49,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From a4136db3a39f8f39901be1a9ee55141362fd6980 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:37:39 +0100 Subject: [PATCH 140/222] build(deps): bump google.golang.org/grpc in /api/v2 (#4625) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.1 to 1.79.2. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.1...v1.79.2) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/v2/go.mod | 2 +- api/v2/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/v2/go.mod b/api/v2/go.mod index 6c14b7e4..289e3b81 100644 --- a/api/v2/go.mod +++ b/api/v2/go.mod @@ -3,7 +3,7 @@ module github.com/dexidp/dex/api/v2 go 1.24.0 require ( - google.golang.org/grpc v1.79.1 + google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 ) diff --git a/api/v2/go.sum b/api/v2/go.sum index 727897fb..e9906532 100644 --- a/api/v2/go.sum +++ b/api/v2/go.sum @@ -32,7 +32,7 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= From 35c0b56569e9ed1cbf444dead6c0c610365fd6cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:13:35 +0100 Subject: [PATCH 141/222] build(deps): bump sigstore/cosign-installer from 4.0.0 to 4.1.0 (#4628) Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/faadad0cce49287aee09b3a48701e75088a2c6ad...ba7bc0a3fef59531c69a25acd34668d6d3fe6f22) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 8da0e462..e2d7c112 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -65,7 +65,7 @@ jobs: uses: anchore/sbom-action/download-syft@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0 - name: Install cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Set image name id: image-name From 7bd3c2a576c6579c72bc88a5afcbda912feadd12 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:15:55 +0100 Subject: [PATCH 142/222] build(deps): bump google.golang.org/api from 0.269.0 to 0.270.0 (#4630) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.269.0 to 0.270.0. - [Release notes](https://github.com/googleapis/google-api-go-client/releases) - [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md) - [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.269.0...v0.270.0) --- updated-dependencies: - dependency-name: google.golang.org/api dependency-version: 0.270.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 702d8ffc..2bb30200 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 golang.org/x/net v0.51.0 golang.org/x/oauth2 v0.36.0 - google.golang.org/api v0.269.0 + google.golang.org/api v0.270.0 google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 ) @@ -70,7 +70,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -119,7 +119,7 @@ require ( golang.org/x/tools v0.41.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index dfa54411..ea5ecfff 100644 --- a/go.sum +++ b/go.sum @@ -95,8 +95,8 @@ 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.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= -github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= @@ -316,14 +316,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= -google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= +google.golang.org/api v0.270.0 h1:4rJZbIuWSTohczG9mG2ukSDdt9qKx4sSSHIydTN26L4= +google.golang.org/api v0.270.0/go.mod h1:5+H3/8DlXpQWrSz4RjGGwz5HfJAQSEI8Bc6JqQNH77U= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From ae8c5af72e2eb7a3b5d1de37e5d785279ba42f44 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:43:20 +0100 Subject: [PATCH 143/222] build(deps): bump anchore/sbom-action from 0.23.0 to 0.23.1 (#4629) --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index e2d7c112..7169a50f 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -62,7 +62,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Set up Syft - uses: anchore/sbom-action/download-syft@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0 + uses: anchore/sbom-action/download-syft@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 - name: Install cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 From 3d97c59032d4feeea731eab8728caa2b13d7a674 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Tue, 10 Mar 2026 15:55:10 +0100 Subject: [PATCH 144/222] test: add concurrency tests for storage implementations (#4631) Signed-off-by: maksim.nabokikh --- storage/conformance/transactions.go | 121 ++++++++++++++++++++++++++++ storage/ent/mysql_test.go | 10 +++ storage/ent/postgres_test.go | 5 ++ storage/ent/sqlite_test.go | 1 + storage/etcd/etcd_test.go | 7 ++ storage/kubernetes/lock.go | 43 +++++++--- storage/kubernetes/storage_test.go | 1 + storage/memory/memory_test.go | 1 + storage/sql/config_test.go | 15 +++- storage/sql/sqlite_test.go | 2 +- 10 files changed, 191 insertions(+), 15 deletions(-) diff --git a/storage/conformance/transactions.go b/storage/conformance/transactions.go index 1383c8e7..5889a024 100644 --- a/storage/conformance/transactions.go +++ b/storage/conformance/transactions.go @@ -2,9 +2,12 @@ package conformance import ( "context" + "strconv" + "sync" "testing" "time" + "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "github.com/dexidp/dex/storage" @@ -26,6 +29,16 @@ func RunTransactionTests(t *testing.T, newStorage func(t *testing.T) storage.Sto }) } +// RunConcurrencyTests runs tests that verify storage implementations handle +// high-contention parallel updates correctly. Unlike RunTransactionTests, +// these tests use real goroutine-based parallelism rather than nested calls, +// and are safe to run on all storage backends (including those with non-reentrant locks). +func RunConcurrencyTests(t *testing.T, newStorage func(t *testing.T) storage.Storage) { + runTests(t, newStorage, []subTest{ + {"RefreshTokenParallelUpdate", testRefreshTokenParallelUpdate}, + }) +} + func testClientConcurrentUpdate(t *testing.T, s storage.Storage) { ctx := t.Context() c := storage.Client{ @@ -180,3 +193,111 @@ func testKeysConcurrentUpdate(t *testing.T, s storage.Storage) { } } } + +// testRefreshTokenParallelUpdate tests that many parallel updates to the same +// refresh token are serialized correctly by the storage and no updates are lost. +// +// Each goroutine atomically increments a counter stored in the Token field. +// After all goroutines finish, the counter must equal the number of successful updates. +// A mismatch indicates lost updates due to broken atomicity. +func testRefreshTokenParallelUpdate(t *testing.T, s storage.Storage) { + ctx := t.Context() + + id := storage.NewID() + refresh := storage.RefreshToken{ + ID: id, + Token: "0", + Nonce: "foo", + ClientID: "client_id", + ConnectorID: "connector_id", + Scopes: []string{"openid"}, + CreatedAt: time.Now().UTC().Round(time.Millisecond), + LastUsed: time.Now().UTC().Round(time.Millisecond), + Claims: storage.Claims{ + UserID: "1", + Username: "jane", + Email: "jane@example.com", + }, + } + + require.NoError(t, s.CreateRefresh(ctx, refresh)) + + const numWorkers = 100 + + type updateResult struct { + err error + newToken string // token value written by this worker's updater + } + + var wg sync.WaitGroup + results := make([]updateResult, numWorkers) + + for i := range numWorkers { + wg.Add(1) + go func() { + defer wg.Done() + results[i].err = s.UpdateRefreshToken(ctx, id, func(old storage.RefreshToken) (storage.RefreshToken, error) { + counter, _ := strconv.Atoi(old.Token) + old.Token = strconv.Itoa(counter + 1) + results[i].newToken = old.Token + return old, nil + }) + }() + } + + wg.Wait() + + errCounts := map[string]int{} + var successes int + writtenTokens := map[string]int{} + for _, r := range results { + if r.err == nil { + successes++ + writtenTokens[r.newToken]++ + } else { + errCounts[r.err.Error()]++ + } + } + + for msg, count := range errCounts { + t.Logf("error (x%d): %s", count, msg) + } + + stored, err := s.GetRefresh(ctx, id) + require.NoError(t, err) + + counter, err := strconv.Atoi(stored.Token) + require.NoError(t, err) + + t.Logf("parallel refresh token updates: %d/%d succeeded, final counter: %d", successes, numWorkers, counter) + + if successes < numWorkers { + t.Errorf("not all updates succeeded: %d/%d (some failed under contention)", successes, numWorkers) + } + + if counter != successes { + t.Errorf("lost updates detected: %d successful updates but counter is %d", successes, counter) + } + + // Each successful updater must have seen a unique counter value. + // Duplicates would mean two updaters read the same state — a sign of broken atomicity. + for token, count := range writtenTokens { + if count > 1 { + t.Errorf("token %q was written by %d updaters — concurrent updaters saw the same state", token, count) + } + } + + // Successful updaters must have produced a contiguous sequence 1..N. + // A gap would mean an updater saw stale state even though the write succeeded. + for i := 1; i <= successes; i++ { + if writtenTokens[strconv.Itoa(i)] != 1 { + t.Errorf("expected token %q to be written exactly once, got %d", strconv.Itoa(i), writtenTokens[strconv.Itoa(i)]) + } + } + + // The token stored in the database must match the highest value written. + // This confirms that the last successful update is the one persisted. + if stored.Token != strconv.Itoa(successes) { + t.Errorf("stored token %q does not match expected final value %q", stored.Token, strconv.Itoa(successes)) + } +} diff --git a/storage/ent/mysql_test.go b/storage/ent/mysql_test.go index b602e4a5..d7a06ffa 100644 --- a/storage/ent/mysql_test.go +++ b/storage/ent/mysql_test.go @@ -105,6 +105,11 @@ func TestMySQL(t *testing.T) { } conformance.RunTests(t, newStorage) conformance.RunTransactionTests(t, newStorage) + + // TODO(nabokihms): ent MySQL does not retry on deadlocks (Error 1213, SQLSTATE 40001: + // Deadlock found when trying to get lock; try restarting transaction). + // Under high contention most updates fail. + // conformance.RunConcurrencyTests(t, newStorage) } func TestMySQL8(t *testing.T) { @@ -126,6 +131,11 @@ func TestMySQL8(t *testing.T) { } conformance.RunTests(t, newStorage) conformance.RunTransactionTests(t, newStorage) + + // TODO(nabokihms): ent MySQL 8 does not retry on deadlocks (Error 1213, SQLSTATE 40001: + // Deadlock found when trying to get lock; try restarting transaction). + // Under high contention most updates fail. + // conformance.RunConcurrencyTests(t, newStorage) } func TestMySQLDSN(t *testing.T) { diff --git a/storage/ent/postgres_test.go b/storage/ent/postgres_test.go index bbbde38e..b53ed382 100644 --- a/storage/ent/postgres_test.go +++ b/storage/ent/postgres_test.go @@ -65,6 +65,11 @@ func TestPostgres(t *testing.T) { } conformance.RunTests(t, newStorage) conformance.RunTransactionTests(t, newStorage) + + // TODO(nabokihms): ent Postgres uses SERIALIZABLE transaction isolation for UpdateRefreshToken, + // but does not retry on serialization failures (pq: could not serialize access due to + // concurrent update, SQLSTATE 40001). Under high contention most updates fail immediately. + // conformance.RunConcurrencyTests(t, newStorage) } func TestPostgresDSN(t *testing.T) { diff --git a/storage/ent/sqlite_test.go b/storage/ent/sqlite_test.go index 55c1b5c5..54638c19 100644 --- a/storage/ent/sqlite_test.go +++ b/storage/ent/sqlite_test.go @@ -21,4 +21,5 @@ func newSQLiteStorage(t *testing.T) storage.Storage { func TestSQLite3(t *testing.T) { conformance.RunTests(t, newSQLiteStorage) + conformance.RunConcurrencyTests(t, newSQLiteStorage) } diff --git a/storage/etcd/etcd_test.go b/storage/etcd/etcd_test.go index 6783c25b..55501b49 100644 --- a/storage/etcd/etcd_test.go +++ b/storage/etcd/etcd_test.go @@ -89,4 +89,11 @@ func TestEtcd(t *testing.T) { withTimeout(time.Minute*1, func() { conformance.RunTransactionTests(t, newStorage) }) + + // TODO(nabokihms): etcd uses compare-and-swap (txnUpdate) for UpdateRefreshToken, + // but does not retry on CAS conflicts ("concurrent conflicting update happened"). + // Under high contention virtually all updates fail — only the first writer succeeds. + // withTimeout(time.Minute*1, func() { + // conformance.RunConcurrencyTests(t, newStorage) + // }) } diff --git a/storage/kubernetes/lock.go b/storage/kubernetes/lock.go index c67380dc..e3bdb444 100644 --- a/storage/kubernetes/lock.go +++ b/storage/kubernetes/lock.go @@ -22,8 +22,18 @@ var ( // - Some of OIDC providers could use the refresh token rotation feature which requires calling refresh only once. // - Providers can limit the rate of requests to the token endpoint, which will lead to the error // in case of many concurrent requests. +// +// The lock uses a Kubernetes annotation on the refresh token resource as a mutex. +// Only one goroutine can hold the lock at a time; others poll until the annotation +// is removed (unlocked) or expires (broken). The Kubernetes resourceVersion on put +// acts as compare-and-swap: if two goroutines race to set the annotation, only one +// succeeds and the other gets a 409 Conflict. type refreshTokenLock struct { - cli *client + cli *client + // waitingState tracks whether this lock instance has lost a compare-and-swap race + // and is now polling for the lock to be released. Used by Unlock to skip the + // annotation removal — only the goroutine that successfully wrote the annotation + // should remove it. waitingState bool } @@ -31,6 +41,8 @@ func newRefreshTokenLock(cli *client) *refreshTokenLock { return &refreshTokenLock{cli: cli} } +// Lock polls until the lock annotation can be set on the refresh token resource. +// Returns nil when the lock is acquired, or an error on timeout (60 attempts × 100ms). func (l *refreshTokenLock) Lock(id string) error { for i := 0; i <= 60; i++ { ok, err := l.setLockAnnotation(id) @@ -45,9 +57,12 @@ func (l *refreshTokenLock) Lock(id string) error { return fmt.Errorf("timeout waiting for refresh token %s lock", id) } +// Unlock removes the lock annotation from the refresh token resource. +// Only the holder of the lock (waitingState == false) performs the removal. func (l *refreshTokenLock) Unlock(id string) { if l.waitingState { - // Do not need to unlock for waiting goroutines, because the have not set it. + // This goroutine never successfully wrote the annotation, so there's + // nothing to remove. Another goroutine holds (or held) the lock. return } @@ -64,6 +79,13 @@ func (l *refreshTokenLock) Unlock(id string) { } } +// setLockAnnotation attempts to acquire the lock by writing an annotation with +// an expiration timestamp. Returns (true, nil) when the caller should keep waiting, +// (false, nil) when the lock is acquired, or (false, err) on a non-retriable error. +// +// The locking protocol relies on Kubernetes optimistic concurrency: every put +// includes the resource's current resourceVersion, so concurrent writes to the +// same object result in a 409 Conflict for all but one writer. func (l *refreshTokenLock) setLockAnnotation(id string) (bool, error) { r, err := l.cli.getRefreshToken(id) if err != nil { @@ -77,13 +99,14 @@ func (l *refreshTokenLock) setLockAnnotation(id string) (bool, error) { val, ok := r.Annotations[lockAnnotation] if !ok { - if l.waitingState { - return false, nil - } - + // No annotation means the lock is free. Every goroutine — whether it's + // a first-time caller or was previously waiting — must compete by writing + // the annotation. The put uses the current resourceVersion, so only one + // writer succeeds; the rest get a 409 Conflict and go back to polling. r.Annotations = lockData err := l.cli.put(resourceRefreshToken, r.ObjectMeta.Name, r) if err == nil { + l.waitingState = false return false, nil } @@ -100,24 +123,24 @@ func (l *refreshTokenLock) setLockAnnotation(id string) (bool, error) { } if !currentTime.After(until) { - // waiting for the lock to be released + // Lock is held by another goroutine and has not expired yet — keep polling. l.waitingState = true return true, nil } - // Lock time is out, lets break the lock and take the advantage + // Lock has expired (holder crashed or is too slow). Attempt to break it by + // overwriting the annotation with a new expiration. Again, only one writer + // can win the compare-and-swap race. r.Annotations = lockData err = l.cli.put(resourceRefreshToken, r.ObjectMeta.Name, r) if err == nil { - // break lock annotation return false, nil } l.cli.logger.Debug("break lock annotation", "error", err) if isKubernetesAPIConflictError(err) { l.waitingState = true - // after breaking error waiting for the lock to be released return true, nil } return false, err diff --git a/storage/kubernetes/storage_test.go b/storage/kubernetes/storage_test.go index 98ef25fa..906e5ce5 100644 --- a/storage/kubernetes/storage_test.go +++ b/storage/kubernetes/storage_test.go @@ -85,6 +85,7 @@ func (s *StorageTestSuite) TestStorage() { } conformance.RunTests(s.T(), newStorage) + conformance.RunConcurrencyTests(s.T(), newStorage) conformance.RunTransactionTests(s.T(), newStorage) } diff --git a/storage/memory/memory_test.go b/storage/memory/memory_test.go index e6e8232f..acd0be1b 100644 --- a/storage/memory/memory_test.go +++ b/storage/memory/memory_test.go @@ -15,4 +15,5 @@ func TestStorage(t *testing.T) { return New(logger) } conformance.RunTests(t, newStorage) + conformance.RunConcurrencyTests(t, newStorage) } diff --git a/storage/sql/config_test.go b/storage/sql/config_test.go index 606c95a8..c098919b 100644 --- a/storage/sql/config_test.go +++ b/storage/sql/config_test.go @@ -50,7 +50,7 @@ type opener interface { open(logger *slog.Logger) (*conn, error) } -func testDB(t *testing.T, o opener, withTransactions bool) { +func testDB(t *testing.T, o opener, withTransactions, withConcurrentTests bool) { // t.Fatal has a bad habit of not actually printing the error fatal := func(i any) { fmt.Fprintln(os.Stdout, i) @@ -71,11 +71,18 @@ func testDB(t *testing.T, o opener, withTransactions bool) { withTimeout(time.Minute*1, func() { conformance.RunTests(t, newStorage) }) + if withTransactions { withTimeout(time.Minute*1, func() { conformance.RunTransactionTests(t, newStorage) }) } + + if withConcurrentTests { + withTimeout(time.Minute*1, func() { + conformance.RunConcurrencyTests(t, newStorage) + }) + } } func getenv(key, defaultVal string) string { @@ -236,7 +243,7 @@ func TestPostgres(t *testing.T) { Mode: pgSSLDisable, // Postgres container doesn't support SSL. }, } - testDB(t, p, true) + testDB(t, p, true, false) } const testMySQLEnv = "DEX_MYSQL_HOST" @@ -273,7 +280,7 @@ func TestMySQL(t *testing.T) { "innodb_lock_wait_timeout": "3", }, } - testDB(t, s, true) + testDB(t, s, true, false) } const testMySQL8Env = "DEX_MYSQL8_HOST" @@ -310,5 +317,5 @@ func TestMySQL8(t *testing.T) { "innodb_lock_wait_timeout": "3", }, } - testDB(t, s, true) + testDB(t, s, true, false) } diff --git a/storage/sql/sqlite_test.go b/storage/sql/sqlite_test.go index 89d06aee..e21cbcc2 100644 --- a/storage/sql/sqlite_test.go +++ b/storage/sql/sqlite_test.go @@ -8,5 +8,5 @@ import ( ) func TestSQLite3(t *testing.T) { - testDB(t, &SQLite3{":memory:"}, false) + testDB(t, &SQLite3{":memory:"}, false, true) } From 47b645406c52e69e645ed1607a7af75479384d5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:10:06 +0100 Subject: [PATCH 145/222] build(deps): bump google.golang.org/api from 0.270.0 to 0.271.0 (#4633) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.270.0 to 0.271.0. - [Release notes](https://github.com/googleapis/google-api-go-client/releases) - [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md) - [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.270.0...v0.271.0) --- updated-dependencies: - dependency-name: google.golang.org/api dependency-version: 0.271.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 2bb30200..f83334a6 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 golang.org/x/net v0.51.0 golang.org/x/oauth2 v0.36.0 - google.golang.org/api v0.270.0 + google.golang.org/api v0.271.0 google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 ) @@ -112,10 +112,10 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.14.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.41.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/go.sum b/go.sum index ea5ecfff..ed20373e 100644 --- a/go.sum +++ b/go.sum @@ -287,8 +287,8 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7 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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -298,8 +298,8 @@ 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -316,8 +316,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.270.0 h1:4rJZbIuWSTohczG9mG2ukSDdt9qKx4sSSHIydTN26L4= -google.golang.org/api v0.270.0/go.mod h1:5+H3/8DlXpQWrSz4RjGGwz5HfJAQSEI8Bc6JqQNH77U= +google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= +google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= From 77777730676b2ad8ac302bc1c5ba6f491f4351f7 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Wed, 11 Mar 2026 07:21:14 +0100 Subject: [PATCH 146/222] feat(connector): connectors for grants (#4619) Signed-off-by: maksim.nabokikh --- api/v2/api.pb.go | 701 ++++++++++++++------------ api/v2/api.proto | 11 + cmd/dex/config.go | 24 +- cmd/dex/config_test.go | 7 + cmd/dex/serve.go | 5 + examples/config-dev.yaml | 14 +- server/api.go | 36 +- server/api_test.go | 99 ++++ server/handlers.go | 87 +++- server/handlers_test.go | 157 ++++++ server/oauth2.go | 10 + server/refreshhandlers.go | 4 + server/server.go | 9 + storage/conformance/conformance.go | 11 +- storage/ent/client/connector.go | 2 + storage/ent/client/types.go | 9 +- storage/ent/db/connector.go | 18 +- storage/ent/db/connector/connector.go | 3 + storage/ent/db/connector/where.go | 10 + storage/ent/db/connector_create.go | 10 + storage/ent/db/connector_update.go | 59 +++ storage/ent/db/migrate/schema.go | 1 + storage/ent/db/mutation.go | 119 ++++- storage/ent/schema/connector.go | 2 + storage/kubernetes/types.go | 12 +- storage/sql/crud.go | 35 +- storage/sql/migrate.go | 7 + storage/storage.go | 4 + 28 files changed, 1087 insertions(+), 379 deletions(-) diff --git a/api/v2/api.pb.go b/api/v2/api.pb.go index 6e8a134e..ee7e90ce 100644 --- a/api/v2/api.pb.go +++ b/api/v2/api.pb.go @@ -1129,6 +1129,7 @@ type Connector struct { 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 } @@ -1191,6 +1192,13 @@ func (x *Connector) GetConfig() []byte { 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"` @@ -1281,21 +1289,71 @@ func (x *CreateConnectorResp) GetAlreadyExists() bool { 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"` + 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[24] + mi := &file_api_v2_api_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1307,7 +1365,7 @@ func (x *UpdateConnectorReq) String() string { func (*UpdateConnectorReq) ProtoMessage() {} func (x *UpdateConnectorReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[24] + mi := &file_api_v2_api_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1320,7 +1378,7 @@ func (x *UpdateConnectorReq) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConnectorReq.ProtoReflect.Descriptor instead. func (*UpdateConnectorReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{24} + return file_api_v2_api_proto_rawDescGZIP(), []int{25} } func (x *UpdateConnectorReq) GetId() string { @@ -1351,6 +1409,13 @@ func (x *UpdateConnectorReq) GetNewConfig() []byte { 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"` @@ -1361,7 +1426,7 @@ type UpdateConnectorResp struct { func (x *UpdateConnectorResp) Reset() { *x = UpdateConnectorResp{} - mi := &file_api_v2_api_proto_msgTypes[25] + mi := &file_api_v2_api_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1373,7 +1438,7 @@ func (x *UpdateConnectorResp) String() string { func (*UpdateConnectorResp) ProtoMessage() {} func (x *UpdateConnectorResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[25] + mi := &file_api_v2_api_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1386,7 +1451,7 @@ func (x *UpdateConnectorResp) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConnectorResp.ProtoReflect.Descriptor instead. func (*UpdateConnectorResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{25} + return file_api_v2_api_proto_rawDescGZIP(), []int{26} } func (x *UpdateConnectorResp) GetNotFound() bool { @@ -1406,7 +1471,7 @@ type DeleteConnectorReq struct { func (x *DeleteConnectorReq) Reset() { *x = DeleteConnectorReq{} - mi := &file_api_v2_api_proto_msgTypes[26] + mi := &file_api_v2_api_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1418,7 +1483,7 @@ func (x *DeleteConnectorReq) String() string { func (*DeleteConnectorReq) ProtoMessage() {} func (x *DeleteConnectorReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[26] + mi := &file_api_v2_api_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1431,7 +1496,7 @@ func (x *DeleteConnectorReq) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteConnectorReq.ProtoReflect.Descriptor instead. func (*DeleteConnectorReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{26} + return file_api_v2_api_proto_rawDescGZIP(), []int{27} } func (x *DeleteConnectorReq) GetId() string { @@ -1451,7 +1516,7 @@ type DeleteConnectorResp struct { func (x *DeleteConnectorResp) Reset() { *x = DeleteConnectorResp{} - mi := &file_api_v2_api_proto_msgTypes[27] + mi := &file_api_v2_api_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1463,7 +1528,7 @@ func (x *DeleteConnectorResp) String() string { func (*DeleteConnectorResp) ProtoMessage() {} func (x *DeleteConnectorResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[27] + mi := &file_api_v2_api_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1476,7 +1541,7 @@ func (x *DeleteConnectorResp) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteConnectorResp.ProtoReflect.Descriptor instead. func (*DeleteConnectorResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{27} + return file_api_v2_api_proto_rawDescGZIP(), []int{28} } func (x *DeleteConnectorResp) GetNotFound() bool { @@ -1495,7 +1560,7 @@ type ListConnectorReq struct { func (x *ListConnectorReq) Reset() { *x = ListConnectorReq{} - mi := &file_api_v2_api_proto_msgTypes[28] + mi := &file_api_v2_api_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1507,7 +1572,7 @@ func (x *ListConnectorReq) String() string { func (*ListConnectorReq) ProtoMessage() {} func (x *ListConnectorReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[28] + mi := &file_api_v2_api_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1520,7 +1585,7 @@ func (x *ListConnectorReq) ProtoReflect() protoreflect.Message { // Deprecated: Use ListConnectorReq.ProtoReflect.Descriptor instead. func (*ListConnectorReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{28} + return file_api_v2_api_proto_rawDescGZIP(), []int{29} } // ListConnectorResp returns a list of connectors. @@ -1533,7 +1598,7 @@ type ListConnectorResp struct { func (x *ListConnectorResp) Reset() { *x = ListConnectorResp{} - mi := &file_api_v2_api_proto_msgTypes[29] + mi := &file_api_v2_api_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1545,7 +1610,7 @@ func (x *ListConnectorResp) String() string { func (*ListConnectorResp) ProtoMessage() {} func (x *ListConnectorResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[29] + mi := &file_api_v2_api_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1558,7 +1623,7 @@ func (x *ListConnectorResp) ProtoReflect() protoreflect.Message { // Deprecated: Use ListConnectorResp.ProtoReflect.Descriptor instead. func (*ListConnectorResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{29} + return file_api_v2_api_proto_rawDescGZIP(), []int{30} } func (x *ListConnectorResp) GetConnectors() []*Connector { @@ -1577,7 +1642,7 @@ type VersionReq struct { func (x *VersionReq) Reset() { *x = VersionReq{} - mi := &file_api_v2_api_proto_msgTypes[30] + mi := &file_api_v2_api_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1589,7 +1654,7 @@ func (x *VersionReq) String() string { func (*VersionReq) ProtoMessage() {} func (x *VersionReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[30] + mi := &file_api_v2_api_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1602,7 +1667,7 @@ func (x *VersionReq) ProtoReflect() protoreflect.Message { // Deprecated: Use VersionReq.ProtoReflect.Descriptor instead. func (*VersionReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{30} + return file_api_v2_api_proto_rawDescGZIP(), []int{31} } // VersionResp holds the version info of components. @@ -1619,7 +1684,7 @@ type VersionResp struct { func (x *VersionResp) Reset() { *x = VersionResp{} - mi := &file_api_v2_api_proto_msgTypes[31] + mi := &file_api_v2_api_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1631,7 +1696,7 @@ func (x *VersionResp) String() string { func (*VersionResp) ProtoMessage() {} func (x *VersionResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[31] + mi := &file_api_v2_api_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1644,7 +1709,7 @@ func (x *VersionResp) ProtoReflect() protoreflect.Message { // Deprecated: Use VersionResp.ProtoReflect.Descriptor instead. func (*VersionResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{31} + return file_api_v2_api_proto_rawDescGZIP(), []int{32} } func (x *VersionResp) GetServer() string { @@ -1670,7 +1735,7 @@ type DiscoveryReq struct { func (x *DiscoveryReq) Reset() { *x = DiscoveryReq{} - mi := &file_api_v2_api_proto_msgTypes[32] + mi := &file_api_v2_api_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1682,7 +1747,7 @@ func (x *DiscoveryReq) String() string { func (*DiscoveryReq) ProtoMessage() {} func (x *DiscoveryReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[32] + mi := &file_api_v2_api_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1695,7 +1760,7 @@ func (x *DiscoveryReq) ProtoReflect() protoreflect.Message { // Deprecated: Use DiscoveryReq.ProtoReflect.Descriptor instead. func (*DiscoveryReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{32} + return file_api_v2_api_proto_rawDescGZIP(), []int{33} } // DiscoverResp holds the version oidc disovery info. @@ -1722,7 +1787,7 @@ type DiscoveryResp struct { func (x *DiscoveryResp) Reset() { *x = DiscoveryResp{} - mi := &file_api_v2_api_proto_msgTypes[33] + mi := &file_api_v2_api_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1734,7 +1799,7 @@ func (x *DiscoveryResp) String() string { func (*DiscoveryResp) ProtoMessage() {} func (x *DiscoveryResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[33] + mi := &file_api_v2_api_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1747,7 +1812,7 @@ func (x *DiscoveryResp) ProtoReflect() protoreflect.Message { // Deprecated: Use DiscoveryResp.ProtoReflect.Descriptor instead. func (*DiscoveryResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{33} + return file_api_v2_api_proto_rawDescGZIP(), []int{34} } func (x *DiscoveryResp) GetIssuer() string { @@ -1869,7 +1934,7 @@ type RefreshTokenRef struct { func (x *RefreshTokenRef) Reset() { *x = RefreshTokenRef{} - mi := &file_api_v2_api_proto_msgTypes[34] + mi := &file_api_v2_api_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1881,7 +1946,7 @@ func (x *RefreshTokenRef) String() string { func (*RefreshTokenRef) ProtoMessage() {} func (x *RefreshTokenRef) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[34] + mi := &file_api_v2_api_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1894,7 +1959,7 @@ func (x *RefreshTokenRef) ProtoReflect() protoreflect.Message { // Deprecated: Use RefreshTokenRef.ProtoReflect.Descriptor instead. func (*RefreshTokenRef) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{34} + return file_api_v2_api_proto_rawDescGZIP(), []int{35} } func (x *RefreshTokenRef) GetId() string { @@ -1936,7 +2001,7 @@ type ListRefreshReq struct { func (x *ListRefreshReq) Reset() { *x = ListRefreshReq{} - mi := &file_api_v2_api_proto_msgTypes[35] + mi := &file_api_v2_api_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1948,7 +2013,7 @@ func (x *ListRefreshReq) String() string { func (*ListRefreshReq) ProtoMessage() {} func (x *ListRefreshReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[35] + mi := &file_api_v2_api_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1961,7 +2026,7 @@ func (x *ListRefreshReq) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRefreshReq.ProtoReflect.Descriptor instead. func (*ListRefreshReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{35} + return file_api_v2_api_proto_rawDescGZIP(), []int{36} } func (x *ListRefreshReq) GetUserId() string { @@ -1981,7 +2046,7 @@ type ListRefreshResp struct { func (x *ListRefreshResp) Reset() { *x = ListRefreshResp{} - mi := &file_api_v2_api_proto_msgTypes[36] + mi := &file_api_v2_api_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1993,7 +2058,7 @@ func (x *ListRefreshResp) String() string { func (*ListRefreshResp) ProtoMessage() {} func (x *ListRefreshResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[36] + mi := &file_api_v2_api_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2006,7 +2071,7 @@ func (x *ListRefreshResp) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRefreshResp.ProtoReflect.Descriptor instead. func (*ListRefreshResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{36} + return file_api_v2_api_proto_rawDescGZIP(), []int{37} } func (x *ListRefreshResp) GetRefreshTokens() []*RefreshTokenRef { @@ -2028,7 +2093,7 @@ type RevokeRefreshReq struct { func (x *RevokeRefreshReq) Reset() { *x = RevokeRefreshReq{} - mi := &file_api_v2_api_proto_msgTypes[37] + mi := &file_api_v2_api_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2040,7 +2105,7 @@ func (x *RevokeRefreshReq) String() string { func (*RevokeRefreshReq) ProtoMessage() {} func (x *RevokeRefreshReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[37] + mi := &file_api_v2_api_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2053,7 +2118,7 @@ func (x *RevokeRefreshReq) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeRefreshReq.ProtoReflect.Descriptor instead. func (*RevokeRefreshReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{37} + return file_api_v2_api_proto_rawDescGZIP(), []int{38} } func (x *RevokeRefreshReq) GetUserId() string { @@ -2081,7 +2146,7 @@ type RevokeRefreshResp struct { func (x *RevokeRefreshResp) Reset() { *x = RevokeRefreshResp{} - mi := &file_api_v2_api_proto_msgTypes[38] + mi := &file_api_v2_api_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2093,7 +2158,7 @@ func (x *RevokeRefreshResp) String() string { func (*RevokeRefreshResp) ProtoMessage() {} func (x *RevokeRefreshResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[38] + mi := &file_api_v2_api_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2106,7 +2171,7 @@ func (x *RevokeRefreshResp) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeRefreshResp.ProtoReflect.Descriptor instead. func (*RevokeRefreshResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{38} + return file_api_v2_api_proto_rawDescGZIP(), []int{39} } func (x *RevokeRefreshResp) GetNotFound() bool { @@ -2126,7 +2191,7 @@ type VerifyPasswordReq struct { func (x *VerifyPasswordReq) Reset() { *x = VerifyPasswordReq{} - mi := &file_api_v2_api_proto_msgTypes[39] + mi := &file_api_v2_api_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2138,7 +2203,7 @@ func (x *VerifyPasswordReq) String() string { func (*VerifyPasswordReq) ProtoMessage() {} func (x *VerifyPasswordReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[39] + mi := &file_api_v2_api_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2151,7 +2216,7 @@ func (x *VerifyPasswordReq) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyPasswordReq.ProtoReflect.Descriptor instead. func (*VerifyPasswordReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{39} + return file_api_v2_api_proto_rawDescGZIP(), []int{40} } func (x *VerifyPasswordReq) GetEmail() string { @@ -2178,7 +2243,7 @@ type VerifyPasswordResp struct { func (x *VerifyPasswordResp) Reset() { *x = VerifyPasswordResp{} - mi := &file_api_v2_api_proto_msgTypes[40] + mi := &file_api_v2_api_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2190,7 +2255,7 @@ func (x *VerifyPasswordResp) String() string { func (*VerifyPasswordResp) ProtoMessage() {} func (x *VerifyPasswordResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[40] + mi := &file_api_v2_api_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2203,7 +2268,7 @@ func (x *VerifyPasswordResp) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyPasswordResp.ProtoReflect.Descriptor instead. func (*VerifyPasswordResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{40} + return file_api_v2_api_proto_rawDescGZIP(), []int{41} } func (x *VerifyPasswordResp) GetVerified() bool { @@ -2321,209 +2386,217 @@ var file_api_v2_api_proto_rawDesc = string([]byte{ 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, - 0x5b, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x0e, 0x0a, 0x02, + 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, 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, 0x79, - 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, 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, + 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, 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, 0x32, 0x8b, 0x09, 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, + 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, 0x32, 0x8b, 0x09, + 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, 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, 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, + 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, 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 ( @@ -2538,7 +2611,7 @@ func file_api_v2_api_proto_rawDescGZIP() []byte { return file_api_v2_api_proto_rawDescData } -var file_api_v2_api_proto_msgTypes = make([]protoimpl.MessageInfo, 41) +var file_api_v2_api_proto_msgTypes = make([]protoimpl.MessageInfo, 42) var file_api_v2_api_proto_goTypes = []any{ (*Client)(nil), // 0: api.Client (*ClientInfo)(nil), // 1: api.ClientInfo @@ -2564,23 +2637,24 @@ var file_api_v2_api_proto_goTypes = []any{ (*Connector)(nil), // 21: api.Connector (*CreateConnectorReq)(nil), // 22: api.CreateConnectorReq (*CreateConnectorResp)(nil), // 23: api.CreateConnectorResp - (*UpdateConnectorReq)(nil), // 24: api.UpdateConnectorReq - (*UpdateConnectorResp)(nil), // 25: api.UpdateConnectorResp - (*DeleteConnectorReq)(nil), // 26: api.DeleteConnectorReq - (*DeleteConnectorResp)(nil), // 27: api.DeleteConnectorResp - (*ListConnectorReq)(nil), // 28: api.ListConnectorReq - (*ListConnectorResp)(nil), // 29: api.ListConnectorResp - (*VersionReq)(nil), // 30: api.VersionReq - (*VersionResp)(nil), // 31: api.VersionResp - (*DiscoveryReq)(nil), // 32: api.DiscoveryReq - (*DiscoveryResp)(nil), // 33: api.DiscoveryResp - (*RefreshTokenRef)(nil), // 34: api.RefreshTokenRef - (*ListRefreshReq)(nil), // 35: api.ListRefreshReq - (*ListRefreshResp)(nil), // 36: api.ListRefreshResp - (*RevokeRefreshReq)(nil), // 37: api.RevokeRefreshReq - (*RevokeRefreshResp)(nil), // 38: api.RevokeRefreshResp - (*VerifyPasswordReq)(nil), // 39: api.VerifyPasswordReq - (*VerifyPasswordResp)(nil), // 40: api.VerifyPasswordResp + (*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 } var file_api_v2_api_proto_depIdxs = []int32{ 0, // 0: api.GetClientResp.client:type_name -> api.Client @@ -2590,49 +2664,50 @@ var file_api_v2_api_proto_depIdxs = []int32{ 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 - 21, // 7: api.ListConnectorResp.connectors:type_name -> api.Connector - 34, // 8: api.ListRefreshResp.refresh_tokens:type_name -> api.RefreshTokenRef - 2, // 9: api.Dex.GetClient:input_type -> api.GetClientReq - 4, // 10: api.Dex.CreateClient:input_type -> api.CreateClientReq - 8, // 11: api.Dex.UpdateClient:input_type -> api.UpdateClientReq - 6, // 12: api.Dex.DeleteClient:input_type -> api.DeleteClientReq - 10, // 13: api.Dex.ListClients:input_type -> api.ListClientReq - 13, // 14: api.Dex.CreatePassword:input_type -> api.CreatePasswordReq - 15, // 15: api.Dex.UpdatePassword:input_type -> api.UpdatePasswordReq - 17, // 16: api.Dex.DeletePassword:input_type -> api.DeletePasswordReq - 19, // 17: api.Dex.ListPasswords:input_type -> api.ListPasswordReq - 22, // 18: api.Dex.CreateConnector:input_type -> api.CreateConnectorReq - 24, // 19: api.Dex.UpdateConnector:input_type -> api.UpdateConnectorReq - 26, // 20: api.Dex.DeleteConnector:input_type -> api.DeleteConnectorReq - 28, // 21: api.Dex.ListConnectors:input_type -> api.ListConnectorReq - 30, // 22: api.Dex.GetVersion:input_type -> api.VersionReq - 32, // 23: api.Dex.GetDiscovery:input_type -> api.DiscoveryReq - 35, // 24: api.Dex.ListRefresh:input_type -> api.ListRefreshReq - 37, // 25: api.Dex.RevokeRefresh:input_type -> api.RevokeRefreshReq - 39, // 26: api.Dex.VerifyPassword:input_type -> api.VerifyPasswordReq - 3, // 27: api.Dex.GetClient:output_type -> api.GetClientResp - 5, // 28: api.Dex.CreateClient:output_type -> api.CreateClientResp - 9, // 29: api.Dex.UpdateClient:output_type -> api.UpdateClientResp - 7, // 30: api.Dex.DeleteClient:output_type -> api.DeleteClientResp - 11, // 31: api.Dex.ListClients:output_type -> api.ListClientResp - 14, // 32: api.Dex.CreatePassword:output_type -> api.CreatePasswordResp - 16, // 33: api.Dex.UpdatePassword:output_type -> api.UpdatePasswordResp - 18, // 34: api.Dex.DeletePassword:output_type -> api.DeletePasswordResp - 20, // 35: api.Dex.ListPasswords:output_type -> api.ListPasswordResp - 23, // 36: api.Dex.CreateConnector:output_type -> api.CreateConnectorResp - 25, // 37: api.Dex.UpdateConnector:output_type -> api.UpdateConnectorResp - 27, // 38: api.Dex.DeleteConnector:output_type -> api.DeleteConnectorResp - 29, // 39: api.Dex.ListConnectors:output_type -> api.ListConnectorResp - 31, // 40: api.Dex.GetVersion:output_type -> api.VersionResp - 33, // 41: api.Dex.GetDiscovery:output_type -> api.DiscoveryResp - 36, // 42: api.Dex.ListRefresh:output_type -> api.ListRefreshResp - 38, // 43: api.Dex.RevokeRefresh:output_type -> api.RevokeRefreshResp - 40, // 44: api.Dex.VerifyPassword:output_type -> api.VerifyPasswordResp - 27, // [27:45] is the sub-list for method output_type - 9, // [9:27] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name + 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 + 2, // 10: api.Dex.GetClient:input_type -> api.GetClientReq + 4, // 11: api.Dex.CreateClient:input_type -> api.CreateClientReq + 8, // 12: api.Dex.UpdateClient:input_type -> api.UpdateClientReq + 6, // 13: api.Dex.DeleteClient:input_type -> api.DeleteClientReq + 10, // 14: api.Dex.ListClients:input_type -> api.ListClientReq + 13, // 15: api.Dex.CreatePassword:input_type -> api.CreatePasswordReq + 15, // 16: api.Dex.UpdatePassword:input_type -> api.UpdatePasswordReq + 17, // 17: api.Dex.DeletePassword:input_type -> api.DeletePasswordReq + 19, // 18: api.Dex.ListPasswords:input_type -> api.ListPasswordReq + 22, // 19: api.Dex.CreateConnector:input_type -> api.CreateConnectorReq + 25, // 20: api.Dex.UpdateConnector:input_type -> api.UpdateConnectorReq + 27, // 21: api.Dex.DeleteConnector:input_type -> api.DeleteConnectorReq + 29, // 22: api.Dex.ListConnectors:input_type -> api.ListConnectorReq + 31, // 23: api.Dex.GetVersion:input_type -> api.VersionReq + 33, // 24: api.Dex.GetDiscovery:input_type -> api.DiscoveryReq + 36, // 25: api.Dex.ListRefresh:input_type -> api.ListRefreshReq + 38, // 26: api.Dex.RevokeRefresh:input_type -> api.RevokeRefreshReq + 40, // 27: api.Dex.VerifyPassword:input_type -> api.VerifyPasswordReq + 3, // 28: api.Dex.GetClient:output_type -> api.GetClientResp + 5, // 29: api.Dex.CreateClient:output_type -> api.CreateClientResp + 9, // 30: api.Dex.UpdateClient:output_type -> api.UpdateClientResp + 7, // 31: api.Dex.DeleteClient:output_type -> api.DeleteClientResp + 11, // 32: api.Dex.ListClients:output_type -> api.ListClientResp + 14, // 33: api.Dex.CreatePassword:output_type -> api.CreatePasswordResp + 16, // 34: api.Dex.UpdatePassword:output_type -> api.UpdatePasswordResp + 18, // 35: api.Dex.DeletePassword:output_type -> api.DeletePasswordResp + 20, // 36: api.Dex.ListPasswords:output_type -> api.ListPasswordResp + 23, // 37: api.Dex.CreateConnector:output_type -> api.CreateConnectorResp + 26, // 38: api.Dex.UpdateConnector:output_type -> api.UpdateConnectorResp + 28, // 39: api.Dex.DeleteConnector:output_type -> api.DeleteConnectorResp + 30, // 40: api.Dex.ListConnectors:output_type -> api.ListConnectorResp + 32, // 41: api.Dex.GetVersion:output_type -> api.VersionResp + 34, // 42: api.Dex.GetDiscovery:output_type -> api.DiscoveryResp + 37, // 43: api.Dex.ListRefresh:output_type -> api.ListRefreshResp + 39, // 44: api.Dex.RevokeRefresh:output_type -> api.RevokeRefreshResp + 41, // 45: api.Dex.VerifyPassword:output_type -> api.VerifyPasswordResp + 28, // [28:46] is the sub-list for method output_type + 10, // [10:28] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_api_v2_api_proto_init() } @@ -2646,7 +2721,7 @@ func file_api_v2_api_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v2_api_proto_rawDesc), len(file_api_v2_api_proto_rawDesc)), NumEnums: 0, - NumMessages: 41, + NumMessages: 42, NumExtensions: 0, NumServices: 1, }, diff --git a/api/v2/api.proto b/api/v2/api.proto index fc14913b..51d6d292 100644 --- a/api/v2/api.proto +++ b/api/v2/api.proto @@ -140,6 +140,7 @@ message Connector { string type = 2; string name = 3; bytes config = 4; + repeated string grant_types = 5; } // CreateConnectorReq is a request to make a connector. @@ -152,6 +153,12 @@ 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 @@ -159,6 +166,10 @@ message UpdateConnectorReq { 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. diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 8f78eea2..a05c3efd 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -459,7 +459,8 @@ type Connector struct { Name string `json:"name"` ID string `json:"id"` - Config server.ConnectorConfig `json:"config"` + Config server.ConnectorConfig `json:"config"` + GrantTypes []string `json:"grantTypes"` } // UnmarshalJSON allows Connector to implement the unmarshaler interface to @@ -470,7 +471,8 @@ 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 := configUnmarshaller(b, &conn); err != nil { return fmt.Errorf("parse connector: %v", err) @@ -508,10 +510,11 @@ func (c *Connector) UnmarshalJSON(b []byte) error { } *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 } @@ -524,10 +527,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 } diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go index 1edca7cf..26385f56 100644 --- a/cmd/dex/config_test.go +++ b/cmd/dex/config_test.go @@ -107,6 +107,9 @@ connectors: - type: mockCallback id: mock name: Example + grantTypes: + - authorization_code + - "urn:ietf:params:oauth:grant-type:token-exchange" - type: oidc id: google name: Google @@ -202,6 +205,10 @@ additionalFeatures: [ ID: "mock", Name: "Example", Config: &mock.CallbackConfig{}, + GrantTypes: []string{ + "authorization_code", + "urn:ietf:params:oauth:grant-type:token-exchange", + }, }, { Type: "oidc", diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 545419dc..cb675869 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -255,6 +255,11 @@ func runServe(options serveOptions) error { if c.Config == nil { return fmt.Errorf("invalid config: no config field for connector %q", c.ID) } + for _, gt := range c.GrantTypes { + if !server.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 diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 9c25b735..840905de 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -100,7 +100,7 @@ telemetry: # format: "text" # can also be "json" # Default values shown below -#oauth2: +# oauth2: # grantTypes determines the allowed set of authorization flows. # grantTypes: # - "authorization_code" @@ -151,6 +151,18 @@ 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 diff --git a/server/api.go b/server/api.go index 202fef36..8ade8a05 100644 --- a/server/api.go +++ b/server/api.go @@ -455,12 +455,19 @@ func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq return nil, errors.New("invalid config supplied") } + for _, gt := range req.Connector.GrantTypes { + if !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 { @@ -487,14 +494,26 @@ func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq return nil, errors.New("no email supplied") } - if len(req.NewConfig) == 0 && req.NewName == "" && req.NewType == "" { + hasUpdate := len(req.NewConfig) != 0 || + req.NewName != "" || + req.NewType != "" || + req.NewGrantTypes != nil + if !hasUpdate { return nil, errors.New("nothing to update") } - if !json.Valid(req.NewConfig) { + 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 !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 @@ -508,6 +527,10 @@ func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq 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) } @@ -561,10 +584,11 @@ func (d dexAPI) ListConnectors(ctx context.Context, req *api.ListConnectorReq) ( 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, + Id: connector.ID, + Name: connector.Name, + Type: connector.Type, + Config: connector.Config, + GrantTypes: connector.GrantTypes, } connectors = append(connectors, &c) } diff --git a/server/api_test.go b/server/api_test.go index 5ddbcc4a..09cfa678 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -606,6 +606,105 @@ func TestUpdateConnector(t *testing.T) { } } +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") diff --git a/server/handlers.go b/server/handlers.go index 10c6688d..6a40bf96 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -142,6 +142,21 @@ func (s *Server) constructDiscovery(ctx context.Context) discovery { return d } +// grantTypeFromAuthRequest determines the grant type from the authorization request parameters. +func (s *Server) grantTypeFromAuthRequest(r *http.Request) string { + redirectURI := r.Form.Get("redirect_uri") + if redirectURI == deviceCallbackURI || strings.HasSuffix(redirectURI, deviceCallbackURI) { + return grantTypeDeviceCode + } + responseType := r.Form.Get("response_type") + for _, rt := range strings.Fields(responseType) { + if rt == "token" || rt == "id_token" { + return grantTypeImplicit + } + } + return grantTypeAuthorizationCode +} + // handleAuthorization handles the OAuth2 auth endpoint. func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -154,13 +169,27 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { } connectorID := r.Form.Get("connector_id") - connectors, err := s.storage.ListConnectors(ctx) + allConnectors, err := s.storage.ListConnectors(ctx) if err != nil { s.logger.ErrorContext(r.Context(), "failed to get list of connectors", "err", err) s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve connector list.") return } + // Determine the grant type from the authorization request to filter connectors. + grantType := s.grantTypeFromAuthRequest(r) + connectors := make([]storage.Connector, 0, len(allConnectors)) + for _, c := range allConnectors { + if GrantTypeAllowed(c.GrantTypes, grantType) { + connectors = append(connectors, c) + } + } + + if len(connectors) == 0 { + s.renderError(r, w, http.StatusBadRequest, "No connectors available for the requested grant type.") + return + } + // We don't need connector_id any more r.Form.Del("connector_id") @@ -187,15 +216,15 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, connURL.String(), http.StatusFound) } - connectorInfos := make([]connectorInfo, len(connectors)) - for index, conn := range connectors { + connectorInfos := make([]connectorInfo, 0, len(connectors)) + for _, conn := range connectors { connURL.Path = s.absPath("/auth", url.PathEscape(conn.ID)) - connectorInfos[index] = connectorInfo{ + connectorInfos = append(connectorInfos, 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 { @@ -235,6 +264,15 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } + // Check if the connector allows the requested grant type. + grantType := s.grantTypeFromAuthRequest(r) + if !GrantTypeAllowed(conn.GrantTypes, grantType) { + s.logger.ErrorContext(r.Context(), "connector does not allow requested grant type", + "connector_id", connID, "grant_type", grantType) + s.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 { s.logger.ErrorContext(r.Context(), "mismatched connector ID in auth request", @@ -995,9 +1033,16 @@ func (s *Server) exchangeAuthCode(ctx context.Context, w http.ResponseWriter, au } reqRefresh := func() bool { - // Ensure the connector supports refresh tokens. + // Determine whether to issue a refresh token. A refresh token is only + // issued when all of the following are true: + // 1. The connector implements RefreshConnector. + // 2. The connector's grantTypes config allows refresh_token. + // 3. The client requested the offline_access scope. // - // Connectors like `saml` do not implement RefreshConnector. + // When any condition is not met, the refresh token is silently omitted + // rather than returning an error. This matches the OAuth2 spec: the + // server is never required to issue a refresh token (RFC 6749 §1.5). + // https://datatracker.ietf.org/doc/html/rfc6749#section-1.5 conn, err := s.getConnector(ctx, authCode.ConnectorID) if err != nil { s.logger.ErrorContext(ctx, "connector not found", "connector_id", authCode.ConnectorID, "err", err) @@ -1010,6 +1055,10 @@ func (s *Server) exchangeAuthCode(ctx context.Context, w http.ResponseWriter, au return false } + if !GrantTypeAllowed(conn.GrantTypes, grantTypeRefreshToken) { + return false + } + for _, scope := range authCode.Scopes { if scope == scopeOfflineAccess { return true @@ -1215,6 +1264,11 @@ func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, cli s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest) return } + if !GrantTypeAllowed(conn.GrantTypes, grantTypePassword) { + s.logger.ErrorContext(r.Context(), "connector does not allow password grant", "connector_id", connID) + s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not support password grant.", http.StatusBadRequest) + return + } passwordConnector, ok := conn.Connector.(connector.PasswordConnector) if !ok { @@ -1261,11 +1315,15 @@ func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, cli } reqRefresh := func() bool { - // Ensure the connector supports refresh tokens. - // - // Connectors like `saml` do not implement RefreshConnector. - _, ok := conn.Connector.(connector.RefreshConnector) - if !ok { + // Same logic as in exchangeAuthCode: silently omit refresh token + // when the connector doesn't support it or grantTypes forbids it. + // See RFC 6749 §1.5 — refresh tokens are never mandatory. + // https://datatracker.ietf.org/doc/html/rfc6749#section-1.5 + if _, ok := conn.Connector.(connector.RefreshConnector); !ok { + return false + } + + if !GrantTypeAllowed(conn.GrantTypes, grantTypeRefreshToken) { return false } @@ -1422,6 +1480,11 @@ func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request, cli s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest) return } + if !GrantTypeAllowed(conn.GrantTypes, grantTypeTokenExchange) { + s.logger.ErrorContext(r.Context(), "connector does not allow token exchange", "connector_id", connID) + s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not support token exchange.", http.StatusBadRequest) + return + } teConn, ok := conn.Connector.(connector.TokenIdentityConnector) if !ok { s.logger.ErrorContext(r.Context(), "connector doesn't implement token exchange", "connector_id", connID) diff --git a/server/handlers_test.go b/server/handlers_test.go index 14bb3da9..43343aee 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -1060,6 +1060,163 @@ func TestHandleTokenExchange(t *testing.T) { } } +func TestHandleTokenExchangeConnectorGrantTypeRestriction(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + }) + }) + defer httpServer.Close() + + // Restrict mock connector to authorization_code only + err := s.storage.UpdateConnector(ctx, "mock", func(c storage.Connector) (storage.Connector, error) { + c.GrantTypes = []string{grantTypeAuthorizationCode} + return c, nil + }) + require.NoError(t, err) + // Clear cached connector to pick up new grant types + s.mu.Lock() + delete(s.connectors, "mock") + s.mu.Unlock() + + vals := make(url.Values) + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("connector_id", "mock") + vals.Set("scope", "openid") + vals.Set("requested_token_type", tokenTypeAccess) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", "foobar") + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + + s.handleToken(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) +} + +func TestHandleAuthorizationConnectorGrantTypeFiltering(t *testing.T) { + tests := []struct { + name string + // grantTypes per connector ID; nil means unrestricted + connectorGrantTypes map[string][]string + responseType string + wantCode int + // wantRedirectContains is checked when wantCode == 302 + wantRedirectContains string + // wantBodyContains is checked when wantCode != 302 + wantBodyContains string + }{ + { + name: "one connector filtered, redirect to remaining", + connectorGrantTypes: map[string][]string{ + "mock": {grantTypeDeviceCode}, + "mock2": nil, + }, + responseType: "code", + wantCode: http.StatusFound, + wantRedirectContains: "/auth/mock2", + }, + { + name: "all connectors filtered", + connectorGrantTypes: map[string][]string{ + "mock": {grantTypeDeviceCode}, + "mock2": {grantTypeDeviceCode}, + }, + responseType: "code", + wantCode: http.StatusBadRequest, + wantBodyContains: "No connectors available", + }, + { + name: "no restrictions, both available", + connectorGrantTypes: map[string][]string{ + "mock": nil, + "mock2": nil, + }, + responseType: "code", + wantCode: http.StatusOK, + }, + { + name: "implicit flow filters auth_code-only connector", + connectorGrantTypes: map[string][]string{ + "mock": {grantTypeAuthorizationCode}, + "mock2": nil, + }, + responseType: "token", + wantCode: http.StatusFound, + wantRedirectContains: "/auth/mock2", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServerMultipleConnectors(t, nil) + defer httpServer.Close() + + for id, gts := range tc.connectorGrantTypes { + err := s.storage.UpdateConnector(ctx, id, func(c storage.Connector) (storage.Connector, error) { + c.GrantTypes = gts + return c, nil + }) + require.NoError(t, err) + s.mu.Lock() + delete(s.connectors, id) + s.mu.Unlock() + } + + rr := httptest.NewRecorder() + reqURL := fmt.Sprintf("%s/auth?response_type=%s&client_id=test&redirect_uri=http://example.com/callback&scope=openid", httpServer.URL, tc.responseType) + req := httptest.NewRequest(http.MethodGet, reqURL, nil) + s.handleAuthorization(rr, req) + + require.Equal(t, tc.wantCode, rr.Code) + if tc.wantRedirectContains != "" { + require.Contains(t, rr.Header().Get("Location"), tc.wantRedirectContains) + } + if tc.wantBodyContains != "" { + require.Contains(t, rr.Body.String(), tc.wantBodyContains) + } + }) + } +} + +func TestHandleConnectorLoginGrantTypeRejection(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + c.Storage.CreateClient(ctx, storage.Client{ + ID: "test-client", + Secret: "secret", + RedirectURIs: []string{"http://example.com/callback"}, + }) + }) + defer httpServer.Close() + + // Restrict mock connector to device_code only + err := s.storage.UpdateConnector(ctx, "mock", func(c storage.Connector) (storage.Connector, error) { + c.GrantTypes = []string{grantTypeDeviceCode} + return c, nil + }) + require.NoError(t, err) + s.mu.Lock() + delete(s.connectors, "mock") + s.mu.Unlock() + + // Try to use mock connector for auth code flow via the full server router + rr := httptest.NewRecorder() + reqURL := httpServer.URL + "/auth/mock?response_type=code&client_id=test-client&redirect_uri=http://example.com/callback&scope=openid" + req := httptest.NewRequest(http.MethodGet, reqURL, nil) + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "does not support this grant type") +} + func setNonEmpty(vals url.Values, key, value string) { if value != "" { vals.Set(key, value) diff --git a/server/oauth2.go b/server/oauth2.go index 8fe75475..91879761 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -146,6 +146,16 @@ const ( grantTypeClientCredentials = "client_credentials" ) +// ConnectorGrantTypes is the set of grant types that can be restricted per connector. +var ConnectorGrantTypes = map[string]bool{ + grantTypeAuthorizationCode: true, + grantTypeRefreshToken: true, + grantTypeImplicit: true, + grantTypePassword: true, + grantTypeDeviceCode: true, + grantTypeTokenExchange: true, +} + const ( // https://www.rfc-editor.org/rfc/rfc8693.html#section-3 tokenTypeAccess = "urn:ietf:params:oauth:token-type:access_token" diff --git a/server/refreshhandlers.go b/server/refreshhandlers.go index b47bd52c..a0807d07 100644 --- a/server/refreshhandlers.go +++ b/server/refreshhandlers.go @@ -202,6 +202,10 @@ func (s *Server) getRefreshTokenFromStorage(ctx context.Context, clientID *strin s.logger.ErrorContext(ctx, "connector not found", "connector_id", refresh.ConnectorID, "err", err) return nil, newInternalServerError() } + if !GrantTypeAllowed(refreshCtx.connector.GrantTypes, grantTypeRefreshToken) { + s.logger.ErrorContext(ctx, "connector does not allow refresh token grant", "connector_id", refresh.ConnectorID) + return nil, &refreshError{msg: errInvalidRequest, desc: "Connector does not support refresh tokens.", code: http.StatusBadRequest} + } // Get Connector Data session, err := s.storage.GetOfflineSessions(ctx, refresh.Claims.UserID, refresh.ConnectorID) diff --git a/server/server.go b/server/server.go index b7c08c64..c4f16536 100644 --- a/server/server.go +++ b/server/server.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "path" + "slices" "sort" "strings" "sync" @@ -57,6 +58,13 @@ const LocalConnector = "local" type Connector struct { ResourceVersion string Connector connector.Connector + GrantTypes []string +} + +// GrantTypeAllowed checks if the given grant type is allowed for this connector. +// If no grant types are configured, all are allowed. +func GrantTypeAllowed(configuredTypes []string, grantType string) bool { + return len(configuredTypes) == 0 || slices.Contains(configuredTypes, grantType) } // Config holds the server's configuration options. @@ -739,6 +747,7 @@ func (s *Server) OpenConnector(conn storage.Connector) (Connector, error) { connector := Connector{ ResourceVersion: conn.ResourceVersion, Connector: c, + GrantTypes: conn.GrantTypes, } s.mu.Lock() s.connectors[conn.ID] = connector diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index b8c1b96d..eeb20cd9 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -630,10 +630,11 @@ func testConnectorCRUD(t *testing.T, s storage.Storage) { id1 := storage.NewID() config1 := []byte(`{"issuer": "https://accounts.google.com"}`) c1 := storage.Connector{ - ID: id1, - Type: "Default", - Name: "Default", - Config: config1, + ID: id1, + Type: "Default", + Name: "Default", + Config: config1, + GrantTypes: []string{"authorization_code", "refresh_token"}, } if err := s.CreateConnector(ctx, c1); err != nil { @@ -674,12 +675,14 @@ func testConnectorCRUD(t *testing.T, s storage.Storage) { if err := s.UpdateConnector(ctx, c1.ID, func(old storage.Connector) (storage.Connector, error) { old.Type = "oidc" + old.GrantTypes = []string{"urn:ietf:params:oauth:grant-type:token-exchange"} return old, nil }); err != nil { t.Fatalf("failed to update Connector: %v", err) } c1.Type = "oidc" + c1.GrantTypes = []string{"urn:ietf:params:oauth:grant-type:token-exchange"} getAndCompare(id1, c1) connectorList := []storage.Connector{c1, c2} diff --git a/storage/ent/client/connector.go b/storage/ent/client/connector.go index f0cff8ba..21e7aec2 100644 --- a/storage/ent/client/connector.go +++ b/storage/ent/client/connector.go @@ -14,6 +14,7 @@ func (d *Database) CreateConnector(ctx context.Context, connector storage.Connec SetType(connector.Type). SetResourceVersion(connector.ResourceVersion). SetConfig(connector.Config). + SetGrantTypes(connector.GrantTypes). Save(ctx) if err != nil { return convertDBError("create connector: %w", err) @@ -75,6 +76,7 @@ func (d *Database) UpdateConnector(ctx context.Context, id string, updater func( SetType(newConnector.Type). SetResourceVersion(newConnector.ResourceVersion). SetConfig(newConnector.Config). + SetGrantTypes(newConnector.GrantTypes). Save(ctx) if err != nil { return rollback(tx, "update connector uploading: %w", err) diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index ab8ee83f..4a0b41f0 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -88,10 +88,11 @@ func toStorageClient(c *db.OAuth2Client) storage.Client { func toStorageConnector(c *db.Connector) storage.Connector { return storage.Connector{ - ID: c.ID, - Type: c.Type, - Name: c.Name, - Config: c.Config, + ID: c.ID, + Type: c.Type, + Name: c.Name, + Config: c.Config, + GrantTypes: c.GrantTypes, } } diff --git a/storage/ent/db/connector.go b/storage/ent/db/connector.go index 2071d14e..812b45f7 100644 --- a/storage/ent/db/connector.go +++ b/storage/ent/db/connector.go @@ -3,6 +3,7 @@ package db import ( + "encoding/json" "fmt" "strings" @@ -23,7 +24,9 @@ type Connector struct { // ResourceVersion holds the value of the "resource_version" field. ResourceVersion string `json:"resource_version,omitempty"` // Config holds the value of the "config" field. - Config []byte `json:"config,omitempty"` + Config []byte `json:"config,omitempty"` + // GrantTypes holds the value of the "grant_types" field. + GrantTypes []string `json:"grant_types,omitempty"` selectValues sql.SelectValues } @@ -32,7 +35,7 @@ func (*Connector) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case connector.FieldConfig: + case connector.FieldConfig, connector.FieldGrantTypes: values[i] = new([]byte) case connector.FieldID, connector.FieldType, connector.FieldName, connector.FieldResourceVersion: values[i] = new(sql.NullString) @@ -81,6 +84,14 @@ func (_m *Connector) assignValues(columns []string, values []any) error { } else if value != nil { _m.Config = *value } + case connector.FieldGrantTypes: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field grant_types", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.GrantTypes); err != nil { + return fmt.Errorf("unmarshal field grant_types: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -128,6 +139,9 @@ func (_m *Connector) String() string { builder.WriteString(", ") builder.WriteString("config=") builder.WriteString(fmt.Sprintf("%v", _m.Config)) + builder.WriteString(", ") + builder.WriteString("grant_types=") + builder.WriteString(fmt.Sprintf("%v", _m.GrantTypes)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/connector/connector.go b/storage/ent/db/connector/connector.go index 996328c1..ca603f4d 100644 --- a/storage/ent/db/connector/connector.go +++ b/storage/ent/db/connector/connector.go @@ -19,6 +19,8 @@ const ( FieldResourceVersion = "resource_version" // FieldConfig holds the string denoting the config field in the database. FieldConfig = "config" + // FieldGrantTypes holds the string denoting the grant_types field in the database. + FieldGrantTypes = "grant_types" // Table holds the table name of the connector in the database. Table = "connectors" ) @@ -30,6 +32,7 @@ var Columns = []string{ FieldName, FieldResourceVersion, FieldConfig, + FieldGrantTypes, } // ValidColumn reports if the column name is valid (part of the table columns). diff --git a/storage/ent/db/connector/where.go b/storage/ent/db/connector/where.go index 39cc477f..a09bec60 100644 --- a/storage/ent/db/connector/where.go +++ b/storage/ent/db/connector/where.go @@ -317,6 +317,16 @@ func ConfigLTE(v []byte) predicate.Connector { return predicate.Connector(sql.FieldLTE(FieldConfig, v)) } +// GrantTypesIsNil applies the IsNil predicate on the "grant_types" field. +func GrantTypesIsNil() predicate.Connector { + return predicate.Connector(sql.FieldIsNull(FieldGrantTypes)) +} + +// GrantTypesNotNil applies the NotNil predicate on the "grant_types" field. +func GrantTypesNotNil() predicate.Connector { + return predicate.Connector(sql.FieldNotNull(FieldGrantTypes)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.Connector) predicate.Connector { return predicate.Connector(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/connector_create.go b/storage/ent/db/connector_create.go index 42da769e..4e9cc35b 100644 --- a/storage/ent/db/connector_create.go +++ b/storage/ent/db/connector_create.go @@ -43,6 +43,12 @@ func (_c *ConnectorCreate) SetConfig(v []byte) *ConnectorCreate { return _c } +// SetGrantTypes sets the "grant_types" field. +func (_c *ConnectorCreate) SetGrantTypes(v []string) *ConnectorCreate { + _c.mutation.SetGrantTypes(v) + return _c +} + // SetID sets the "id" field. func (_c *ConnectorCreate) SetID(v string) *ConnectorCreate { _c.mutation.SetID(v) @@ -161,6 +167,10 @@ func (_c *ConnectorCreate) createSpec() (*Connector, *sqlgraph.CreateSpec) { _spec.SetField(connector.FieldConfig, field.TypeBytes, value) _node.Config = value } + if value, ok := _c.mutation.GrantTypes(); ok { + _spec.SetField(connector.FieldGrantTypes, field.TypeJSON, value) + _node.GrantTypes = value + } return _node, _spec } diff --git a/storage/ent/db/connector_update.go b/storage/ent/db/connector_update.go index bbcfd565..d9b58d04 100644 --- a/storage/ent/db/connector_update.go +++ b/storage/ent/db/connector_update.go @@ -9,6 +9,7 @@ import ( "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/dialect/sql/sqljson" "entgo.io/ent/schema/field" "github.com/dexidp/dex/storage/ent/db/connector" "github.com/dexidp/dex/storage/ent/db/predicate" @@ -75,6 +76,24 @@ func (_u *ConnectorUpdate) SetConfig(v []byte) *ConnectorUpdate { return _u } +// SetGrantTypes sets the "grant_types" field. +func (_u *ConnectorUpdate) SetGrantTypes(v []string) *ConnectorUpdate { + _u.mutation.SetGrantTypes(v) + return _u +} + +// AppendGrantTypes appends value to the "grant_types" field. +func (_u *ConnectorUpdate) AppendGrantTypes(v []string) *ConnectorUpdate { + _u.mutation.AppendGrantTypes(v) + return _u +} + +// ClearGrantTypes clears the value of the "grant_types" field. +func (_u *ConnectorUpdate) ClearGrantTypes() *ConnectorUpdate { + _u.mutation.ClearGrantTypes() + return _u +} + // Mutation returns the ConnectorMutation object of the builder. func (_u *ConnectorUpdate) Mutation() *ConnectorMutation { return _u.mutation @@ -146,6 +165,17 @@ func (_u *ConnectorUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.Config(); ok { _spec.SetField(connector.FieldConfig, field.TypeBytes, value) } + if value, ok := _u.mutation.GrantTypes(); ok { + _spec.SetField(connector.FieldGrantTypes, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedGrantTypes(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, connector.FieldGrantTypes, value) + }) + } + if _u.mutation.GrantTypesCleared() { + _spec.ClearField(connector.FieldGrantTypes, field.TypeJSON) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{connector.Label} @@ -214,6 +244,24 @@ func (_u *ConnectorUpdateOne) SetConfig(v []byte) *ConnectorUpdateOne { return _u } +// SetGrantTypes sets the "grant_types" field. +func (_u *ConnectorUpdateOne) SetGrantTypes(v []string) *ConnectorUpdateOne { + _u.mutation.SetGrantTypes(v) + return _u +} + +// AppendGrantTypes appends value to the "grant_types" field. +func (_u *ConnectorUpdateOne) AppendGrantTypes(v []string) *ConnectorUpdateOne { + _u.mutation.AppendGrantTypes(v) + return _u +} + +// ClearGrantTypes clears the value of the "grant_types" field. +func (_u *ConnectorUpdateOne) ClearGrantTypes() *ConnectorUpdateOne { + _u.mutation.ClearGrantTypes() + return _u +} + // Mutation returns the ConnectorMutation object of the builder. func (_u *ConnectorUpdateOne) Mutation() *ConnectorMutation { return _u.mutation @@ -315,6 +363,17 @@ func (_u *ConnectorUpdateOne) sqlSave(ctx context.Context) (_node *Connector, er if value, ok := _u.mutation.Config(); ok { _spec.SetField(connector.FieldConfig, field.TypeBytes, value) } + if value, ok := _u.mutation.GrantTypes(); ok { + _spec.SetField(connector.FieldGrantTypes, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedGrantTypes(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, connector.FieldGrantTypes, value) + }) + } + if _u.mutation.GrantTypesCleared() { + _spec.ClearField(connector.FieldGrantTypes, field.TypeJSON) + } _node = &Connector{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index b61d4697..b4a94a65 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -70,6 +70,7 @@ var ( {Name: "name", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "resource_version", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "config", Type: field.TypeBytes}, + {Name: "grant_types", Type: field.TypeJSON, Nullable: true}, } // ConnectorsTable holds the schema information for the "connectors" table. ConnectorsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index a5cb61d2..0b299bf8 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -2720,17 +2720,19 @@ func (m *AuthRequestMutation) ResetEdge(name string) error { // ConnectorMutation represents an operation that mutates the Connector nodes in the graph. type ConnectorMutation struct { config - op Op - typ string - id *string - _type *string - name *string - resource_version *string - _config *[]byte - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*Connector, error) - predicates []predicate.Connector + op Op + typ string + id *string + _type *string + name *string + resource_version *string + _config *[]byte + grant_types *[]string + appendgrant_types []string + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*Connector, error) + predicates []predicate.Connector } var _ ent.Mutation = (*ConnectorMutation)(nil) @@ -2981,6 +2983,71 @@ func (m *ConnectorMutation) ResetConfig() { m._config = nil } +// SetGrantTypes sets the "grant_types" field. +func (m *ConnectorMutation) SetGrantTypes(s []string) { + m.grant_types = &s + m.appendgrant_types = nil +} + +// GrantTypes returns the value of the "grant_types" field in the mutation. +func (m *ConnectorMutation) GrantTypes() (r []string, exists bool) { + v := m.grant_types + if v == nil { + return + } + return *v, true +} + +// OldGrantTypes returns the old "grant_types" field's value of the Connector entity. +// If the Connector object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ConnectorMutation) OldGrantTypes(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldGrantTypes is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldGrantTypes requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldGrantTypes: %w", err) + } + return oldValue.GrantTypes, nil +} + +// AppendGrantTypes adds s to the "grant_types" field. +func (m *ConnectorMutation) AppendGrantTypes(s []string) { + m.appendgrant_types = append(m.appendgrant_types, s...) +} + +// AppendedGrantTypes returns the list of values that were appended to the "grant_types" field in this mutation. +func (m *ConnectorMutation) AppendedGrantTypes() ([]string, bool) { + if len(m.appendgrant_types) == 0 { + return nil, false + } + return m.appendgrant_types, true +} + +// ClearGrantTypes clears the value of the "grant_types" field. +func (m *ConnectorMutation) ClearGrantTypes() { + m.grant_types = nil + m.appendgrant_types = nil + m.clearedFields[connector.FieldGrantTypes] = struct{}{} +} + +// GrantTypesCleared returns if the "grant_types" field was cleared in this mutation. +func (m *ConnectorMutation) GrantTypesCleared() bool { + _, ok := m.clearedFields[connector.FieldGrantTypes] + return ok +} + +// ResetGrantTypes resets all changes to the "grant_types" field. +func (m *ConnectorMutation) ResetGrantTypes() { + m.grant_types = nil + m.appendgrant_types = nil + delete(m.clearedFields, connector.FieldGrantTypes) +} + // Where appends a list predicates to the ConnectorMutation builder. func (m *ConnectorMutation) Where(ps ...predicate.Connector) { m.predicates = append(m.predicates, ps...) @@ -3015,7 +3082,7 @@ func (m *ConnectorMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ConnectorMutation) Fields() []string { - fields := make([]string, 0, 4) + fields := make([]string, 0, 5) if m._type != nil { fields = append(fields, connector.FieldType) } @@ -3028,6 +3095,9 @@ func (m *ConnectorMutation) Fields() []string { if m._config != nil { fields = append(fields, connector.FieldConfig) } + if m.grant_types != nil { + fields = append(fields, connector.FieldGrantTypes) + } return fields } @@ -3044,6 +3114,8 @@ func (m *ConnectorMutation) Field(name string) (ent.Value, bool) { return m.ResourceVersion() case connector.FieldConfig: return m.Config() + case connector.FieldGrantTypes: + return m.GrantTypes() } return nil, false } @@ -3061,6 +3133,8 @@ func (m *ConnectorMutation) OldField(ctx context.Context, name string) (ent.Valu return m.OldResourceVersion(ctx) case connector.FieldConfig: return m.OldConfig(ctx) + case connector.FieldGrantTypes: + return m.OldGrantTypes(ctx) } return nil, fmt.Errorf("unknown Connector field %s", name) } @@ -3098,6 +3172,13 @@ func (m *ConnectorMutation) SetField(name string, value ent.Value) error { } m.SetConfig(v) return nil + case connector.FieldGrantTypes: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetGrantTypes(v) + return nil } return fmt.Errorf("unknown Connector field %s", name) } @@ -3127,7 +3208,11 @@ func (m *ConnectorMutation) AddField(name string, value ent.Value) error { // ClearedFields returns all nullable fields that were cleared during this // mutation. func (m *ConnectorMutation) ClearedFields() []string { - return nil + var fields []string + if m.FieldCleared(connector.FieldGrantTypes) { + fields = append(fields, connector.FieldGrantTypes) + } + return fields } // FieldCleared returns a boolean indicating if a field with the given name was @@ -3140,6 +3225,11 @@ func (m *ConnectorMutation) FieldCleared(name string) bool { // ClearField clears the value of the field with the given name. It returns an // error if the field is not defined in the schema. func (m *ConnectorMutation) ClearField(name string) error { + switch name { + case connector.FieldGrantTypes: + m.ClearGrantTypes() + return nil + } return fmt.Errorf("unknown Connector nullable field %s", name) } @@ -3159,6 +3249,9 @@ func (m *ConnectorMutation) ResetField(name string) error { case connector.FieldConfig: m.ResetConfig() return nil + case connector.FieldGrantTypes: + m.ResetGrantTypes() + return nil } return fmt.Errorf("unknown Connector field %s", name) } diff --git a/storage/ent/schema/connector.go b/storage/ent/schema/connector.go index 41b65eb4..191092c5 100644 --- a/storage/ent/schema/connector.go +++ b/storage/ent/schema/connector.go @@ -38,6 +38,8 @@ func (Connector) Fields() []ent.Field { field.Text("resource_version"). SchemaType(textSchema), field.Bytes("config"), + field.JSON("grant_types", []string{}). + Optional(), } } diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 9e0f7e68..502df413 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -721,6 +721,8 @@ type Connector struct { Name string `json:"name,omitempty"` // Config holds connector specific configuration information Config []byte `json:"config,omitempty"` + // GrantTypes is a list of grant types that this connector is allowed to be used with. + GrantTypes []string `json:"grantTypes,omitempty"` } func (cli *client) fromStorageConnector(c storage.Connector) Connector { @@ -733,10 +735,11 @@ func (cli *client) fromStorageConnector(c storage.Connector) Connector { Name: c.ID, Namespace: cli.namespace, }, - ID: c.ID, - Type: c.Type, - Name: c.Name, - Config: c.Config, + ID: c.ID, + Type: c.Type, + Name: c.Name, + Config: c.Config, + GrantTypes: c.GrantTypes, } } @@ -747,6 +750,7 @@ func toStorageConnector(c Connector) storage.Connector { Name: c.Name, ResourceVersion: c.ObjectMeta.ResourceVersion, Config: c.Config, + GrantTypes: c.GrantTypes, } } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index e60f29d5..c03714b3 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -769,15 +769,19 @@ func scanOfflineSessions(s scanner) (o storage.OfflineSessions, err error) { } func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) error { - _, err := c.Exec(` + grantTypes, err := json.Marshal(connector.GrantTypes) + if err != nil { + return fmt.Errorf("marshal connector grant types: %v", err) + } + _, err = c.Exec(` insert into connector ( - id, type, name, resource_version, config + id, type, name, resource_version, config, grant_types ) values ( - $1, $2, $3, $4, $5 + $1, $2, $3, $4, $5, $6 ); `, - connector.ID, connector.Type, connector.Name, connector.ResourceVersion, connector.Config, + connector.ID, connector.Type, connector.Name, connector.ResourceVersion, connector.Config, grantTypes, ) if err != nil { if c.alreadyExistsCheck(err) { @@ -799,16 +803,21 @@ func (c *conn) UpdateConnector(ctx context.Context, id string, updater func(s st if err != nil { return err } + grantTypes, err := json.Marshal(newConn.GrantTypes) + if err != nil { + return fmt.Errorf("marshal connector grant types: %v", err) + } _, err = tx.Exec(` update connector set type = $1, name = $2, resource_version = $3, - config = $4 - where id = $5; + config = $4, + grant_types = $5 + where id = $6; `, - newConn.Type, newConn.Name, newConn.ResourceVersion, newConn.Config, connector.ID, + newConn.Type, newConn.Name, newConn.ResourceVersion, newConn.Config, grantTypes, connector.ID, ) if err != nil { return fmt.Errorf("update connector: %v", err) @@ -824,15 +833,16 @@ func (c *conn) GetConnector(ctx context.Context, id string) (storage.Connector, func getConnector(ctx context.Context, q querier, id string) (storage.Connector, error) { return scanConnector(q.QueryRow(` select - id, type, name, resource_version, config + id, type, name, resource_version, config, grant_types from connector where id = $1; `, id)) } func scanConnector(s scanner) (c storage.Connector, err error) { + var grantTypes []byte err = s.Scan( - &c.ID, &c.Type, &c.Name, &c.ResourceVersion, &c.Config, + &c.ID, &c.Type, &c.Name, &c.ResourceVersion, &c.Config, &grantTypes, ) if err != nil { if err == sql.ErrNoRows { @@ -840,13 +850,18 @@ func scanConnector(s scanner) (c storage.Connector, err error) { } return c, fmt.Errorf("select connector: %v", err) } + if len(grantTypes) > 0 { + if err := json.Unmarshal(grantTypes, &c.GrantTypes); err != nil { + return c, fmt.Errorf("unmarshal connector grant types: %v", err) + } + } return c, nil } func (c *conn) ListConnectors(ctx context.Context) ([]storage.Connector, error) { rows, err := c.Query(` select - id, type, name, resource_version, config + id, type, name, resource_version, config, grant_types from connector; `) if err != nil { diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 9a1807c9..01f393f1 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -374,4 +374,11 @@ var migrations = []migration{ }, flavor: &flavorMySQL, }, + { + stmts: []string{ + ` + alter table connector + add column grant_types bytea;`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index b6e4d285..599badb6 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -388,6 +388,10 @@ type Connector struct { // However, fixing this requires migrating Kubernetes objects for all previously created connectors, // or making Dex reading both tags and act accordingly. Config []byte `json:"email"` + + // GrantTypes is a list of grant types that this connector is allowed to be used with. + // If empty, all grant types are allowed. + GrantTypes []string `json:"grantTypes,omitempty"` } // VerificationKey is a rotated signing key which can still be used to verify From f80a89dd5defc3a31262710bf5d1160d25f9ffb7 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Wed, 11 Mar 2026 23:00:38 +0100 Subject: [PATCH 147/222] feat(client): add allowed connectors field to client configuration (#4610) Signed-off-by: maksim.nabokikh --- api/api.pb.go | 348 +++++----- api/api.proto | 2 + api/v2/api.pb.go | 675 ++++++++++---------- api/v2/api.proto | 3 + config.yaml.dist | 10 + examples/config-dev.yaml | 5 + server/api.go | 46 +- server/handlers.go | 75 ++- server/handlers_test.go | 184 +++++- server/oauth2.go | 3 + storage/conformance/conformance.go | 11 +- storage/ent/client/client.go | 2 + storage/ent/client/types.go | 15 +- storage/ent/db/migrate/schema.go | 1 + storage/ent/db/mutation.go | 122 +++- storage/ent/db/oauth2client.go | 19 +- storage/ent/db/oauth2client/oauth2client.go | 3 + storage/ent/db/oauth2client/where.go | 10 + storage/ent/db/oauth2client_create.go | 10 + storage/ent/db/oauth2client_update.go | 58 ++ storage/ent/schema/client.go | 2 + storage/kubernetes/types.go | 32 +- storage/sql/crud.go | 25 +- storage/sql/migrate.go | 8 + storage/storage.go | 4 + 25 files changed, 1113 insertions(+), 560 deletions(-) diff --git a/api/api.pb.go b/api/api.pb.go index 702d3758..23c61410 100644 --- a/api/api.pb.go +++ b/api/api.pb.go @@ -23,16 +23,17 @@ const ( // Client represents an OAuth2 client. type Client struct { - 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + 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() { @@ -114,6 +115,13 @@ 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 `protogen:"open.v1"` @@ -305,14 +313,15 @@ func (x *DeleteClientResp) GetNotFound() bool { // UpdateClientReq is a request to update an existing client. type UpdateClientReq 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"` - 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + 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() { @@ -380,6 +389,13 @@ 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 `protogen:"open.v1"` @@ -1326,7 +1342,7 @@ var File_api_api_proto protoreflect.FileDescriptor 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, @@ -1338,155 +1354,161 @@ var file_api_api_proto_rawDesc = string([]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 ( diff --git a/api/api.proto b/api/api.proto index cfb7979c..01e3db17 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. diff --git a/api/v2/api.pb.go b/api/v2/api.pb.go index ee7e90ce..84955190 100644 --- a/api/v2/api.pb.go +++ b/api/v2/api.pb.go @@ -23,16 +23,17 @@ const ( // Client represents an OAuth2 client. type Client struct { - 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + 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() { @@ -114,17 +115,25 @@ func (x *Client) GetLogoUrl() string { return "" } +func (x *Client) GetAllowedConnectors() []string { + if x != nil { + return x.AllowedConnectors + } + return nil +} + // 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ClientInfo) Reset() { @@ -199,6 +208,13 @@ func (x *ClientInfo) GetLogoUrl() string { return "" } +func (x *ClientInfo) GetAllowedConnectors() []string { + if x != nil { + return x.AllowedConnectors + } + return nil +} + // GetClientReq is a request to retrieve client details. type GetClientReq struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -481,14 +497,15 @@ func (x *DeleteClientResp) GetNotFound() bool { // UpdateClientReq is a request to update an existing client. type UpdateClientReq 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"` - 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + 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() { @@ -556,6 +573,13 @@ 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 `protogen:"open.v1"` @@ -2289,7 +2313,7 @@ var File_api_v2_api_proto protoreflect.FileDescriptor 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, 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, @@ -2301,302 +2325,311 @@ var file_api_v2_api_proto_rawDesc = string([]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, 0xad, 0x01, 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, 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, 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, + 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, 0x22, 0xdc, 0x01, 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, 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, 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, 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, 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, + 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, 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, 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, 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, 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, + 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, 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, 0x8b, 0x09, - 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, 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, + 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, 0x8b, 0x09, 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, 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 ( diff --git a/api/v2/api.proto b/api/v2/api.proto index 51d6d292..4d5b850e 100644 --- a/api/v2/api.proto +++ b/api/v2/api.proto @@ -14,6 +14,7 @@ message Client { bool public = 5; string name = 6; string logo_url = 7; + repeated string allowed_connectors = 8; } // ClientInfo represents an OAuth2 client without sensitive information. @@ -24,6 +25,7 @@ message ClientInfo { bool public = 4; string name = 5; string logo_url = 6; + repeated string allowed_connectors = 7; } // GetClientReq is a request to retrieve client details. @@ -66,6 +68,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. diff --git a/config.yaml.dist b/config.yaml.dist index e570d7b2..3f888e08 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -134,6 +134,16 @@ web: # - /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 # Connectors are used to authenticate users against upstream identity providers. # diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 840905de..616cbf71 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -133,6 +133,10 @@ staticClients: - '/dex/device/callback' 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 # Example using environment variables # Set DEX_CLIENT_ID and DEX_SECURE_CLIENT_SECRET before starting Dex @@ -147,6 +151,7 @@ staticClients: # - /device/callback # name: 'Static Client for Device Flow' # public: true + connectors: - type: mockCallback id: mock diff --git a/server/api.go b/server/api.go index 8ade8a05..1d2f73ba 100644 --- a/server/api.go +++ b/server/api.go @@ -58,13 +58,14 @@ func (d dexAPI) GetClient(ctx context.Context, req *api.GetClientReq) (*api.GetC 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, + Id: c.ID, + Name: c.Name, + Secret: c.Secret, + RedirectUris: c.RedirectURIs, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + LogoUrl: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, }, }, nil } @@ -82,13 +83,14 @@ func (d dexAPI) CreateClient(ctx context.Context, req *api.CreateClientReq) (*ap } 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, + 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, } if err := d.s.CreateClient(ctx, c); err != nil { if err == storage.ErrAlreadyExists { @@ -121,6 +123,9 @@ func (d dexAPI) UpdateClient(ctx context.Context, req *api.UpdateClientReq) (*ap if req.LogoUrl != "" { old.LogoURL = req.LogoUrl } + if req.AllowedConnectors != nil { + old.AllowedConnectors = req.AllowedConnectors + } return old, nil }) if err != nil { @@ -155,12 +160,13 @@ func (d dexAPI) ListClients(ctx context.Context, req *api.ListClientReq) (*api.L 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, + Id: client.ID, + Name: client.Name, + RedirectUris: client.RedirectURIs, + TrustedPeers: client.TrustedPeers, + Public: client.Public, + LogoUrl: client.LogoURL, + AllowedConnectors: client.AllowedConnectors, } clients = append(clients, &c) } diff --git a/server/handlers.go b/server/handlers.go index 6a40bf96..a7a55aa2 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -185,8 +185,17 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { } } + // Filter connectors based on the client's allowed connectors list. + // client_id is required per RFC 6749 §4.1.1. + client, authErr := s.getClientWithAuthError(ctx, r.Form.Get("client_id")) + if authErr != nil { + s.renderError(r, w, authErr.Status, authErr.Error()) + return + } + connectors = filterConnectors(connectors, client.AllowedConnectors) + if len(connectors) == 0 { - s.renderError(r, w, http.StatusBadRequest, "No connectors available for the requested grant type.") + s.renderError(r, w, http.StatusBadRequest, "No connectors available for this client.") return } @@ -232,6 +241,57 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { } } +// filterConnectors filters the list of connectors by the allowed connector IDs. +// If allowedConnectors is empty, all connectors are returned (no filtering). +func filterConnectors(connectors []storage.Connector, allowedConnectors []string) []storage.Connector { + if len(allowedConnectors) == 0 { + return connectors + } + + allowed := make(map[string]bool, len(allowedConnectors)) + for _, id := range allowedConnectors { + allowed[id] = true + } + + filtered := make([]storage.Connector, 0, len(connectors)) + for _, c := range connectors { + if allowed[c.ID] { + filtered = append(filtered, c) + } + } + return filtered +} + +// isConnectorAllowed checks if a connector ID is in the client's allowed connectors list. +// If allowedConnectors is empty, all connectors are allowed. +func isConnectorAllowed(allowedConnectors []string, connectorID string) bool { + if len(allowedConnectors) == 0 { + return true + } + for _, id := range allowedConnectors { + if id == connectorID { + return true + } + } + return false +} + +// 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 (s *Server) getClientWithAuthError(ctx context.Context, clientID string) (storage.Client, *displayedAuthErr) { + client, err := s.storage.GetClient(ctx, clientID) + if err != nil { + if err == storage.ErrNotFound { + s.logger.ErrorContext(ctx, "invalid client_id provided", "client_id", clientID) + return storage.Client{}, newDisplayedErr(http.StatusBadRequest, "Invalid client_id provided.") + } + s.logger.ErrorContext(ctx, "failed to get client", "client_id", clientID, "err", err) + return storage.Client{}, newDisplayedErr(http.StatusInternalServerError, "Database error.") + } + return client, nil +} + func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { ctx := r.Context() authReq, err := s.parseAuthorizationRequest(r) @@ -257,6 +317,19 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } + // Validate that the connector is allowed for this client. + client, authErr := s.getClientWithAuthError(ctx, authReq.ClientID) + if authErr != nil { + s.renderError(r, w, authErr.Status, authErr.Error()) + return + } + if !isConnectorAllowed(client.AllowedConnectors, connID) { + s.logger.ErrorContext(r.Context(), "connector not allowed for client", + "connector_id", connID, "client_id", authReq.ClientID) + s.renderError(r, w, http.StatusForbidden, "Connector not allowed for this client.") + return + } + conn, err := s.getConnector(ctx, connID) if err != nil { s.logger.ErrorContext(r.Context(), "Failed to get connector", "err", err) diff --git a/server/handlers_test.go b/server/handlers_test.go index 43343aee..12c664f5 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -1156,7 +1156,12 @@ func TestHandleAuthorizationConnectorGrantTypeFiltering(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ctx := t.Context() - httpServer, s := newTestServerMultipleConnectors(t, nil) + httpServer, s := newTestServerMultipleConnectors(t, func(c *Config) { + c.Storage.CreateClient(ctx, storage.Client{ + ID: "test", + RedirectURIs: []string{"http://example.com/callback"}, + }) + }) defer httpServer.Close() for id, gts := range tc.connectorGrantTypes { @@ -1337,3 +1342,180 @@ func (m *mockSAMLRefreshConnector) HandlePOST(s connector.Scopes, samlResponse, func (m *mockSAMLRefreshConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { return m.refreshIdentity, nil } + +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 := filterConnectors(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 := isConnectorAllowed(tc.allowedConnectors, tc.connectorID) + require.Equal(t, tc.want, got) + }) + } +} + +func TestHandleAuthorizationWithAllowedConnectors(t *testing.T) { + ctx := t.Context() + + httpServer, s := newTestServerMultipleConnectors(t, nil) + defer httpServer.Close() + + // Create a client that only allows "mock" connector (not "mock2") + client := storage.Client{ + ID: "filtered-client", + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + Name: "Filtered Client", + AllowedConnectors: []string{"mock"}, + } + require.NoError(t, s.storage.CreateClient(ctx, client)) + + // Request the auth page with this client - should only show "mock" connector + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", fmt.Sprintf("/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=openid", + client.ID, url.QueryEscape("https://example.com/callback")), nil) + s.ServeHTTP(rr, req) + + // With only one allowed connector and alwaysShowLogin=false (default), + // the server should redirect directly to the connector + require.Equal(t, http.StatusFound, rr.Code) + location := rr.Header().Get("Location") + require.Contains(t, location, "/auth/mock") + require.NotContains(t, location, "mock2") +} + +func TestHandleAuthorizationWithNoMatchingConnectors(t *testing.T) { + ctx := t.Context() + + httpServer, s := newTestServerMultipleConnectors(t, nil) + defer httpServer.Close() + + // Create a client that only allows a non-existent connector + client := storage.Client{ + ID: "no-connectors-client", + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + Name: "No Connectors Client", + AllowedConnectors: []string{"nonexistent"}, + } + require.NoError(t, s.storage.CreateClient(ctx, client)) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", fmt.Sprintf("/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=openid", + client.ID, url.QueryEscape("https://example.com/callback")), nil) + s.ServeHTTP(rr, req) + + // Should return an error, not an empty login page + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestHandleAuthorizationWithoutAllowedConnectors(t *testing.T) { + ctx := t.Context() + + httpServer, s := newTestServerMultipleConnectors(t, nil) + defer httpServer.Close() + + // Create a client with no connector restrictions + client := storage.Client{ + ID: "unfiltered-client", + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + Name: "Unfiltered Client", + } + require.NoError(t, s.storage.CreateClient(ctx, client)) + + // Request the auth page - should show all connectors (rendered as HTML) + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", fmt.Sprintf("/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=openid", + client.ID, url.QueryEscape("https://example.com/callback")), nil) + s.ServeHTTP(rr, req) + + // With multiple connectors and no filter, the login page should be rendered (200 OK) + require.Equal(t, http.StatusOK, rr.Code) +} diff --git a/server/oauth2.go b/server/oauth2.go index 91879761..2300e934 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -475,6 +475,9 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques if !validateConnectorID(connectors, connectorID) { return nil, newRedirectedErr(errInvalidRequest, "Invalid ConnectorID") } + if !isConnectorAllowed(client.AllowedConnectors, connectorID) { + return nil, newRedirectedErr(errInvalidRequest, "Connector not allowed for this client") + } } // dex doesn't support request parameter and must return request_not_supported error diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index eeb20cd9..92a4d140 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -262,11 +262,12 @@ func testClientCRUD(t *testing.T, s storage.Storage) { ctx := t.Context() id1 := storage.NewID() c1 := storage.Client{ - ID: id1, - Secret: "foobar", - RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"}, - Name: "dex client", - LogoURL: "https://goo.gl/JIyzIC", + ID: id1, + Secret: "foobar", + RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"}, + Name: "dex client", + LogoURL: "https://goo.gl/JIyzIC", + AllowedConnectors: []string{"github", "google"}, } err := s.DeleteClient(ctx, id1) mustBeErrNotFound(t, "client", err) diff --git a/storage/ent/client/client.go b/storage/ent/client/client.go index 1957a76a..a4f0d942 100644 --- a/storage/ent/client/client.go +++ b/storage/ent/client/client.go @@ -16,6 +16,7 @@ func (d *Database) CreateClient(ctx context.Context, client storage.Client) erro SetLogoURL(client.LogoURL). SetRedirectUris(client.RedirectURIs). SetTrustedPeers(client.TrustedPeers). + SetAllowedConnectors(client.AllowedConnectors). Save(ctx) if err != nil { return convertDBError("create oauth2 client: %w", err) @@ -79,6 +80,7 @@ func (d *Database) UpdateClient(ctx context.Context, id string, updater func(old SetLogoURL(newClient.LogoURL). SetRedirectUris(newClient.RedirectURIs). SetTrustedPeers(newClient.TrustedPeers). + SetAllowedConnectors(newClient.AllowedConnectors). Save(ctx) if err != nil { return rollback(tx, "update client uploading: %w", err) diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 4a0b41f0..a59fb6f5 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -76,13 +76,14 @@ func toStorageAuthCode(a *db.AuthCode) storage.AuthCode { func toStorageClient(c *db.OAuth2Client) storage.Client { return storage.Client{ - ID: c.ID, - Secret: c.Secret, - RedirectURIs: c.RedirectUris, - TrustedPeers: c.TrustedPeers, - Public: c.Public, - Name: c.Name, - LogoURL: c.LogoURL, + ID: c.ID, + Secret: c.Secret, + RedirectURIs: c.RedirectUris, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + Name: c.Name, + LogoURL: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, } } diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index b4a94a65..b59e455b 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -135,6 +135,7 @@ var ( {Name: "public", Type: field.TypeBool}, {Name: "name", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "logo_url", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "allowed_connectors", Type: field.TypeJSON, Nullable: true}, } // Oauth2clientsTable holds the schema information for the "oauth2clients" table. Oauth2clientsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 0b299bf8..73c7f095 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -5214,21 +5214,23 @@ func (m *KeysMutation) ResetEdge(name string) error { // OAuth2ClientMutation represents an operation that mutates the OAuth2Client nodes in the graph. type OAuth2ClientMutation struct { config - op Op - typ string - id *string - secret *string - redirect_uris *[]string - appendredirect_uris []string - trusted_peers *[]string - appendtrusted_peers []string - public *bool - name *string - logo_url *string - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*OAuth2Client, error) - predicates []predicate.OAuth2Client + op Op + typ string + id *string + secret *string + redirect_uris *[]string + appendredirect_uris []string + trusted_peers *[]string + appendtrusted_peers []string + public *bool + name *string + logo_url *string + allowed_connectors *[]string + appendallowed_connectors []string + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*OAuth2Client, error) + predicates []predicate.OAuth2Client } var _ ent.Mutation = (*OAuth2ClientMutation)(nil) @@ -5609,6 +5611,71 @@ func (m *OAuth2ClientMutation) ResetLogoURL() { m.logo_url = nil } +// SetAllowedConnectors sets the "allowed_connectors" field. +func (m *OAuth2ClientMutation) SetAllowedConnectors(s []string) { + m.allowed_connectors = &s + m.appendallowed_connectors = nil +} + +// AllowedConnectors returns the value of the "allowed_connectors" field in the mutation. +func (m *OAuth2ClientMutation) AllowedConnectors() (r []string, exists bool) { + v := m.allowed_connectors + if v == nil { + return + } + return *v, true +} + +// OldAllowedConnectors returns the old "allowed_connectors" field's value of the OAuth2Client entity. +// If the OAuth2Client object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *OAuth2ClientMutation) OldAllowedConnectors(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAllowedConnectors is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAllowedConnectors requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAllowedConnectors: %w", err) + } + return oldValue.AllowedConnectors, nil +} + +// AppendAllowedConnectors adds s to the "allowed_connectors" field. +func (m *OAuth2ClientMutation) AppendAllowedConnectors(s []string) { + m.appendallowed_connectors = append(m.appendallowed_connectors, s...) +} + +// AppendedAllowedConnectors returns the list of values that were appended to the "allowed_connectors" field in this mutation. +func (m *OAuth2ClientMutation) AppendedAllowedConnectors() ([]string, bool) { + if len(m.appendallowed_connectors) == 0 { + return nil, false + } + return m.appendallowed_connectors, true +} + +// ClearAllowedConnectors clears the value of the "allowed_connectors" field. +func (m *OAuth2ClientMutation) ClearAllowedConnectors() { + m.allowed_connectors = nil + m.appendallowed_connectors = nil + m.clearedFields[oauth2client.FieldAllowedConnectors] = struct{}{} +} + +// AllowedConnectorsCleared returns if the "allowed_connectors" field was cleared in this mutation. +func (m *OAuth2ClientMutation) AllowedConnectorsCleared() bool { + _, ok := m.clearedFields[oauth2client.FieldAllowedConnectors] + return ok +} + +// ResetAllowedConnectors resets all changes to the "allowed_connectors" field. +func (m *OAuth2ClientMutation) ResetAllowedConnectors() { + m.allowed_connectors = nil + m.appendallowed_connectors = nil + delete(m.clearedFields, oauth2client.FieldAllowedConnectors) +} + // Where appends a list predicates to the OAuth2ClientMutation builder. func (m *OAuth2ClientMutation) Where(ps ...predicate.OAuth2Client) { m.predicates = append(m.predicates, ps...) @@ -5643,7 +5710,7 @@ func (m *OAuth2ClientMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *OAuth2ClientMutation) Fields() []string { - fields := make([]string, 0, 6) + fields := make([]string, 0, 7) if m.secret != nil { fields = append(fields, oauth2client.FieldSecret) } @@ -5662,6 +5729,9 @@ func (m *OAuth2ClientMutation) Fields() []string { if m.logo_url != nil { fields = append(fields, oauth2client.FieldLogoURL) } + if m.allowed_connectors != nil { + fields = append(fields, oauth2client.FieldAllowedConnectors) + } return fields } @@ -5682,6 +5752,8 @@ func (m *OAuth2ClientMutation) Field(name string) (ent.Value, bool) { return m.Name() case oauth2client.FieldLogoURL: return m.LogoURL() + case oauth2client.FieldAllowedConnectors: + return m.AllowedConnectors() } return nil, false } @@ -5703,6 +5775,8 @@ func (m *OAuth2ClientMutation) OldField(ctx context.Context, name string) (ent.V return m.OldName(ctx) case oauth2client.FieldLogoURL: return m.OldLogoURL(ctx) + case oauth2client.FieldAllowedConnectors: + return m.OldAllowedConnectors(ctx) } return nil, fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -5754,6 +5828,13 @@ func (m *OAuth2ClientMutation) SetField(name string, value ent.Value) error { } m.SetLogoURL(v) return nil + case oauth2client.FieldAllowedConnectors: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAllowedConnectors(v) + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -5790,6 +5871,9 @@ func (m *OAuth2ClientMutation) ClearedFields() []string { if m.FieldCleared(oauth2client.FieldTrustedPeers) { fields = append(fields, oauth2client.FieldTrustedPeers) } + if m.FieldCleared(oauth2client.FieldAllowedConnectors) { + fields = append(fields, oauth2client.FieldAllowedConnectors) + } return fields } @@ -5810,6 +5894,9 @@ func (m *OAuth2ClientMutation) ClearField(name string) error { case oauth2client.FieldTrustedPeers: m.ClearTrustedPeers() return nil + case oauth2client.FieldAllowedConnectors: + m.ClearAllowedConnectors() + return nil } return fmt.Errorf("unknown OAuth2Client nullable field %s", name) } @@ -5836,6 +5923,9 @@ func (m *OAuth2ClientMutation) ResetField(name string) error { case oauth2client.FieldLogoURL: m.ResetLogoURL() return nil + case oauth2client.FieldAllowedConnectors: + m.ResetAllowedConnectors() + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } diff --git a/storage/ent/db/oauth2client.go b/storage/ent/db/oauth2client.go index f8491be2..c6671aba 100644 --- a/storage/ent/db/oauth2client.go +++ b/storage/ent/db/oauth2client.go @@ -28,8 +28,10 @@ type OAuth2Client struct { // Name holds the value of the "name" field. Name string `json:"name,omitempty"` // LogoURL holds the value of the "logo_url" field. - LogoURL string `json:"logo_url,omitempty"` - selectValues sql.SelectValues + LogoURL string `json:"logo_url,omitempty"` + // AllowedConnectors holds the value of the "allowed_connectors" field. + AllowedConnectors []string `json:"allowed_connectors,omitempty"` + selectValues sql.SelectValues } // scanValues returns the types for scanning values from sql.Rows. @@ -37,7 +39,7 @@ func (*OAuth2Client) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers: + case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors: values[i] = new([]byte) case oauth2client.FieldPublic: values[i] = new(sql.NullBool) @@ -104,6 +106,14 @@ func (_m *OAuth2Client) assignValues(columns []string, values []any) error { } else if value.Valid { _m.LogoURL = value.String } + case oauth2client.FieldAllowedConnectors: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field allowed_connectors", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.AllowedConnectors); err != nil { + return fmt.Errorf("unmarshal field allowed_connectors: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -157,6 +167,9 @@ func (_m *OAuth2Client) String() string { builder.WriteString(", ") builder.WriteString("logo_url=") builder.WriteString(_m.LogoURL) + builder.WriteString(", ") + builder.WriteString("allowed_connectors=") + builder.WriteString(fmt.Sprintf("%v", _m.AllowedConnectors)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/oauth2client/oauth2client.go b/storage/ent/db/oauth2client/oauth2client.go index 08df76be..529f4c8d 100644 --- a/storage/ent/db/oauth2client/oauth2client.go +++ b/storage/ent/db/oauth2client/oauth2client.go @@ -23,6 +23,8 @@ const ( FieldName = "name" // FieldLogoURL holds the string denoting the logo_url field in the database. FieldLogoURL = "logo_url" + // FieldAllowedConnectors holds the string denoting the allowed_connectors field in the database. + FieldAllowedConnectors = "allowed_connectors" // Table holds the table name of the oauth2client in the database. Table = "oauth2clients" ) @@ -36,6 +38,7 @@ var Columns = []string{ FieldPublic, FieldName, FieldLogoURL, + FieldAllowedConnectors, } // ValidColumn reports if the column name is valid (part of the table columns). diff --git a/storage/ent/db/oauth2client/where.go b/storage/ent/db/oauth2client/where.go index 55aee79b..1425bf7e 100644 --- a/storage/ent/db/oauth2client/where.go +++ b/storage/ent/db/oauth2client/where.go @@ -307,6 +307,16 @@ func LogoURLContainsFold(v string) predicate.OAuth2Client { return predicate.OAuth2Client(sql.FieldContainsFold(FieldLogoURL, v)) } +// AllowedConnectorsIsNil applies the IsNil predicate on the "allowed_connectors" field. +func AllowedConnectorsIsNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldIsNull(FieldAllowedConnectors)) +} + +// AllowedConnectorsNotNil applies the NotNil predicate on the "allowed_connectors" field. +func AllowedConnectorsNotNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldNotNull(FieldAllowedConnectors)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.OAuth2Client) predicate.OAuth2Client { return predicate.OAuth2Client(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/oauth2client_create.go b/storage/ent/db/oauth2client_create.go index 2bbf1f50..fe29ab2a 100644 --- a/storage/ent/db/oauth2client_create.go +++ b/storage/ent/db/oauth2client_create.go @@ -55,6 +55,12 @@ func (_c *OAuth2ClientCreate) SetLogoURL(v string) *OAuth2ClientCreate { return _c } +// SetAllowedConnectors sets the "allowed_connectors" field. +func (_c *OAuth2ClientCreate) SetAllowedConnectors(v []string) *OAuth2ClientCreate { + _c.mutation.SetAllowedConnectors(v) + return _c +} + // SetID sets the "id" field. func (_c *OAuth2ClientCreate) SetID(v string) *OAuth2ClientCreate { _c.mutation.SetID(v) @@ -186,6 +192,10 @@ func (_c *OAuth2ClientCreate) createSpec() (*OAuth2Client, *sqlgraph.CreateSpec) _spec.SetField(oauth2client.FieldLogoURL, field.TypeString, value) _node.LogoURL = value } + if value, ok := _c.mutation.AllowedConnectors(); ok { + _spec.SetField(oauth2client.FieldAllowedConnectors, field.TypeJSON, value) + _node.AllowedConnectors = value + } return _node, _spec } diff --git a/storage/ent/db/oauth2client_update.go b/storage/ent/db/oauth2client_update.go index 69f2c044..3fdbdbdc 100644 --- a/storage/ent/db/oauth2client_update.go +++ b/storage/ent/db/oauth2client_update.go @@ -120,6 +120,24 @@ func (_u *OAuth2ClientUpdate) SetNillableLogoURL(v *string) *OAuth2ClientUpdate return _u } +// SetAllowedConnectors sets the "allowed_connectors" field. +func (_u *OAuth2ClientUpdate) SetAllowedConnectors(v []string) *OAuth2ClientUpdate { + _u.mutation.SetAllowedConnectors(v) + return _u +} + +// AppendAllowedConnectors appends value to the "allowed_connectors" field. +func (_u *OAuth2ClientUpdate) AppendAllowedConnectors(v []string) *OAuth2ClientUpdate { + _u.mutation.AppendAllowedConnectors(v) + return _u +} + +// ClearAllowedConnectors clears the value of the "allowed_connectors" field. +func (_u *OAuth2ClientUpdate) ClearAllowedConnectors() *OAuth2ClientUpdate { + _u.mutation.ClearAllowedConnectors() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdate) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -218,6 +236,17 @@ func (_u *OAuth2ClientUpdate) sqlSave(ctx context.Context) (_node int, err error if value, ok := _u.mutation.LogoURL(); ok { _spec.SetField(oauth2client.FieldLogoURL, field.TypeString, value) } + if value, ok := _u.mutation.AllowedConnectors(); ok { + _spec.SetField(oauth2client.FieldAllowedConnectors, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedAllowedConnectors(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldAllowedConnectors, value) + }) + } + if _u.mutation.AllowedConnectorsCleared() { + _spec.ClearField(oauth2client.FieldAllowedConnectors, field.TypeJSON) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{oauth2client.Label} @@ -330,6 +359,24 @@ func (_u *OAuth2ClientUpdateOne) SetNillableLogoURL(v *string) *OAuth2ClientUpda return _u } +// SetAllowedConnectors sets the "allowed_connectors" field. +func (_u *OAuth2ClientUpdateOne) SetAllowedConnectors(v []string) *OAuth2ClientUpdateOne { + _u.mutation.SetAllowedConnectors(v) + return _u +} + +// AppendAllowedConnectors appends value to the "allowed_connectors" field. +func (_u *OAuth2ClientUpdateOne) AppendAllowedConnectors(v []string) *OAuth2ClientUpdateOne { + _u.mutation.AppendAllowedConnectors(v) + return _u +} + +// ClearAllowedConnectors clears the value of the "allowed_connectors" field. +func (_u *OAuth2ClientUpdateOne) ClearAllowedConnectors() *OAuth2ClientUpdateOne { + _u.mutation.ClearAllowedConnectors() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdateOne) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -458,6 +505,17 @@ func (_u *OAuth2ClientUpdateOne) sqlSave(ctx context.Context) (_node *OAuth2Clie if value, ok := _u.mutation.LogoURL(); ok { _spec.SetField(oauth2client.FieldLogoURL, field.TypeString, value) } + if value, ok := _u.mutation.AllowedConnectors(); ok { + _spec.SetField(oauth2client.FieldAllowedConnectors, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedAllowedConnectors(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldAllowedConnectors, value) + }) + } + if _u.mutation.AllowedConnectorsCleared() { + _spec.ClearField(oauth2client.FieldAllowedConnectors, field.TypeJSON) + } _node = &OAuth2Client{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/schema/client.go b/storage/ent/schema/client.go index b897c52a..f0e10606 100644 --- a/storage/ent/schema/client.go +++ b/storage/ent/schema/client.go @@ -45,6 +45,8 @@ func (OAuth2Client) Fields() []ent.Field { field.Text("logo_url"). SchemaType(textSchema). NotEmpty(), + field.JSON("allowed_connectors", []string{}). + Optional(), } } diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 502df413..abcd907e 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -251,6 +251,8 @@ type Client struct { Name string `json:"name,omitempty"` LogoURL string `json:"logoURL,omitempty"` + + AllowedConnectors []string `json:"allowedConnectors,omitempty"` } // ClientList is a list of Clients. @@ -270,25 +272,27 @@ func (cli *client) fromStorageClient(c storage.Client) Client { Name: cli.idToName(c.ID), Namespace: cli.namespace, }, - ID: c.ID, - Secret: c.Secret, - RedirectURIs: c.RedirectURIs, - TrustedPeers: c.TrustedPeers, - Public: c.Public, - Name: c.Name, - LogoURL: c.LogoURL, + ID: c.ID, + Secret: c.Secret, + RedirectURIs: c.RedirectURIs, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + Name: c.Name, + LogoURL: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, } } func toStorageClient(c Client) storage.Client { return storage.Client{ - ID: c.ID, - Secret: c.Secret, - RedirectURIs: c.RedirectURIs, - TrustedPeers: c.TrustedPeers, - Public: c.Public, - Name: c.Name, - LogoURL: c.LogoURL, + ID: c.ID, + Secret: c.Secret, + RedirectURIs: c.RedirectURIs, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + Name: c.Name, + LogoURL: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, } } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index c03714b3..33e69fc0 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -513,9 +513,10 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto trusted_peers = $3, public = $4, name = $5, - logo_url = $6 - where id = $7; - `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, id, + logo_url = $6, + allowed_connectors = $7 + where id = $8; + `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, encoder(nc.AllowedConnectors), id, ) if err != nil { return fmt.Errorf("update client: %v", err) @@ -527,12 +528,12 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { _, err := c.Exec(` insert into client ( - id, secret, redirect_uris, trusted_peers, public, name, logo_url + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors ) - values ($1, $2, $3, $4, $5, $6, $7); + values ($1, $2, $3, $4, $5, $6, $7, $8); `, cli.ID, cli.Secret, encoder(cli.RedirectURIs), encoder(cli.TrustedPeers), - cli.Public, cli.Name, cli.LogoURL, + cli.Public, cli.Name, cli.LogoURL, encoder(cli.AllowedConnectors), ) if err != nil { if c.alreadyExistsCheck(err) { @@ -546,7 +547,7 @@ func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { func getClient(ctx context.Context, q querier, id string) (storage.Client, error) { return scanClient(q.QueryRow(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors from client where id = $1; `, id)) } @@ -558,7 +559,7 @@ func (c *conn) GetClient(ctx context.Context, id string) (storage.Client, error) func (c *conn) ListClients(ctx context.Context) ([]storage.Client, error) { rows, err := c.Query(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors from client; `) if err != nil { @@ -581,9 +582,10 @@ func (c *conn) ListClients(ctx context.Context) ([]storage.Client, error) { } func scanClient(s scanner) (cli storage.Client, err error) { + var allowedConnectors []byte err = s.Scan( &cli.ID, &cli.Secret, decoder(&cli.RedirectURIs), decoder(&cli.TrustedPeers), - &cli.Public, &cli.Name, &cli.LogoURL, + &cli.Public, &cli.Name, &cli.LogoURL, &allowedConnectors, ) if err != nil { if err == sql.ErrNoRows { @@ -591,6 +593,11 @@ func scanClient(s scanner) (cli storage.Client, err error) { } return cli, fmt.Errorf("get client: %v", err) } + if len(allowedConnectors) > 0 { + if err := json.Unmarshal(allowedConnectors, &cli.AllowedConnectors); err != nil { + return cli, fmt.Errorf("unmarshal client allowed connectors: %v", err) + } + } return cli, nil } diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 01f393f1..440acfd5 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -381,4 +381,12 @@ var migrations = []migration{ add column grant_types bytea;`, }, }, + // Migration for adding allowed_connectors to client table + { + stmts: []string{ + ` + alter table client + add column allowed_connectors bytea;`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index 599badb6..e7bddef9 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -171,6 +171,10 @@ type Client struct { // Name and LogoURL used when displaying this client to the end user. Name string `json:"name"` LogoURL string `json:"logoURL"` + + // AllowedConnectors is a list of connector IDs that the client is allowed to use for authentication. + // If empty, all connectors are allowed. + AllowedConnectors []string `json:"allowedConnectors"` } // Claims represents the ID Token claims supported by the server. From 80d297b8a43d912740a0c898d99772a705e4e57a Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Thu, 12 Mar 2026 09:18:12 +0100 Subject: [PATCH 148/222] feat: update CSS for improved theming and button styles (#4634) --- examples/config-dev.yaml | 1 + web/static/img/mock-icon.svg | 5 ++ web/static/main.css | 61 ++++++++++---------- web/themes/dark/styles.css | 105 ++++++++++++++++++----------------- web/themes/light/styles.css | 80 ++++++++++++++------------ 5 files changed, 137 insertions(+), 115 deletions(-) create mode 100644 web/static/img/mock-icon.svg diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 616cbf71..eb48e581 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -68,6 +68,7 @@ web: # issuer: dex # logoURL: theme/logo.png # dir: web/ +# Allowed values: light, dark # theme: light # Configuration for telemetry diff --git a/web/static/img/mock-icon.svg b/web/static/img/mock-icon.svg new file mode 100644 index 00000000..fda905ca --- /dev/null +++ b/web/static/img/mock-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/static/main.css b/web/static/main.css index f5c61d7f..47e5c9a6 100644 --- a/web/static/main.css +++ b/web/static/main.css @@ -8,28 +8,29 @@ body { .dex-container { color: #333; - margin: 45px auto; - max-width: 500px; + margin: 60px auto; + max-width: 480px; min-width: 320px; + padding: 0 16px; text-align: center; } .dex-btn { - border-radius: 4px; + border-radius: 8px; border: 0; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.25), 0 0 1px rgba(0, 0, 0, 0.25); cursor: pointer; - font-size: 16px; + font-size: 15px; padding: 0; + transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease; } -.dex-btn:focus { - outline: none; +.dex-btn:focus-visible { + outline: 2px solid #4A90D9; + outline-offset: 2px; } .dex-btn:active { - box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - outline: none; + transform: scale(0.98); } .dex-btn:disabled { @@ -40,17 +41,17 @@ body { .dex-btn-icon { background-position: center; background-repeat: no-repeat; - background-size: 24px; - border-radius: 4px 0 0 4px; + background-size: 20px; + border-radius: 8px 0 0 8px; float: left; - height: 36px; - margin-right: 5px; - width: 36px; + height: 40px; + margin-right: 4px; + width: 40px; } .dex-btn-icon--google { background-color: #FFFFFF; - background-image: url(../static/img/google-icon.svg);; + background-image: url(../static/img/google-icon.svg); } .dex-btn-icon--local { @@ -115,39 +116,43 @@ body { background-image: url(../static/img/microsoft-icon.svg); } +.dex-btn-icon--mockCallback, +.dex-btn-icon--mockPassword { + background-color: #6c5ce7; + background-image: url(../static/img/mock-icon.svg); +} + .dex-btn-text { font-weight: 600; - line-height: 36px; - padding: 6px 12px; + line-height: 40px; + padding: 6px 14px; text-align: center; } .dex-subtle-text { - color: #999; - font-size: 12px; + color: #888; + font-size: 13px; } .dex-separator { - color: #999; + color: #aaa; } .dex-list { - color: #999; + color: #888; display: inline-block; - font-size: 12px; + font-size: 13px; list-style: circle; text-align: left; } .dex-error-box { - background-color: #DD1327; + background-color: #e5383b; + border-radius: 6px; color: #fff; font-size: 14px; font-weight: normal; - max-width: 320px; - padding: 4px 0; -} - -.dex-error-box { margin: 20px auto; + max-width: 320px; + padding: 8px 12px; } diff --git a/web/themes/dark/styles.css b/web/themes/dark/styles.css index edf30412..153f17eb 100644 --- a/web/themes/dark/styles.css +++ b/web/themes/dark/styles.css @@ -1,122 +1,127 @@ .theme-body { - background-color: #0f1218; - color: #c8d1d9; - font-family: 'Source Sans Pro', Helvetica, sans-serif; + background-color: #131519; + color: #b8bcc4; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; } .theme-navbar { - background-color: #161b22; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); - color: #161B2B; + background-color: #1a1d23; + border-bottom: 1px solid #2a2d35; + color: #b8bcc4; font-size: 13px; - font-weight: 100; - height: 46px; + font-weight: 400; + height: 52px; overflow: hidden; - padding: 0 10px; + padding: 0 16px; } .theme-navbar__logo-wrap { display: inline-block; height: 100%; overflow: hidden; - padding: 10px 15px; + padding: 12px 15px; width: 300px; } .theme-navbar__logo { height: 100%; - max-height: 25px; + max-height: 26px; } .theme-heading { + color: #dcdfe5; font-size: 20px; - font-weight: 500; + font-weight: 600; + margin-bottom: 16px; margin-top: 0; - color: #c8d1d9; } .theme-panel { - background-color: #161b22; - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); - padding: 30px; + background-color: #1a1d23; + border: 1px solid #2a2d35; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + padding: 32px; } .theme-btn-provider { - background-color: #1e242d; - color: #c8d1d9; - border: 1px solid #30373c; - min-width: 250px; + background-color: #22252c; + border: 1px solid #33363e; + color: #b8bcc4; + min-width: 260px; } .theme-btn-provider:hover { - background-color: #212731; - color: #ffffff; + background-color: #2a2d35; + border-color: #3e414a; + color: #dcdfe5; } .theme-btn--primary { - background-color: #1e242d; + background-color: #3d3f47; border: none; - color: #c8d1d9; + color: #e8eaed; min-width: 200px; - padding: 6px 12px; + padding: 8px 16px; } .theme-btn--primary:hover { - background-color: #212731; - color: #e9e9e9; + background-color: #4a4c55; + color: #fff; } .theme-btn--success { - background-color: #1891bb; - color: #e9e9e9; - width: 250px; + background-color: #2d7d9a; + color: #e8eaed; + width: 260px; } .theme-btn--success:hover { - background-color: #1da5d4; + background-color: #358fae; } .theme-form-row { display: block; - margin: 20px auto; + margin: 16px auto; } .theme-form-input { + background-color: #131519; + border: 1px solid #33363e; + border-radius: 8px; + color: #b8bcc4; display: block; - height: 36px; - padding: 6px 12px; font-size: 14px; - line-height: 1.42857143; - border: 1px solid #515559; - border-radius: 4px; - color: #c8d1d9; - background-color: #0f1218; - box-shadow: inset 0 1px 1px rgb(27, 40, 46); - width: 250px; + height: 40px; + line-height: 1.5; margin: auto; + padding: 8px 12px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 260px; } .theme-form-input:focus, .theme-form-input:active { + border-color: #5a9bb5; + box-shadow: 0 0 0 3px rgba(90, 155, 181, 0.15); + color: #dcdfe5; outline: none; - border-color: #f8f9f9; - color: #c8d1d9; } .theme-form-label { - width: 250px; + color: #b8bcc4; + font-size: 14px; + font-weight: 500; margin: 4px auto; - text-align: left; position: relative; - font-size: 13px; - font-weight: 600; - color: #c8d1d9; + text-align: left; + width: 260px; } .theme-link-back { - margin-top: 4px; + margin-top: 8px; } .dex-container { - color: #c8d1d9; + color: #b8bcc4; } diff --git a/web/themes/light/styles.css b/web/themes/light/styles.css index 2d920571..61586722 100644 --- a/web/themes/light/styles.css +++ b/web/themes/light/styles.css @@ -1,113 +1,119 @@ .theme-body { - background-color: #efefef; - color: #333; - font-family: 'Source Sans Pro', Helvetica, sans-serif; + background-color: #f4f5f7; + color: #1a1a1a; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; } .theme-navbar { background-color: #fff; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); - color: #333; + border-bottom: 1px solid #e1e4e8; + color: #1a1a1a; font-size: 13px; - font-weight: 100; - height: 46px; + font-weight: 400; + height: 52px; overflow: hidden; - padding: 0 10px; + padding: 0 16px; } .theme-navbar__logo-wrap { display: inline-block; height: 100%; overflow: hidden; - padding: 10px 15px; + padding: 12px 15px; width: 300px; } .theme-navbar__logo { height: 100%; - max-height: 25px; + max-height: 26px; } .theme-heading { font-size: 20px; - font-weight: 500; - margin-bottom: 10px; + font-weight: 600; + margin-bottom: 16px; margin-top: 0; } .theme-panel { background-color: #fff; - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); - padding: 30px; + border: 1px solid #e1e4e8; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06); + padding: 32px; } .theme-btn-provider { background-color: #fff; - color: #333; - min-width: 250px; + border: 1px solid #d0d5dd; + color: #1a1a1a; + min-width: 260px; } .theme-btn-provider:hover { - color: #999; + background-color: #f9fafb; + border-color: #b0b5bd; + color: #1a1a1a; } .theme-btn--primary { - background-color: #333; + background-color: #1a1a1a; border: none; color: #fff; min-width: 200px; - padding: 6px 12px; + padding: 8px 16px; } .theme-btn--primary:hover { - background-color: #666; + background-color: #333; color: #fff; } .theme-btn--success { - background-color: #2FC98E; + background-color: #16a34a; color: #fff; - width: 250px; + width: 260px; } .theme-btn--success:hover { - background-color: #49E3A8; + background-color: #15803d; } .theme-form-row { display: block; - margin: 20px auto; + margin: 16px auto; } .theme-form-input { - border-radius: 4px; - border: 1px solid #CCC; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - color: #666; + border-radius: 8px; + border: 1px solid #d0d5dd; + color: #1a1a1a; display: block; font-size: 14px; - height: 36px; - line-height: 1.42857143; + height: 40px; + line-height: 1.5; margin: auto; - padding: 6px 12px; - width: 250px; + padding: 8px 12px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 260px; } .theme-form-input:focus, .theme-form-input:active { - border-color: #66AFE9; + border-color: #4A90D9; + box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.15); outline: none; } .theme-form-label { - font-size: 13px; - font-weight: 600; + font-size: 14px; + font-weight: 500; margin: 4px auto; position: relative; text-align: left; - width: 250px; + width: 260px; } .theme-link-back { - margin-top: 4px; + margin-top: 8px; } From 734d60f4850cdcc55a5f8504fc841a7820fee227 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:20:10 +0100 Subject: [PATCH 149/222] build(deps): bump golang.org/x/crypto from 0.48.0 to 0.49.0 (#4636) --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index f83334a6..f6560405 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/stretchr/testify v1.11.1 go.etcd.io/etcd/client/pkg/v3 v3.6.8 go.etcd.io/etcd/client/v3 v3.6.8 - golang.org/x/crypto v0.48.0 + golang.org/x/crypto v0.49.0 golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 golang.org/x/net v0.51.0 golang.org/x/oauth2 v0.36.0 @@ -111,12 +111,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/mod v0.32.0 // indirect + golang.org/x/mod v0.33.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/tools v0.42.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect diff --git a/go.sum b/go.sum index ed20373e..13e035a7 100644 --- a/go.sum +++ b/go.sum @@ -268,14 +268,14 @@ 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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 h1:fGZugkZk2UgYBxtpKmvub51Yno1LJDeEsRp2xGD+0gY= golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -292,20 +292,20 @@ golang.org/x/sync v0.20.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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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= From 13f012fb81a80e08a05ff47fe23dd98ee6ad4c6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:45:00 +0100 Subject: [PATCH 150/222] build(deps): bump golang.org/x/net from 0.51.0 to 0.52.0 (#4635) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.51.0 to 0.52.0. - [Commits](https://github.com/golang/net/compare/v0.51.0...v0.52.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.52.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f6560405..99e88730 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( go.etcd.io/etcd/client/v3 v3.6.8 golang.org/x/crypto v0.49.0 golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 - golang.org/x/net v0.51.0 + golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.271.0 google.golang.org/grpc v1.79.2 diff --git a/go.sum b/go.sum index 13e035a7..f735bcd6 100644 --- a/go.sum +++ b/go.sum @@ -280,8 +280,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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= From 2bda64690d1b4f63faba7ce7d4e461062c85cbe8 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 13 Mar 2026 13:09:30 +0100 Subject: [PATCH 151/222] test: fix token introspection tests to use consistent timestamps (#4639) Signed-off-by: maksim.nabokikh --- server/introspectionhandler_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/introspectionhandler_test.go b/server/introspectionhandler_test.go index 61ff3cf2..799f0000 100644 --- a/server/introspectionhandler_test.go +++ b/server/introspectionhandler_test.go @@ -298,7 +298,7 @@ func TestHandleIntrospect(t *testing.T) { { testName: "Access Token: active", token: activeAccessToken, - response: toJSON(getIntrospectionValue(s.issuerURL, time.Now(), expiry, "access_token")), + response: toJSON(getIntrospectionValue(s.issuerURL, t0, expiry, "access_token")), responseStatusCode: 200, }, { @@ -311,7 +311,7 @@ func TestHandleIntrospect(t *testing.T) { { testName: "Refresh Token: active", token: activeRefreshToken, - response: toJSON(getIntrospectionValue(s.issuerURL, time.Now(), time.Now().Add(s.refreshTokenPolicy.absoluteLifetime), "refresh_token")), + response: toJSON(getIntrospectionValue(s.issuerURL, t0, t0.Add(s.refreshTokenPolicy.absoluteLifetime), "refresh_token")), responseStatusCode: 200, }, { From 5bbfbbe168a747b017710dd8804846ddf390cbb2 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 13 Mar 2026 13:09:44 +0100 Subject: [PATCH 152/222] feat: add PKCE (Proof Key for Code Exchange) configuration to OAuth2 settings (#4638) Signed-off-by: maksim.nabokikh --- cmd/dex/config.go | 11 +++++ cmd/dex/serve.go | 14 ++++--- config.yaml.dist | 7 ++++ examples/config-dev.yaml | 24 +++++++---- server/handlers.go | 2 +- server/oauth2.go | 9 +++- server/oauth2_test.go | 90 ++++++++++++++++++++++++++++++++++++++++ server/server.go | 27 ++++++++++++ 8 files changed, 168 insertions(+), 16 deletions(-) diff --git a/cmd/dex/config.go b/cmd/dex/config.go index a05c3efd..913d4dfe 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -99,6 +99,7 @@ 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")) } @@ -171,6 +172,16 @@ 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. diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index cb675869..cd9d3839 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -362,11 +362,15 @@ func runServe(options serveOptions) error { } serverConfig := server.Config{ - AllowedGrantTypes: c.OAuth2.GrantTypes, - SupportedResponseTypes: c.OAuth2.ResponseTypes, - SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, - AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen, - PasswordConnector: c.OAuth2.PasswordConnector, + AllowedGrantTypes: c.OAuth2.GrantTypes, + SupportedResponseTypes: c.OAuth2.ResponseTypes, + SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, + AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen, + PasswordConnector: c.OAuth2.PasswordConnector, + PKCE: server.PKCEConfig{ + Enforce: c.OAuth2.PKCE.Enforce, + CodeChallengeMethodsSupported: c.OAuth2.PKCE.CodeChallengeMethodsSupported, + }, Headers: c.Web.Headers.ToHTTPHeader(), AllowedOrigins: c.Web.AllowedOrigins, AllowedHeaders: c.Web.AllowedHeaders, diff --git a/config.yaml.dist b/config.yaml.dist index 3f888e08..5d2c37ea 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -109,6 +109,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. # diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index eb48e581..0e8bb575 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -102,7 +102,7 @@ telemetry: # Default values shown below # oauth2: - # grantTypes determines the allowed set of authorization flows. +# # grantTypes determines the allowed set of authorization flows. # grantTypes: # - "authorization_code" # - "client_credentials" @@ -111,18 +111,24 @@ telemetry: # - "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 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"] # Instead of reading from an external storage, use this list of clients. # diff --git a/server/handlers.go b/server/handlers.go index a7a55aa2..e60715d9 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -116,7 +116,7 @@ func (s *Server) constructDiscovery(ctx context.Context) discovery { Introspect: s.absURL("/token/introspect"), Subjects: []string{"public"}, IDTokenAlgs: []string{string(jose.RS256)}, - CodeChallengeAlgs: []string{codeChallengeMethodS256, codeChallengeMethodPlain}, + CodeChallengeAlgs: s.pkce.CodeChallengeMethodsSupported, Scopes: []string{"openid", "email", "groups", "profile", "offline_access"}, AuthMethods: []string{"client_secret_basic", "client_secret_post"}, Claims: []string{ diff --git a/server/oauth2.go b/server/oauth2.go index 2300e934..9f12d1d0 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -14,6 +14,7 @@ import ( "net" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -486,11 +487,17 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques return nil, newRedirectedErr(errRequestNotSupported, "Server does not support request parameter.") } - if codeChallengeMethod != codeChallengeMethodS256 && codeChallengeMethod != codeChallengeMethodPlain { + if codeChallenge != "" && !slices.Contains(s.pkce.CodeChallengeMethodsSupported, codeChallengeMethod) { description := fmt.Sprintf("Unsupported PKCE challenge method (%q).", codeChallengeMethod) return nil, newRedirectedErr(errInvalidRequest, description) } + // Enforce PKCE if configured. + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.1 + if s.pkce.Enforce && codeChallenge == "" { + return nil, newRedirectedErr(errInvalidRequest, "PKCE is required. The code_challenge parameter must be provided.") + } + var ( unrecognized []string invalidScopes []string diff --git a/server/oauth2_test.go b/server/oauth2_test.go index ea930cb3..6e1528ce 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -53,6 +53,7 @@ func TestParseAuthorizationRequest(t *testing.T) { name string clients []storage.Client supportedResponseTypes []string + pkce PKCEConfig usePOST bool @@ -319,6 +320,92 @@ func TestParseAuthorizationRequest(t *testing.T) { }, expectedError: &redirectedAuthErr{Type: errInvalidRequest}, }, + { + 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: errInvalidRequest}, + }, + { + 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: errInvalidRequest}, + }, + { + 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 { @@ -326,6 +413,9 @@ func TestParseAuthorizationRequest(t *testing.T) { httpServer, server := newTestServerMultipleConnectors(t, func(c *Config) { c.SupportedResponseTypes = 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() diff --git a/server/server.go b/server/server.go index c4f16536..e6945c72 100644 --- a/server/server.go +++ b/server/server.go @@ -114,6 +114,9 @@ type Config struct { // If set, the server will use this connector to handle password grants PasswordConnector string + // PKCE configuration + PKCE PKCEConfig + GCFrequency time.Duration // Defaults to 5 minutes // If specified, the server will use this function for determining time. @@ -166,6 +169,14 @@ type WebConfig struct { Extra map[string]string } +// 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 +} + func value(val, defaultValue time.Duration) time.Duration { if val == 0 { return defaultValue @@ -201,6 +212,8 @@ type Server struct { supportedGrantTypes []string + pkce PKCEConfig + now func() time.Time idTokensValidFor time.Duration @@ -236,6 +249,19 @@ func newServer(ctx context.Context, c Config) (*Server, error) { c.AllowedHeaders = []string{"Authorization"} } + supportedChallengeMethods := map[string]bool{ + codeChallengeMethodS256: true, + codeChallengeMethodPlain: true, + } + if len(c.PKCE.CodeChallengeMethodsSupported) == 0 { + c.PKCE.CodeChallengeMethodsSupported = []string{codeChallengeMethodS256, codeChallengeMethodPlain} + } + for _, m := range c.PKCE.CodeChallengeMethodsSupported { + if !supportedChallengeMethods[m] { + return nil, fmt.Errorf("unsupported PKCE challenge method %q", m) + } + } + allSupportedGrants := map[string]bool{ grantTypeAuthorizationCode: true, grantTypeRefreshToken: true, @@ -310,6 +336,7 @@ func newServer(ctx context.Context, c Config) (*Server, error) { storage: newKeyCacher(c.Storage, now), supportedResponseTypes: supportedRes, supportedGrantTypes: supportedGrants, + pkce: c.PKCE, idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), From 0568abeb03110fd2e74c2d6c43d4b34caefe1db6 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 13 Mar 2026 20:38:49 +0100 Subject: [PATCH 153/222] DEP: CEL integration (#4601) Signed-off-by: maksim.nabokikh Signed-off-by: Maksim Nabokikh Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../cel-expressions-2026-02-28.md | 732 ++++++++++++++++++ 1 file changed, 732 insertions(+) create mode 100644 docs/enhancements/cel-expressions-2026-02-28.md 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 00000000..efd2831f --- /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. + + From 175dc57a3bde331e830fa340934a5bcd2086a5d1 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 13 Mar 2026 21:25:00 +0100 Subject: [PATCH 154/222] feat(cel): implement CEL compiler with library (#4607) Signed-off-by: maksim.nabokikh --- go.mod | 5 +- go.sum | 11 +- pkg/cel/cel.go | 232 +++++++++++++++++++++++++++ pkg/cel/cel_test.go | 280 +++++++++++++++++++++++++++++++++ pkg/cel/cost.go | 105 +++++++++++++ pkg/cel/cost_test.go | 137 ++++++++++++++++ pkg/cel/doc.go | 5 + pkg/cel/library/doc.go | 4 + pkg/cel/library/email.go | 73 +++++++++ pkg/cel/library/email_test.go | 106 +++++++++++++ pkg/cel/library/groups.go | 123 +++++++++++++++ pkg/cel/library/groups_test.go | 141 +++++++++++++++++ pkg/cel/types.go | 109 +++++++++++++ pkg/featureflags/doc.go | 3 + pkg/groups/doc.go | 2 + pkg/groups/groups.go | 1 - pkg/httpclient/doc.go | 3 + 17 files changed, 1336 insertions(+), 4 deletions(-) create mode 100644 pkg/cel/cel.go create mode 100644 pkg/cel/cel_test.go create mode 100644 pkg/cel/cost.go create mode 100644 pkg/cel/cost_test.go create mode 100644 pkg/cel/doc.go create mode 100644 pkg/cel/library/doc.go create mode 100644 pkg/cel/library/email.go create mode 100644 pkg/cel/library/email_test.go create mode 100644 pkg/cel/library/groups.go create mode 100644 pkg/cel/library/groups_test.go create mode 100644 pkg/cel/types.go create mode 100644 pkg/featureflags/doc.go create mode 100644 pkg/groups/doc.go create mode 100644 pkg/httpclient/doc.go diff --git a/go.mod b/go.mod index 99e88730..a9f5c352 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.3 github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-sql-driver/mysql v1.9.3 + github.com/google/cel-go v0.27.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 @@ -34,7 +35,7 @@ require ( go.etcd.io/etcd/client/pkg/v3 v3.6.8 go.etcd.io/etcd/client/v3 v3.6.8 golang.org/x/crypto v0.49.0 - golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.271.0 @@ -44,6 +45,7 @@ require ( require ( ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect + cel.dev/expr v0.25.1 // indirect cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect dario.cat/mergo v1.0.1 // indirect @@ -52,6 +54,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // 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/bmatcuk/doublestar v1.3.4 // indirect diff --git a/go.sum b/go.sum index f735bcd6..b8f97985 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc= ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= +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.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -30,6 +32,8 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l 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.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= @@ -89,6 +93,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= 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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -264,14 +270,15 @@ 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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 h1:fGZugkZk2UgYBxtpKmvub51Yno1LJDeEsRp2xGD+0gY= -golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= diff --git a/pkg/cel/cel.go b/pkg/cel/cel.go new file mode 100644 index 00000000..8dd686ba --- /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 00000000..b211f344 --- /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 00000000..d7a09102 --- /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 00000000..9a068be4 --- /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 00000000..64c1dbd3 --- /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 00000000..1452d2b9 --- /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 00000000..38fe0dee --- /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 00000000..d13e73a1 --- /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 00000000..fd7f3603 --- /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 00000000..70a68fb2 --- /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 00000000..4e657922 --- /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 00000000..27033293 --- /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/groups/doc.go b/pkg/groups/doc.go new file mode 100644 index 00000000..f1a21d02 --- /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 5dde65ab..d31a5dee 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 00000000..3d028a3a --- /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 From e8f79fe9ab8193de09ad027defc17d9b4d7ccd46 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Sat, 14 Mar 2026 10:35:28 +0100 Subject: [PATCH 155/222] DEP: Auth Sessions - Introduce (#4561) Signed-off-by: maksim.nabokikh Signed-off-by: Maksim Nabokikh Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/enhancements/auth-sessions-2026-02-18.md | 1505 +++++++++++++++++ 1 file changed, 1505 insertions(+) create mode 100644 docs/enhancements/auth-sessions-2026-02-18.md 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 00000000..28f5208c --- /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 + From 5a4395fd12de61478fc1b701e5d1fda29ce883c6 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Sat, 14 Mar 2026 12:58:18 +0100 Subject: [PATCH 156/222] feat: add UserIdentity entity and CRUD operations (#4643) Signed-off-by: maksim.nabokikh Signed-off-by: Maksim Nabokikh Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/featureflags/set.go | 3 + storage/conformance/conformance.go | 82 ++ storage/ent/client/offlinesession.go | 8 +- storage/ent/client/types.go | 33 + storage/ent/client/useridentity.go | 130 +++ storage/ent/client/utils.go | 8 +- storage/ent/db/client.go | 150 ++- storage/ent/db/ent.go | 2 + storage/ent/db/hook/hook.go | 12 + storage/ent/db/migrate/schema.go | 23 + storage/ent/db/mutation.go | 967 ++++++++++++++++++++ storage/ent/db/predicate/predicate.go | 3 + storage/ent/db/runtime.go | 35 + storage/ent/db/tx.go | 3 + storage/ent/db/useridentity.go | 232 +++++ storage/ent/db/useridentity/useridentity.go | 144 +++ storage/ent/db/useridentity/where.go | 705 ++++++++++++++ storage/ent/db/useridentity_create.go | 416 +++++++++ storage/ent/db/useridentity_delete.go | 88 ++ storage/ent/db/useridentity_query.go | 527 +++++++++++ storage/ent/db/useridentity_update.go | 629 +++++++++++++ storage/ent/schema/useridentity.go | 56 ++ storage/etcd/etcd.go | 60 ++ storage/etcd/types.go | 40 + storage/kubernetes/storage.go | 66 ++ storage/kubernetes/types.go | 76 ++ storage/memory/memory.go | 88 +- storage/sql/crud.go | 151 +++ storage/sql/migrate.go | 20 + storage/storage.go | 16 + 30 files changed, 4754 insertions(+), 19 deletions(-) create mode 100644 storage/ent/client/useridentity.go create mode 100644 storage/ent/db/useridentity.go create mode 100644 storage/ent/db/useridentity/useridentity.go create mode 100644 storage/ent/db/useridentity/where.go create mode 100644 storage/ent/db/useridentity_create.go create mode 100644 storage/ent/db/useridentity_delete.go create mode 100644 storage/ent/db/useridentity_query.go create mode 100644 storage/ent/db/useridentity_update.go create mode 100644 storage/ent/schema/useridentity.go diff --git a/pkg/featureflags/set.go b/pkg/featureflags/set.go index dcea1ca7..d3942979 100644 --- a/pkg/featureflags/set.go +++ b/pkg/featureflags/set.go @@ -21,4 +21,7 @@ var ( // 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) ) diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index 92a4d140..ec3cae11 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -51,6 +51,7 @@ func RunTests(t *testing.T, newStorage func(t *testing.T) storage.Storage) { {"TimezoneSupport", testTimezones}, {"DeviceRequestCRUD", testDeviceRequestCRUD}, {"DeviceTokenCRUD", testDeviceTokenCRUD}, + {"UserIdentityCRUD", testUserIdentityCRUD}, }) } @@ -1084,3 +1085,84 @@ func testDeviceTokenCRUD(t *testing.T, s storage.Storage) { t.Fatalf("storage does not support PKCE, wanted challenge=%#v got %#v", codeChallenge, got.PKCE) } } + +func testUserIdentityCRUD(t *testing.T, s storage.Storage) { + ctx := t.Context() + + now := time.Now().UTC().Round(time.Millisecond) + + u1 := storage.UserIdentity{ + UserID: "user1", + ConnectorID: "conn1", + Claims: storage.Claims{ + UserID: "user1", + Username: "jane", + Email: "jane@example.com", + EmailVerified: true, + Groups: []string{"a", "b"}, + }, + Consents: make(map[string][]string), + CreatedAt: now, + LastLogin: now, + BlockedUntil: time.Unix(0, 0).UTC(), + } + + // Create with empty Consents map. + if err := s.CreateUserIdentity(ctx, u1); err != nil { + t.Fatalf("create user identity: %v", err) + } + + // Duplicate create should return ErrAlreadyExists. + err := s.CreateUserIdentity(ctx, u1) + mustBeErrAlreadyExists(t, "user identity", err) + + // Get and compare. + got, err := s.GetUserIdentity(ctx, u1.UserID, u1.ConnectorID) + if err != nil { + t.Fatalf("get user identity: %v", err) + } + + got.CreatedAt = got.CreatedAt.UTC().Round(time.Millisecond) + got.LastLogin = got.LastLogin.UTC().Round(time.Millisecond) + got.BlockedUntil = got.BlockedUntil.UTC().Round(time.Millisecond) + u1.BlockedUntil = u1.BlockedUntil.UTC().Round(time.Millisecond) + if diff := pretty.Compare(u1, got); diff != "" { + t.Errorf("user identity retrieved from storage did not match: %s", diff) + } + + // Update: add consent entry. + if err := s.UpdateUserIdentity(ctx, u1.UserID, u1.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + old.Consents["client1"] = []string{"openid", "email"} + return old, nil + }); err != nil { + t.Fatalf("update user identity: %v", err) + } + + // Get and verify updated consents. + got, err = s.GetUserIdentity(ctx, u1.UserID, u1.ConnectorID) + if err != nil { + t.Fatalf("get user identity after update: %v", err) + } + wantConsents := map[string][]string{"client1": {"openid", "email"}} + if diff := pretty.Compare(wantConsents, got.Consents); diff != "" { + t.Errorf("user identity consents did not match after update: %s", diff) + } + + // List and verify. + identities, err := s.ListUserIdentities(ctx) + if err != nil { + t.Fatalf("list user identities: %v", err) + } + if len(identities) != 1 { + t.Fatalf("expected 1 user identity, got %d", len(identities)) + } + + // Delete. + if err := s.DeleteUserIdentity(ctx, u1.UserID, u1.ConnectorID); err != nil { + t.Fatalf("delete user identity: %v", err) + } + + // Get deleted should return ErrNotFound. + _, err = s.GetUserIdentity(ctx, u1.UserID, u1.ConnectorID) + mustBeErrNotFound(t, "user identity", err) +} diff --git a/storage/ent/client/offlinesession.go b/storage/ent/client/offlinesession.go index 9d608cb6..c8f18433 100644 --- a/storage/ent/client/offlinesession.go +++ b/storage/ent/client/offlinesession.go @@ -15,7 +15,7 @@ func (d *Database) CreateOfflineSessions(ctx context.Context, session storage.Of return fmt.Errorf("encode refresh offline session: %w", err) } - id := offlineSessionID(session.UserID, session.ConnID, d.hasher) + id := compositeKeyID(session.UserID, session.ConnID, d.hasher) _, err = d.client.OfflineSession.Create(). SetID(id). SetUserID(session.UserID). @@ -31,7 +31,7 @@ func (d *Database) CreateOfflineSessions(ctx context.Context, session storage.Of // GetOfflineSessions extracts an offline session from the database by user id and connector id. func (d *Database) GetOfflineSessions(ctx context.Context, userID, connID string) (storage.OfflineSessions, error) { - id := offlineSessionID(userID, connID, d.hasher) + id := compositeKeyID(userID, connID, d.hasher) offlineSession, err := d.client.OfflineSession.Get(ctx, id) if err != nil { @@ -42,7 +42,7 @@ func (d *Database) GetOfflineSessions(ctx context.Context, userID, connID string // DeleteOfflineSessions deletes an offline session from the database by user id and connector id. func (d *Database) DeleteOfflineSessions(ctx context.Context, userID, connID string) error { - id := offlineSessionID(userID, connID, d.hasher) + id := compositeKeyID(userID, connID, d.hasher) err := d.client.OfflineSession.DeleteOneID(id).Exec(ctx) if err != nil { @@ -53,7 +53,7 @@ func (d *Database) DeleteOfflineSessions(ctx context.Context, userID, connID str // UpdateOfflineSessions changes an offline session by user id and connector id using an updater function. func (d *Database) UpdateOfflineSessions(ctx context.Context, userID string, connID string, updater func(s storage.OfflineSessions) (storage.OfflineSessions, error)) error { - id := offlineSessionID(userID, connID, d.hasher) + id := compositeKeyID(userID, connID, d.hasher) tx, err := d.BeginTx(ctx) if err != nil { diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index a59fb6f5..535ab5f8 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -163,6 +163,39 @@ func toStorageDeviceRequest(r *db.DeviceRequest) storage.DeviceRequest { } } +func toStorageUserIdentity(u *db.UserIdentity) storage.UserIdentity { + s := storage.UserIdentity{ + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: storage.Claims{ + UserID: u.ClaimsUserID, + Username: u.ClaimsUsername, + PreferredUsername: u.ClaimsPreferredUsername, + Email: u.ClaimsEmail, + EmailVerified: u.ClaimsEmailVerified, + Groups: u.ClaimsGroups, + }, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, + } + + if u.Consents != nil { + if err := json.Unmarshal(u.Consents, &s.Consents); err != nil { + // Correctness of json structure is guaranteed on uploading + panic(err) + } + if s.Consents == nil { + // Ensure Consents is non-nil even if JSON was "null". + s.Consents = make(map[string][]string) + } + } else { + // Server code assumes this will be non-nil. + s.Consents = make(map[string][]string) + } + return s +} + func toStorageDeviceToken(t *db.DeviceToken) storage.DeviceToken { return storage.DeviceToken{ DeviceCode: t.DeviceCode, diff --git a/storage/ent/client/useridentity.go b/storage/ent/client/useridentity.go new file mode 100644 index 00000000..1cf87919 --- /dev/null +++ b/storage/ent/client/useridentity.go @@ -0,0 +1,130 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/dexidp/dex/storage" +) + +// CreateUserIdentity saves provided user identity into the database. +func (d *Database) CreateUserIdentity(ctx context.Context, identity storage.UserIdentity) error { + if identity.Consents == nil { + identity.Consents = make(map[string][]string) + } + encodedConsents, err := json.Marshal(identity.Consents) + if err != nil { + return fmt.Errorf("encode consents user identity: %w", err) + } + + id := compositeKeyID(identity.UserID, identity.ConnectorID, d.hasher) + _, err = d.client.UserIdentity.Create(). + SetID(id). + SetUserID(identity.UserID). + SetConnectorID(identity.ConnectorID). + SetClaimsUserID(identity.Claims.UserID). + SetClaimsUsername(identity.Claims.Username). + SetClaimsPreferredUsername(identity.Claims.PreferredUsername). + SetClaimsEmail(identity.Claims.Email). + SetClaimsEmailVerified(identity.Claims.EmailVerified). + SetClaimsGroups(identity.Claims.Groups). + SetConsents(encodedConsents). + SetCreatedAt(identity.CreatedAt). + SetLastLogin(identity.LastLogin). + SetBlockedUntil(identity.BlockedUntil). + Save(ctx) + if err != nil { + return convertDBError("create user identity: %w", err) + } + return nil +} + +// GetUserIdentity extracts a user identity from the database by user id and connector id. +func (d *Database) GetUserIdentity(ctx context.Context, userID, connectorID string) (storage.UserIdentity, error) { + id := compositeKeyID(userID, connectorID, d.hasher) + + userIdentity, err := d.client.UserIdentity.Get(ctx, id) + if err != nil { + return storage.UserIdentity{}, convertDBError("get user identity: %w", err) + } + return toStorageUserIdentity(userIdentity), nil +} + +// DeleteUserIdentity deletes a user identity from the database by user id and connector id. +func (d *Database) DeleteUserIdentity(ctx context.Context, userID, connectorID string) error { + id := compositeKeyID(userID, connectorID, d.hasher) + + err := d.client.UserIdentity.DeleteOneID(id).Exec(ctx) + if err != nil { + return convertDBError("delete user identity: %w", err) + } + return nil +} + +// UpdateUserIdentity changes a user identity by user id and connector id using an updater function. +func (d *Database) UpdateUserIdentity(ctx context.Context, userID string, connectorID string, updater func(u storage.UserIdentity) (storage.UserIdentity, error)) error { + id := compositeKeyID(userID, connectorID, d.hasher) + + tx, err := d.BeginTx(ctx) + if err != nil { + return convertDBError("update user identity tx: %w", err) + } + + userIdentity, err := tx.UserIdentity.Get(ctx, id) + if err != nil { + return rollback(tx, "update user identity database: %w", err) + } + + newUserIdentity, err := updater(toStorageUserIdentity(userIdentity)) + if err != nil { + return rollback(tx, "update user identity updating: %w", err) + } + + if newUserIdentity.Consents == nil { + newUserIdentity.Consents = make(map[string][]string) + } + + encodedConsents, err := json.Marshal(newUserIdentity.Consents) + if err != nil { + return rollback(tx, "encode consents user identity: %w", err) + } + + _, err = tx.UserIdentity.UpdateOneID(id). + SetUserID(newUserIdentity.UserID). + SetConnectorID(newUserIdentity.ConnectorID). + SetClaimsUserID(newUserIdentity.Claims.UserID). + SetClaimsUsername(newUserIdentity.Claims.Username). + SetClaimsPreferredUsername(newUserIdentity.Claims.PreferredUsername). + SetClaimsEmail(newUserIdentity.Claims.Email). + SetClaimsEmailVerified(newUserIdentity.Claims.EmailVerified). + SetClaimsGroups(newUserIdentity.Claims.Groups). + SetConsents(encodedConsents). + SetCreatedAt(newUserIdentity.CreatedAt). + SetLastLogin(newUserIdentity.LastLogin). + SetBlockedUntil(newUserIdentity.BlockedUntil). + Save(ctx) + if err != nil { + return rollback(tx, "update user identity uploading: %w", err) + } + + if err = tx.Commit(); err != nil { + return rollback(tx, "update user identity commit: %w", err) + } + + return nil +} + +// ListUserIdentities lists all user identities in the database. +func (d *Database) ListUserIdentities(ctx context.Context) ([]storage.UserIdentity, error) { + userIdentities, err := d.client.UserIdentity.Query().All(ctx) + if err != nil { + return nil, convertDBError("list user identities: %w", err) + } + + storageUserIdentities := make([]storage.UserIdentity, 0, len(userIdentities)) + for _, u := range userIdentities { + storageUserIdentities = append(storageUserIdentities, toStorageUserIdentity(u)) + } + return storageUserIdentities, nil +} diff --git a/storage/ent/client/utils.go b/storage/ent/client/utils.go index 65c037ac..950e612c 100644 --- a/storage/ent/client/utils.go +++ b/storage/ent/client/utils.go @@ -32,13 +32,13 @@ func convertDBError(t string, err error) error { return fmt.Errorf(t, err) } -// compose hashed id from user and connection id to use it as primary key +// compositeKeyID composes a hashed id from two key parts to use as primary key. // ent doesn't support multi-key primary yet // https://github.com/facebook/ent/issues/400 -func offlineSessionID(userID string, connID string, hasher func() hash.Hash) string { +func compositeKeyID(first string, second string, hasher func() hash.Hash) string { h := hasher() - h.Write([]byte(userID)) - h.Write([]byte(connID)) + h.Write([]byte(first)) + h.Write([]byte(second)) return fmt.Sprintf("%x", h.Sum(nil)) } diff --git a/storage/ent/db/client.go b/storage/ent/db/client.go index 4fb28cb3..efb6da6b 100644 --- a/storage/ent/db/client.go +++ b/storage/ent/db/client.go @@ -24,6 +24,7 @@ import ( "github.com/dexidp/dex/storage/ent/db/offlinesession" "github.com/dexidp/dex/storage/ent/db/password" "github.com/dexidp/dex/storage/ent/db/refreshtoken" + "github.com/dexidp/dex/storage/ent/db/useridentity" ) // Client is the client that holds all ent builders. @@ -51,6 +52,8 @@ type Client struct { Password *PasswordClient // RefreshToken is the client for interacting with the RefreshToken builders. RefreshToken *RefreshTokenClient + // UserIdentity is the client for interacting with the UserIdentity builders. + UserIdentity *UserIdentityClient } // NewClient creates a new client configured with the given options. @@ -72,6 +75,7 @@ func (c *Client) init() { c.OfflineSession = NewOfflineSessionClient(c.config) c.Password = NewPasswordClient(c.config) c.RefreshToken = NewRefreshTokenClient(c.config) + c.UserIdentity = NewUserIdentityClient(c.config) } type ( @@ -174,6 +178,7 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { OfflineSession: NewOfflineSessionClient(cfg), Password: NewPasswordClient(cfg), RefreshToken: NewRefreshTokenClient(cfg), + UserIdentity: NewUserIdentityClient(cfg), }, nil } @@ -203,6 +208,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) OfflineSession: NewOfflineSessionClient(cfg), Password: NewPasswordClient(cfg), RefreshToken: NewRefreshTokenClient(cfg), + UserIdentity: NewUserIdentityClient(cfg), }, nil } @@ -233,7 +239,7 @@ func (c *Client) Close() error { func (c *Client) Use(hooks ...Hook) { for _, n := range []interface{ Use(...Hook) }{ c.AuthCode, c.AuthRequest, c.Connector, c.DeviceRequest, c.DeviceToken, c.Keys, - c.OAuth2Client, c.OfflineSession, c.Password, c.RefreshToken, + c.OAuth2Client, c.OfflineSession, c.Password, c.RefreshToken, c.UserIdentity, } { n.Use(hooks...) } @@ -244,7 +250,7 @@ func (c *Client) Use(hooks ...Hook) { func (c *Client) Intercept(interceptors ...Interceptor) { for _, n := range []interface{ Intercept(...Interceptor) }{ c.AuthCode, c.AuthRequest, c.Connector, c.DeviceRequest, c.DeviceToken, c.Keys, - c.OAuth2Client, c.OfflineSession, c.Password, c.RefreshToken, + c.OAuth2Client, c.OfflineSession, c.Password, c.RefreshToken, c.UserIdentity, } { n.Intercept(interceptors...) } @@ -273,6 +279,8 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { return c.Password.mutate(ctx, m) case *RefreshTokenMutation: return c.RefreshToken.mutate(ctx, m) + case *UserIdentityMutation: + return c.UserIdentity.mutate(ctx, m) default: return nil, fmt.Errorf("db: unknown mutation type %T", m) } @@ -1608,14 +1616,148 @@ func (c *RefreshTokenClient) mutate(ctx context.Context, m *RefreshTokenMutation } } +// UserIdentityClient is a client for the UserIdentity schema. +type UserIdentityClient struct { + config +} + +// NewUserIdentityClient returns a client for the UserIdentity from the given config. +func NewUserIdentityClient(c config) *UserIdentityClient { + return &UserIdentityClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `useridentity.Hooks(f(g(h())))`. +func (c *UserIdentityClient) Use(hooks ...Hook) { + c.hooks.UserIdentity = append(c.hooks.UserIdentity, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `useridentity.Intercept(f(g(h())))`. +func (c *UserIdentityClient) Intercept(interceptors ...Interceptor) { + c.inters.UserIdentity = append(c.inters.UserIdentity, interceptors...) +} + +// Create returns a builder for creating a UserIdentity entity. +func (c *UserIdentityClient) Create() *UserIdentityCreate { + mutation := newUserIdentityMutation(c.config, OpCreate) + return &UserIdentityCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of UserIdentity entities. +func (c *UserIdentityClient) CreateBulk(builders ...*UserIdentityCreate) *UserIdentityCreateBulk { + return &UserIdentityCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *UserIdentityClient) MapCreateBulk(slice any, setFunc func(*UserIdentityCreate, int)) *UserIdentityCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &UserIdentityCreateBulk{err: fmt.Errorf("calling to UserIdentityClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*UserIdentityCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &UserIdentityCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for UserIdentity. +func (c *UserIdentityClient) Update() *UserIdentityUpdate { + mutation := newUserIdentityMutation(c.config, OpUpdate) + return &UserIdentityUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *UserIdentityClient) UpdateOne(_m *UserIdentity) *UserIdentityUpdateOne { + mutation := newUserIdentityMutation(c.config, OpUpdateOne, withUserIdentity(_m)) + return &UserIdentityUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *UserIdentityClient) UpdateOneID(id string) *UserIdentityUpdateOne { + mutation := newUserIdentityMutation(c.config, OpUpdateOne, withUserIdentityID(id)) + return &UserIdentityUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for UserIdentity. +func (c *UserIdentityClient) Delete() *UserIdentityDelete { + mutation := newUserIdentityMutation(c.config, OpDelete) + return &UserIdentityDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *UserIdentityClient) DeleteOne(_m *UserIdentity) *UserIdentityDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *UserIdentityClient) DeleteOneID(id string) *UserIdentityDeleteOne { + builder := c.Delete().Where(useridentity.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &UserIdentityDeleteOne{builder} +} + +// Query returns a query builder for UserIdentity. +func (c *UserIdentityClient) Query() *UserIdentityQuery { + return &UserIdentityQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeUserIdentity}, + inters: c.Interceptors(), + } +} + +// Get returns a UserIdentity entity by its id. +func (c *UserIdentityClient) Get(ctx context.Context, id string) (*UserIdentity, error) { + return c.Query().Where(useridentity.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *UserIdentityClient) GetX(ctx context.Context, id string) *UserIdentity { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// Hooks returns the client hooks. +func (c *UserIdentityClient) Hooks() []Hook { + return c.hooks.UserIdentity +} + +// Interceptors returns the client interceptors. +func (c *UserIdentityClient) Interceptors() []Interceptor { + return c.inters.UserIdentity +} + +func (c *UserIdentityClient) mutate(ctx context.Context, m *UserIdentityMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&UserIdentityCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&UserIdentityUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&UserIdentityUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&UserIdentityDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("db: unknown UserIdentity mutation op: %q", m.Op()) + } +} + // hooks and interceptors per client, for fast access. type ( hooks struct { AuthCode, AuthRequest, Connector, DeviceRequest, DeviceToken, Keys, - OAuth2Client, OfflineSession, Password, RefreshToken []ent.Hook + OAuth2Client, OfflineSession, Password, RefreshToken, UserIdentity []ent.Hook } inters struct { AuthCode, AuthRequest, Connector, DeviceRequest, DeviceToken, Keys, - OAuth2Client, OfflineSession, Password, RefreshToken []ent.Interceptor + OAuth2Client, OfflineSession, Password, RefreshToken, + UserIdentity []ent.Interceptor } ) diff --git a/storage/ent/db/ent.go b/storage/ent/db/ent.go index 06bee261..fe965a79 100644 --- a/storage/ent/db/ent.go +++ b/storage/ent/db/ent.go @@ -22,6 +22,7 @@ import ( "github.com/dexidp/dex/storage/ent/db/offlinesession" "github.com/dexidp/dex/storage/ent/db/password" "github.com/dexidp/dex/storage/ent/db/refreshtoken" + "github.com/dexidp/dex/storage/ent/db/useridentity" ) // ent aliases to avoid import conflicts in user's code. @@ -92,6 +93,7 @@ func checkColumn(t, c string) error { offlinesession.Table: offlinesession.ValidColumn, password.Table: password.ValidColumn, refreshtoken.Table: refreshtoken.ValidColumn, + useridentity.Table: useridentity.ValidColumn, }) }) return columnCheck(t, c) diff --git a/storage/ent/db/hook/hook.go b/storage/ent/db/hook/hook.go index 12cb91c6..008dc7ee 100644 --- a/storage/ent/db/hook/hook.go +++ b/storage/ent/db/hook/hook.go @@ -129,6 +129,18 @@ func (f RefreshTokenFunc) Mutate(ctx context.Context, m db.Mutation) (db.Value, return nil, fmt.Errorf("unexpected mutation type %T. expect *db.RefreshTokenMutation", m) } +// The UserIdentityFunc type is an adapter to allow the use of ordinary +// function as UserIdentity mutator. +type UserIdentityFunc func(context.Context, *db.UserIdentityMutation) (db.Value, error) + +// Mutate calls f(ctx, m). +func (f UserIdentityFunc) Mutate(ctx context.Context, m db.Mutation) (db.Value, error) { + if mv, ok := m.(*db.UserIdentityMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *db.UserIdentityMutation", m) +} + // Condition is a hook condition function. type Condition func(context.Context, db.Mutation) bool diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index b59e455b..1b9b61c9 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -200,6 +200,28 @@ var ( Columns: RefreshTokensColumns, PrimaryKey: []*schema.Column{RefreshTokensColumns[0]}, } + // UserIdentitiesColumns holds the columns for the "user_identities" table. + UserIdentitiesColumns = []*schema.Column{ + {Name: "id", Type: field.TypeString, Unique: true, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "user_id", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "connector_id", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "claims_user_id", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "claims_username", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "claims_preferred_username", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "claims_email", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "claims_email_verified", Type: field.TypeBool, Default: false}, + {Name: "claims_groups", Type: field.TypeJSON, Nullable: true}, + {Name: "consents", Type: field.TypeBytes}, + {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, + {Name: "last_login", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, + {Name: "blocked_until", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, + } + // UserIdentitiesTable holds the schema information for the "user_identities" table. + UserIdentitiesTable = &schema.Table{ + Name: "user_identities", + Columns: UserIdentitiesColumns, + PrimaryKey: []*schema.Column{UserIdentitiesColumns[0]}, + } // Tables holds all the tables in the schema. Tables = []*schema.Table{ AuthCodesTable, @@ -212,6 +234,7 @@ var ( OfflineSessionsTable, PasswordsTable, RefreshTokensTable, + UserIdentitiesTable, } ) diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 73c7f095..8625471a 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -23,6 +23,7 @@ import ( "github.com/dexidp/dex/storage/ent/db/password" "github.com/dexidp/dex/storage/ent/db/predicate" "github.com/dexidp/dex/storage/ent/db/refreshtoken" + "github.com/dexidp/dex/storage/ent/db/useridentity" jose "github.com/go-jose/go-jose/v4" ) @@ -45,6 +46,7 @@ const ( TypeOfflineSession = "OfflineSession" TypePassword = "Password" TypeRefreshToken = "RefreshToken" + TypeUserIdentity = "UserIdentity" ) // AuthCodeMutation represents an operation that mutates the AuthCode nodes in the graph. @@ -8437,3 +8439,968 @@ func (m *RefreshTokenMutation) ClearEdge(name string) error { func (m *RefreshTokenMutation) ResetEdge(name string) error { return fmt.Errorf("unknown RefreshToken edge %s", name) } + +// UserIdentityMutation represents an operation that mutates the UserIdentity nodes in the graph. +type UserIdentityMutation struct { + config + op Op + typ string + id *string + user_id *string + connector_id *string + claims_user_id *string + claims_username *string + claims_preferred_username *string + claims_email *string + claims_email_verified *bool + claims_groups *[]string + appendclaims_groups []string + consents *[]byte + created_at *time.Time + last_login *time.Time + blocked_until *time.Time + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*UserIdentity, error) + predicates []predicate.UserIdentity +} + +var _ ent.Mutation = (*UserIdentityMutation)(nil) + +// useridentityOption allows management of the mutation configuration using functional options. +type useridentityOption func(*UserIdentityMutation) + +// newUserIdentityMutation creates new mutation for the UserIdentity entity. +func newUserIdentityMutation(c config, op Op, opts ...useridentityOption) *UserIdentityMutation { + m := &UserIdentityMutation{ + config: c, + op: op, + typ: TypeUserIdentity, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withUserIdentityID sets the ID field of the mutation. +func withUserIdentityID(id string) useridentityOption { + return func(m *UserIdentityMutation) { + var ( + err error + once sync.Once + value *UserIdentity + ) + m.oldValue = func(ctx context.Context) (*UserIdentity, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().UserIdentity.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withUserIdentity sets the old UserIdentity of the mutation. +func withUserIdentity(node *UserIdentity) useridentityOption { + return func(m *UserIdentityMutation) { + m.oldValue = func(context.Context) (*UserIdentity, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m UserIdentityMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m UserIdentityMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("db: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// SetID sets the value of the id field. Note that this +// operation is only accepted on creation of UserIdentity entities. +func (m *UserIdentityMutation) SetID(id string) { + m.id = &id +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *UserIdentityMutation) ID() (id string, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *UserIdentityMutation) IDs(ctx context.Context) ([]string, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []string{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().UserIdentity.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetUserID sets the "user_id" field. +func (m *UserIdentityMutation) SetUserID(s string) { + m.user_id = &s +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *UserIdentityMutation) UserID() (r string, exists bool) { + v := m.user_id + if v == nil { + return + } + return *v, true +} + +// OldUserID returns the old "user_id" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldUserID(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserID: %w", err) + } + return oldValue.UserID, nil +} + +// ResetUserID resets all changes to the "user_id" field. +func (m *UserIdentityMutation) ResetUserID() { + m.user_id = nil +} + +// SetConnectorID sets the "connector_id" field. +func (m *UserIdentityMutation) SetConnectorID(s string) { + m.connector_id = &s +} + +// ConnectorID returns the value of the "connector_id" field in the mutation. +func (m *UserIdentityMutation) ConnectorID() (r string, exists bool) { + v := m.connector_id + if v == nil { + return + } + return *v, true +} + +// OldConnectorID returns the old "connector_id" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldConnectorID(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldConnectorID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldConnectorID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldConnectorID: %w", err) + } + return oldValue.ConnectorID, nil +} + +// ResetConnectorID resets all changes to the "connector_id" field. +func (m *UserIdentityMutation) ResetConnectorID() { + m.connector_id = nil +} + +// SetClaimsUserID sets the "claims_user_id" field. +func (m *UserIdentityMutation) SetClaimsUserID(s string) { + m.claims_user_id = &s +} + +// ClaimsUserID returns the value of the "claims_user_id" field in the mutation. +func (m *UserIdentityMutation) ClaimsUserID() (r string, exists bool) { + v := m.claims_user_id + if v == nil { + return + } + return *v, true +} + +// OldClaimsUserID returns the old "claims_user_id" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldClaimsUserID(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldClaimsUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldClaimsUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldClaimsUserID: %w", err) + } + return oldValue.ClaimsUserID, nil +} + +// ResetClaimsUserID resets all changes to the "claims_user_id" field. +func (m *UserIdentityMutation) ResetClaimsUserID() { + m.claims_user_id = nil +} + +// SetClaimsUsername sets the "claims_username" field. +func (m *UserIdentityMutation) SetClaimsUsername(s string) { + m.claims_username = &s +} + +// ClaimsUsername returns the value of the "claims_username" field in the mutation. +func (m *UserIdentityMutation) ClaimsUsername() (r string, exists bool) { + v := m.claims_username + if v == nil { + return + } + return *v, true +} + +// OldClaimsUsername returns the old "claims_username" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldClaimsUsername(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldClaimsUsername is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldClaimsUsername requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldClaimsUsername: %w", err) + } + return oldValue.ClaimsUsername, nil +} + +// ResetClaimsUsername resets all changes to the "claims_username" field. +func (m *UserIdentityMutation) ResetClaimsUsername() { + m.claims_username = nil +} + +// SetClaimsPreferredUsername sets the "claims_preferred_username" field. +func (m *UserIdentityMutation) SetClaimsPreferredUsername(s string) { + m.claims_preferred_username = &s +} + +// ClaimsPreferredUsername returns the value of the "claims_preferred_username" field in the mutation. +func (m *UserIdentityMutation) ClaimsPreferredUsername() (r string, exists bool) { + v := m.claims_preferred_username + if v == nil { + return + } + return *v, true +} + +// OldClaimsPreferredUsername returns the old "claims_preferred_username" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldClaimsPreferredUsername(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldClaimsPreferredUsername is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldClaimsPreferredUsername requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldClaimsPreferredUsername: %w", err) + } + return oldValue.ClaimsPreferredUsername, nil +} + +// ResetClaimsPreferredUsername resets all changes to the "claims_preferred_username" field. +func (m *UserIdentityMutation) ResetClaimsPreferredUsername() { + m.claims_preferred_username = nil +} + +// SetClaimsEmail sets the "claims_email" field. +func (m *UserIdentityMutation) SetClaimsEmail(s string) { + m.claims_email = &s +} + +// ClaimsEmail returns the value of the "claims_email" field in the mutation. +func (m *UserIdentityMutation) ClaimsEmail() (r string, exists bool) { + v := m.claims_email + if v == nil { + return + } + return *v, true +} + +// OldClaimsEmail returns the old "claims_email" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldClaimsEmail(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldClaimsEmail is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldClaimsEmail requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldClaimsEmail: %w", err) + } + return oldValue.ClaimsEmail, nil +} + +// ResetClaimsEmail resets all changes to the "claims_email" field. +func (m *UserIdentityMutation) ResetClaimsEmail() { + m.claims_email = nil +} + +// SetClaimsEmailVerified sets the "claims_email_verified" field. +func (m *UserIdentityMutation) SetClaimsEmailVerified(b bool) { + m.claims_email_verified = &b +} + +// ClaimsEmailVerified returns the value of the "claims_email_verified" field in the mutation. +func (m *UserIdentityMutation) ClaimsEmailVerified() (r bool, exists bool) { + v := m.claims_email_verified + if v == nil { + return + } + return *v, true +} + +// OldClaimsEmailVerified returns the old "claims_email_verified" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldClaimsEmailVerified(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldClaimsEmailVerified is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldClaimsEmailVerified requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldClaimsEmailVerified: %w", err) + } + return oldValue.ClaimsEmailVerified, nil +} + +// ResetClaimsEmailVerified resets all changes to the "claims_email_verified" field. +func (m *UserIdentityMutation) ResetClaimsEmailVerified() { + m.claims_email_verified = nil +} + +// SetClaimsGroups sets the "claims_groups" field. +func (m *UserIdentityMutation) SetClaimsGroups(s []string) { + m.claims_groups = &s + m.appendclaims_groups = nil +} + +// ClaimsGroups returns the value of the "claims_groups" field in the mutation. +func (m *UserIdentityMutation) ClaimsGroups() (r []string, exists bool) { + v := m.claims_groups + if v == nil { + return + } + return *v, true +} + +// OldClaimsGroups returns the old "claims_groups" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldClaimsGroups(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldClaimsGroups is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldClaimsGroups requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldClaimsGroups: %w", err) + } + return oldValue.ClaimsGroups, nil +} + +// AppendClaimsGroups adds s to the "claims_groups" field. +func (m *UserIdentityMutation) AppendClaimsGroups(s []string) { + m.appendclaims_groups = append(m.appendclaims_groups, s...) +} + +// AppendedClaimsGroups returns the list of values that were appended to the "claims_groups" field in this mutation. +func (m *UserIdentityMutation) AppendedClaimsGroups() ([]string, bool) { + if len(m.appendclaims_groups) == 0 { + return nil, false + } + return m.appendclaims_groups, true +} + +// ClearClaimsGroups clears the value of the "claims_groups" field. +func (m *UserIdentityMutation) ClearClaimsGroups() { + m.claims_groups = nil + m.appendclaims_groups = nil + m.clearedFields[useridentity.FieldClaimsGroups] = struct{}{} +} + +// ClaimsGroupsCleared returns if the "claims_groups" field was cleared in this mutation. +func (m *UserIdentityMutation) ClaimsGroupsCleared() bool { + _, ok := m.clearedFields[useridentity.FieldClaimsGroups] + return ok +} + +// ResetClaimsGroups resets all changes to the "claims_groups" field. +func (m *UserIdentityMutation) ResetClaimsGroups() { + m.claims_groups = nil + m.appendclaims_groups = nil + delete(m.clearedFields, useridentity.FieldClaimsGroups) +} + +// SetConsents sets the "consents" field. +func (m *UserIdentityMutation) SetConsents(b []byte) { + m.consents = &b +} + +// Consents returns the value of the "consents" field in the mutation. +func (m *UserIdentityMutation) Consents() (r []byte, exists bool) { + v := m.consents + if v == nil { + return + } + return *v, true +} + +// OldConsents returns the old "consents" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldConsents(ctx context.Context) (v []byte, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldConsents is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldConsents requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldConsents: %w", err) + } + return oldValue.Consents, nil +} + +// ResetConsents resets all changes to the "consents" field. +func (m *UserIdentityMutation) ResetConsents() { + m.consents = nil +} + +// SetCreatedAt sets the "created_at" field. +func (m *UserIdentityMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *UserIdentityMutation) CreatedAt() (r time.Time, exists bool) { + v := m.created_at + if v == nil { + return + } + return *v, true +} + +// OldCreatedAt returns the old "created_at" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) + } + return oldValue.CreatedAt, nil +} + +// ResetCreatedAt resets all changes to the "created_at" field. +func (m *UserIdentityMutation) ResetCreatedAt() { + m.created_at = nil +} + +// SetLastLogin sets the "last_login" field. +func (m *UserIdentityMutation) SetLastLogin(t time.Time) { + m.last_login = &t +} + +// LastLogin returns the value of the "last_login" field in the mutation. +func (m *UserIdentityMutation) LastLogin() (r time.Time, exists bool) { + v := m.last_login + if v == nil { + return + } + return *v, true +} + +// OldLastLogin returns the old "last_login" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldLastLogin(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldLastLogin is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldLastLogin requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldLastLogin: %w", err) + } + return oldValue.LastLogin, nil +} + +// ResetLastLogin resets all changes to the "last_login" field. +func (m *UserIdentityMutation) ResetLastLogin() { + m.last_login = nil +} + +// SetBlockedUntil sets the "blocked_until" field. +func (m *UserIdentityMutation) SetBlockedUntil(t time.Time) { + m.blocked_until = &t +} + +// BlockedUntil returns the value of the "blocked_until" field in the mutation. +func (m *UserIdentityMutation) BlockedUntil() (r time.Time, exists bool) { + v := m.blocked_until + if v == nil { + return + } + return *v, true +} + +// OldBlockedUntil returns the old "blocked_until" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldBlockedUntil(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBlockedUntil is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBlockedUntil requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBlockedUntil: %w", err) + } + return oldValue.BlockedUntil, nil +} + +// ResetBlockedUntil resets all changes to the "blocked_until" field. +func (m *UserIdentityMutation) ResetBlockedUntil() { + m.blocked_until = nil +} + +// Where appends a list predicates to the UserIdentityMutation builder. +func (m *UserIdentityMutation) Where(ps ...predicate.UserIdentity) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the UserIdentityMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *UserIdentityMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.UserIdentity, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *UserIdentityMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *UserIdentityMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (UserIdentity). +func (m *UserIdentityMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *UserIdentityMutation) Fields() []string { + fields := make([]string, 0, 12) + if m.user_id != nil { + fields = append(fields, useridentity.FieldUserID) + } + if m.connector_id != nil { + fields = append(fields, useridentity.FieldConnectorID) + } + if m.claims_user_id != nil { + fields = append(fields, useridentity.FieldClaimsUserID) + } + if m.claims_username != nil { + fields = append(fields, useridentity.FieldClaimsUsername) + } + if m.claims_preferred_username != nil { + fields = append(fields, useridentity.FieldClaimsPreferredUsername) + } + if m.claims_email != nil { + fields = append(fields, useridentity.FieldClaimsEmail) + } + if m.claims_email_verified != nil { + fields = append(fields, useridentity.FieldClaimsEmailVerified) + } + if m.claims_groups != nil { + fields = append(fields, useridentity.FieldClaimsGroups) + } + if m.consents != nil { + fields = append(fields, useridentity.FieldConsents) + } + if m.created_at != nil { + fields = append(fields, useridentity.FieldCreatedAt) + } + if m.last_login != nil { + fields = append(fields, useridentity.FieldLastLogin) + } + if m.blocked_until != nil { + fields = append(fields, useridentity.FieldBlockedUntil) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *UserIdentityMutation) Field(name string) (ent.Value, bool) { + switch name { + case useridentity.FieldUserID: + return m.UserID() + case useridentity.FieldConnectorID: + return m.ConnectorID() + case useridentity.FieldClaimsUserID: + return m.ClaimsUserID() + case useridentity.FieldClaimsUsername: + return m.ClaimsUsername() + case useridentity.FieldClaimsPreferredUsername: + return m.ClaimsPreferredUsername() + case useridentity.FieldClaimsEmail: + return m.ClaimsEmail() + case useridentity.FieldClaimsEmailVerified: + return m.ClaimsEmailVerified() + case useridentity.FieldClaimsGroups: + return m.ClaimsGroups() + case useridentity.FieldConsents: + return m.Consents() + case useridentity.FieldCreatedAt: + return m.CreatedAt() + case useridentity.FieldLastLogin: + return m.LastLogin() + case useridentity.FieldBlockedUntil: + return m.BlockedUntil() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *UserIdentityMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case useridentity.FieldUserID: + return m.OldUserID(ctx) + case useridentity.FieldConnectorID: + return m.OldConnectorID(ctx) + case useridentity.FieldClaimsUserID: + return m.OldClaimsUserID(ctx) + case useridentity.FieldClaimsUsername: + return m.OldClaimsUsername(ctx) + case useridentity.FieldClaimsPreferredUsername: + return m.OldClaimsPreferredUsername(ctx) + case useridentity.FieldClaimsEmail: + return m.OldClaimsEmail(ctx) + case useridentity.FieldClaimsEmailVerified: + return m.OldClaimsEmailVerified(ctx) + case useridentity.FieldClaimsGroups: + return m.OldClaimsGroups(ctx) + case useridentity.FieldConsents: + return m.OldConsents(ctx) + case useridentity.FieldCreatedAt: + return m.OldCreatedAt(ctx) + case useridentity.FieldLastLogin: + return m.OldLastLogin(ctx) + case useridentity.FieldBlockedUntil: + return m.OldBlockedUntil(ctx) + } + return nil, fmt.Errorf("unknown UserIdentity field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *UserIdentityMutation) SetField(name string, value ent.Value) error { + switch name { + case useridentity.FieldUserID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserID(v) + return nil + case useridentity.FieldConnectorID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetConnectorID(v) + return nil + case useridentity.FieldClaimsUserID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetClaimsUserID(v) + return nil + case useridentity.FieldClaimsUsername: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetClaimsUsername(v) + return nil + case useridentity.FieldClaimsPreferredUsername: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetClaimsPreferredUsername(v) + return nil + case useridentity.FieldClaimsEmail: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetClaimsEmail(v) + return nil + case useridentity.FieldClaimsEmailVerified: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetClaimsEmailVerified(v) + return nil + case useridentity.FieldClaimsGroups: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetClaimsGroups(v) + return nil + case useridentity.FieldConsents: + v, ok := value.([]byte) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetConsents(v) + return nil + case useridentity.FieldCreatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedAt(v) + return nil + case useridentity.FieldLastLogin: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetLastLogin(v) + return nil + case useridentity.FieldBlockedUntil: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBlockedUntil(v) + return nil + } + return fmt.Errorf("unknown UserIdentity field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *UserIdentityMutation) AddedFields() []string { + return nil +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *UserIdentityMutation) AddedField(name string) (ent.Value, bool) { + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *UserIdentityMutation) AddField(name string, value ent.Value) error { + switch name { + } + return fmt.Errorf("unknown UserIdentity numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *UserIdentityMutation) ClearedFields() []string { + var fields []string + if m.FieldCleared(useridentity.FieldClaimsGroups) { + fields = append(fields, useridentity.FieldClaimsGroups) + } + return fields +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *UserIdentityMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *UserIdentityMutation) ClearField(name string) error { + switch name { + case useridentity.FieldClaimsGroups: + m.ClearClaimsGroups() + return nil + } + return fmt.Errorf("unknown UserIdentity nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *UserIdentityMutation) ResetField(name string) error { + switch name { + case useridentity.FieldUserID: + m.ResetUserID() + return nil + case useridentity.FieldConnectorID: + m.ResetConnectorID() + return nil + case useridentity.FieldClaimsUserID: + m.ResetClaimsUserID() + return nil + case useridentity.FieldClaimsUsername: + m.ResetClaimsUsername() + return nil + case useridentity.FieldClaimsPreferredUsername: + m.ResetClaimsPreferredUsername() + return nil + case useridentity.FieldClaimsEmail: + m.ResetClaimsEmail() + return nil + case useridentity.FieldClaimsEmailVerified: + m.ResetClaimsEmailVerified() + return nil + case useridentity.FieldClaimsGroups: + m.ResetClaimsGroups() + return nil + case useridentity.FieldConsents: + m.ResetConsents() + return nil + case useridentity.FieldCreatedAt: + m.ResetCreatedAt() + return nil + case useridentity.FieldLastLogin: + m.ResetLastLogin() + return nil + case useridentity.FieldBlockedUntil: + m.ResetBlockedUntil() + return nil + } + return fmt.Errorf("unknown UserIdentity field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *UserIdentityMutation) AddedEdges() []string { + edges := make([]string, 0, 0) + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *UserIdentityMutation) AddedIDs(name string) []ent.Value { + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *UserIdentityMutation) RemovedEdges() []string { + edges := make([]string, 0, 0) + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *UserIdentityMutation) RemovedIDs(name string) []ent.Value { + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *UserIdentityMutation) ClearedEdges() []string { + edges := make([]string, 0, 0) + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *UserIdentityMutation) EdgeCleared(name string) bool { + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *UserIdentityMutation) ClearEdge(name string) error { + return fmt.Errorf("unknown UserIdentity unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *UserIdentityMutation) ResetEdge(name string) error { + return fmt.Errorf("unknown UserIdentity edge %s", name) +} diff --git a/storage/ent/db/predicate/predicate.go b/storage/ent/db/predicate/predicate.go index ed07a071..90ddffcb 100644 --- a/storage/ent/db/predicate/predicate.go +++ b/storage/ent/db/predicate/predicate.go @@ -35,3 +35,6 @@ type Password func(*sql.Selector) // RefreshToken is the predicate function for refreshtoken builders. type RefreshToken func(*sql.Selector) + +// UserIdentity is the predicate function for useridentity builders. +type UserIdentity func(*sql.Selector) diff --git a/storage/ent/db/runtime.go b/storage/ent/db/runtime.go index fdb47bd7..b6f34aef 100644 --- a/storage/ent/db/runtime.go +++ b/storage/ent/db/runtime.go @@ -15,6 +15,7 @@ import ( "github.com/dexidp/dex/storage/ent/db/offlinesession" "github.com/dexidp/dex/storage/ent/db/password" "github.com/dexidp/dex/storage/ent/db/refreshtoken" + "github.com/dexidp/dex/storage/ent/db/useridentity" "github.com/dexidp/dex/storage/ent/schema" ) @@ -274,4 +275,38 @@ func init() { refreshtokenDescID := refreshtokenFields[0].Descriptor() // refreshtoken.IDValidator is a validator for the "id" field. It is called by the builders before save. refreshtoken.IDValidator = refreshtokenDescID.Validators[0].(func(string) error) + useridentityFields := schema.UserIdentity{}.Fields() + _ = useridentityFields + // useridentityDescUserID is the schema descriptor for user_id field. + useridentityDescUserID := useridentityFields[1].Descriptor() + // useridentity.UserIDValidator is a validator for the "user_id" field. It is called by the builders before save. + useridentity.UserIDValidator = useridentityDescUserID.Validators[0].(func(string) error) + // useridentityDescConnectorID is the schema descriptor for connector_id field. + useridentityDescConnectorID := useridentityFields[2].Descriptor() + // useridentity.ConnectorIDValidator is a validator for the "connector_id" field. It is called by the builders before save. + useridentity.ConnectorIDValidator = useridentityDescConnectorID.Validators[0].(func(string) error) + // useridentityDescClaimsUserID is the schema descriptor for claims_user_id field. + useridentityDescClaimsUserID := useridentityFields[3].Descriptor() + // useridentity.DefaultClaimsUserID holds the default value on creation for the claims_user_id field. + useridentity.DefaultClaimsUserID = useridentityDescClaimsUserID.Default.(string) + // useridentityDescClaimsUsername is the schema descriptor for claims_username field. + useridentityDescClaimsUsername := useridentityFields[4].Descriptor() + // useridentity.DefaultClaimsUsername holds the default value on creation for the claims_username field. + useridentity.DefaultClaimsUsername = useridentityDescClaimsUsername.Default.(string) + // useridentityDescClaimsPreferredUsername is the schema descriptor for claims_preferred_username field. + useridentityDescClaimsPreferredUsername := useridentityFields[5].Descriptor() + // useridentity.DefaultClaimsPreferredUsername holds the default value on creation for the claims_preferred_username field. + useridentity.DefaultClaimsPreferredUsername = useridentityDescClaimsPreferredUsername.Default.(string) + // useridentityDescClaimsEmail is the schema descriptor for claims_email field. + useridentityDescClaimsEmail := useridentityFields[6].Descriptor() + // useridentity.DefaultClaimsEmail holds the default value on creation for the claims_email field. + useridentity.DefaultClaimsEmail = useridentityDescClaimsEmail.Default.(string) + // useridentityDescClaimsEmailVerified is the schema descriptor for claims_email_verified field. + useridentityDescClaimsEmailVerified := useridentityFields[7].Descriptor() + // useridentity.DefaultClaimsEmailVerified holds the default value on creation for the claims_email_verified field. + useridentity.DefaultClaimsEmailVerified = useridentityDescClaimsEmailVerified.Default.(bool) + // useridentityDescID is the schema descriptor for id field. + useridentityDescID := useridentityFields[0].Descriptor() + // useridentity.IDValidator is a validator for the "id" field. It is called by the builders before save. + useridentity.IDValidator = useridentityDescID.Validators[0].(func(string) error) } diff --git a/storage/ent/db/tx.go b/storage/ent/db/tx.go index 42ba241a..77be8a8f 100644 --- a/storage/ent/db/tx.go +++ b/storage/ent/db/tx.go @@ -32,6 +32,8 @@ type Tx struct { Password *PasswordClient // RefreshToken is the client for interacting with the RefreshToken builders. RefreshToken *RefreshTokenClient + // UserIdentity is the client for interacting with the UserIdentity builders. + UserIdentity *UserIdentityClient // lazily loaded. client *Client @@ -173,6 +175,7 @@ func (tx *Tx) init() { tx.OfflineSession = NewOfflineSessionClient(tx.config) tx.Password = NewPasswordClient(tx.config) tx.RefreshToken = NewRefreshTokenClient(tx.config) + tx.UserIdentity = NewUserIdentityClient(tx.config) } // txDriver wraps the given dialect.Tx with a nop dialect.Driver implementation. diff --git a/storage/ent/db/useridentity.go b/storage/ent/db/useridentity.go new file mode 100644 index 00000000..7127299b --- /dev/null +++ b/storage/ent/db/useridentity.go @@ -0,0 +1,232 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "github.com/dexidp/dex/storage/ent/db/useridentity" +) + +// UserIdentity is the model entity for the UserIdentity schema. +type UserIdentity struct { + config `json:"-"` + // ID of the ent. + ID string `json:"id,omitempty"` + // UserID holds the value of the "user_id" field. + UserID string `json:"user_id,omitempty"` + // ConnectorID holds the value of the "connector_id" field. + ConnectorID string `json:"connector_id,omitempty"` + // ClaimsUserID holds the value of the "claims_user_id" field. + ClaimsUserID string `json:"claims_user_id,omitempty"` + // ClaimsUsername holds the value of the "claims_username" field. + ClaimsUsername string `json:"claims_username,omitempty"` + // ClaimsPreferredUsername holds the value of the "claims_preferred_username" field. + ClaimsPreferredUsername string `json:"claims_preferred_username,omitempty"` + // ClaimsEmail holds the value of the "claims_email" field. + ClaimsEmail string `json:"claims_email,omitempty"` + // ClaimsEmailVerified holds the value of the "claims_email_verified" field. + ClaimsEmailVerified bool `json:"claims_email_verified,omitempty"` + // ClaimsGroups holds the value of the "claims_groups" field. + ClaimsGroups []string `json:"claims_groups,omitempty"` + // Consents holds the value of the "consents" field. + Consents []byte `json:"consents,omitempty"` + // CreatedAt holds the value of the "created_at" field. + CreatedAt time.Time `json:"created_at,omitempty"` + // LastLogin holds the value of the "last_login" field. + LastLogin time.Time `json:"last_login,omitempty"` + // BlockedUntil holds the value of the "blocked_until" field. + BlockedUntil time.Time `json:"blocked_until,omitempty"` + selectValues sql.SelectValues +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*UserIdentity) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case useridentity.FieldClaimsGroups, useridentity.FieldConsents: + values[i] = new([]byte) + case useridentity.FieldClaimsEmailVerified: + values[i] = new(sql.NullBool) + case useridentity.FieldID, useridentity.FieldUserID, useridentity.FieldConnectorID, useridentity.FieldClaimsUserID, useridentity.FieldClaimsUsername, useridentity.FieldClaimsPreferredUsername, useridentity.FieldClaimsEmail: + values[i] = new(sql.NullString) + case useridentity.FieldCreatedAt, useridentity.FieldLastLogin, useridentity.FieldBlockedUntil: + values[i] = new(sql.NullTime) + default: + values[i] = new(sql.UnknownType) + } + } + return values, nil +} + +// assignValues assigns the values that were returned from sql.Rows (after scanning) +// to the UserIdentity fields. +func (_m *UserIdentity) assignValues(columns []string, values []any) error { + if m, n := len(values), len(columns); m < n { + return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) + } + for i := range columns { + switch columns[i] { + case useridentity.FieldID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field id", values[i]) + } else if value.Valid { + _m.ID = value.String + } + case useridentity.FieldUserID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field user_id", values[i]) + } else if value.Valid { + _m.UserID = value.String + } + case useridentity.FieldConnectorID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field connector_id", values[i]) + } else if value.Valid { + _m.ConnectorID = value.String + } + case useridentity.FieldClaimsUserID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field claims_user_id", values[i]) + } else if value.Valid { + _m.ClaimsUserID = value.String + } + case useridentity.FieldClaimsUsername: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field claims_username", values[i]) + } else if value.Valid { + _m.ClaimsUsername = value.String + } + case useridentity.FieldClaimsPreferredUsername: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field claims_preferred_username", values[i]) + } else if value.Valid { + _m.ClaimsPreferredUsername = value.String + } + case useridentity.FieldClaimsEmail: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field claims_email", values[i]) + } else if value.Valid { + _m.ClaimsEmail = value.String + } + case useridentity.FieldClaimsEmailVerified: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field claims_email_verified", values[i]) + } else if value.Valid { + _m.ClaimsEmailVerified = value.Bool + } + case useridentity.FieldClaimsGroups: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field claims_groups", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.ClaimsGroups); err != nil { + return fmt.Errorf("unmarshal field claims_groups: %w", err) + } + } + case useridentity.FieldConsents: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field consents", values[i]) + } else if value != nil { + _m.Consents = *value + } + case useridentity.FieldCreatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field created_at", values[i]) + } else if value.Valid { + _m.CreatedAt = value.Time + } + case useridentity.FieldLastLogin: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field last_login", values[i]) + } else if value.Valid { + _m.LastLogin = value.Time + } + case useridentity.FieldBlockedUntil: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field blocked_until", values[i]) + } else if value.Valid { + _m.BlockedUntil = value.Time + } + default: + _m.selectValues.Set(columns[i], values[i]) + } + } + return nil +} + +// Value returns the ent.Value that was dynamically selected and assigned to the UserIdentity. +// This includes values selected through modifiers, order, etc. +func (_m *UserIdentity) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// Update returns a builder for updating this UserIdentity. +// Note that you need to call UserIdentity.Unwrap() before calling this method if this UserIdentity +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *UserIdentity) Update() *UserIdentityUpdateOne { + return NewUserIdentityClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the UserIdentity entity that was returned from a transaction after it was closed, +// so that all future queries will be executed through the driver which created the transaction. +func (_m *UserIdentity) Unwrap() *UserIdentity { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("db: UserIdentity is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *UserIdentity) String() string { + var builder strings.Builder + builder.WriteString("UserIdentity(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("user_id=") + builder.WriteString(_m.UserID) + builder.WriteString(", ") + builder.WriteString("connector_id=") + builder.WriteString(_m.ConnectorID) + builder.WriteString(", ") + builder.WriteString("claims_user_id=") + builder.WriteString(_m.ClaimsUserID) + builder.WriteString(", ") + builder.WriteString("claims_username=") + builder.WriteString(_m.ClaimsUsername) + builder.WriteString(", ") + builder.WriteString("claims_preferred_username=") + builder.WriteString(_m.ClaimsPreferredUsername) + builder.WriteString(", ") + builder.WriteString("claims_email=") + builder.WriteString(_m.ClaimsEmail) + builder.WriteString(", ") + builder.WriteString("claims_email_verified=") + builder.WriteString(fmt.Sprintf("%v", _m.ClaimsEmailVerified)) + builder.WriteString(", ") + builder.WriteString("claims_groups=") + builder.WriteString(fmt.Sprintf("%v", _m.ClaimsGroups)) + builder.WriteString(", ") + builder.WriteString("consents=") + builder.WriteString(fmt.Sprintf("%v", _m.Consents)) + builder.WriteString(", ") + builder.WriteString("created_at=") + builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("last_login=") + builder.WriteString(_m.LastLogin.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("blocked_until=") + builder.WriteString(_m.BlockedUntil.Format(time.ANSIC)) + builder.WriteByte(')') + return builder.String() +} + +// UserIdentities is a parsable slice of UserIdentity. +type UserIdentities []*UserIdentity diff --git a/storage/ent/db/useridentity/useridentity.go b/storage/ent/db/useridentity/useridentity.go new file mode 100644 index 00000000..f08d74ec --- /dev/null +++ b/storage/ent/db/useridentity/useridentity.go @@ -0,0 +1,144 @@ +// Code generated by ent, DO NOT EDIT. + +package useridentity + +import ( + "entgo.io/ent/dialect/sql" +) + +const ( + // Label holds the string label denoting the useridentity type in the database. + Label = "user_identity" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldUserID holds the string denoting the user_id field in the database. + FieldUserID = "user_id" + // FieldConnectorID holds the string denoting the connector_id field in the database. + FieldConnectorID = "connector_id" + // FieldClaimsUserID holds the string denoting the claims_user_id field in the database. + FieldClaimsUserID = "claims_user_id" + // FieldClaimsUsername holds the string denoting the claims_username field in the database. + FieldClaimsUsername = "claims_username" + // FieldClaimsPreferredUsername holds the string denoting the claims_preferred_username field in the database. + FieldClaimsPreferredUsername = "claims_preferred_username" + // FieldClaimsEmail holds the string denoting the claims_email field in the database. + FieldClaimsEmail = "claims_email" + // FieldClaimsEmailVerified holds the string denoting the claims_email_verified field in the database. + FieldClaimsEmailVerified = "claims_email_verified" + // FieldClaimsGroups holds the string denoting the claims_groups field in the database. + FieldClaimsGroups = "claims_groups" + // FieldConsents holds the string denoting the consents field in the database. + FieldConsents = "consents" + // FieldCreatedAt holds the string denoting the created_at field in the database. + FieldCreatedAt = "created_at" + // FieldLastLogin holds the string denoting the last_login field in the database. + FieldLastLogin = "last_login" + // FieldBlockedUntil holds the string denoting the blocked_until field in the database. + FieldBlockedUntil = "blocked_until" + // Table holds the table name of the useridentity in the database. + Table = "user_identities" +) + +// Columns holds all SQL columns for useridentity fields. +var Columns = []string{ + FieldID, + FieldUserID, + FieldConnectorID, + FieldClaimsUserID, + FieldClaimsUsername, + FieldClaimsPreferredUsername, + FieldClaimsEmail, + FieldClaimsEmailVerified, + FieldClaimsGroups, + FieldConsents, + FieldCreatedAt, + FieldLastLogin, + FieldBlockedUntil, +} + +// ValidColumn reports if the column name is valid (part of the table columns). +func ValidColumn(column string) bool { + for i := range Columns { + if column == Columns[i] { + return true + } + } + return false +} + +var ( + // UserIDValidator is a validator for the "user_id" field. It is called by the builders before save. + UserIDValidator func(string) error + // ConnectorIDValidator is a validator for the "connector_id" field. It is called by the builders before save. + ConnectorIDValidator func(string) error + // DefaultClaimsUserID holds the default value on creation for the "claims_user_id" field. + DefaultClaimsUserID string + // DefaultClaimsUsername holds the default value on creation for the "claims_username" field. + DefaultClaimsUsername string + // DefaultClaimsPreferredUsername holds the default value on creation for the "claims_preferred_username" field. + DefaultClaimsPreferredUsername string + // DefaultClaimsEmail holds the default value on creation for the "claims_email" field. + DefaultClaimsEmail string + // DefaultClaimsEmailVerified holds the default value on creation for the "claims_email_verified" field. + DefaultClaimsEmailVerified bool + // IDValidator is a validator for the "id" field. It is called by the builders before save. + IDValidator func(string) error +) + +// OrderOption defines the ordering options for the UserIdentity queries. +type OrderOption func(*sql.Selector) + +// ByID orders the results by the id field. +func ByID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldID, opts...).ToFunc() +} + +// ByUserID orders the results by the user_id field. +func ByUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserID, opts...).ToFunc() +} + +// ByConnectorID orders the results by the connector_id field. +func ByConnectorID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldConnectorID, opts...).ToFunc() +} + +// ByClaimsUserID orders the results by the claims_user_id field. +func ByClaimsUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldClaimsUserID, opts...).ToFunc() +} + +// ByClaimsUsername orders the results by the claims_username field. +func ByClaimsUsername(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldClaimsUsername, opts...).ToFunc() +} + +// ByClaimsPreferredUsername orders the results by the claims_preferred_username field. +func ByClaimsPreferredUsername(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldClaimsPreferredUsername, opts...).ToFunc() +} + +// ByClaimsEmail orders the results by the claims_email field. +func ByClaimsEmail(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldClaimsEmail, opts...).ToFunc() +} + +// ByClaimsEmailVerified orders the results by the claims_email_verified field. +func ByClaimsEmailVerified(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldClaimsEmailVerified, opts...).ToFunc() +} + +// ByCreatedAt orders the results by the created_at field. +func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() +} + +// ByLastLogin orders the results by the last_login field. +func ByLastLogin(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldLastLogin, opts...).ToFunc() +} + +// ByBlockedUntil orders the results by the blocked_until field. +func ByBlockedUntil(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBlockedUntil, opts...).ToFunc() +} diff --git a/storage/ent/db/useridentity/where.go b/storage/ent/db/useridentity/where.go new file mode 100644 index 00000000..201d340f --- /dev/null +++ b/storage/ent/db/useridentity/where.go @@ -0,0 +1,705 @@ +// Code generated by ent, DO NOT EDIT. + +package useridentity + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "github.com/dexidp/dex/storage/ent/db/predicate" +) + +// ID filters vertices based on their ID field. +func ID(id string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldID, id)) +} + +// IDEqualFold applies the EqualFold predicate on the ID field. +func IDEqualFold(id string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEqualFold(FieldID, id)) +} + +// IDContainsFold applies the ContainsFold predicate on the ID field. +func IDContainsFold(id string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContainsFold(FieldID, id)) +} + +// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. +func UserID(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldUserID, v)) +} + +// ConnectorID applies equality check predicate on the "connector_id" field. It's identical to ConnectorIDEQ. +func ConnectorID(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldConnectorID, v)) +} + +// ClaimsUserID applies equality check predicate on the "claims_user_id" field. It's identical to ClaimsUserIDEQ. +func ClaimsUserID(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsUserID, v)) +} + +// ClaimsUsername applies equality check predicate on the "claims_username" field. It's identical to ClaimsUsernameEQ. +func ClaimsUsername(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsUsername, v)) +} + +// ClaimsPreferredUsername applies equality check predicate on the "claims_preferred_username" field. It's identical to ClaimsPreferredUsernameEQ. +func ClaimsPreferredUsername(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsPreferredUsername, v)) +} + +// ClaimsEmail applies equality check predicate on the "claims_email" field. It's identical to ClaimsEmailEQ. +func ClaimsEmail(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsEmail, v)) +} + +// ClaimsEmailVerified applies equality check predicate on the "claims_email_verified" field. It's identical to ClaimsEmailVerifiedEQ. +func ClaimsEmailVerified(v bool) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsEmailVerified, v)) +} + +// Consents applies equality check predicate on the "consents" field. It's identical to ConsentsEQ. +func Consents(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldConsents, v)) +} + +// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. +func CreatedAt(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldCreatedAt, v)) +} + +// LastLogin applies equality check predicate on the "last_login" field. It's identical to LastLoginEQ. +func LastLogin(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldLastLogin, v)) +} + +// BlockedUntil applies equality check predicate on the "blocked_until" field. It's identical to BlockedUntilEQ. +func BlockedUntil(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldBlockedUntil, v)) +} + +// UserIDEQ applies the EQ predicate on the "user_id" field. +func UserIDEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldUserID, v)) +} + +// UserIDNEQ applies the NEQ predicate on the "user_id" field. +func UserIDNEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldUserID, v)) +} + +// UserIDIn applies the In predicate on the "user_id" field. +func UserIDIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldUserID, vs...)) +} + +// UserIDNotIn applies the NotIn predicate on the "user_id" field. +func UserIDNotIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldUserID, vs...)) +} + +// UserIDGT applies the GT predicate on the "user_id" field. +func UserIDGT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldUserID, v)) +} + +// UserIDGTE applies the GTE predicate on the "user_id" field. +func UserIDGTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldUserID, v)) +} + +// UserIDLT applies the LT predicate on the "user_id" field. +func UserIDLT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldUserID, v)) +} + +// UserIDLTE applies the LTE predicate on the "user_id" field. +func UserIDLTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldUserID, v)) +} + +// UserIDContains applies the Contains predicate on the "user_id" field. +func UserIDContains(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContains(FieldUserID, v)) +} + +// UserIDHasPrefix applies the HasPrefix predicate on the "user_id" field. +func UserIDHasPrefix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasPrefix(FieldUserID, v)) +} + +// UserIDHasSuffix applies the HasSuffix predicate on the "user_id" field. +func UserIDHasSuffix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasSuffix(FieldUserID, v)) +} + +// UserIDEqualFold applies the EqualFold predicate on the "user_id" field. +func UserIDEqualFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEqualFold(FieldUserID, v)) +} + +// UserIDContainsFold applies the ContainsFold predicate on the "user_id" field. +func UserIDContainsFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContainsFold(FieldUserID, v)) +} + +// ConnectorIDEQ applies the EQ predicate on the "connector_id" field. +func ConnectorIDEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldConnectorID, v)) +} + +// ConnectorIDNEQ applies the NEQ predicate on the "connector_id" field. +func ConnectorIDNEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldConnectorID, v)) +} + +// ConnectorIDIn applies the In predicate on the "connector_id" field. +func ConnectorIDIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldConnectorID, vs...)) +} + +// ConnectorIDNotIn applies the NotIn predicate on the "connector_id" field. +func ConnectorIDNotIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldConnectorID, vs...)) +} + +// ConnectorIDGT applies the GT predicate on the "connector_id" field. +func ConnectorIDGT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldConnectorID, v)) +} + +// ConnectorIDGTE applies the GTE predicate on the "connector_id" field. +func ConnectorIDGTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldConnectorID, v)) +} + +// ConnectorIDLT applies the LT predicate on the "connector_id" field. +func ConnectorIDLT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldConnectorID, v)) +} + +// ConnectorIDLTE applies the LTE predicate on the "connector_id" field. +func ConnectorIDLTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldConnectorID, v)) +} + +// ConnectorIDContains applies the Contains predicate on the "connector_id" field. +func ConnectorIDContains(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContains(FieldConnectorID, v)) +} + +// ConnectorIDHasPrefix applies the HasPrefix predicate on the "connector_id" field. +func ConnectorIDHasPrefix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasPrefix(FieldConnectorID, v)) +} + +// ConnectorIDHasSuffix applies the HasSuffix predicate on the "connector_id" field. +func ConnectorIDHasSuffix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasSuffix(FieldConnectorID, v)) +} + +// ConnectorIDEqualFold applies the EqualFold predicate on the "connector_id" field. +func ConnectorIDEqualFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEqualFold(FieldConnectorID, v)) +} + +// ConnectorIDContainsFold applies the ContainsFold predicate on the "connector_id" field. +func ConnectorIDContainsFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContainsFold(FieldConnectorID, v)) +} + +// ClaimsUserIDEQ applies the EQ predicate on the "claims_user_id" field. +func ClaimsUserIDEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsUserID, v)) +} + +// ClaimsUserIDNEQ applies the NEQ predicate on the "claims_user_id" field. +func ClaimsUserIDNEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldClaimsUserID, v)) +} + +// ClaimsUserIDIn applies the In predicate on the "claims_user_id" field. +func ClaimsUserIDIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldClaimsUserID, vs...)) +} + +// ClaimsUserIDNotIn applies the NotIn predicate on the "claims_user_id" field. +func ClaimsUserIDNotIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldClaimsUserID, vs...)) +} + +// ClaimsUserIDGT applies the GT predicate on the "claims_user_id" field. +func ClaimsUserIDGT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldClaimsUserID, v)) +} + +// ClaimsUserIDGTE applies the GTE predicate on the "claims_user_id" field. +func ClaimsUserIDGTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldClaimsUserID, v)) +} + +// ClaimsUserIDLT applies the LT predicate on the "claims_user_id" field. +func ClaimsUserIDLT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldClaimsUserID, v)) +} + +// ClaimsUserIDLTE applies the LTE predicate on the "claims_user_id" field. +func ClaimsUserIDLTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldClaimsUserID, v)) +} + +// ClaimsUserIDContains applies the Contains predicate on the "claims_user_id" field. +func ClaimsUserIDContains(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContains(FieldClaimsUserID, v)) +} + +// ClaimsUserIDHasPrefix applies the HasPrefix predicate on the "claims_user_id" field. +func ClaimsUserIDHasPrefix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasPrefix(FieldClaimsUserID, v)) +} + +// ClaimsUserIDHasSuffix applies the HasSuffix predicate on the "claims_user_id" field. +func ClaimsUserIDHasSuffix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasSuffix(FieldClaimsUserID, v)) +} + +// ClaimsUserIDEqualFold applies the EqualFold predicate on the "claims_user_id" field. +func ClaimsUserIDEqualFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEqualFold(FieldClaimsUserID, v)) +} + +// ClaimsUserIDContainsFold applies the ContainsFold predicate on the "claims_user_id" field. +func ClaimsUserIDContainsFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContainsFold(FieldClaimsUserID, v)) +} + +// ClaimsUsernameEQ applies the EQ predicate on the "claims_username" field. +func ClaimsUsernameEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsUsername, v)) +} + +// ClaimsUsernameNEQ applies the NEQ predicate on the "claims_username" field. +func ClaimsUsernameNEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldClaimsUsername, v)) +} + +// ClaimsUsernameIn applies the In predicate on the "claims_username" field. +func ClaimsUsernameIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldClaimsUsername, vs...)) +} + +// ClaimsUsernameNotIn applies the NotIn predicate on the "claims_username" field. +func ClaimsUsernameNotIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldClaimsUsername, vs...)) +} + +// ClaimsUsernameGT applies the GT predicate on the "claims_username" field. +func ClaimsUsernameGT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldClaimsUsername, v)) +} + +// ClaimsUsernameGTE applies the GTE predicate on the "claims_username" field. +func ClaimsUsernameGTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldClaimsUsername, v)) +} + +// ClaimsUsernameLT applies the LT predicate on the "claims_username" field. +func ClaimsUsernameLT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldClaimsUsername, v)) +} + +// ClaimsUsernameLTE applies the LTE predicate on the "claims_username" field. +func ClaimsUsernameLTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldClaimsUsername, v)) +} + +// ClaimsUsernameContains applies the Contains predicate on the "claims_username" field. +func ClaimsUsernameContains(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContains(FieldClaimsUsername, v)) +} + +// ClaimsUsernameHasPrefix applies the HasPrefix predicate on the "claims_username" field. +func ClaimsUsernameHasPrefix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasPrefix(FieldClaimsUsername, v)) +} + +// ClaimsUsernameHasSuffix applies the HasSuffix predicate on the "claims_username" field. +func ClaimsUsernameHasSuffix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasSuffix(FieldClaimsUsername, v)) +} + +// ClaimsUsernameEqualFold applies the EqualFold predicate on the "claims_username" field. +func ClaimsUsernameEqualFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEqualFold(FieldClaimsUsername, v)) +} + +// ClaimsUsernameContainsFold applies the ContainsFold predicate on the "claims_username" field. +func ClaimsUsernameContainsFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContainsFold(FieldClaimsUsername, v)) +} + +// ClaimsPreferredUsernameEQ applies the EQ predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameNEQ applies the NEQ predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameNEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameIn applies the In predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldClaimsPreferredUsername, vs...)) +} + +// ClaimsPreferredUsernameNotIn applies the NotIn predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameNotIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldClaimsPreferredUsername, vs...)) +} + +// ClaimsPreferredUsernameGT applies the GT predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameGT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameGTE applies the GTE predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameGTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameLT applies the LT predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameLT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameLTE applies the LTE predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameLTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameContains applies the Contains predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameContains(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContains(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameHasPrefix applies the HasPrefix predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameHasPrefix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasPrefix(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameHasSuffix applies the HasSuffix predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameHasSuffix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasSuffix(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameEqualFold applies the EqualFold predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameEqualFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEqualFold(FieldClaimsPreferredUsername, v)) +} + +// ClaimsPreferredUsernameContainsFold applies the ContainsFold predicate on the "claims_preferred_username" field. +func ClaimsPreferredUsernameContainsFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContainsFold(FieldClaimsPreferredUsername, v)) +} + +// ClaimsEmailEQ applies the EQ predicate on the "claims_email" field. +func ClaimsEmailEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsEmail, v)) +} + +// ClaimsEmailNEQ applies the NEQ predicate on the "claims_email" field. +func ClaimsEmailNEQ(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldClaimsEmail, v)) +} + +// ClaimsEmailIn applies the In predicate on the "claims_email" field. +func ClaimsEmailIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldClaimsEmail, vs...)) +} + +// ClaimsEmailNotIn applies the NotIn predicate on the "claims_email" field. +func ClaimsEmailNotIn(vs ...string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldClaimsEmail, vs...)) +} + +// ClaimsEmailGT applies the GT predicate on the "claims_email" field. +func ClaimsEmailGT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldClaimsEmail, v)) +} + +// ClaimsEmailGTE applies the GTE predicate on the "claims_email" field. +func ClaimsEmailGTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldClaimsEmail, v)) +} + +// ClaimsEmailLT applies the LT predicate on the "claims_email" field. +func ClaimsEmailLT(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldClaimsEmail, v)) +} + +// ClaimsEmailLTE applies the LTE predicate on the "claims_email" field. +func ClaimsEmailLTE(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldClaimsEmail, v)) +} + +// ClaimsEmailContains applies the Contains predicate on the "claims_email" field. +func ClaimsEmailContains(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContains(FieldClaimsEmail, v)) +} + +// ClaimsEmailHasPrefix applies the HasPrefix predicate on the "claims_email" field. +func ClaimsEmailHasPrefix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasPrefix(FieldClaimsEmail, v)) +} + +// ClaimsEmailHasSuffix applies the HasSuffix predicate on the "claims_email" field. +func ClaimsEmailHasSuffix(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldHasSuffix(FieldClaimsEmail, v)) +} + +// ClaimsEmailEqualFold applies the EqualFold predicate on the "claims_email" field. +func ClaimsEmailEqualFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEqualFold(FieldClaimsEmail, v)) +} + +// ClaimsEmailContainsFold applies the ContainsFold predicate on the "claims_email" field. +func ClaimsEmailContainsFold(v string) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldContainsFold(FieldClaimsEmail, v)) +} + +// ClaimsEmailVerifiedEQ applies the EQ predicate on the "claims_email_verified" field. +func ClaimsEmailVerifiedEQ(v bool) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldClaimsEmailVerified, v)) +} + +// ClaimsEmailVerifiedNEQ applies the NEQ predicate on the "claims_email_verified" field. +func ClaimsEmailVerifiedNEQ(v bool) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldClaimsEmailVerified, v)) +} + +// ClaimsGroupsIsNil applies the IsNil predicate on the "claims_groups" field. +func ClaimsGroupsIsNil() predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIsNull(FieldClaimsGroups)) +} + +// ClaimsGroupsNotNil applies the NotNil predicate on the "claims_groups" field. +func ClaimsGroupsNotNil() predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotNull(FieldClaimsGroups)) +} + +// ConsentsEQ applies the EQ predicate on the "consents" field. +func ConsentsEQ(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldConsents, v)) +} + +// ConsentsNEQ applies the NEQ predicate on the "consents" field. +func ConsentsNEQ(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldConsents, v)) +} + +// ConsentsIn applies the In predicate on the "consents" field. +func ConsentsIn(vs ...[]byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldConsents, vs...)) +} + +// ConsentsNotIn applies the NotIn predicate on the "consents" field. +func ConsentsNotIn(vs ...[]byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldConsents, vs...)) +} + +// ConsentsGT applies the GT predicate on the "consents" field. +func ConsentsGT(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldConsents, v)) +} + +// ConsentsGTE applies the GTE predicate on the "consents" field. +func ConsentsGTE(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldConsents, v)) +} + +// ConsentsLT applies the LT predicate on the "consents" field. +func ConsentsLT(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldConsents, v)) +} + +// ConsentsLTE applies the LTE predicate on the "consents" field. +func ConsentsLTE(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldConsents, v)) +} + +// CreatedAtEQ applies the EQ predicate on the "created_at" field. +func CreatedAtEQ(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldCreatedAt, v)) +} + +// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. +func CreatedAtNEQ(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldCreatedAt, v)) +} + +// CreatedAtIn applies the In predicate on the "created_at" field. +func CreatedAtIn(vs ...time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldCreatedAt, vs...)) +} + +// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. +func CreatedAtNotIn(vs ...time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldCreatedAt, vs...)) +} + +// CreatedAtGT applies the GT predicate on the "created_at" field. +func CreatedAtGT(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldCreatedAt, v)) +} + +// CreatedAtGTE applies the GTE predicate on the "created_at" field. +func CreatedAtGTE(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldCreatedAt, v)) +} + +// CreatedAtLT applies the LT predicate on the "created_at" field. +func CreatedAtLT(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldCreatedAt, v)) +} + +// CreatedAtLTE applies the LTE predicate on the "created_at" field. +func CreatedAtLTE(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldCreatedAt, v)) +} + +// LastLoginEQ applies the EQ predicate on the "last_login" field. +func LastLoginEQ(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldLastLogin, v)) +} + +// LastLoginNEQ applies the NEQ predicate on the "last_login" field. +func LastLoginNEQ(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldLastLogin, v)) +} + +// LastLoginIn applies the In predicate on the "last_login" field. +func LastLoginIn(vs ...time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldLastLogin, vs...)) +} + +// LastLoginNotIn applies the NotIn predicate on the "last_login" field. +func LastLoginNotIn(vs ...time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldLastLogin, vs...)) +} + +// LastLoginGT applies the GT predicate on the "last_login" field. +func LastLoginGT(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldLastLogin, v)) +} + +// LastLoginGTE applies the GTE predicate on the "last_login" field. +func LastLoginGTE(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldLastLogin, v)) +} + +// LastLoginLT applies the LT predicate on the "last_login" field. +func LastLoginLT(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldLastLogin, v)) +} + +// LastLoginLTE applies the LTE predicate on the "last_login" field. +func LastLoginLTE(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldLastLogin, v)) +} + +// BlockedUntilEQ applies the EQ predicate on the "blocked_until" field. +func BlockedUntilEQ(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldBlockedUntil, v)) +} + +// BlockedUntilNEQ applies the NEQ predicate on the "blocked_until" field. +func BlockedUntilNEQ(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldBlockedUntil, v)) +} + +// BlockedUntilIn applies the In predicate on the "blocked_until" field. +func BlockedUntilIn(vs ...time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldBlockedUntil, vs...)) +} + +// BlockedUntilNotIn applies the NotIn predicate on the "blocked_until" field. +func BlockedUntilNotIn(vs ...time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldBlockedUntil, vs...)) +} + +// BlockedUntilGT applies the GT predicate on the "blocked_until" field. +func BlockedUntilGT(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldBlockedUntil, v)) +} + +// BlockedUntilGTE applies the GTE predicate on the "blocked_until" field. +func BlockedUntilGTE(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldBlockedUntil, v)) +} + +// BlockedUntilLT applies the LT predicate on the "blocked_until" field. +func BlockedUntilLT(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldBlockedUntil, v)) +} + +// BlockedUntilLTE applies the LTE predicate on the "blocked_until" field. +func BlockedUntilLTE(v time.Time) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldBlockedUntil, v)) +} + +// And groups predicates with the AND operator between them. +func And(predicates ...predicate.UserIdentity) predicate.UserIdentity { + return predicate.UserIdentity(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.UserIdentity) predicate.UserIdentity { + return predicate.UserIdentity(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.UserIdentity) predicate.UserIdentity { + return predicate.UserIdentity(sql.NotPredicates(p)) +} diff --git a/storage/ent/db/useridentity_create.go b/storage/ent/db/useridentity_create.go new file mode 100644 index 00000000..336d5c30 --- /dev/null +++ b/storage/ent/db/useridentity_create.go @@ -0,0 +1,416 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage/ent/db/useridentity" +) + +// UserIdentityCreate is the builder for creating a UserIdentity entity. +type UserIdentityCreate struct { + config + mutation *UserIdentityMutation + hooks []Hook +} + +// SetUserID sets the "user_id" field. +func (_c *UserIdentityCreate) SetUserID(v string) *UserIdentityCreate { + _c.mutation.SetUserID(v) + return _c +} + +// SetConnectorID sets the "connector_id" field. +func (_c *UserIdentityCreate) SetConnectorID(v string) *UserIdentityCreate { + _c.mutation.SetConnectorID(v) + return _c +} + +// SetClaimsUserID sets the "claims_user_id" field. +func (_c *UserIdentityCreate) SetClaimsUserID(v string) *UserIdentityCreate { + _c.mutation.SetClaimsUserID(v) + return _c +} + +// SetNillableClaimsUserID sets the "claims_user_id" field if the given value is not nil. +func (_c *UserIdentityCreate) SetNillableClaimsUserID(v *string) *UserIdentityCreate { + if v != nil { + _c.SetClaimsUserID(*v) + } + return _c +} + +// SetClaimsUsername sets the "claims_username" field. +func (_c *UserIdentityCreate) SetClaimsUsername(v string) *UserIdentityCreate { + _c.mutation.SetClaimsUsername(v) + return _c +} + +// SetNillableClaimsUsername sets the "claims_username" field if the given value is not nil. +func (_c *UserIdentityCreate) SetNillableClaimsUsername(v *string) *UserIdentityCreate { + if v != nil { + _c.SetClaimsUsername(*v) + } + return _c +} + +// SetClaimsPreferredUsername sets the "claims_preferred_username" field. +func (_c *UserIdentityCreate) SetClaimsPreferredUsername(v string) *UserIdentityCreate { + _c.mutation.SetClaimsPreferredUsername(v) + return _c +} + +// SetNillableClaimsPreferredUsername sets the "claims_preferred_username" field if the given value is not nil. +func (_c *UserIdentityCreate) SetNillableClaimsPreferredUsername(v *string) *UserIdentityCreate { + if v != nil { + _c.SetClaimsPreferredUsername(*v) + } + return _c +} + +// SetClaimsEmail sets the "claims_email" field. +func (_c *UserIdentityCreate) SetClaimsEmail(v string) *UserIdentityCreate { + _c.mutation.SetClaimsEmail(v) + return _c +} + +// SetNillableClaimsEmail sets the "claims_email" field if the given value is not nil. +func (_c *UserIdentityCreate) SetNillableClaimsEmail(v *string) *UserIdentityCreate { + if v != nil { + _c.SetClaimsEmail(*v) + } + return _c +} + +// SetClaimsEmailVerified sets the "claims_email_verified" field. +func (_c *UserIdentityCreate) SetClaimsEmailVerified(v bool) *UserIdentityCreate { + _c.mutation.SetClaimsEmailVerified(v) + return _c +} + +// SetNillableClaimsEmailVerified sets the "claims_email_verified" field if the given value is not nil. +func (_c *UserIdentityCreate) SetNillableClaimsEmailVerified(v *bool) *UserIdentityCreate { + if v != nil { + _c.SetClaimsEmailVerified(*v) + } + return _c +} + +// SetClaimsGroups sets the "claims_groups" field. +func (_c *UserIdentityCreate) SetClaimsGroups(v []string) *UserIdentityCreate { + _c.mutation.SetClaimsGroups(v) + return _c +} + +// SetConsents sets the "consents" field. +func (_c *UserIdentityCreate) SetConsents(v []byte) *UserIdentityCreate { + _c.mutation.SetConsents(v) + return _c +} + +// SetCreatedAt sets the "created_at" field. +func (_c *UserIdentityCreate) SetCreatedAt(v time.Time) *UserIdentityCreate { + _c.mutation.SetCreatedAt(v) + return _c +} + +// SetLastLogin sets the "last_login" field. +func (_c *UserIdentityCreate) SetLastLogin(v time.Time) *UserIdentityCreate { + _c.mutation.SetLastLogin(v) + return _c +} + +// SetBlockedUntil sets the "blocked_until" field. +func (_c *UserIdentityCreate) SetBlockedUntil(v time.Time) *UserIdentityCreate { + _c.mutation.SetBlockedUntil(v) + return _c +} + +// SetID sets the "id" field. +func (_c *UserIdentityCreate) SetID(v string) *UserIdentityCreate { + _c.mutation.SetID(v) + return _c +} + +// Mutation returns the UserIdentityMutation object of the builder. +func (_c *UserIdentityCreate) Mutation() *UserIdentityMutation { + return _c.mutation +} + +// Save creates the UserIdentity in the database. +func (_c *UserIdentityCreate) Save(ctx context.Context) (*UserIdentity, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *UserIdentityCreate) SaveX(ctx context.Context) *UserIdentity { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *UserIdentityCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *UserIdentityCreate) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_c *UserIdentityCreate) defaults() { + if _, ok := _c.mutation.ClaimsUserID(); !ok { + v := useridentity.DefaultClaimsUserID + _c.mutation.SetClaimsUserID(v) + } + if _, ok := _c.mutation.ClaimsUsername(); !ok { + v := useridentity.DefaultClaimsUsername + _c.mutation.SetClaimsUsername(v) + } + if _, ok := _c.mutation.ClaimsPreferredUsername(); !ok { + v := useridentity.DefaultClaimsPreferredUsername + _c.mutation.SetClaimsPreferredUsername(v) + } + if _, ok := _c.mutation.ClaimsEmail(); !ok { + v := useridentity.DefaultClaimsEmail + _c.mutation.SetClaimsEmail(v) + } + if _, ok := _c.mutation.ClaimsEmailVerified(); !ok { + v := useridentity.DefaultClaimsEmailVerified + _c.mutation.SetClaimsEmailVerified(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *UserIdentityCreate) check() error { + if _, ok := _c.mutation.UserID(); !ok { + return &ValidationError{Name: "user_id", err: errors.New(`db: missing required field "UserIdentity.user_id"`)} + } + if v, ok := _c.mutation.UserID(); ok { + if err := useridentity.UserIDValidator(v); err != nil { + return &ValidationError{Name: "user_id", err: fmt.Errorf(`db: validator failed for field "UserIdentity.user_id": %w`, err)} + } + } + if _, ok := _c.mutation.ConnectorID(); !ok { + return &ValidationError{Name: "connector_id", err: errors.New(`db: missing required field "UserIdentity.connector_id"`)} + } + if v, ok := _c.mutation.ConnectorID(); ok { + if err := useridentity.ConnectorIDValidator(v); err != nil { + return &ValidationError{Name: "connector_id", err: fmt.Errorf(`db: validator failed for field "UserIdentity.connector_id": %w`, err)} + } + } + if _, ok := _c.mutation.ClaimsUserID(); !ok { + return &ValidationError{Name: "claims_user_id", err: errors.New(`db: missing required field "UserIdentity.claims_user_id"`)} + } + if _, ok := _c.mutation.ClaimsUsername(); !ok { + return &ValidationError{Name: "claims_username", err: errors.New(`db: missing required field "UserIdentity.claims_username"`)} + } + if _, ok := _c.mutation.ClaimsPreferredUsername(); !ok { + return &ValidationError{Name: "claims_preferred_username", err: errors.New(`db: missing required field "UserIdentity.claims_preferred_username"`)} + } + if _, ok := _c.mutation.ClaimsEmail(); !ok { + return &ValidationError{Name: "claims_email", err: errors.New(`db: missing required field "UserIdentity.claims_email"`)} + } + if _, ok := _c.mutation.ClaimsEmailVerified(); !ok { + return &ValidationError{Name: "claims_email_verified", err: errors.New(`db: missing required field "UserIdentity.claims_email_verified"`)} + } + if _, ok := _c.mutation.Consents(); !ok { + return &ValidationError{Name: "consents", err: errors.New(`db: missing required field "UserIdentity.consents"`)} + } + if _, ok := _c.mutation.CreatedAt(); !ok { + return &ValidationError{Name: "created_at", err: errors.New(`db: missing required field "UserIdentity.created_at"`)} + } + if _, ok := _c.mutation.LastLogin(); !ok { + return &ValidationError{Name: "last_login", err: errors.New(`db: missing required field "UserIdentity.last_login"`)} + } + if _, ok := _c.mutation.BlockedUntil(); !ok { + return &ValidationError{Name: "blocked_until", err: errors.New(`db: missing required field "UserIdentity.blocked_until"`)} + } + if v, ok := _c.mutation.ID(); ok { + if err := useridentity.IDValidator(v); err != nil { + return &ValidationError{Name: "id", err: fmt.Errorf(`db: validator failed for field "UserIdentity.id": %w`, err)} + } + } + return nil +} + +func (_c *UserIdentityCreate) sqlSave(ctx context.Context) (*UserIdentity, error) { + if err := _c.check(); err != nil { + return nil, err + } + _node, _spec := _c.createSpec() + if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + if _spec.ID.Value != nil { + if id, ok := _spec.ID.Value.(string); ok { + _node.ID = id + } else { + return nil, fmt.Errorf("unexpected UserIdentity.ID type: %T", _spec.ID.Value) + } + } + _c.mutation.id = &_node.ID + _c.mutation.done = true + return _node, nil +} + +func (_c *UserIdentityCreate) createSpec() (*UserIdentity, *sqlgraph.CreateSpec) { + var ( + _node = &UserIdentity{config: _c.config} + _spec = sqlgraph.NewCreateSpec(useridentity.Table, sqlgraph.NewFieldSpec(useridentity.FieldID, field.TypeString)) + ) + if id, ok := _c.mutation.ID(); ok { + _node.ID = id + _spec.ID.Value = id + } + if value, ok := _c.mutation.UserID(); ok { + _spec.SetField(useridentity.FieldUserID, field.TypeString, value) + _node.UserID = value + } + if value, ok := _c.mutation.ConnectorID(); ok { + _spec.SetField(useridentity.FieldConnectorID, field.TypeString, value) + _node.ConnectorID = value + } + if value, ok := _c.mutation.ClaimsUserID(); ok { + _spec.SetField(useridentity.FieldClaimsUserID, field.TypeString, value) + _node.ClaimsUserID = value + } + if value, ok := _c.mutation.ClaimsUsername(); ok { + _spec.SetField(useridentity.FieldClaimsUsername, field.TypeString, value) + _node.ClaimsUsername = value + } + if value, ok := _c.mutation.ClaimsPreferredUsername(); ok { + _spec.SetField(useridentity.FieldClaimsPreferredUsername, field.TypeString, value) + _node.ClaimsPreferredUsername = value + } + if value, ok := _c.mutation.ClaimsEmail(); ok { + _spec.SetField(useridentity.FieldClaimsEmail, field.TypeString, value) + _node.ClaimsEmail = value + } + if value, ok := _c.mutation.ClaimsEmailVerified(); ok { + _spec.SetField(useridentity.FieldClaimsEmailVerified, field.TypeBool, value) + _node.ClaimsEmailVerified = value + } + if value, ok := _c.mutation.ClaimsGroups(); ok { + _spec.SetField(useridentity.FieldClaimsGroups, field.TypeJSON, value) + _node.ClaimsGroups = value + } + if value, ok := _c.mutation.Consents(); ok { + _spec.SetField(useridentity.FieldConsents, field.TypeBytes, value) + _node.Consents = value + } + if value, ok := _c.mutation.CreatedAt(); ok { + _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) + _node.CreatedAt = value + } + if value, ok := _c.mutation.LastLogin(); ok { + _spec.SetField(useridentity.FieldLastLogin, field.TypeTime, value) + _node.LastLogin = value + } + if value, ok := _c.mutation.BlockedUntil(); ok { + _spec.SetField(useridentity.FieldBlockedUntil, field.TypeTime, value) + _node.BlockedUntil = value + } + return _node, _spec +} + +// UserIdentityCreateBulk is the builder for creating many UserIdentity entities in bulk. +type UserIdentityCreateBulk struct { + config + err error + builders []*UserIdentityCreate +} + +// Save creates the UserIdentity entities in the database. +func (_c *UserIdentityCreateBulk) Save(ctx context.Context) ([]*UserIdentity, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*UserIdentity, len(_c.builders)) + mutators := make([]Mutator, len(_c.builders)) + for i := range _c.builders { + func(i int, root context.Context) { + builder := _c.builders[i] + builder.defaults() + var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { + mutation, ok := m.(*UserIdentityMutation) + if !ok { + return nil, fmt.Errorf("unexpected mutation type %T", m) + } + if err := builder.check(); err != nil { + return nil, err + } + builder.mutation = mutation + var err error + nodes[i], specs[i] = builder.createSpec() + if i < len(mutators)-1 { + _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) + } else { + spec := &sqlgraph.BatchCreateSpec{Nodes: specs} + // Invoke the actual operation on the latest mutation in the chain. + if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + } + } + if err != nil { + return nil, err + } + mutation.id = &nodes[i].ID + mutation.done = true + return nodes[i], nil + }) + for i := len(builder.hooks) - 1; i >= 0; i-- { + mut = builder.hooks[i](mut) + } + mutators[i] = mut + }(i, ctx) + } + if len(mutators) > 0 { + if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { + return nil, err + } + } + return nodes, nil +} + +// SaveX is like Save, but panics if an error occurs. +func (_c *UserIdentityCreateBulk) SaveX(ctx context.Context) []*UserIdentity { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *UserIdentityCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *UserIdentityCreateBulk) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/storage/ent/db/useridentity_delete.go b/storage/ent/db/useridentity_delete.go new file mode 100644 index 00000000..0bc51e24 --- /dev/null +++ b/storage/ent/db/useridentity_delete.go @@ -0,0 +1,88 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage/ent/db/predicate" + "github.com/dexidp/dex/storage/ent/db/useridentity" +) + +// UserIdentityDelete is the builder for deleting a UserIdentity entity. +type UserIdentityDelete struct { + config + hooks []Hook + mutation *UserIdentityMutation +} + +// Where appends a list predicates to the UserIdentityDelete builder. +func (_d *UserIdentityDelete) Where(ps ...predicate.UserIdentity) *UserIdentityDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *UserIdentityDelete) Exec(ctx context.Context) (int, error) { + return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *UserIdentityDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *UserIdentityDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(useridentity.Table, sqlgraph.NewFieldSpec(useridentity.FieldID, field.TypeString)) + if ps := _d.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) + if err != nil && sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + _d.mutation.done = true + return affected, err +} + +// UserIdentityDeleteOne is the builder for deleting a single UserIdentity entity. +type UserIdentityDeleteOne struct { + _d *UserIdentityDelete +} + +// Where appends a list predicates to the UserIdentityDelete builder. +func (_d *UserIdentityDeleteOne) Where(ps ...predicate.UserIdentity) *UserIdentityDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *UserIdentityDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{useridentity.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *UserIdentityDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/storage/ent/db/useridentity_query.go b/storage/ent/db/useridentity_query.go new file mode 100644 index 00000000..3e509038 --- /dev/null +++ b/storage/ent/db/useridentity_query.go @@ -0,0 +1,527 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + "fmt" + "math" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage/ent/db/predicate" + "github.com/dexidp/dex/storage/ent/db/useridentity" +) + +// UserIdentityQuery is the builder for querying UserIdentity entities. +type UserIdentityQuery struct { + config + ctx *QueryContext + order []useridentity.OrderOption + inters []Interceptor + predicates []predicate.UserIdentity + // intermediate query (i.e. traversal path). + sql *sql.Selector + path func(context.Context) (*sql.Selector, error) +} + +// Where adds a new predicate for the UserIdentityQuery builder. +func (_q *UserIdentityQuery) Where(ps ...predicate.UserIdentity) *UserIdentityQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *UserIdentityQuery) Limit(limit int) *UserIdentityQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *UserIdentityQuery) Offset(offset int) *UserIdentityQuery { + _q.ctx.Offset = &offset + return _q +} + +// Unique configures the query builder to filter duplicate records on query. +// By default, unique is set to true, and can be disabled using this method. +func (_q *UserIdentityQuery) Unique(unique bool) *UserIdentityQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *UserIdentityQuery) Order(o ...useridentity.OrderOption) *UserIdentityQuery { + _q.order = append(_q.order, o...) + return _q +} + +// First returns the first UserIdentity entity from the query. +// Returns a *NotFoundError when no UserIdentity was found. +func (_q *UserIdentityQuery) First(ctx context.Context) (*UserIdentity, error) { + nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &NotFoundError{useridentity.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *UserIdentityQuery) FirstX(ctx context.Context) *UserIdentity { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first UserIdentity ID from the query. +// Returns a *NotFoundError when no UserIdentity ID was found. +func (_q *UserIdentityQuery) FirstID(ctx context.Context) (id string, err error) { + var ids []string + if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { + return + } + if len(ids) == 0 { + err = &NotFoundError{useridentity.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *UserIdentityQuery) FirstIDX(ctx context.Context) string { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single UserIdentity entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one UserIdentity entity is found. +// Returns a *NotFoundError when no UserIdentity entities are found. +func (_q *UserIdentityQuery) Only(ctx context.Context) (*UserIdentity, error) { + nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) + if err != nil { + return nil, err + } + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return nil, &NotFoundError{useridentity.Label} + default: + return nil, &NotSingularError{useridentity.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *UserIdentityQuery) OnlyX(ctx context.Context) *UserIdentity { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only UserIdentity ID in the query. +// Returns a *NotSingularError when more than one UserIdentity ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *UserIdentityQuery) OnlyID(ctx context.Context) (id string, err error) { + var ids []string + if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { + return + } + switch len(ids) { + case 1: + id = ids[0] + case 0: + err = &NotFoundError{useridentity.Label} + default: + err = &NotSingularError{useridentity.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *UserIdentityQuery) OnlyIDX(ctx context.Context) string { + id, err := _q.OnlyID(ctx) + if err != nil { + panic(err) + } + return id +} + +// All executes the query and returns a list of UserIdentities. +func (_q *UserIdentityQuery) All(ctx context.Context) ([]*UserIdentity, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*UserIdentity, *UserIdentityQuery]() + return withInterceptors[[]*UserIdentity](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *UserIdentityQuery) AllX(ctx context.Context) []*UserIdentity { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of UserIdentity IDs. +func (_q *UserIdentityQuery) IDs(ctx context.Context) (ids []string, err error) { + if _q.ctx.Unique == nil && _q.path != nil { + _q.Unique(true) + } + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) + if err = _q.Select(useridentity.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *UserIdentityQuery) IDsX(ctx context.Context) []string { + ids, err := _q.IDs(ctx) + if err != nil { + panic(err) + } + return ids +} + +// Count returns the count of the given query. +func (_q *UserIdentityQuery) Count(ctx context.Context) (int, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) + if err := _q.prepareQuery(ctx); err != nil { + return 0, err + } + return withInterceptors[int](ctx, _q, querierCount[*UserIdentityQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *UserIdentityQuery) CountX(ctx context.Context) int { + count, err := _q.Count(ctx) + if err != nil { + panic(err) + } + return count +} + +// Exist returns true if the query has elements in the graph. +func (_q *UserIdentityQuery) Exist(ctx context.Context) (bool, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) + switch _, err := _q.FirstID(ctx); { + case IsNotFound(err): + return false, nil + case err != nil: + return false, fmt.Errorf("db: check existence: %w", err) + default: + return true, nil + } +} + +// ExistX is like Exist, but panics if an error occurs. +func (_q *UserIdentityQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the UserIdentityQuery builder, including all associated steps. It can be +// used to prepare common query builders and use them differently after the clone is made. +func (_q *UserIdentityQuery) Clone() *UserIdentityQuery { + if _q == nil { + return nil + } + return &UserIdentityQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]useridentity.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.UserIdentity{}, _q.predicates...), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + } +} + +// GroupBy is used to group vertices by one or more fields/columns. +// It is often used with aggregate functions, like: count, max, mean, min, sum. +// +// Example: +// +// var v []struct { +// UserID string `json:"user_id,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.UserIdentity.Query(). +// GroupBy(useridentity.FieldUserID). +// Aggregate(db.Count()). +// Scan(ctx, &v) +func (_q *UserIdentityQuery) GroupBy(field string, fields ...string) *UserIdentityGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &UserIdentityGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = useridentity.Label + grbuild.scan = grbuild.Scan + return grbuild +} + +// Select allows the selection one or more fields/columns for the given query, +// instead of selecting all fields in the entity. +// +// Example: +// +// var v []struct { +// UserID string `json:"user_id,omitempty"` +// } +// +// client.UserIdentity.Query(). +// Select(useridentity.FieldUserID). +// Scan(ctx, &v) +func (_q *UserIdentityQuery) Select(fields ...string) *UserIdentitySelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &UserIdentitySelect{UserIdentityQuery: _q} + sbuild.label = useridentity.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a UserIdentitySelect configured with the given aggregations. +func (_q *UserIdentityQuery) Aggregate(fns ...AggregateFunc) *UserIdentitySelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *UserIdentityQuery) prepareQuery(ctx context.Context) error { + for _, inter := range _q.inters { + if inter == nil { + return fmt.Errorf("db: uninitialized interceptor (forgotten import db/runtime?)") + } + if trv, ok := inter.(Traverser); ok { + if err := trv.Traverse(ctx, _q); err != nil { + return err + } + } + } + for _, f := range _q.ctx.Fields { + if !useridentity.ValidColumn(f) { + return &ValidationError{Name: f, err: fmt.Errorf("db: invalid field %q for query", f)} + } + } + if _q.path != nil { + prev, err := _q.path(ctx) + if err != nil { + return err + } + _q.sql = prev + } + return nil +} + +func (_q *UserIdentityQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*UserIdentity, error) { + var ( + nodes = []*UserIdentity{} + _spec = _q.querySpec() + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*UserIdentity).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &UserIdentity{config: _q.config} + nodes = append(nodes, node) + return node.assignValues(columns, values) + } + for i := range hooks { + hooks[i](ctx, _spec) + } + if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nodes, nil + } + return nodes, nil +} + +func (_q *UserIdentityQuery) sqlCount(ctx context.Context) (int, error) { + _spec := _q.querySpec() + _spec.Node.Columns = _q.ctx.Fields + if len(_q.ctx.Fields) > 0 { + _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique + } + return sqlgraph.CountNodes(ctx, _q.driver, _spec) +} + +func (_q *UserIdentityQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(useridentity.Table, useridentity.Columns, sqlgraph.NewFieldSpec(useridentity.FieldID, field.TypeString)) + _spec.From = _q.sql + if unique := _q.ctx.Unique; unique != nil { + _spec.Unique = *unique + } else if _q.path != nil { + _spec.Unique = true + } + if fields := _q.ctx.Fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, useridentity.FieldID) + for i := range fields { + if fields[i] != useridentity.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + } + if ps := _q.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if limit := _q.ctx.Limit; limit != nil { + _spec.Limit = *limit + } + if offset := _q.ctx.Offset; offset != nil { + _spec.Offset = *offset + } + if ps := _q.order; len(ps) > 0 { + _spec.Order = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + return _spec +} + +func (_q *UserIdentityQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(useridentity.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = useridentity.Columns + } + selector := builder.Select(t1.Columns(columns...)...).From(t1) + if _q.sql != nil { + selector = _q.sql + selector.Select(selector.Columns(columns...)...) + } + if _q.ctx.Unique != nil && *_q.ctx.Unique { + selector.Distinct() + } + for _, p := range _q.predicates { + p(selector) + } + for _, p := range _q.order { + p(selector) + } + if offset := _q.ctx.Offset; offset != nil { + // limit is mandatory for offset clause. We start + // with default value, and override it below if needed. + selector.Offset(*offset).Limit(math.MaxInt32) + } + if limit := _q.ctx.Limit; limit != nil { + selector.Limit(*limit) + } + return selector +} + +// UserIdentityGroupBy is the group-by builder for UserIdentity entities. +type UserIdentityGroupBy struct { + selector + build *UserIdentityQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *UserIdentityGroupBy) Aggregate(fns ...AggregateFunc) *UserIdentityGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *UserIdentityGroupBy) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) + if err := _g.build.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*UserIdentityQuery, *UserIdentityGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *UserIdentityGroupBy) sqlScan(ctx context.Context, root *UserIdentityQuery, v any) error { + selector := root.sqlQuery(ctx).Select() + aggregation := make([]string, 0, len(_g.fns)) + for _, fn := range _g.fns { + aggregation = append(aggregation, fn(selector)) + } + if len(selector.SelectedColumns()) == 0 { + columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) + for _, f := range *_g.flds { + columns = append(columns, selector.C(f)) + } + columns = append(columns, aggregation...) + selector.Select(columns...) + } + selector.GroupBy(selector.Columns(*_g.flds...)...) + if err := selector.Err(); err != nil { + return err + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// UserIdentitySelect is the builder for selecting fields of UserIdentity entities. +type UserIdentitySelect struct { + *UserIdentityQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *UserIdentitySelect) Aggregate(fns ...AggregateFunc) *UserIdentitySelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *UserIdentitySelect) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) + if err := _s.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*UserIdentityQuery, *UserIdentitySelect](ctx, _s.UserIdentityQuery, _s, _s.inters, v) +} + +func (_s *UserIdentitySelect) sqlScan(ctx context.Context, root *UserIdentityQuery, v any) error { + selector := root.sqlQuery(ctx) + aggregation := make([]string, 0, len(_s.fns)) + for _, fn := range _s.fns { + aggregation = append(aggregation, fn(selector)) + } + switch n := len(*_s.selector.flds); { + case n == 0 && len(aggregation) > 0: + selector.Select(aggregation...) + case n != 0 && len(aggregation) > 0: + selector.AppendSelect(aggregation...) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _s.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} diff --git a/storage/ent/db/useridentity_update.go b/storage/ent/db/useridentity_update.go new file mode 100644 index 00000000..27ee0d3a --- /dev/null +++ b/storage/ent/db/useridentity_update.go @@ -0,0 +1,629 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/dialect/sql/sqljson" + "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage/ent/db/predicate" + "github.com/dexidp/dex/storage/ent/db/useridentity" +) + +// UserIdentityUpdate is the builder for updating UserIdentity entities. +type UserIdentityUpdate struct { + config + hooks []Hook + mutation *UserIdentityMutation +} + +// Where appends a list predicates to the UserIdentityUpdate builder. +func (_u *UserIdentityUpdate) Where(ps ...predicate.UserIdentity) *UserIdentityUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *UserIdentityUpdate) SetUserID(v string) *UserIdentityUpdate { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableUserID(v *string) *UserIdentityUpdate { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetConnectorID sets the "connector_id" field. +func (_u *UserIdentityUpdate) SetConnectorID(v string) *UserIdentityUpdate { + _u.mutation.SetConnectorID(v) + return _u +} + +// SetNillableConnectorID sets the "connector_id" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableConnectorID(v *string) *UserIdentityUpdate { + if v != nil { + _u.SetConnectorID(*v) + } + return _u +} + +// SetClaimsUserID sets the "claims_user_id" field. +func (_u *UserIdentityUpdate) SetClaimsUserID(v string) *UserIdentityUpdate { + _u.mutation.SetClaimsUserID(v) + return _u +} + +// SetNillableClaimsUserID sets the "claims_user_id" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableClaimsUserID(v *string) *UserIdentityUpdate { + if v != nil { + _u.SetClaimsUserID(*v) + } + return _u +} + +// SetClaimsUsername sets the "claims_username" field. +func (_u *UserIdentityUpdate) SetClaimsUsername(v string) *UserIdentityUpdate { + _u.mutation.SetClaimsUsername(v) + return _u +} + +// SetNillableClaimsUsername sets the "claims_username" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableClaimsUsername(v *string) *UserIdentityUpdate { + if v != nil { + _u.SetClaimsUsername(*v) + } + return _u +} + +// SetClaimsPreferredUsername sets the "claims_preferred_username" field. +func (_u *UserIdentityUpdate) SetClaimsPreferredUsername(v string) *UserIdentityUpdate { + _u.mutation.SetClaimsPreferredUsername(v) + return _u +} + +// SetNillableClaimsPreferredUsername sets the "claims_preferred_username" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableClaimsPreferredUsername(v *string) *UserIdentityUpdate { + if v != nil { + _u.SetClaimsPreferredUsername(*v) + } + return _u +} + +// SetClaimsEmail sets the "claims_email" field. +func (_u *UserIdentityUpdate) SetClaimsEmail(v string) *UserIdentityUpdate { + _u.mutation.SetClaimsEmail(v) + return _u +} + +// SetNillableClaimsEmail sets the "claims_email" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableClaimsEmail(v *string) *UserIdentityUpdate { + if v != nil { + _u.SetClaimsEmail(*v) + } + return _u +} + +// SetClaimsEmailVerified sets the "claims_email_verified" field. +func (_u *UserIdentityUpdate) SetClaimsEmailVerified(v bool) *UserIdentityUpdate { + _u.mutation.SetClaimsEmailVerified(v) + return _u +} + +// SetNillableClaimsEmailVerified sets the "claims_email_verified" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableClaimsEmailVerified(v *bool) *UserIdentityUpdate { + if v != nil { + _u.SetClaimsEmailVerified(*v) + } + return _u +} + +// SetClaimsGroups sets the "claims_groups" field. +func (_u *UserIdentityUpdate) SetClaimsGroups(v []string) *UserIdentityUpdate { + _u.mutation.SetClaimsGroups(v) + return _u +} + +// AppendClaimsGroups appends value to the "claims_groups" field. +func (_u *UserIdentityUpdate) AppendClaimsGroups(v []string) *UserIdentityUpdate { + _u.mutation.AppendClaimsGroups(v) + return _u +} + +// ClearClaimsGroups clears the value of the "claims_groups" field. +func (_u *UserIdentityUpdate) ClearClaimsGroups() *UserIdentityUpdate { + _u.mutation.ClearClaimsGroups() + return _u +} + +// SetConsents sets the "consents" field. +func (_u *UserIdentityUpdate) SetConsents(v []byte) *UserIdentityUpdate { + _u.mutation.SetConsents(v) + return _u +} + +// SetCreatedAt sets the "created_at" field. +func (_u *UserIdentityUpdate) SetCreatedAt(v time.Time) *UserIdentityUpdate { + _u.mutation.SetCreatedAt(v) + return _u +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableCreatedAt(v *time.Time) *UserIdentityUpdate { + if v != nil { + _u.SetCreatedAt(*v) + } + return _u +} + +// SetLastLogin sets the "last_login" field. +func (_u *UserIdentityUpdate) SetLastLogin(v time.Time) *UserIdentityUpdate { + _u.mutation.SetLastLogin(v) + return _u +} + +// SetNillableLastLogin sets the "last_login" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableLastLogin(v *time.Time) *UserIdentityUpdate { + if v != nil { + _u.SetLastLogin(*v) + } + return _u +} + +// SetBlockedUntil sets the "blocked_until" field. +func (_u *UserIdentityUpdate) SetBlockedUntil(v time.Time) *UserIdentityUpdate { + _u.mutation.SetBlockedUntil(v) + return _u +} + +// SetNillableBlockedUntil sets the "blocked_until" field if the given value is not nil. +func (_u *UserIdentityUpdate) SetNillableBlockedUntil(v *time.Time) *UserIdentityUpdate { + if v != nil { + _u.SetBlockedUntil(*v) + } + return _u +} + +// Mutation returns the UserIdentityMutation object of the builder. +func (_u *UserIdentityUpdate) Mutation() *UserIdentityMutation { + return _u.mutation +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *UserIdentityUpdate) Save(ctx context.Context) (int, error) { + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *UserIdentityUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *UserIdentityUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *UserIdentityUpdate) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *UserIdentityUpdate) check() error { + if v, ok := _u.mutation.UserID(); ok { + if err := useridentity.UserIDValidator(v); err != nil { + return &ValidationError{Name: "user_id", err: fmt.Errorf(`db: validator failed for field "UserIdentity.user_id": %w`, err)} + } + } + if v, ok := _u.mutation.ConnectorID(); ok { + if err := useridentity.ConnectorIDValidator(v); err != nil { + return &ValidationError{Name: "connector_id", err: fmt.Errorf(`db: validator failed for field "UserIdentity.connector_id": %w`, err)} + } + } + return nil +} + +func (_u *UserIdentityUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(useridentity.Table, useridentity.Columns, sqlgraph.NewFieldSpec(useridentity.FieldID, field.TypeString)) + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.UserID(); ok { + _spec.SetField(useridentity.FieldUserID, field.TypeString, value) + } + if value, ok := _u.mutation.ConnectorID(); ok { + _spec.SetField(useridentity.FieldConnectorID, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsUserID(); ok { + _spec.SetField(useridentity.FieldClaimsUserID, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsUsername(); ok { + _spec.SetField(useridentity.FieldClaimsUsername, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsPreferredUsername(); ok { + _spec.SetField(useridentity.FieldClaimsPreferredUsername, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsEmail(); ok { + _spec.SetField(useridentity.FieldClaimsEmail, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsEmailVerified(); ok { + _spec.SetField(useridentity.FieldClaimsEmailVerified, field.TypeBool, value) + } + if value, ok := _u.mutation.ClaimsGroups(); ok { + _spec.SetField(useridentity.FieldClaimsGroups, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedClaimsGroups(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, useridentity.FieldClaimsGroups, value) + }) + } + if _u.mutation.ClaimsGroupsCleared() { + _spec.ClearField(useridentity.FieldClaimsGroups, field.TypeJSON) + } + if value, ok := _u.mutation.Consents(); ok { + _spec.SetField(useridentity.FieldConsents, field.TypeBytes, value) + } + if value, ok := _u.mutation.CreatedAt(); ok { + _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.LastLogin(); ok { + _spec.SetField(useridentity.FieldLastLogin, field.TypeTime, value) + } + if value, ok := _u.mutation.BlockedUntil(); ok { + _spec.SetField(useridentity.FieldBlockedUntil, field.TypeTime, value) + } + if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{useridentity.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// UserIdentityUpdateOne is the builder for updating a single UserIdentity entity. +type UserIdentityUpdateOne struct { + config + fields []string + hooks []Hook + mutation *UserIdentityMutation +} + +// SetUserID sets the "user_id" field. +func (_u *UserIdentityUpdateOne) SetUserID(v string) *UserIdentityUpdateOne { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableUserID(v *string) *UserIdentityUpdateOne { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetConnectorID sets the "connector_id" field. +func (_u *UserIdentityUpdateOne) SetConnectorID(v string) *UserIdentityUpdateOne { + _u.mutation.SetConnectorID(v) + return _u +} + +// SetNillableConnectorID sets the "connector_id" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableConnectorID(v *string) *UserIdentityUpdateOne { + if v != nil { + _u.SetConnectorID(*v) + } + return _u +} + +// SetClaimsUserID sets the "claims_user_id" field. +func (_u *UserIdentityUpdateOne) SetClaimsUserID(v string) *UserIdentityUpdateOne { + _u.mutation.SetClaimsUserID(v) + return _u +} + +// SetNillableClaimsUserID sets the "claims_user_id" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableClaimsUserID(v *string) *UserIdentityUpdateOne { + if v != nil { + _u.SetClaimsUserID(*v) + } + return _u +} + +// SetClaimsUsername sets the "claims_username" field. +func (_u *UserIdentityUpdateOne) SetClaimsUsername(v string) *UserIdentityUpdateOne { + _u.mutation.SetClaimsUsername(v) + return _u +} + +// SetNillableClaimsUsername sets the "claims_username" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableClaimsUsername(v *string) *UserIdentityUpdateOne { + if v != nil { + _u.SetClaimsUsername(*v) + } + return _u +} + +// SetClaimsPreferredUsername sets the "claims_preferred_username" field. +func (_u *UserIdentityUpdateOne) SetClaimsPreferredUsername(v string) *UserIdentityUpdateOne { + _u.mutation.SetClaimsPreferredUsername(v) + return _u +} + +// SetNillableClaimsPreferredUsername sets the "claims_preferred_username" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableClaimsPreferredUsername(v *string) *UserIdentityUpdateOne { + if v != nil { + _u.SetClaimsPreferredUsername(*v) + } + return _u +} + +// SetClaimsEmail sets the "claims_email" field. +func (_u *UserIdentityUpdateOne) SetClaimsEmail(v string) *UserIdentityUpdateOne { + _u.mutation.SetClaimsEmail(v) + return _u +} + +// SetNillableClaimsEmail sets the "claims_email" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableClaimsEmail(v *string) *UserIdentityUpdateOne { + if v != nil { + _u.SetClaimsEmail(*v) + } + return _u +} + +// SetClaimsEmailVerified sets the "claims_email_verified" field. +func (_u *UserIdentityUpdateOne) SetClaimsEmailVerified(v bool) *UserIdentityUpdateOne { + _u.mutation.SetClaimsEmailVerified(v) + return _u +} + +// SetNillableClaimsEmailVerified sets the "claims_email_verified" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableClaimsEmailVerified(v *bool) *UserIdentityUpdateOne { + if v != nil { + _u.SetClaimsEmailVerified(*v) + } + return _u +} + +// SetClaimsGroups sets the "claims_groups" field. +func (_u *UserIdentityUpdateOne) SetClaimsGroups(v []string) *UserIdentityUpdateOne { + _u.mutation.SetClaimsGroups(v) + return _u +} + +// AppendClaimsGroups appends value to the "claims_groups" field. +func (_u *UserIdentityUpdateOne) AppendClaimsGroups(v []string) *UserIdentityUpdateOne { + _u.mutation.AppendClaimsGroups(v) + return _u +} + +// ClearClaimsGroups clears the value of the "claims_groups" field. +func (_u *UserIdentityUpdateOne) ClearClaimsGroups() *UserIdentityUpdateOne { + _u.mutation.ClearClaimsGroups() + return _u +} + +// SetConsents sets the "consents" field. +func (_u *UserIdentityUpdateOne) SetConsents(v []byte) *UserIdentityUpdateOne { + _u.mutation.SetConsents(v) + return _u +} + +// SetCreatedAt sets the "created_at" field. +func (_u *UserIdentityUpdateOne) SetCreatedAt(v time.Time) *UserIdentityUpdateOne { + _u.mutation.SetCreatedAt(v) + return _u +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableCreatedAt(v *time.Time) *UserIdentityUpdateOne { + if v != nil { + _u.SetCreatedAt(*v) + } + return _u +} + +// SetLastLogin sets the "last_login" field. +func (_u *UserIdentityUpdateOne) SetLastLogin(v time.Time) *UserIdentityUpdateOne { + _u.mutation.SetLastLogin(v) + return _u +} + +// SetNillableLastLogin sets the "last_login" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableLastLogin(v *time.Time) *UserIdentityUpdateOne { + if v != nil { + _u.SetLastLogin(*v) + } + return _u +} + +// SetBlockedUntil sets the "blocked_until" field. +func (_u *UserIdentityUpdateOne) SetBlockedUntil(v time.Time) *UserIdentityUpdateOne { + _u.mutation.SetBlockedUntil(v) + return _u +} + +// SetNillableBlockedUntil sets the "blocked_until" field if the given value is not nil. +func (_u *UserIdentityUpdateOne) SetNillableBlockedUntil(v *time.Time) *UserIdentityUpdateOne { + if v != nil { + _u.SetBlockedUntil(*v) + } + return _u +} + +// Mutation returns the UserIdentityMutation object of the builder. +func (_u *UserIdentityUpdateOne) Mutation() *UserIdentityMutation { + return _u.mutation +} + +// Where appends a list predicates to the UserIdentityUpdate builder. +func (_u *UserIdentityUpdateOne) Where(ps ...predicate.UserIdentity) *UserIdentityUpdateOne { + _u.mutation.Where(ps...) + return _u +} + +// Select allows selecting one or more fields (columns) of the returned entity. +// The default is selecting all fields defined in the entity schema. +func (_u *UserIdentityUpdateOne) Select(field string, fields ...string) *UserIdentityUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated UserIdentity entity. +func (_u *UserIdentityUpdateOne) Save(ctx context.Context) (*UserIdentity, error) { + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *UserIdentityUpdateOne) SaveX(ctx context.Context) *UserIdentity { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *UserIdentityUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *UserIdentityUpdateOne) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *UserIdentityUpdateOne) check() error { + if v, ok := _u.mutation.UserID(); ok { + if err := useridentity.UserIDValidator(v); err != nil { + return &ValidationError{Name: "user_id", err: fmt.Errorf(`db: validator failed for field "UserIdentity.user_id": %w`, err)} + } + } + if v, ok := _u.mutation.ConnectorID(); ok { + if err := useridentity.ConnectorIDValidator(v); err != nil { + return &ValidationError{Name: "connector_id", err: fmt.Errorf(`db: validator failed for field "UserIdentity.connector_id": %w`, err)} + } + } + return nil +} + +func (_u *UserIdentityUpdateOne) sqlSave(ctx context.Context) (_node *UserIdentity, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(useridentity.Table, useridentity.Columns, sqlgraph.NewFieldSpec(useridentity.FieldID, field.TypeString)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`db: missing "UserIdentity.id" for update`)} + } + _spec.Node.ID.Value = id + if fields := _u.fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, useridentity.FieldID) + for _, f := range fields { + if !useridentity.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("db: invalid field %q for query", f)} + } + if f != useridentity.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, f) + } + } + } + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.UserID(); ok { + _spec.SetField(useridentity.FieldUserID, field.TypeString, value) + } + if value, ok := _u.mutation.ConnectorID(); ok { + _spec.SetField(useridentity.FieldConnectorID, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsUserID(); ok { + _spec.SetField(useridentity.FieldClaimsUserID, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsUsername(); ok { + _spec.SetField(useridentity.FieldClaimsUsername, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsPreferredUsername(); ok { + _spec.SetField(useridentity.FieldClaimsPreferredUsername, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsEmail(); ok { + _spec.SetField(useridentity.FieldClaimsEmail, field.TypeString, value) + } + if value, ok := _u.mutation.ClaimsEmailVerified(); ok { + _spec.SetField(useridentity.FieldClaimsEmailVerified, field.TypeBool, value) + } + if value, ok := _u.mutation.ClaimsGroups(); ok { + _spec.SetField(useridentity.FieldClaimsGroups, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedClaimsGroups(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, useridentity.FieldClaimsGroups, value) + }) + } + if _u.mutation.ClaimsGroupsCleared() { + _spec.ClearField(useridentity.FieldClaimsGroups, field.TypeJSON) + } + if value, ok := _u.mutation.Consents(); ok { + _spec.SetField(useridentity.FieldConsents, field.TypeBytes, value) + } + if value, ok := _u.mutation.CreatedAt(); ok { + _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.LastLogin(); ok { + _spec.SetField(useridentity.FieldLastLogin, field.TypeTime, value) + } + if value, ok := _u.mutation.BlockedUntil(); ok { + _spec.SetField(useridentity.FieldBlockedUntil, field.TypeTime, value) + } + _node = &UserIdentity{config: _u.config} + _spec.Assign = _node.assignValues + _spec.ScanValues = _node.scanValues + if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{useridentity.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + _u.mutation.done = true + return _node, nil +} diff --git a/storage/ent/schema/useridentity.go b/storage/ent/schema/useridentity.go new file mode 100644 index 00000000..a4928240 --- /dev/null +++ b/storage/ent/schema/useridentity.go @@ -0,0 +1,56 @@ +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema/field" +) + +// UserIdentity holds the schema definition for the UserIdentity entity. +type UserIdentity struct { + ent.Schema +} + +// Fields of the UserIdentity. +func (UserIdentity) Fields() []ent.Field { + return []ent.Field{ + // Using id field here because it's impossible to create multi-key primary yet + field.Text("id"). + SchemaType(textSchema). + NotEmpty(). + Unique(), + field.Text("user_id"). + SchemaType(textSchema). + NotEmpty(), + field.Text("connector_id"). + SchemaType(textSchema). + NotEmpty(), + field.Text("claims_user_id"). + SchemaType(textSchema). + Default(""), + field.Text("claims_username"). + SchemaType(textSchema). + Default(""), + field.Text("claims_preferred_username"). + SchemaType(textSchema). + Default(""), + field.Text("claims_email"). + SchemaType(textSchema). + Default(""), + field.Bool("claims_email_verified"). + Default(false), + field.JSON("claims_groups", []string{}). + Optional(), + field.Bytes("consents"), + field.Time("created_at"). + SchemaType(timeSchema), + field.Time("last_login"). + SchemaType(timeSchema), + field.Time("blocked_until"). + SchemaType(timeSchema), + } +} + +// Edges of the UserIdentity. +func (UserIdentity) Edges() []ent.Edge { + return []ent.Edge{} +} diff --git a/storage/etcd/etcd.go b/storage/etcd/etcd.go index 8ccf502f..d2248ea2 100644 --- a/storage/etcd/etcd.go +++ b/storage/etcd/etcd.go @@ -24,6 +24,7 @@ const ( keysName = "openid-connect-keys" deviceRequestPrefix = "device_req/" deviceTokenPrefix = "device_token/" + userIdentityPrefix = "user_identity/" // defaultStorageTimeout will be applied to all storage's operations. defaultStorageTimeout = 5 * time.Second @@ -366,6 +367,61 @@ func (c *conn) DeleteOfflineSessions(ctx context.Context, userID string, connID return c.deleteKey(ctx, keySession(userID, connID)) } +func (c *conn) CreateUserIdentity(ctx context.Context, u storage.UserIdentity) error { + return c.txnCreate(ctx, keyUserIdentity(u.UserID, u.ConnectorID), fromStorageUserIdentity(u)) +} + +func (c *conn) GetUserIdentity(ctx context.Context, userID, connectorID string) (u storage.UserIdentity, err error) { + ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) + defer cancel() + var ui UserIdentity + if err = c.getKey(ctx, keyUserIdentity(userID, connectorID), &ui); err != nil { + return + } + return toStorageUserIdentity(ui), nil +} + +func (c *conn) UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u storage.UserIdentity) (storage.UserIdentity, error)) error { + ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) + defer cancel() + return c.txnUpdate(ctx, keyUserIdentity(userID, connectorID), func(currentValue []byte) ([]byte, error) { + var current UserIdentity + if len(currentValue) > 0 { + if err := json.Unmarshal(currentValue, ¤t); err != nil { + return nil, err + } + } + updated, err := updater(toStorageUserIdentity(current)) + if err != nil { + return nil, err + } + return json.Marshal(fromStorageUserIdentity(updated)) + }) +} + +func (c *conn) DeleteUserIdentity(ctx context.Context, userID, connectorID string) error { + ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) + defer cancel() + return c.deleteKey(ctx, keyUserIdentity(userID, connectorID)) +} + +func (c *conn) ListUserIdentities(ctx context.Context) (identities []storage.UserIdentity, err error) { + ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) + defer cancel() + res, err := c.db.Get(ctx, userIdentityPrefix, clientv3.WithPrefix()) + if err != nil { + return identities, err + } + for _, v := range res.Kvs { + var ui UserIdentity + if err = json.Unmarshal(v.Value, &ui); err != nil { + return identities, err + } + identities = append(identities, toStorageUserIdentity(ui)) + } + return identities, nil +} + func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) error { return c.txnCreate(ctx, keyID(connectorPrefix, connector.ID), connector) } @@ -557,6 +613,10 @@ func keySession(userID, connID string) string { return offlineSessionPrefix + strings.ToLower(userID+"|"+connID) } +func keyUserIdentity(userID, connectorID string) string { + return userIdentityPrefix + strings.ToLower(userID+"|"+connectorID) +} + func (c *conn) CreateDeviceRequest(ctx context.Context, d storage.DeviceRequest) error { return c.txnCreate(ctx, keyID(deviceRequestPrefix, d.UserCode), fromStorageDeviceRequest(d)) } diff --git a/storage/etcd/types.go b/storage/etcd/types.go index b3756604..ea4d216c 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -256,6 +256,46 @@ func toStorageOfflineSessions(o OfflineSessions) storage.OfflineSessions { return s } +// UserIdentity is a mirrored struct from storage with JSON struct tags +type UserIdentity struct { + UserID string `json:"user_id,omitempty"` + ConnectorID string `json:"connector_id,omitempty"` + Claims Claims `json:"claims,omitempty"` + Consents map[string][]string `json:"consents,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastLogin time.Time `json:"last_login"` + BlockedUntil time.Time `json:"blocked_until"` +} + +func fromStorageUserIdentity(u storage.UserIdentity) UserIdentity { + return UserIdentity{ + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: fromStorageClaims(u.Claims), + Consents: u.Consents, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, + } +} + +func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { + s := storage.UserIdentity{ + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: toStorageClaims(u.Claims), + Consents: u.Consents, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, + } + if s.Consents == nil { + // Server code assumes this will be non-nil. + s.Consents = make(map[string][]string) + } + return s +} + // DeviceRequest is a mirrored struct from storage with JSON struct tags type DeviceRequest struct { UserCode string `json:"user_code"` diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go index 028ced7e..fd756845 100644 --- a/storage/kubernetes/storage.go +++ b/storage/kubernetes/storage.go @@ -25,6 +25,7 @@ const ( kindConnector = "Connector" kindDeviceRequest = "DeviceRequest" kindDeviceToken = "DeviceToken" + kindUserIdentity = "UserIdentity" ) const ( @@ -38,6 +39,7 @@ const ( resourceConnector = "connectors" resourceDeviceRequest = "devicerequests" resourceDeviceToken = "devicetokens" + resourceUserIdentity = "useridentities" ) const ( @@ -743,6 +745,70 @@ func (cli *client) UpdateDeviceToken(ctx context.Context, deviceCode string, upd }) } +func (cli *client) CreateUserIdentity(ctx context.Context, u storage.UserIdentity) error { + return cli.post(resourceUserIdentity, cli.fromStorageUserIdentity(u)) +} + +func (cli *client) GetUserIdentity(ctx context.Context, userID, connectorID string) (storage.UserIdentity, error) { + u, err := cli.getUserIdentity(userID, connectorID) + if err != nil { + return storage.UserIdentity{}, err + } + return toStorageUserIdentity(u), nil +} + +func (cli *client) getUserIdentity(userID, connectorID string) (u UserIdentity, err error) { + name := cli.offlineTokenName(userID, connectorID) + if err = cli.get(resourceUserIdentity, name, &u); err != nil { + return UserIdentity{}, err + } + if userID != u.UserID || connectorID != u.ConnectorID { + return UserIdentity{}, fmt.Errorf("get user identity: wrong identity retrieved") + } + return u, nil +} + +func (cli *client) UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(old storage.UserIdentity) (storage.UserIdentity, error)) error { + return retryOnConflict(ctx, func() error { + u, err := cli.getUserIdentity(userID, connectorID) + if err != nil { + return err + } + + updated, err := updater(toStorageUserIdentity(u)) + if err != nil { + return err + } + + newUserIdentity := cli.fromStorageUserIdentity(updated) + newUserIdentity.ObjectMeta = u.ObjectMeta + return cli.put(resourceUserIdentity, u.ObjectMeta.Name, newUserIdentity) + }) +} + +func (cli *client) DeleteUserIdentity(ctx context.Context, userID, connectorID string) error { + // Check for hash collision. + u, err := cli.getUserIdentity(userID, connectorID) + if err != nil { + return err + } + return cli.delete(resourceUserIdentity, u.ObjectMeta.Name) +} + +func (cli *client) ListUserIdentities(ctx context.Context) ([]storage.UserIdentity, error) { + var userIdentityList UserIdentityList + if err := cli.list(resourceUserIdentity, &userIdentityList); err != nil { + return nil, fmt.Errorf("failed to list user identities: %v", err) + } + + userIdentities := make([]storage.UserIdentity, len(userIdentityList.UserIdentities)) + for i, u := range userIdentityList.UserIdentities { + userIdentities[i] = toStorageUserIdentity(u) + } + + return userIdentities, nil +} + func isKubernetesAPIConflictError(err error) bool { if httpErr, ok := err.(httpError); ok { if httpErr.StatusCode() == http.StatusConflict { diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index abcd907e..44ab4943 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -226,6 +226,23 @@ func customResourceDefinitions(apiVersion string) []k8sapi.CustomResourceDefinit }, }, }, + { + ObjectMeta: k8sapi.ObjectMeta{ + Name: "useridentities.dex.coreos.com", + }, + TypeMeta: crdMeta, + Spec: k8sapi.CustomResourceDefinitionSpec{ + Group: apiGroup, + Version: version, + Versions: versions, + Scope: scope, + Names: k8sapi.CustomResourceDefinitionNames{ + Plural: "useridentities", + Singular: "useridentity", + Kind: "UserIdentity", + }, + }, + }, } } @@ -872,3 +889,62 @@ func toStorageDeviceToken(t DeviceToken) storage.DeviceToken { }, } } + +// UserIdentity is a mirrored struct from storage with JSON struct tags and Kubernetes +// type metadata. +type UserIdentity struct { + k8sapi.TypeMeta `json:",inline"` + k8sapi.ObjectMeta `json:"metadata,omitempty"` + + UserID string `json:"userID,omitempty"` + ConnectorID string `json:"connectorID,omitempty"` + Claims Claims `json:"claims,omitempty"` + Consents map[string][]string `json:"consents,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + LastLogin time.Time `json:"lastLogin,omitempty"` + BlockedUntil time.Time `json:"blockedUntil,omitempty"` +} + +// UserIdentityList is a list of UserIdentities. +type UserIdentityList struct { + k8sapi.TypeMeta `json:",inline"` + k8sapi.ListMeta `json:"metadata,omitempty"` + UserIdentities []UserIdentity `json:"items"` +} + +func (cli *client) fromStorageUserIdentity(u storage.UserIdentity) UserIdentity { + return UserIdentity{ + TypeMeta: k8sapi.TypeMeta{ + Kind: kindUserIdentity, + APIVersion: cli.apiVersion, + }, + ObjectMeta: k8sapi.ObjectMeta{ + Name: cli.offlineTokenName(u.UserID, u.ConnectorID), + Namespace: cli.namespace, + }, + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: fromStorageClaims(u.Claims), + Consents: u.Consents, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, + } +} + +func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { + s := storage.UserIdentity{ + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: toStorageClaims(u.Claims), + Consents: u.Consents, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, + } + if s.Consents == nil { + // Server code assumes this will be non-nil. + s.Consents = make(map[string][]string) + } + return s +} diff --git a/storage/memory/memory.go b/storage/memory/memory.go index eff75e71..ecf3a410 100644 --- a/storage/memory/memory.go +++ b/storage/memory/memory.go @@ -21,7 +21,8 @@ func New(logger *slog.Logger) storage.Storage { refreshTokens: make(map[string]storage.RefreshToken), authReqs: make(map[string]storage.AuthRequest), passwords: make(map[string]storage.Password), - offlineSessions: make(map[offlineSessionID]storage.OfflineSessions), + offlineSessions: make(map[compositeKeyID]storage.OfflineSessions), + userIdentities: make(map[compositeKeyID]storage.UserIdentity), connectors: make(map[string]storage.Connector), deviceRequests: make(map[string]storage.DeviceRequest), deviceTokens: make(map[string]storage.DeviceToken), @@ -48,7 +49,8 @@ type memStorage struct { refreshTokens map[string]storage.RefreshToken authReqs map[string]storage.AuthRequest passwords map[string]storage.Password - offlineSessions map[offlineSessionID]storage.OfflineSessions + offlineSessions map[compositeKeyID]storage.OfflineSessions + userIdentities map[compositeKeyID]storage.UserIdentity connectors map[string]storage.Connector deviceRequests map[string]storage.DeviceRequest deviceTokens map[string]storage.DeviceToken @@ -58,7 +60,7 @@ type memStorage struct { logger *slog.Logger } -type offlineSessionID struct { +type compositeKeyID struct { userID string connID string } @@ -158,7 +160,7 @@ func (s *memStorage) CreatePassword(ctx context.Context, p storage.Password) (er } func (s *memStorage) CreateOfflineSessions(ctx context.Context, o storage.OfflineSessions) (err error) { - id := offlineSessionID{ + id := compositeKeyID{ userID: o.UserID, connID: o.ConnID, } @@ -172,6 +174,78 @@ func (s *memStorage) CreateOfflineSessions(ctx context.Context, o storage.Offlin return } +func (s *memStorage) CreateUserIdentity(ctx context.Context, u storage.UserIdentity) (err error) { + id := compositeKeyID{ + userID: u.UserID, + connID: u.ConnectorID, + } + s.tx(func() { + if _, ok := s.userIdentities[id]; ok { + err = storage.ErrAlreadyExists + } else { + s.userIdentities[id] = u + } + }) + return +} + +func (s *memStorage) GetUserIdentity(ctx context.Context, userID, connectorID string) (u storage.UserIdentity, err error) { + id := compositeKeyID{ + userID: userID, + connID: connectorID, + } + s.tx(func() { + var ok bool + if u, ok = s.userIdentities[id]; !ok { + err = storage.ErrNotFound + return + } + }) + return +} + +func (s *memStorage) UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u storage.UserIdentity) (storage.UserIdentity, error)) (err error) { + id := compositeKeyID{ + userID: userID, + connID: connectorID, + } + s.tx(func() { + r, ok := s.userIdentities[id] + if !ok { + err = storage.ErrNotFound + return + } + if r, err = updater(r); err == nil { + s.userIdentities[id] = r + } + }) + return +} + +func (s *memStorage) DeleteUserIdentity(ctx context.Context, userID, connectorID string) (err error) { + id := compositeKeyID{ + userID: userID, + connID: connectorID, + } + s.tx(func() { + if _, ok := s.userIdentities[id]; !ok { + err = storage.ErrNotFound + return + } + delete(s.userIdentities, id) + }) + return +} + +func (s *memStorage) ListUserIdentities(ctx context.Context) (identities []storage.UserIdentity, err error) { + s.tx(func() { + for _, u := range s.userIdentities { + identities = append(identities, u) + } + }) + return +} + func (s *memStorage) CreateConnector(ctx context.Context, connector storage.Connector) (err error) { s.tx(func() { if _, ok := s.connectors[connector.ID]; ok { @@ -243,7 +317,7 @@ func (s *memStorage) GetAuthRequest(ctx context.Context, id string) (req storage } func (s *memStorage) GetOfflineSessions(ctx context.Context, userID string, connID string) (o storage.OfflineSessions, err error) { - id := offlineSessionID{ + id := compositeKeyID{ userID: userID, connID: connID, } @@ -360,7 +434,7 @@ func (s *memStorage) DeleteAuthRequest(ctx context.Context, id string) (err erro } func (s *memStorage) DeleteOfflineSessions(ctx context.Context, userID string, connID string) (err error) { - id := offlineSessionID{ + id := compositeKeyID{ userID: userID, connID: connID, } @@ -453,7 +527,7 @@ func (s *memStorage) UpdateRefreshToken(ctx context.Context, id string, updater } func (s *memStorage) UpdateOfflineSessions(ctx context.Context, userID string, connID string, updater func(o storage.OfflineSessions) (storage.OfflineSessions, error)) (err error) { - id := offlineSessionID{ + id := compositeKeyID{ userID: userID, connID: connID, } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 33e69fc0..04c9be3b 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -775,6 +775,157 @@ func scanOfflineSessions(s scanner) (o storage.OfflineSessions, err error) { return o, nil } +func (c *conn) CreateUserIdentity(ctx context.Context, u storage.UserIdentity) error { + _, err := c.Exec(` + insert into user_identity ( + user_id, connector_id, + claims_user_id, claims_username, claims_preferred_username, + claims_email, claims_email_verified, claims_groups, + consents, + created_at, last_login, blocked_until + ) + values ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 + ); + `, + u.UserID, u.ConnectorID, + u.Claims.UserID, u.Claims.Username, u.Claims.PreferredUsername, + u.Claims.Email, u.Claims.EmailVerified, encoder(u.Claims.Groups), + encoder(u.Consents), + u.CreatedAt, u.LastLogin, u.BlockedUntil, + ) + if err != nil { + if c.alreadyExistsCheck(err) { + return storage.ErrAlreadyExists + } + return fmt.Errorf("insert user identity: %v", err) + } + return nil +} + +func (c *conn) UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u storage.UserIdentity) (storage.UserIdentity, error)) error { + return c.ExecTx(func(tx *trans) error { + u, err := getUserIdentity(ctx, tx, userID, connectorID) + if err != nil { + return err + } + + newIdentity, err := updater(u) + if err != nil { + return err + } + _, err = tx.Exec(` + update user_identity + set + claims_user_id = $1, + claims_username = $2, + claims_preferred_username = $3, + claims_email = $4, + claims_email_verified = $5, + claims_groups = $6, + consents = $7, + created_at = $8, + last_login = $9, + blocked_until = $10 + where user_id = $11 AND connector_id = $12; + `, + newIdentity.Claims.UserID, newIdentity.Claims.Username, newIdentity.Claims.PreferredUsername, + newIdentity.Claims.Email, newIdentity.Claims.EmailVerified, encoder(newIdentity.Claims.Groups), + encoder(newIdentity.Consents), + newIdentity.CreatedAt, newIdentity.LastLogin, newIdentity.BlockedUntil, + u.UserID, u.ConnectorID, + ) + if err != nil { + return fmt.Errorf("update user identity: %v", err) + } + return nil + }) +} + +func (c *conn) GetUserIdentity(ctx context.Context, userID, connectorID string) (storage.UserIdentity, error) { + return getUserIdentity(ctx, c, userID, connectorID) +} + +func getUserIdentity(ctx context.Context, q querier, userID, connectorID string) (storage.UserIdentity, error) { + return scanUserIdentity(q.QueryRow(` + select + user_id, connector_id, + claims_user_id, claims_username, claims_preferred_username, + claims_email, claims_email_verified, claims_groups, + consents, + created_at, last_login, blocked_until + from user_identity + where user_id = $1 AND connector_id = $2; + `, userID, connectorID)) +} + +func (c *conn) ListUserIdentities(ctx context.Context) ([]storage.UserIdentity, error) { + rows, err := c.Query(` + select + user_id, connector_id, + claims_user_id, claims_username, claims_preferred_username, + claims_email, claims_email_verified, claims_groups, + consents, + created_at, last_login, blocked_until + from user_identity; + `) + if err != nil { + return nil, fmt.Errorf("query: %v", err) + } + defer rows.Close() + + var identities []storage.UserIdentity + for rows.Next() { + u, err := scanUserIdentity(rows) + if err != nil { + return nil, err + } + identities = append(identities, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan: %v", err) + } + return identities, nil +} + +func scanUserIdentity(s scanner) (u storage.UserIdentity, err error) { + err = s.Scan( + &u.UserID, &u.ConnectorID, + &u.Claims.UserID, &u.Claims.Username, &u.Claims.PreferredUsername, + &u.Claims.Email, &u.Claims.EmailVerified, decoder(&u.Claims.Groups), + decoder(&u.Consents), + &u.CreatedAt, &u.LastLogin, &u.BlockedUntil, + ) + if err != nil { + if err == sql.ErrNoRows { + return u, storage.ErrNotFound + } + return u, fmt.Errorf("select user identity: %v", err) + } + if u.Consents == nil { + u.Consents = make(map[string][]string) + } + return u, nil +} + +func (c *conn) DeleteUserIdentity(ctx context.Context, userID, connectorID string) error { + result, err := c.Exec(`delete from user_identity where user_id = $1 AND connector_id = $2`, userID, connectorID) + if err != nil { + return fmt.Errorf("delete user_identity: user_id = %s, connector_id = %s: %w", userID, connectorID, err) + } + + // For now mandate that the driver implements RowsAffected. If we ever need to support + // a driver that doesn't implement this, we can run this in a transaction with a get beforehand. + n, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %v", err) + } + if n < 1 { + return storage.ErrNotFound + } + return nil +} + func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) error { grantTypes, err := json.Marshal(connector.GrantTypes) if err != nil { diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 440acfd5..053e9e6a 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -389,4 +389,24 @@ var migrations = []migration{ add column allowed_connectors bytea;`, }, }, + { + stmts: []string{ + ` + create table user_identity ( + user_id text not null, + connector_id text not null, + claims_user_id text not null, + claims_username text not null, + claims_preferred_username text not null default '', + claims_email text not null, + claims_email_verified boolean not null, + claims_groups bytea not null, + consents bytea not null, + created_at timestamptz not null, + last_login timestamptz not null, + blocked_until timestamptz not null, + PRIMARY KEY (user_id, connector_id) + );`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index e7bddef9..3e332a80 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -83,6 +83,7 @@ type Storage interface { CreateRefresh(ctx context.Context, r RefreshToken) error CreatePassword(ctx context.Context, p Password) error CreateOfflineSessions(ctx context.Context, s OfflineSessions) error + CreateUserIdentity(ctx context.Context, u UserIdentity) error CreateConnector(ctx context.Context, c Connector) error CreateDeviceRequest(ctx context.Context, d DeviceRequest) error CreateDeviceToken(ctx context.Context, d DeviceToken) error @@ -96,6 +97,7 @@ type Storage interface { GetRefresh(ctx context.Context, id string) (RefreshToken, error) GetPassword(ctx context.Context, email string) (Password, error) GetOfflineSessions(ctx context.Context, userID string, connID string) (OfflineSessions, error) + GetUserIdentity(ctx context.Context, userID, connectorID string) (UserIdentity, error) GetConnector(ctx context.Context, id string) (Connector, error) GetDeviceRequest(ctx context.Context, userCode string) (DeviceRequest, error) GetDeviceToken(ctx context.Context, deviceCode string) (DeviceToken, error) @@ -104,6 +106,7 @@ type Storage interface { ListRefreshTokens(ctx context.Context) ([]RefreshToken, error) ListPasswords(ctx context.Context) ([]Password, error) ListConnectors(ctx context.Context) ([]Connector, error) + ListUserIdentities(ctx context.Context) ([]UserIdentity, error) // Delete methods MUST be atomic. DeleteAuthRequest(ctx context.Context, id string) error @@ -112,6 +115,7 @@ type Storage interface { DeleteRefresh(ctx context.Context, id string) error DeletePassword(ctx context.Context, email string) error DeleteOfflineSessions(ctx context.Context, userID string, connID string) error + DeleteUserIdentity(ctx context.Context, userID, connectorID string) error DeleteConnector(ctx context.Context, id string) error // Update methods take a function for updating an object then performs that update within @@ -134,6 +138,7 @@ type Storage interface { UpdateRefreshToken(ctx context.Context, id string, updater func(r RefreshToken) (RefreshToken, error)) error UpdatePassword(ctx context.Context, email string, updater func(p Password) (Password, error)) error UpdateOfflineSessions(ctx context.Context, userID string, connID string, updater func(s OfflineSessions) (OfflineSessions, error)) error + UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u UserIdentity) (UserIdentity, error)) error UpdateConnector(ctx context.Context, id string, updater func(c Connector) (Connector, error)) error UpdateDeviceToken(ctx context.Context, deviceCode string, updater func(t DeviceToken) (DeviceToken, error)) error @@ -320,6 +325,17 @@ type RefreshTokenRef struct { LastUsed time.Time } +// UserIdentity represents persistent per-user identity data. +type UserIdentity struct { + UserID string + ConnectorID string + Claims Claims + Consents map[string][]string // clientID -> approved scopes + CreatedAt time.Time + LastLogin time.Time + BlockedUntil time.Time +} + // OfflineSessions objects are sessions pertaining to users with refresh tokens. type OfflineSessions struct { // UserID of an end user who has logged into the server. From 4fb3e7810b011a1d888bd66e06cf3120a6952ba0 Mon Sep 17 00:00:00 2001 From: Mark Liu Date: Mon, 16 Mar 2026 07:51:44 +1100 Subject: [PATCH 157/222] feat(logger): add excludeFields config for PII redaction (#4621) Adds an slog.Handler wrapper (excludingHandler) that drops log attributes matching a configured set of keys. This allows GDPR-sensitive deployments to suppress PII fields like email, username, preferred_username, or groups at the logger level rather than per-callsite. Also adds user_id to the "login successful" log line so operators who exclude PII fields still have a pseudonymous identifier. Closes #4391 --------- Signed-off-by: Mark Liu --- cmd/dex/config.go | 6 ++ cmd/dex/excluding_handler.go | 56 ++++++++++++ cmd/dex/excluding_handler_test.go | 141 ++++++++++++++++++++++++++++++ cmd/dex/logger.go | 4 +- cmd/dex/serve.go | 2 +- cmd/dex/serve_test.go | 6 +- config.yaml.dist | 2 + server/handlers.go | 5 +- 8 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 cmd/dex/excluding_handler.go create mode 100644 cmd/dex/excluding_handler_test.go diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 913d4dfe..8efc3c38 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -571,6 +571,12 @@ type Logger struct { // 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 { diff --git a/cmd/dex/excluding_handler.go b/cmd/dex/excluding_handler.go new file mode 100644 index 00000000..c5d03e44 --- /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 00000000..e0306d60 --- /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 index c1fe6b4a..fd4bd294 100644 --- a/cmd/dex/logger.go +++ b/cmd/dex/logger.go @@ -12,7 +12,7 @@ import ( var logFormats = []string{"json", "text"} -func newLogger(level slog.Level, format string) (*slog.Logger, error) { +func newLogger(level slog.Level, format string, excludeFields []string) (*slog.Logger, error) { var handler slog.Handler switch strings.ToLower(format) { case "", "text": @@ -27,6 +27,8 @@ func newLogger(level slog.Level, format string) (*slog.Logger, error) { 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 } diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index cd9d3839..5cc0877a 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -109,7 +109,7 @@ func runServe(options serveOptions) error { 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) } diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go index 9e214480..12d0c0ff 100644 --- a/cmd/dex/serve_test.go +++ b/cmd/dex/serve_test.go @@ -9,19 +9,19 @@ import ( func TestNewLogger(t *testing.T) { t.Run("JSON", func(t *testing.T) { - logger, err := newLogger(slog.LevelInfo, "json") + 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") + 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") + 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/config.yaml.dist b/config.yaml.dist index 5d2c37ea..917f8d1f 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -72,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. diff --git a/server/handlers.go b/server/handlers.go index e60715d9..815b4451 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -669,8 +669,9 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, } s.logger.InfoContext(ctx, "login successful", - "connector_id", authReq.ConnectorID, "username", claims.Username, - "preferred_username", claims.PreferredUsername, "email", email, "groups", claims.Groups) + "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 { From fe79863158c4da4f23685a8269d67c0cdadb3631 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:13:22 +0100 Subject: [PATCH 158/222] build(deps): bump mheap/github-action-required-labels (#4649) Bumps [mheap/github-action-required-labels](https://github.com/mheap/github-action-required-labels) from 5.5.1 to 5.5.2. - [Release notes](https://github.com/mheap/github-action-required-labels/releases) - [Commits](https://github.com/mheap/github-action-required-labels/compare/8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509...0ac283b4e65c1fb28ce6079dea5546ceca98ccbe) --- updated-dependencies: - dependency-name: mheap/github-action-required-labels dependency-version: 5.5.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/checks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index feb7cf38..4aec25e0 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -16,7 +16,7 @@ jobs: steps: - name: Check minimum labels - uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5 + uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5 with: mode: minimum count: 1 From 4433b362b12dd4893d3e94105eba24b862f7758f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:24:28 +0100 Subject: [PATCH 159/222] build(deps): bump distroless/static-debian13 from `f512d81` to `e3f9456` (#4648) Bumps distroless/static-debian13 from `f512d81` to `e3f9456`. --- updated-dependencies: - dependency-name: distroless/static-debian13 dependency-version: nonroot dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cae465b6..69f25eb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,7 @@ FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f 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:f512d819b8f109f2375e8b51d8cfd8aafe81034bc3e319740128b7d7f70d5036 AS distroless +FROM gcr.io/distroless/static-debian13:nonroot@sha256:e3f945647ffb95b5839c07038d64f9811adf17308b9121d8a2b87b6a22a80a39 AS distroless FROM $BASE_IMAGE From 93985dedffd7f0048d30182a8ed3e82846775258 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Mon, 16 Mar 2026 08:45:39 +0100 Subject: [PATCH 160/222] fix: increase lock acquisition attempts from 60 to 200 for better reliability (#4644) Signed-off-by: maksim.nabokikh --- storage/kubernetes/lock.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/storage/kubernetes/lock.go b/storage/kubernetes/lock.go index e3bdb444..092f6d0e 100644 --- a/storage/kubernetes/lock.go +++ b/storage/kubernetes/lock.go @@ -42,9 +42,9 @@ func newRefreshTokenLock(cli *client) *refreshTokenLock { } // Lock polls until the lock annotation can be set on the refresh token resource. -// Returns nil when the lock is acquired, or an error on timeout (60 attempts × 100ms). +// Returns nil when the lock is acquired, or an error on timeout (200 attempts × 100ms). func (l *refreshTokenLock) Lock(id string) error { - for i := 0; i <= 60; i++ { + for i := 0; i <= 200; i++ { ok, err := l.setLockAnnotation(id) if err != nil { return err From 0f9b7eba7781a89d502f757efc3f393ebb3f8c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20Tafral=C4=B1?= Date: Mon, 16 Mar 2026 15:48:11 +0300 Subject: [PATCH 161/222] Pin GitHub API version in requests (#4647) Signed-off-by: utafrali --- connector/github/github.go | 4 ++++ connector/github/github_test.go | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/connector/github/github.go b/connector/github/github.go index eb19f778..0712d3b9 100644 --- a/connector/github/github.go +++ b/connector/github/github.go @@ -28,6 +28,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 @@ -462,6 +464,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) @@ -659,6 +662,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) diff --git a/connector/github/github_test.go b/connector/github/github_test.go index 543cfc89..de351496 100644 --- a/connector/github/github_test.go +++ b/connector/github/github_test.go @@ -485,6 +485,21 @@ func Test_Open_PreferredDomainConfig(t *testing.T) { } } +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) { From 12339f2cef5a367bcafcb5a275e6c078f54ca6c4 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Mon, 16 Mar 2026 13:53:27 +0100 Subject: [PATCH 162/222] feat: implement user identity creation and persisting consent (#4645) Signed-off-by: maksim.nabokikh --- server/handlers.go | 83 +++++++++ server/handlers_test.go | 393 ++++++++++++++++++++++++++++++++++++++++ server/server.go | 5 + 3 files changed, 481 insertions(+) diff --git a/server/handlers.go b/server/handlers.go index 815b4451..20fd85bf 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -23,6 +23,7 @@ import ( "github.com/gorilla/mux" "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/pkg/featureflags" "github.com/dexidp/dex/server/internal" "github.com/dexidp/dex/storage" ) @@ -717,11 +718,60 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, } } + // Create or update UserIdentity to persist user claims across sessions. + var userIdentity *storage.UserIdentity + if featureflags.SessionsEnabled.Enabled() { + now := s.now() + + ui, err := s.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 := s.storage.CreateUserIdentity(ctx, ui); err != nil { + s.logger.ErrorContext(ctx, "failed to create user identity", "err", err) + return "", false, err + } + case err == nil: + if err := s.storage.UpdateUserIdentity(ctx, identity.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + if len(identity.ConnectorData) > 0 { + old.Claims = claims + old.LastLogin = now + return old, nil + } + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to update user identity", "err", err) + return "", false, err + } + // Update the existing UserIdentity obj with new claims to use them later in the flow. + ui.Claims = claims + ui.LastLogin = now + default: + s.logger.ErrorContext(ctx, "failed to get user identity", "err", err) + return "", false, err + } + userIdentity = &ui + } + // we can skip the redirect to /approval and go ahead and send code if it's not required if s.skipApproval && !authReq.ForceApprovalPrompt { return "", true, nil } + // Skip approval if user already consented to the requested scopes for this client. + if !authReq.ForceApprovalPrompt && userIdentity != nil { + if scopesCoveredByConsent(userIdentity.Consents[authReq.ClientID], authReq.Scopes) { + return "", true, nil + } + } + // an HMAC is used here to ensure that the request ID is unpredictable, ensuring that an attacker who intercepted the original // flow would be unable to poll for the result at the /approval endpoint h := hmac.New(sha256.New, authReq.HMACKey) @@ -787,6 +837,18 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { s.renderError(r, w, http.StatusInternalServerError, "Approval rejected.") return } + // Persist user-approved scopes as consent for this client. + if featureflags.SessionsEnabled.Enabled() { + if err := s.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 { + s.logger.ErrorContext(ctx, "failed to update user identity consents", "err", err) + } + } s.sendCodeResponse(w, r, authReq) } } @@ -932,6 +994,27 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe http.Redirect(w, r, u.String(), 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 == scopeOpenID { + continue + } + if _, ok := approvedSet[scope]; !ok { + return false + } + } + + return true +} + func (s *Server) withClientFromStorage(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request, storage.Client)) { ctx := r.Context() clientID, clientSecret, ok := r.BasicAuth() diff --git a/server/handlers_test.go b/server/handlers_test.go index 12c664f5..933e9e4d 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -3,6 +3,9 @@ package server import ( "bytes" "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -495,6 +498,396 @@ func TestHandlePassword_LocalPasswordDBClaims(t *testing.T) { require.Equal(t, []string{"team-a", "team-a/admins"}, claims.Groups) } +func setSessionsEnabled(t *testing.T, enabled bool) { + t.Helper() + if enabled { + t.Setenv("DEX_SESSIONS_ENABLED", "true") + } else { + t.Setenv("DEX_SESSIONS_ENABLED", "false") + } +} + +func TestFinalizeLoginCreatesUserIdentity(t *testing.T) { + ctx := t.Context() + setSessionsEnabled(t, true) + + connID := "mockPw" + authReqID := "test-create-ui" + expiry := time.Now().Add(100 * time.Second) + + httpServer, s := newTestServer(t, func(c *Config) { + c.SkipApprovalScreen = true + c.Now = time.Now + }) + defer httpServer.Close() + + sc := storage.Connector{ + ID: connID, + Type: "mockPassword", + Name: "MockPassword", + ResourceVersion: "1", + Config: []byte(`{"username": "foo", "password": "password"}`), + } + require.NoError(t, s.storage.CreateConnector(ctx, sc)) + _, err := s.OpenConnector(sc) + require.NoError(t, err) + + authReq := storage.AuthRequest{ + ID: authReqID, + ConnectorID: connID, + RedirectURI: "cb", + Expiry: expiry, + ResponseTypes: []string{responseTypeCode}, + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + rr := httptest.NewRecorder() + reqPath := fmt.Sprintf("/auth/%s/login?state=%s&back=&login=foo&password=password", connID, authReqID) + s.handlePasswordLogin(rr, httptest.NewRequest("POST", reqPath, nil)) + + require.Equal(t, 303, rr.Code) + + ui, err := s.storage.GetUserIdentity(ctx, "0-385-28089-0", connID) + require.NoError(t, err, "UserIdentity should exist after login") + require.Equal(t, "0-385-28089-0", ui.UserID) + require.Equal(t, connID, ui.ConnectorID) + require.Equal(t, "kilgore@kilgore.trout", ui.Claims.Email) + require.NotZero(t, ui.CreatedAt, "CreatedAt should be set") + require.NotZero(t, ui.LastLogin, "LastLogin should be set") +} + +func TestFinalizeLoginUpdatesUserIdentity(t *testing.T) { + ctx := t.Context() + setSessionsEnabled(t, true) + + connID := "mockPw" + authReqID := "test-update-ui" + expiry := time.Now().Add(100 * time.Second) + oldTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + httpServer, s := newTestServer(t, func(c *Config) { + c.SkipApprovalScreen = true + c.Now = time.Now + }) + defer httpServer.Close() + + sc := storage.Connector{ + ID: connID, + Type: "mockPassword", + Name: "MockPassword", + ResourceVersion: "1", + Config: []byte(`{"username": "foo", "password": "password"}`), + } + require.NoError(t, s.storage.CreateConnector(ctx, sc)) + _, err := s.OpenConnector(sc) + require.NoError(t, err) + + // Pre-create UserIdentity with old data + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "0-385-28089-0", + ConnectorID: connID, + Claims: storage.Claims{ + UserID: "0-385-28089-0", + Username: "Old Name", + Email: "old@example.com", + }, + Consents: map[string][]string{"existing-client": {"openid"}}, + CreatedAt: oldTime, + LastLogin: oldTime, + })) + + authReq := storage.AuthRequest{ + ID: authReqID, + ConnectorID: connID, + RedirectURI: "cb", + Expiry: expiry, + ResponseTypes: []string{responseTypeCode}, + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + rr := httptest.NewRecorder() + reqPath := fmt.Sprintf("/auth/%s/login?state=%s&back=&login=foo&password=password", connID, authReqID) + s.handlePasswordLogin(rr, httptest.NewRequest("POST", reqPath, nil)) + + require.Equal(t, 303, rr.Code) + + ui, err := s.storage.GetUserIdentity(ctx, "0-385-28089-0", connID) + require.NoError(t, err, "UserIdentity should exist after login") + require.Equal(t, "Kilgore Trout", ui.Claims.Username, "claims should be refreshed from the connector") + require.Equal(t, "kilgore@kilgore.trout", ui.Claims.Email, "claims should be refreshed from the connector") + require.True(t, ui.LastLogin.After(oldTime), "LastLogin should be updated") + require.Equal(t, oldTime, ui.CreatedAt, "CreatedAt should not change on update") + require.Equal(t, []string{"openid"}, ui.Consents["existing-client"], "existing consents should be preserved") +} + +func TestFinalizeLoginSkipsUserIdentityWhenDisabled(t *testing.T) { + ctx := t.Context() + setSessionsEnabled(t, false) + + connID := "mockPw" + authReqID := "test-no-ui" + expiry := time.Now().Add(100 * time.Second) + + httpServer, s := newTestServer(t, func(c *Config) { + c.SkipApprovalScreen = true + c.Now = time.Now + }) + defer httpServer.Close() + + sc := storage.Connector{ + ID: connID, + Type: "mockPassword", + Name: "MockPassword", + ResourceVersion: "1", + Config: []byte(`{"username": "foo", "password": "password"}`), + } + require.NoError(t, s.storage.CreateConnector(ctx, sc)) + _, err := s.OpenConnector(sc) + require.NoError(t, err) + + authReq := storage.AuthRequest{ + ID: authReqID, + ConnectorID: connID, + RedirectURI: "cb", + Expiry: expiry, + ResponseTypes: []string{responseTypeCode}, + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + rr := httptest.NewRecorder() + reqPath := fmt.Sprintf("/auth/%s/login?state=%s&back=&login=foo&password=password", connID, authReqID) + s.handlePasswordLogin(rr, httptest.NewRequest("POST", reqPath, nil)) + + require.Equal(t, 303, rr.Code) + + _, err = s.storage.GetUserIdentity(ctx, "0-385-28089-0", connID) + require.ErrorIs(t, err, storage.ErrNotFound, "UserIdentity should not be created when sessions disabled") +} + +func TestSkipApprovalWithExistingConsent(t *testing.T) { + ctx := t.Context() + setSessionsEnabled(t, true) + + connID := "mock" + authReqID := "test-consent-skip" + expiry := time.Now().Add(100 * time.Second) + + tests := []struct { + name string + consents map[string][]string + scopes []string + clientID string + forcePrompt bool + wantPath string + }{ + { + name: "Existing consent covers requested scopes", + consents: map[string][]string{"test": {"email", "profile"}}, + scopes: []string{"openid", "email", "profile"}, + clientID: "test", + wantPath: "/callback/cb", + }, + { + name: "Existing consent missing a scope", + consents: map[string][]string{"test": {"email"}}, + scopes: []string{"openid", "email", "profile"}, + clientID: "test", + wantPath: "/approval", + }, + { + name: "Force approval overrides consent", + consents: map[string][]string{"test": {"email", "profile"}}, + scopes: []string{"openid", "email", "profile"}, + clientID: "test", + forcePrompt: true, + wantPath: "/approval", + }, + { + name: "No consent for this client", + consents: map[string][]string{"other-client": {"email"}}, + scopes: []string{"openid", "email"}, + clientID: "test", + wantPath: "/approval", + }, + { + name: "Only openid scope - skip with empty consent", + consents: map[string][]string{"test": {}}, + scopes: []string{"openid"}, + clientID: "test", + wantPath: "/callback/cb", + }, + { + name: "offline_access requires consent", + consents: map[string][]string{"test": {}}, + scopes: []string{"openid", "offline_access"}, + clientID: "test", + wantPath: "/approval", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + httpServer, s := newTestServer(t, func(c *Config) { + c.SkipApprovalScreen = false + c.Now = time.Now + }) + defer httpServer.Close() + + // Pre-create UserIdentity with consents + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "0-385-28089-0", + ConnectorID: connID, + Claims: storage.Claims{ + UserID: "0-385-28089-0", + Username: "Kilgore Trout", + Email: "kilgore@kilgore.trout", + EmailVerified: true, + }, + Consents: tc.consents, + CreatedAt: time.Now(), + LastLogin: time.Now(), + })) + + authReq := storage.AuthRequest{ + ID: authReqID, + ConnectorID: connID, + ClientID: tc.clientID, + RedirectURI: "cb", + Expiry: expiry, + ResponseTypes: []string{responseTypeCode}, + Scopes: tc.scopes, + ForceApprovalPrompt: tc.forcePrompt, + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + rr := httptest.NewRecorder() + reqPath := fmt.Sprintf("/callback/%s?state=%s", connID, authReqID) + s.handleConnectorCallback(rr, httptest.NewRequest("GET", reqPath, nil)) + + require.Equal(t, 303, rr.Code) + cb, err := url.Parse(rr.Result().Header.Get("Location")) + require.NoError(t, err) + require.Equal(t, tc.wantPath, cb.Path) + }) + } +} + +func TestConsentPersistedOnApproval(t *testing.T) { + ctx := t.Context() + setSessionsEnabled(t, true) + + httpServer, s := newTestServer(t, nil) + defer httpServer.Close() + + userID := "test-user" + connectorID := "mock" + clientID := "test" + + // Pre-create UserIdentity (would have been created during login) + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: userID, + ConnectorID: connectorID, + Claims: storage.Claims{UserID: userID}, + Consents: make(map[string][]string), + CreatedAt: time.Now(), + LastLogin: time.Now(), + })) + + authReq := storage.AuthRequest{ + ID: "approval-consent-test", + ClientID: clientID, + ConnectorID: connectorID, + ResponseTypes: []string{responseTypeCode}, + RedirectURI: "https://client.example/callback", + Expiry: time.Now().Add(time.Minute), + LoggedIn: true, + Claims: storage.Claims{UserID: userID}, + Scopes: []string{"openid", "email", "profile"}, + HMACKey: []byte("consent-test-key"), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + h := hmac.New(sha256.New, authReq.HMACKey) + h.Write([]byte(authReq.ID)) + mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + form := url.Values{ + "approval": {"approve"}, + "req": {authReq.ID}, + "hmac": {mac}, + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/approval", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusSeeOther, rr.Code, "approval should redirect") + + ui, err := s.storage.GetUserIdentity(ctx, userID, connectorID) + require.NoError(t, err, "UserIdentity should exist") + require.Equal(t, []string{"openid", "email", "profile"}, ui.Consents[clientID], "approved scopes should be persisted") +} + +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) + }) + } +} + func TestHandlePasswordLoginWithSkipApproval(t *testing.T) { ctx := t.Context() diff --git a/server/server.go b/server/server.go index e6945c72..e63cb278 100644 --- a/server/server.go +++ b/server/server.go @@ -45,6 +45,7 @@ import ( "github.com/dexidp/dex/connector/oidc" "github.com/dexidp/dex/connector/openshift" "github.com/dexidp/dex/connector/saml" + "github.com/dexidp/dex/pkg/featureflags" "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" "github.com/dexidp/dex/web" @@ -377,6 +378,10 @@ func newServer(ctx context.Context, c Config) (*Server, error) { return nil, fmt.Errorf("server: failed to open all connectors (%d/%d)", failedCount, len(storageConnectors)) } + if featureflags.SessionsEnabled.Enabled() { + s.logger.InfoContext(ctx, "sessions feature flag is enabled") + } + instrumentHandler := func(_ string, handler http.Handler) http.HandlerFunc { return handler.ServeHTTP } From 6b9ce00e119edfe47cfcf1709cf5640f7b9c75d9 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Mon, 16 Mar 2026 17:06:53 +0100 Subject: [PATCH 163/222] feat: implement AuthSession CRUD operations (#4646) Signed-off-by: maksim.nabokikh --- storage/conformance/conformance.go | 100 ++++ storage/ent/client/authsession.go | 108 +++++ storage/ent/client/types.go | 22 + storage/ent/db/authsession.go | 150 ++++++ storage/ent/db/authsession/authsession.go | 83 ++++ storage/ent/db/authsession/where.go | 355 ++++++++++++++ storage/ent/db/authsession_create.go | 282 +++++++++++ storage/ent/db/authsession_delete.go | 88 ++++ storage/ent/db/authsession_query.go | 527 +++++++++++++++++++++ storage/ent/db/authsession_update.go | 330 +++++++++++++ storage/ent/db/client.go | 155 +++++- storage/ent/db/ent.go | 2 + storage/ent/db/hook/hook.go | 12 + storage/ent/db/migrate/schema.go | 16 + storage/ent/db/mutation.go | 550 ++++++++++++++++++++++ storage/ent/db/predicate/predicate.go | 3 + storage/ent/db/runtime.go | 15 + storage/ent/db/tx.go | 3 + storage/ent/schema/authsession.go | 37 ++ storage/etcd/etcd.go | 60 +++ storage/etcd/types.go | 36 ++ storage/kubernetes/storage.go | 50 ++ storage/kubernetes/types.go | 69 +++ storage/memory/memory.go | 57 +++ storage/sql/crud.go | 130 +++++ storage/sql/migrate.go | 13 + storage/storage.go | 25 + 27 files changed, 3272 insertions(+), 6 deletions(-) create mode 100644 storage/ent/client/authsession.go create mode 100644 storage/ent/db/authsession.go create mode 100644 storage/ent/db/authsession/authsession.go create mode 100644 storage/ent/db/authsession/where.go create mode 100644 storage/ent/db/authsession_create.go create mode 100644 storage/ent/db/authsession_delete.go create mode 100644 storage/ent/db/authsession_query.go create mode 100644 storage/ent/db/authsession_update.go create mode 100644 storage/ent/schema/authsession.go diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index ec3cae11..94b23745 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -52,6 +52,7 @@ func RunTests(t *testing.T, newStorage func(t *testing.T) storage.Storage) { {"DeviceRequestCRUD", testDeviceRequestCRUD}, {"DeviceTokenCRUD", testDeviceTokenCRUD}, {"UserIdentityCRUD", testUserIdentityCRUD}, + {"AuthSessionCRUD", testAuthSessionCRUD}, }) } @@ -1166,3 +1167,102 @@ func testUserIdentityCRUD(t *testing.T, s storage.Storage) { _, err = s.GetUserIdentity(ctx, u1.UserID, u1.ConnectorID) mustBeErrNotFound(t, "user identity", err) } + +func testAuthSessionCRUD(t *testing.T, s storage.Storage) { + ctx := t.Context() + + now := time.Now().UTC().Round(time.Millisecond) + + session := storage.AuthSession{ + ID: storage.NewID(), + ClientStates: map[string]*storage.ClientAuthState{ + "client1": { + UserID: "user1", + ConnectorID: "conn1", + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + LastActivity: now, + LastTokenIssuedAt: now, + }, + }, + CreatedAt: now, + LastActivity: now, + IPAddress: "192.168.1.1", + UserAgent: "TestBrowser/1.0", + } + + // Create. + if err := s.CreateAuthSession(ctx, session); err != nil { + t.Fatalf("create auth session: %v", err) + } + + // Duplicate create should return ErrAlreadyExists. + err := s.CreateAuthSession(ctx, session) + mustBeErrAlreadyExists(t, "auth session", err) + + // Get and compare. + got, err := s.GetAuthSession(ctx, session.ID) + if err != nil { + t.Fatalf("get auth session: %v", err) + } + + got.CreatedAt = got.CreatedAt.UTC().Round(time.Millisecond) + got.LastActivity = got.LastActivity.UTC().Round(time.Millisecond) + for _, cs := range got.ClientStates { + cs.ExpiresAt = cs.ExpiresAt.UTC().Round(time.Millisecond) + cs.LastActivity = cs.LastActivity.UTC().Round(time.Millisecond) + cs.LastTokenIssuedAt = cs.LastTokenIssuedAt.UTC().Round(time.Millisecond) + } + if diff := pretty.Compare(session, got); diff != "" { + t.Errorf("auth session retrieved from storage did not match: %s", diff) + } + + // Update: add a new client state. + newNow := now.Add(time.Minute) + if err := s.UpdateAuthSession(ctx, session.ID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.ClientStates["client2"] = &storage.ClientAuthState{ + UserID: "user2", + ConnectorID: "conn2", + Active: true, + ExpiresAt: newNow.Add(24 * time.Hour), + LastActivity: newNow, + } + old.LastActivity = newNow + return old, nil + }); err != nil { + t.Fatalf("update auth session: %v", err) + } + + // Get and verify update. + got, err = s.GetAuthSession(ctx, session.ID) + if err != nil { + t.Fatalf("get auth session after update: %v", err) + } + if len(got.ClientStates) != 2 { + t.Fatalf("expected 2 client states, got %d", len(got.ClientStates)) + } + if got.ClientStates["client2"] == nil { + t.Fatal("expected client2 state to exist") + } + if got.ClientStates["client2"].UserID != "user2" { + t.Errorf("expected client2 user_id to be user2, got %s", got.ClientStates["client2"].UserID) + } + + // List and verify. + sessions, err := s.ListAuthSessions(ctx) + if err != nil { + t.Fatalf("list auth sessions: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("expected 1 auth session, got %d", len(sessions)) + } + + // Delete. + if err := s.DeleteAuthSession(ctx, session.ID); err != nil { + t.Fatalf("delete auth session: %v", err) + } + + // Get deleted should return ErrNotFound. + _, err = s.GetAuthSession(ctx, session.ID) + mustBeErrNotFound(t, "auth session", err) +} diff --git a/storage/ent/client/authsession.go b/storage/ent/client/authsession.go new file mode 100644 index 00000000..439bdbe3 --- /dev/null +++ b/storage/ent/client/authsession.go @@ -0,0 +1,108 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/dexidp/dex/storage" +) + +// CreateAuthSession saves provided auth session into the database. +func (d *Database) CreateAuthSession(ctx context.Context, session storage.AuthSession) error { + if session.ClientStates == nil { + session.ClientStates = make(map[string]*storage.ClientAuthState) + } + encodedStates, err := json.Marshal(session.ClientStates) + if err != nil { + return fmt.Errorf("encode client states auth session: %w", err) + } + + _, err = d.client.AuthSession.Create(). + SetID(session.ID). + SetClientStates(encodedStates). + SetCreatedAt(session.CreatedAt). + SetLastActivity(session.LastActivity). + SetIPAddress(session.IPAddress). + SetUserAgent(session.UserAgent). + Save(ctx) + if err != nil { + return convertDBError("create auth session: %w", err) + } + return nil +} + +// GetAuthSession extracts an auth session from the database by session ID. +func (d *Database) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { + authSession, err := d.client.AuthSession.Get(ctx, sessionID) + if err != nil { + return storage.AuthSession{}, convertDBError("get auth session: %w", err) + } + return toStorageAuthSession(authSession), nil +} + +// ListAuthSessions extracts all auth sessions from the database. +func (d *Database) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, error) { + authSessions, err := d.client.AuthSession.Query().All(ctx) + if err != nil { + return nil, convertDBError("list auth sessions: %w", err) + } + + storageAuthSessions := make([]storage.AuthSession, 0, len(authSessions)) + for _, s := range authSessions { + storageAuthSessions = append(storageAuthSessions, toStorageAuthSession(s)) + } + return storageAuthSessions, nil +} + +// DeleteAuthSession deletes an auth session from the database by session ID. +func (d *Database) DeleteAuthSession(ctx context.Context, sessionID string) error { + err := d.client.AuthSession.DeleteOneID(sessionID).Exec(ctx) + if err != nil { + return convertDBError("delete auth session: %w", err) + } + return nil +} + +// UpdateAuthSession changes an auth session using an updater function. +func (d *Database) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { + tx, err := d.BeginTx(ctx) + if err != nil { + return convertDBError("update auth session tx: %w", err) + } + + authSession, err := tx.AuthSession.Get(ctx, sessionID) + if err != nil { + return rollback(tx, "update auth session database: %w", err) + } + + newSession, err := updater(toStorageAuthSession(authSession)) + if err != nil { + return rollback(tx, "update auth session updating: %w", err) + } + + if newSession.ClientStates == nil { + newSession.ClientStates = make(map[string]*storage.ClientAuthState) + } + + encodedStates, err := json.Marshal(newSession.ClientStates) + if err != nil { + return rollback(tx, "encode client states auth session: %w", err) + } + + _, err = tx.AuthSession.UpdateOneID(sessionID). + SetClientStates(encodedStates). + SetLastActivity(newSession.LastActivity). + SetIPAddress(newSession.IPAddress). + SetUserAgent(newSession.UserAgent). + Save(ctx) + if err != nil { + return rollback(tx, "update auth session updating: %w", err) + } + + if err = tx.Commit(); err != nil { + return rollback(tx, "update auth session commit: %w", err) + } + + return nil +} diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 535ab5f8..f8e99c4a 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -196,6 +196,28 @@ func toStorageUserIdentity(u *db.UserIdentity) storage.UserIdentity { return s } +func toStorageAuthSession(s *db.AuthSession) storage.AuthSession { + result := storage.AuthSession{ + ID: s.ID, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + } + + if s.ClientStates != nil { + if err := json.Unmarshal(s.ClientStates, &result.ClientStates); err != nil { + panic(err) + } + if result.ClientStates == nil { + result.ClientStates = make(map[string]*storage.ClientAuthState) + } + } else { + result.ClientStates = make(map[string]*storage.ClientAuthState) + } + return result +} + func toStorageDeviceToken(t *db.DeviceToken) storage.DeviceToken { return storage.DeviceToken{ DeviceCode: t.DeviceCode, diff --git a/storage/ent/db/authsession.go b/storage/ent/db/authsession.go new file mode 100644 index 00000000..b81479c7 --- /dev/null +++ b/storage/ent/db/authsession.go @@ -0,0 +1,150 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "fmt" + "strings" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "github.com/dexidp/dex/storage/ent/db/authsession" +) + +// AuthSession is the model entity for the AuthSession schema. +type AuthSession struct { + config `json:"-"` + // ID of the ent. + ID string `json:"id,omitempty"` + // ClientStates holds the value of the "client_states" field. + ClientStates []byte `json:"client_states,omitempty"` + // CreatedAt holds the value of the "created_at" field. + CreatedAt time.Time `json:"created_at,omitempty"` + // LastActivity holds the value of the "last_activity" field. + LastActivity time.Time `json:"last_activity,omitempty"` + // IPAddress holds the value of the "ip_address" field. + IPAddress string `json:"ip_address,omitempty"` + // UserAgent holds the value of the "user_agent" field. + UserAgent string `json:"user_agent,omitempty"` + selectValues sql.SelectValues +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*AuthSession) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case authsession.FieldClientStates: + values[i] = new([]byte) + case authsession.FieldID, authsession.FieldIPAddress, authsession.FieldUserAgent: + values[i] = new(sql.NullString) + case authsession.FieldCreatedAt, authsession.FieldLastActivity: + values[i] = new(sql.NullTime) + default: + values[i] = new(sql.UnknownType) + } + } + return values, nil +} + +// assignValues assigns the values that were returned from sql.Rows (after scanning) +// to the AuthSession fields. +func (_m *AuthSession) assignValues(columns []string, values []any) error { + if m, n := len(values), len(columns); m < n { + return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) + } + for i := range columns { + switch columns[i] { + case authsession.FieldID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field id", values[i]) + } else if value.Valid { + _m.ID = value.String + } + case authsession.FieldClientStates: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field client_states", values[i]) + } else if value != nil { + _m.ClientStates = *value + } + case authsession.FieldCreatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field created_at", values[i]) + } else if value.Valid { + _m.CreatedAt = value.Time + } + case authsession.FieldLastActivity: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field last_activity", values[i]) + } else if value.Valid { + _m.LastActivity = value.Time + } + case authsession.FieldIPAddress: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field ip_address", values[i]) + } else if value.Valid { + _m.IPAddress = value.String + } + case authsession.FieldUserAgent: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field user_agent", values[i]) + } else if value.Valid { + _m.UserAgent = value.String + } + default: + _m.selectValues.Set(columns[i], values[i]) + } + } + return nil +} + +// Value returns the ent.Value that was dynamically selected and assigned to the AuthSession. +// This includes values selected through modifiers, order, etc. +func (_m *AuthSession) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// Update returns a builder for updating this AuthSession. +// Note that you need to call AuthSession.Unwrap() before calling this method if this AuthSession +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *AuthSession) Update() *AuthSessionUpdateOne { + return NewAuthSessionClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the AuthSession entity that was returned from a transaction after it was closed, +// so that all future queries will be executed through the driver which created the transaction. +func (_m *AuthSession) Unwrap() *AuthSession { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("db: AuthSession is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *AuthSession) String() string { + var builder strings.Builder + builder.WriteString("AuthSession(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("client_states=") + builder.WriteString(fmt.Sprintf("%v", _m.ClientStates)) + builder.WriteString(", ") + builder.WriteString("created_at=") + builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("last_activity=") + builder.WriteString(_m.LastActivity.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("ip_address=") + builder.WriteString(_m.IPAddress) + builder.WriteString(", ") + builder.WriteString("user_agent=") + builder.WriteString(_m.UserAgent) + builder.WriteByte(')') + return builder.String() +} + +// AuthSessions is a parsable slice of AuthSession. +type AuthSessions []*AuthSession diff --git a/storage/ent/db/authsession/authsession.go b/storage/ent/db/authsession/authsession.go new file mode 100644 index 00000000..e2548f90 --- /dev/null +++ b/storage/ent/db/authsession/authsession.go @@ -0,0 +1,83 @@ +// Code generated by ent, DO NOT EDIT. + +package authsession + +import ( + "entgo.io/ent/dialect/sql" +) + +const ( + // Label holds the string label denoting the authsession type in the database. + Label = "auth_session" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldClientStates holds the string denoting the client_states field in the database. + FieldClientStates = "client_states" + // FieldCreatedAt holds the string denoting the created_at field in the database. + FieldCreatedAt = "created_at" + // FieldLastActivity holds the string denoting the last_activity field in the database. + FieldLastActivity = "last_activity" + // FieldIPAddress holds the string denoting the ip_address field in the database. + FieldIPAddress = "ip_address" + // FieldUserAgent holds the string denoting the user_agent field in the database. + FieldUserAgent = "user_agent" + // Table holds the table name of the authsession in the database. + Table = "auth_sessions" +) + +// Columns holds all SQL columns for authsession fields. +var Columns = []string{ + FieldID, + FieldClientStates, + FieldCreatedAt, + FieldLastActivity, + FieldIPAddress, + FieldUserAgent, +} + +// ValidColumn reports if the column name is valid (part of the table columns). +func ValidColumn(column string) bool { + for i := range Columns { + if column == Columns[i] { + return true + } + } + return false +} + +var ( + // DefaultIPAddress holds the default value on creation for the "ip_address" field. + DefaultIPAddress string + // DefaultUserAgent holds the default value on creation for the "user_agent" field. + DefaultUserAgent string + // IDValidator is a validator for the "id" field. It is called by the builders before save. + IDValidator func(string) error +) + +// OrderOption defines the ordering options for the AuthSession queries. +type OrderOption func(*sql.Selector) + +// ByID orders the results by the id field. +func ByID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldID, opts...).ToFunc() +} + +// ByCreatedAt orders the results by the created_at field. +func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() +} + +// ByLastActivity orders the results by the last_activity field. +func ByLastActivity(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldLastActivity, opts...).ToFunc() +} + +// ByIPAddress orders the results by the ip_address field. +func ByIPAddress(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldIPAddress, opts...).ToFunc() +} + +// ByUserAgent orders the results by the user_agent field. +func ByUserAgent(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserAgent, opts...).ToFunc() +} diff --git a/storage/ent/db/authsession/where.go b/storage/ent/db/authsession/where.go new file mode 100644 index 00000000..a4f52894 --- /dev/null +++ b/storage/ent/db/authsession/where.go @@ -0,0 +1,355 @@ +// Code generated by ent, DO NOT EDIT. + +package authsession + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "github.com/dexidp/dex/storage/ent/db/predicate" +) + +// ID filters vertices based on their ID field. +func ID(id string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldID, id)) +} + +// IDEqualFold applies the EqualFold predicate on the ID field. +func IDEqualFold(id string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEqualFold(FieldID, id)) +} + +// IDContainsFold applies the ContainsFold predicate on the ID field. +func IDContainsFold(id string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContainsFold(FieldID, id)) +} + +// ClientStates applies equality check predicate on the "client_states" field. It's identical to ClientStatesEQ. +func ClientStates(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldClientStates, v)) +} + +// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. +func CreatedAt(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldCreatedAt, v)) +} + +// LastActivity applies equality check predicate on the "last_activity" field. It's identical to LastActivityEQ. +func LastActivity(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldLastActivity, v)) +} + +// IPAddress applies equality check predicate on the "ip_address" field. It's identical to IPAddressEQ. +func IPAddress(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldIPAddress, v)) +} + +// UserAgent applies equality check predicate on the "user_agent" field. It's identical to UserAgentEQ. +func UserAgent(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldUserAgent, v)) +} + +// ClientStatesEQ applies the EQ predicate on the "client_states" field. +func ClientStatesEQ(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldClientStates, v)) +} + +// ClientStatesNEQ applies the NEQ predicate on the "client_states" field. +func ClientStatesNEQ(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldClientStates, v)) +} + +// ClientStatesIn applies the In predicate on the "client_states" field. +func ClientStatesIn(vs ...[]byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldClientStates, vs...)) +} + +// ClientStatesNotIn applies the NotIn predicate on the "client_states" field. +func ClientStatesNotIn(vs ...[]byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldClientStates, vs...)) +} + +// ClientStatesGT applies the GT predicate on the "client_states" field. +func ClientStatesGT(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldClientStates, v)) +} + +// ClientStatesGTE applies the GTE predicate on the "client_states" field. +func ClientStatesGTE(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldClientStates, v)) +} + +// ClientStatesLT applies the LT predicate on the "client_states" field. +func ClientStatesLT(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldClientStates, v)) +} + +// ClientStatesLTE applies the LTE predicate on the "client_states" field. +func ClientStatesLTE(v []byte) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldClientStates, v)) +} + +// CreatedAtEQ applies the EQ predicate on the "created_at" field. +func CreatedAtEQ(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldCreatedAt, v)) +} + +// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. +func CreatedAtNEQ(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldCreatedAt, v)) +} + +// CreatedAtIn applies the In predicate on the "created_at" field. +func CreatedAtIn(vs ...time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldCreatedAt, vs...)) +} + +// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. +func CreatedAtNotIn(vs ...time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldCreatedAt, vs...)) +} + +// CreatedAtGT applies the GT predicate on the "created_at" field. +func CreatedAtGT(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldCreatedAt, v)) +} + +// CreatedAtGTE applies the GTE predicate on the "created_at" field. +func CreatedAtGTE(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldCreatedAt, v)) +} + +// CreatedAtLT applies the LT predicate on the "created_at" field. +func CreatedAtLT(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldCreatedAt, v)) +} + +// CreatedAtLTE applies the LTE predicate on the "created_at" field. +func CreatedAtLTE(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldCreatedAt, v)) +} + +// LastActivityEQ applies the EQ predicate on the "last_activity" field. +func LastActivityEQ(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldLastActivity, v)) +} + +// LastActivityNEQ applies the NEQ predicate on the "last_activity" field. +func LastActivityNEQ(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldLastActivity, v)) +} + +// LastActivityIn applies the In predicate on the "last_activity" field. +func LastActivityIn(vs ...time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldLastActivity, vs...)) +} + +// LastActivityNotIn applies the NotIn predicate on the "last_activity" field. +func LastActivityNotIn(vs ...time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldLastActivity, vs...)) +} + +// LastActivityGT applies the GT predicate on the "last_activity" field. +func LastActivityGT(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldLastActivity, v)) +} + +// LastActivityGTE applies the GTE predicate on the "last_activity" field. +func LastActivityGTE(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldLastActivity, v)) +} + +// LastActivityLT applies the LT predicate on the "last_activity" field. +func LastActivityLT(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldLastActivity, v)) +} + +// LastActivityLTE applies the LTE predicate on the "last_activity" field. +func LastActivityLTE(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldLastActivity, v)) +} + +// IPAddressEQ applies the EQ predicate on the "ip_address" field. +func IPAddressEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldIPAddress, v)) +} + +// IPAddressNEQ applies the NEQ predicate on the "ip_address" field. +func IPAddressNEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldIPAddress, v)) +} + +// IPAddressIn applies the In predicate on the "ip_address" field. +func IPAddressIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldIPAddress, vs...)) +} + +// IPAddressNotIn applies the NotIn predicate on the "ip_address" field. +func IPAddressNotIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldIPAddress, vs...)) +} + +// IPAddressGT applies the GT predicate on the "ip_address" field. +func IPAddressGT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldIPAddress, v)) +} + +// IPAddressGTE applies the GTE predicate on the "ip_address" field. +func IPAddressGTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldIPAddress, v)) +} + +// IPAddressLT applies the LT predicate on the "ip_address" field. +func IPAddressLT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldIPAddress, v)) +} + +// IPAddressLTE applies the LTE predicate on the "ip_address" field. +func IPAddressLTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldIPAddress, v)) +} + +// IPAddressContains applies the Contains predicate on the "ip_address" field. +func IPAddressContains(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContains(FieldIPAddress, v)) +} + +// IPAddressHasPrefix applies the HasPrefix predicate on the "ip_address" field. +func IPAddressHasPrefix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasPrefix(FieldIPAddress, v)) +} + +// IPAddressHasSuffix applies the HasSuffix predicate on the "ip_address" field. +func IPAddressHasSuffix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasSuffix(FieldIPAddress, v)) +} + +// IPAddressEqualFold applies the EqualFold predicate on the "ip_address" field. +func IPAddressEqualFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEqualFold(FieldIPAddress, v)) +} + +// IPAddressContainsFold applies the ContainsFold predicate on the "ip_address" field. +func IPAddressContainsFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContainsFold(FieldIPAddress, v)) +} + +// UserAgentEQ applies the EQ predicate on the "user_agent" field. +func UserAgentEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldUserAgent, v)) +} + +// UserAgentNEQ applies the NEQ predicate on the "user_agent" field. +func UserAgentNEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldUserAgent, v)) +} + +// UserAgentIn applies the In predicate on the "user_agent" field. +func UserAgentIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldUserAgent, vs...)) +} + +// UserAgentNotIn applies the NotIn predicate on the "user_agent" field. +func UserAgentNotIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldUserAgent, vs...)) +} + +// UserAgentGT applies the GT predicate on the "user_agent" field. +func UserAgentGT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldUserAgent, v)) +} + +// UserAgentGTE applies the GTE predicate on the "user_agent" field. +func UserAgentGTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldUserAgent, v)) +} + +// UserAgentLT applies the LT predicate on the "user_agent" field. +func UserAgentLT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldUserAgent, v)) +} + +// UserAgentLTE applies the LTE predicate on the "user_agent" field. +func UserAgentLTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldUserAgent, v)) +} + +// UserAgentContains applies the Contains predicate on the "user_agent" field. +func UserAgentContains(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContains(FieldUserAgent, v)) +} + +// UserAgentHasPrefix applies the HasPrefix predicate on the "user_agent" field. +func UserAgentHasPrefix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasPrefix(FieldUserAgent, v)) +} + +// UserAgentHasSuffix applies the HasSuffix predicate on the "user_agent" field. +func UserAgentHasSuffix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasSuffix(FieldUserAgent, v)) +} + +// UserAgentEqualFold applies the EqualFold predicate on the "user_agent" field. +func UserAgentEqualFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEqualFold(FieldUserAgent, v)) +} + +// UserAgentContainsFold applies the ContainsFold predicate on the "user_agent" field. +func UserAgentContainsFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContainsFold(FieldUserAgent, v)) +} + +// And groups predicates with the AND operator between them. +func And(predicates ...predicate.AuthSession) predicate.AuthSession { + return predicate.AuthSession(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.AuthSession) predicate.AuthSession { + return predicate.AuthSession(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.AuthSession) predicate.AuthSession { + return predicate.AuthSession(sql.NotPredicates(p)) +} diff --git a/storage/ent/db/authsession_create.go b/storage/ent/db/authsession_create.go new file mode 100644 index 00000000..a680d675 --- /dev/null +++ b/storage/ent/db/authsession_create.go @@ -0,0 +1,282 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage/ent/db/authsession" +) + +// AuthSessionCreate is the builder for creating a AuthSession entity. +type AuthSessionCreate struct { + config + mutation *AuthSessionMutation + hooks []Hook +} + +// SetClientStates sets the "client_states" field. +func (_c *AuthSessionCreate) SetClientStates(v []byte) *AuthSessionCreate { + _c.mutation.SetClientStates(v) + return _c +} + +// SetCreatedAt sets the "created_at" field. +func (_c *AuthSessionCreate) SetCreatedAt(v time.Time) *AuthSessionCreate { + _c.mutation.SetCreatedAt(v) + return _c +} + +// SetLastActivity sets the "last_activity" field. +func (_c *AuthSessionCreate) SetLastActivity(v time.Time) *AuthSessionCreate { + _c.mutation.SetLastActivity(v) + return _c +} + +// SetIPAddress sets the "ip_address" field. +func (_c *AuthSessionCreate) SetIPAddress(v string) *AuthSessionCreate { + _c.mutation.SetIPAddress(v) + return _c +} + +// SetNillableIPAddress sets the "ip_address" field if the given value is not nil. +func (_c *AuthSessionCreate) SetNillableIPAddress(v *string) *AuthSessionCreate { + if v != nil { + _c.SetIPAddress(*v) + } + return _c +} + +// SetUserAgent sets the "user_agent" field. +func (_c *AuthSessionCreate) SetUserAgent(v string) *AuthSessionCreate { + _c.mutation.SetUserAgent(v) + return _c +} + +// SetNillableUserAgent sets the "user_agent" field if the given value is not nil. +func (_c *AuthSessionCreate) SetNillableUserAgent(v *string) *AuthSessionCreate { + if v != nil { + _c.SetUserAgent(*v) + } + return _c +} + +// SetID sets the "id" field. +func (_c *AuthSessionCreate) SetID(v string) *AuthSessionCreate { + _c.mutation.SetID(v) + return _c +} + +// Mutation returns the AuthSessionMutation object of the builder. +func (_c *AuthSessionCreate) Mutation() *AuthSessionMutation { + return _c.mutation +} + +// Save creates the AuthSession in the database. +func (_c *AuthSessionCreate) Save(ctx context.Context) (*AuthSession, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *AuthSessionCreate) SaveX(ctx context.Context) *AuthSession { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *AuthSessionCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *AuthSessionCreate) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_c *AuthSessionCreate) defaults() { + if _, ok := _c.mutation.IPAddress(); !ok { + v := authsession.DefaultIPAddress + _c.mutation.SetIPAddress(v) + } + if _, ok := _c.mutation.UserAgent(); !ok { + v := authsession.DefaultUserAgent + _c.mutation.SetUserAgent(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *AuthSessionCreate) check() error { + if _, ok := _c.mutation.ClientStates(); !ok { + return &ValidationError{Name: "client_states", err: errors.New(`db: missing required field "AuthSession.client_states"`)} + } + if _, ok := _c.mutation.CreatedAt(); !ok { + return &ValidationError{Name: "created_at", err: errors.New(`db: missing required field "AuthSession.created_at"`)} + } + if _, ok := _c.mutation.LastActivity(); !ok { + return &ValidationError{Name: "last_activity", err: errors.New(`db: missing required field "AuthSession.last_activity"`)} + } + if _, ok := _c.mutation.IPAddress(); !ok { + return &ValidationError{Name: "ip_address", err: errors.New(`db: missing required field "AuthSession.ip_address"`)} + } + if _, ok := _c.mutation.UserAgent(); !ok { + return &ValidationError{Name: "user_agent", err: errors.New(`db: missing required field "AuthSession.user_agent"`)} + } + if v, ok := _c.mutation.ID(); ok { + if err := authsession.IDValidator(v); err != nil { + return &ValidationError{Name: "id", err: fmt.Errorf(`db: validator failed for field "AuthSession.id": %w`, err)} + } + } + return nil +} + +func (_c *AuthSessionCreate) sqlSave(ctx context.Context) (*AuthSession, error) { + if err := _c.check(); err != nil { + return nil, err + } + _node, _spec := _c.createSpec() + if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + if _spec.ID.Value != nil { + if id, ok := _spec.ID.Value.(string); ok { + _node.ID = id + } else { + return nil, fmt.Errorf("unexpected AuthSession.ID type: %T", _spec.ID.Value) + } + } + _c.mutation.id = &_node.ID + _c.mutation.done = true + return _node, nil +} + +func (_c *AuthSessionCreate) createSpec() (*AuthSession, *sqlgraph.CreateSpec) { + var ( + _node = &AuthSession{config: _c.config} + _spec = sqlgraph.NewCreateSpec(authsession.Table, sqlgraph.NewFieldSpec(authsession.FieldID, field.TypeString)) + ) + if id, ok := _c.mutation.ID(); ok { + _node.ID = id + _spec.ID.Value = id + } + if value, ok := _c.mutation.ClientStates(); ok { + _spec.SetField(authsession.FieldClientStates, field.TypeBytes, value) + _node.ClientStates = value + } + if value, ok := _c.mutation.CreatedAt(); ok { + _spec.SetField(authsession.FieldCreatedAt, field.TypeTime, value) + _node.CreatedAt = value + } + if value, ok := _c.mutation.LastActivity(); ok { + _spec.SetField(authsession.FieldLastActivity, field.TypeTime, value) + _node.LastActivity = value + } + if value, ok := _c.mutation.IPAddress(); ok { + _spec.SetField(authsession.FieldIPAddress, field.TypeString, value) + _node.IPAddress = value + } + if value, ok := _c.mutation.UserAgent(); ok { + _spec.SetField(authsession.FieldUserAgent, field.TypeString, value) + _node.UserAgent = value + } + return _node, _spec +} + +// AuthSessionCreateBulk is the builder for creating many AuthSession entities in bulk. +type AuthSessionCreateBulk struct { + config + err error + builders []*AuthSessionCreate +} + +// Save creates the AuthSession entities in the database. +func (_c *AuthSessionCreateBulk) Save(ctx context.Context) ([]*AuthSession, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*AuthSession, len(_c.builders)) + mutators := make([]Mutator, len(_c.builders)) + for i := range _c.builders { + func(i int, root context.Context) { + builder := _c.builders[i] + builder.defaults() + var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { + mutation, ok := m.(*AuthSessionMutation) + if !ok { + return nil, fmt.Errorf("unexpected mutation type %T", m) + } + if err := builder.check(); err != nil { + return nil, err + } + builder.mutation = mutation + var err error + nodes[i], specs[i] = builder.createSpec() + if i < len(mutators)-1 { + _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) + } else { + spec := &sqlgraph.BatchCreateSpec{Nodes: specs} + // Invoke the actual operation on the latest mutation in the chain. + if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + } + } + if err != nil { + return nil, err + } + mutation.id = &nodes[i].ID + mutation.done = true + return nodes[i], nil + }) + for i := len(builder.hooks) - 1; i >= 0; i-- { + mut = builder.hooks[i](mut) + } + mutators[i] = mut + }(i, ctx) + } + if len(mutators) > 0 { + if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { + return nil, err + } + } + return nodes, nil +} + +// SaveX is like Save, but panics if an error occurs. +func (_c *AuthSessionCreateBulk) SaveX(ctx context.Context) []*AuthSession { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *AuthSessionCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *AuthSessionCreateBulk) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/storage/ent/db/authsession_delete.go b/storage/ent/db/authsession_delete.go new file mode 100644 index 00000000..63116fb4 --- /dev/null +++ b/storage/ent/db/authsession_delete.go @@ -0,0 +1,88 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage/ent/db/authsession" + "github.com/dexidp/dex/storage/ent/db/predicate" +) + +// AuthSessionDelete is the builder for deleting a AuthSession entity. +type AuthSessionDelete struct { + config + hooks []Hook + mutation *AuthSessionMutation +} + +// Where appends a list predicates to the AuthSessionDelete builder. +func (_d *AuthSessionDelete) Where(ps ...predicate.AuthSession) *AuthSessionDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *AuthSessionDelete) Exec(ctx context.Context) (int, error) { + return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *AuthSessionDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *AuthSessionDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(authsession.Table, sqlgraph.NewFieldSpec(authsession.FieldID, field.TypeString)) + if ps := _d.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) + if err != nil && sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + _d.mutation.done = true + return affected, err +} + +// AuthSessionDeleteOne is the builder for deleting a single AuthSession entity. +type AuthSessionDeleteOne struct { + _d *AuthSessionDelete +} + +// Where appends a list predicates to the AuthSessionDelete builder. +func (_d *AuthSessionDeleteOne) Where(ps ...predicate.AuthSession) *AuthSessionDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *AuthSessionDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{authsession.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *AuthSessionDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/storage/ent/db/authsession_query.go b/storage/ent/db/authsession_query.go new file mode 100644 index 00000000..dc3528f9 --- /dev/null +++ b/storage/ent/db/authsession_query.go @@ -0,0 +1,527 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + "fmt" + "math" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage/ent/db/authsession" + "github.com/dexidp/dex/storage/ent/db/predicate" +) + +// AuthSessionQuery is the builder for querying AuthSession entities. +type AuthSessionQuery struct { + config + ctx *QueryContext + order []authsession.OrderOption + inters []Interceptor + predicates []predicate.AuthSession + // intermediate query (i.e. traversal path). + sql *sql.Selector + path func(context.Context) (*sql.Selector, error) +} + +// Where adds a new predicate for the AuthSessionQuery builder. +func (_q *AuthSessionQuery) Where(ps ...predicate.AuthSession) *AuthSessionQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *AuthSessionQuery) Limit(limit int) *AuthSessionQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *AuthSessionQuery) Offset(offset int) *AuthSessionQuery { + _q.ctx.Offset = &offset + return _q +} + +// Unique configures the query builder to filter duplicate records on query. +// By default, unique is set to true, and can be disabled using this method. +func (_q *AuthSessionQuery) Unique(unique bool) *AuthSessionQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *AuthSessionQuery) Order(o ...authsession.OrderOption) *AuthSessionQuery { + _q.order = append(_q.order, o...) + return _q +} + +// First returns the first AuthSession entity from the query. +// Returns a *NotFoundError when no AuthSession was found. +func (_q *AuthSessionQuery) First(ctx context.Context) (*AuthSession, error) { + nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &NotFoundError{authsession.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *AuthSessionQuery) FirstX(ctx context.Context) *AuthSession { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first AuthSession ID from the query. +// Returns a *NotFoundError when no AuthSession ID was found. +func (_q *AuthSessionQuery) FirstID(ctx context.Context) (id string, err error) { + var ids []string + if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { + return + } + if len(ids) == 0 { + err = &NotFoundError{authsession.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *AuthSessionQuery) FirstIDX(ctx context.Context) string { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single AuthSession entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one AuthSession entity is found. +// Returns a *NotFoundError when no AuthSession entities are found. +func (_q *AuthSessionQuery) Only(ctx context.Context) (*AuthSession, error) { + nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) + if err != nil { + return nil, err + } + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return nil, &NotFoundError{authsession.Label} + default: + return nil, &NotSingularError{authsession.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *AuthSessionQuery) OnlyX(ctx context.Context) *AuthSession { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only AuthSession ID in the query. +// Returns a *NotSingularError when more than one AuthSession ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *AuthSessionQuery) OnlyID(ctx context.Context) (id string, err error) { + var ids []string + if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { + return + } + switch len(ids) { + case 1: + id = ids[0] + case 0: + err = &NotFoundError{authsession.Label} + default: + err = &NotSingularError{authsession.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *AuthSessionQuery) OnlyIDX(ctx context.Context) string { + id, err := _q.OnlyID(ctx) + if err != nil { + panic(err) + } + return id +} + +// All executes the query and returns a list of AuthSessions. +func (_q *AuthSessionQuery) All(ctx context.Context) ([]*AuthSession, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*AuthSession, *AuthSessionQuery]() + return withInterceptors[[]*AuthSession](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *AuthSessionQuery) AllX(ctx context.Context) []*AuthSession { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of AuthSession IDs. +func (_q *AuthSessionQuery) IDs(ctx context.Context) (ids []string, err error) { + if _q.ctx.Unique == nil && _q.path != nil { + _q.Unique(true) + } + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) + if err = _q.Select(authsession.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *AuthSessionQuery) IDsX(ctx context.Context) []string { + ids, err := _q.IDs(ctx) + if err != nil { + panic(err) + } + return ids +} + +// Count returns the count of the given query. +func (_q *AuthSessionQuery) Count(ctx context.Context) (int, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) + if err := _q.prepareQuery(ctx); err != nil { + return 0, err + } + return withInterceptors[int](ctx, _q, querierCount[*AuthSessionQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *AuthSessionQuery) CountX(ctx context.Context) int { + count, err := _q.Count(ctx) + if err != nil { + panic(err) + } + return count +} + +// Exist returns true if the query has elements in the graph. +func (_q *AuthSessionQuery) Exist(ctx context.Context) (bool, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) + switch _, err := _q.FirstID(ctx); { + case IsNotFound(err): + return false, nil + case err != nil: + return false, fmt.Errorf("db: check existence: %w", err) + default: + return true, nil + } +} + +// ExistX is like Exist, but panics if an error occurs. +func (_q *AuthSessionQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the AuthSessionQuery builder, including all associated steps. It can be +// used to prepare common query builders and use them differently after the clone is made. +func (_q *AuthSessionQuery) Clone() *AuthSessionQuery { + if _q == nil { + return nil + } + return &AuthSessionQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]authsession.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.AuthSession{}, _q.predicates...), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + } +} + +// GroupBy is used to group vertices by one or more fields/columns. +// It is often used with aggregate functions, like: count, max, mean, min, sum. +// +// Example: +// +// var v []struct { +// ClientStates []byte `json:"client_states,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.AuthSession.Query(). +// GroupBy(authsession.FieldClientStates). +// Aggregate(db.Count()). +// Scan(ctx, &v) +func (_q *AuthSessionQuery) GroupBy(field string, fields ...string) *AuthSessionGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &AuthSessionGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = authsession.Label + grbuild.scan = grbuild.Scan + return grbuild +} + +// Select allows the selection one or more fields/columns for the given query, +// instead of selecting all fields in the entity. +// +// Example: +// +// var v []struct { +// ClientStates []byte `json:"client_states,omitempty"` +// } +// +// client.AuthSession.Query(). +// Select(authsession.FieldClientStates). +// Scan(ctx, &v) +func (_q *AuthSessionQuery) Select(fields ...string) *AuthSessionSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &AuthSessionSelect{AuthSessionQuery: _q} + sbuild.label = authsession.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a AuthSessionSelect configured with the given aggregations. +func (_q *AuthSessionQuery) Aggregate(fns ...AggregateFunc) *AuthSessionSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *AuthSessionQuery) prepareQuery(ctx context.Context) error { + for _, inter := range _q.inters { + if inter == nil { + return fmt.Errorf("db: uninitialized interceptor (forgotten import db/runtime?)") + } + if trv, ok := inter.(Traverser); ok { + if err := trv.Traverse(ctx, _q); err != nil { + return err + } + } + } + for _, f := range _q.ctx.Fields { + if !authsession.ValidColumn(f) { + return &ValidationError{Name: f, err: fmt.Errorf("db: invalid field %q for query", f)} + } + } + if _q.path != nil { + prev, err := _q.path(ctx) + if err != nil { + return err + } + _q.sql = prev + } + return nil +} + +func (_q *AuthSessionQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*AuthSession, error) { + var ( + nodes = []*AuthSession{} + _spec = _q.querySpec() + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*AuthSession).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &AuthSession{config: _q.config} + nodes = append(nodes, node) + return node.assignValues(columns, values) + } + for i := range hooks { + hooks[i](ctx, _spec) + } + if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nodes, nil + } + return nodes, nil +} + +func (_q *AuthSessionQuery) sqlCount(ctx context.Context) (int, error) { + _spec := _q.querySpec() + _spec.Node.Columns = _q.ctx.Fields + if len(_q.ctx.Fields) > 0 { + _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique + } + return sqlgraph.CountNodes(ctx, _q.driver, _spec) +} + +func (_q *AuthSessionQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(authsession.Table, authsession.Columns, sqlgraph.NewFieldSpec(authsession.FieldID, field.TypeString)) + _spec.From = _q.sql + if unique := _q.ctx.Unique; unique != nil { + _spec.Unique = *unique + } else if _q.path != nil { + _spec.Unique = true + } + if fields := _q.ctx.Fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, authsession.FieldID) + for i := range fields { + if fields[i] != authsession.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + } + if ps := _q.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if limit := _q.ctx.Limit; limit != nil { + _spec.Limit = *limit + } + if offset := _q.ctx.Offset; offset != nil { + _spec.Offset = *offset + } + if ps := _q.order; len(ps) > 0 { + _spec.Order = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + return _spec +} + +func (_q *AuthSessionQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(authsession.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = authsession.Columns + } + selector := builder.Select(t1.Columns(columns...)...).From(t1) + if _q.sql != nil { + selector = _q.sql + selector.Select(selector.Columns(columns...)...) + } + if _q.ctx.Unique != nil && *_q.ctx.Unique { + selector.Distinct() + } + for _, p := range _q.predicates { + p(selector) + } + for _, p := range _q.order { + p(selector) + } + if offset := _q.ctx.Offset; offset != nil { + // limit is mandatory for offset clause. We start + // with default value, and override it below if needed. + selector.Offset(*offset).Limit(math.MaxInt32) + } + if limit := _q.ctx.Limit; limit != nil { + selector.Limit(*limit) + } + return selector +} + +// AuthSessionGroupBy is the group-by builder for AuthSession entities. +type AuthSessionGroupBy struct { + selector + build *AuthSessionQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *AuthSessionGroupBy) Aggregate(fns ...AggregateFunc) *AuthSessionGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *AuthSessionGroupBy) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) + if err := _g.build.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*AuthSessionQuery, *AuthSessionGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *AuthSessionGroupBy) sqlScan(ctx context.Context, root *AuthSessionQuery, v any) error { + selector := root.sqlQuery(ctx).Select() + aggregation := make([]string, 0, len(_g.fns)) + for _, fn := range _g.fns { + aggregation = append(aggregation, fn(selector)) + } + if len(selector.SelectedColumns()) == 0 { + columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) + for _, f := range *_g.flds { + columns = append(columns, selector.C(f)) + } + columns = append(columns, aggregation...) + selector.Select(columns...) + } + selector.GroupBy(selector.Columns(*_g.flds...)...) + if err := selector.Err(); err != nil { + return err + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// AuthSessionSelect is the builder for selecting fields of AuthSession entities. +type AuthSessionSelect struct { + *AuthSessionQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *AuthSessionSelect) Aggregate(fns ...AggregateFunc) *AuthSessionSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *AuthSessionSelect) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) + if err := _s.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*AuthSessionQuery, *AuthSessionSelect](ctx, _s.AuthSessionQuery, _s, _s.inters, v) +} + +func (_s *AuthSessionSelect) sqlScan(ctx context.Context, root *AuthSessionQuery, v any) error { + selector := root.sqlQuery(ctx) + aggregation := make([]string, 0, len(_s.fns)) + for _, fn := range _s.fns { + aggregation = append(aggregation, fn(selector)) + } + switch n := len(*_s.selector.flds); { + case n == 0 && len(aggregation) > 0: + selector.Select(aggregation...) + case n != 0 && len(aggregation) > 0: + selector.AppendSelect(aggregation...) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _s.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} diff --git a/storage/ent/db/authsession_update.go b/storage/ent/db/authsession_update.go new file mode 100644 index 00000000..e91999bd --- /dev/null +++ b/storage/ent/db/authsession_update.go @@ -0,0 +1,330 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage/ent/db/authsession" + "github.com/dexidp/dex/storage/ent/db/predicate" +) + +// AuthSessionUpdate is the builder for updating AuthSession entities. +type AuthSessionUpdate struct { + config + hooks []Hook + mutation *AuthSessionMutation +} + +// Where appends a list predicates to the AuthSessionUpdate builder. +func (_u *AuthSessionUpdate) Where(ps ...predicate.AuthSession) *AuthSessionUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetClientStates sets the "client_states" field. +func (_u *AuthSessionUpdate) SetClientStates(v []byte) *AuthSessionUpdate { + _u.mutation.SetClientStates(v) + return _u +} + +// SetCreatedAt sets the "created_at" field. +func (_u *AuthSessionUpdate) SetCreatedAt(v time.Time) *AuthSessionUpdate { + _u.mutation.SetCreatedAt(v) + return _u +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableCreatedAt(v *time.Time) *AuthSessionUpdate { + if v != nil { + _u.SetCreatedAt(*v) + } + return _u +} + +// SetLastActivity sets the "last_activity" field. +func (_u *AuthSessionUpdate) SetLastActivity(v time.Time) *AuthSessionUpdate { + _u.mutation.SetLastActivity(v) + return _u +} + +// SetNillableLastActivity sets the "last_activity" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableLastActivity(v *time.Time) *AuthSessionUpdate { + if v != nil { + _u.SetLastActivity(*v) + } + return _u +} + +// SetIPAddress sets the "ip_address" field. +func (_u *AuthSessionUpdate) SetIPAddress(v string) *AuthSessionUpdate { + _u.mutation.SetIPAddress(v) + return _u +} + +// SetNillableIPAddress sets the "ip_address" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableIPAddress(v *string) *AuthSessionUpdate { + if v != nil { + _u.SetIPAddress(*v) + } + return _u +} + +// SetUserAgent sets the "user_agent" field. +func (_u *AuthSessionUpdate) SetUserAgent(v string) *AuthSessionUpdate { + _u.mutation.SetUserAgent(v) + return _u +} + +// SetNillableUserAgent sets the "user_agent" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableUserAgent(v *string) *AuthSessionUpdate { + if v != nil { + _u.SetUserAgent(*v) + } + return _u +} + +// Mutation returns the AuthSessionMutation object of the builder. +func (_u *AuthSessionUpdate) Mutation() *AuthSessionMutation { + return _u.mutation +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *AuthSessionUpdate) Save(ctx context.Context) (int, error) { + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *AuthSessionUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *AuthSessionUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *AuthSessionUpdate) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +func (_u *AuthSessionUpdate) sqlSave(ctx context.Context) (_node int, err error) { + _spec := sqlgraph.NewUpdateSpec(authsession.Table, authsession.Columns, sqlgraph.NewFieldSpec(authsession.FieldID, field.TypeString)) + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.ClientStates(); ok { + _spec.SetField(authsession.FieldClientStates, field.TypeBytes, value) + } + if value, ok := _u.mutation.CreatedAt(); ok { + _spec.SetField(authsession.FieldCreatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.LastActivity(); ok { + _spec.SetField(authsession.FieldLastActivity, field.TypeTime, value) + } + if value, ok := _u.mutation.IPAddress(); ok { + _spec.SetField(authsession.FieldIPAddress, field.TypeString, value) + } + if value, ok := _u.mutation.UserAgent(); ok { + _spec.SetField(authsession.FieldUserAgent, field.TypeString, value) + } + if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{authsession.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// AuthSessionUpdateOne is the builder for updating a single AuthSession entity. +type AuthSessionUpdateOne struct { + config + fields []string + hooks []Hook + mutation *AuthSessionMutation +} + +// SetClientStates sets the "client_states" field. +func (_u *AuthSessionUpdateOne) SetClientStates(v []byte) *AuthSessionUpdateOne { + _u.mutation.SetClientStates(v) + return _u +} + +// SetCreatedAt sets the "created_at" field. +func (_u *AuthSessionUpdateOne) SetCreatedAt(v time.Time) *AuthSessionUpdateOne { + _u.mutation.SetCreatedAt(v) + return _u +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableCreatedAt(v *time.Time) *AuthSessionUpdateOne { + if v != nil { + _u.SetCreatedAt(*v) + } + return _u +} + +// SetLastActivity sets the "last_activity" field. +func (_u *AuthSessionUpdateOne) SetLastActivity(v time.Time) *AuthSessionUpdateOne { + _u.mutation.SetLastActivity(v) + return _u +} + +// SetNillableLastActivity sets the "last_activity" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableLastActivity(v *time.Time) *AuthSessionUpdateOne { + if v != nil { + _u.SetLastActivity(*v) + } + return _u +} + +// SetIPAddress sets the "ip_address" field. +func (_u *AuthSessionUpdateOne) SetIPAddress(v string) *AuthSessionUpdateOne { + _u.mutation.SetIPAddress(v) + return _u +} + +// SetNillableIPAddress sets the "ip_address" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableIPAddress(v *string) *AuthSessionUpdateOne { + if v != nil { + _u.SetIPAddress(*v) + } + return _u +} + +// SetUserAgent sets the "user_agent" field. +func (_u *AuthSessionUpdateOne) SetUserAgent(v string) *AuthSessionUpdateOne { + _u.mutation.SetUserAgent(v) + return _u +} + +// SetNillableUserAgent sets the "user_agent" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableUserAgent(v *string) *AuthSessionUpdateOne { + if v != nil { + _u.SetUserAgent(*v) + } + return _u +} + +// Mutation returns the AuthSessionMutation object of the builder. +func (_u *AuthSessionUpdateOne) Mutation() *AuthSessionMutation { + return _u.mutation +} + +// Where appends a list predicates to the AuthSessionUpdate builder. +func (_u *AuthSessionUpdateOne) Where(ps ...predicate.AuthSession) *AuthSessionUpdateOne { + _u.mutation.Where(ps...) + return _u +} + +// Select allows selecting one or more fields (columns) of the returned entity. +// The default is selecting all fields defined in the entity schema. +func (_u *AuthSessionUpdateOne) Select(field string, fields ...string) *AuthSessionUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated AuthSession entity. +func (_u *AuthSessionUpdateOne) Save(ctx context.Context) (*AuthSession, error) { + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *AuthSessionUpdateOne) SaveX(ctx context.Context) *AuthSession { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *AuthSessionUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *AuthSessionUpdateOne) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +func (_u *AuthSessionUpdateOne) sqlSave(ctx context.Context) (_node *AuthSession, err error) { + _spec := sqlgraph.NewUpdateSpec(authsession.Table, authsession.Columns, sqlgraph.NewFieldSpec(authsession.FieldID, field.TypeString)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`db: missing "AuthSession.id" for update`)} + } + _spec.Node.ID.Value = id + if fields := _u.fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, authsession.FieldID) + for _, f := range fields { + if !authsession.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("db: invalid field %q for query", f)} + } + if f != authsession.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, f) + } + } + } + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.ClientStates(); ok { + _spec.SetField(authsession.FieldClientStates, field.TypeBytes, value) + } + if value, ok := _u.mutation.CreatedAt(); ok { + _spec.SetField(authsession.FieldCreatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.LastActivity(); ok { + _spec.SetField(authsession.FieldLastActivity, field.TypeTime, value) + } + if value, ok := _u.mutation.IPAddress(); ok { + _spec.SetField(authsession.FieldIPAddress, field.TypeString, value) + } + if value, ok := _u.mutation.UserAgent(); ok { + _spec.SetField(authsession.FieldUserAgent, field.TypeString, value) + } + _node = &AuthSession{config: _u.config} + _spec.Assign = _node.assignValues + _spec.ScanValues = _node.scanValues + if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{authsession.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + _u.mutation.done = true + return _node, nil +} diff --git a/storage/ent/db/client.go b/storage/ent/db/client.go index efb6da6b..ffd70add 100644 --- a/storage/ent/db/client.go +++ b/storage/ent/db/client.go @@ -16,6 +16,7 @@ import ( "entgo.io/ent/dialect/sql" "github.com/dexidp/dex/storage/ent/db/authcode" "github.com/dexidp/dex/storage/ent/db/authrequest" + "github.com/dexidp/dex/storage/ent/db/authsession" "github.com/dexidp/dex/storage/ent/db/connector" "github.com/dexidp/dex/storage/ent/db/devicerequest" "github.com/dexidp/dex/storage/ent/db/devicetoken" @@ -36,6 +37,8 @@ type Client struct { AuthCode *AuthCodeClient // AuthRequest is the client for interacting with the AuthRequest builders. AuthRequest *AuthRequestClient + // AuthSession is the client for interacting with the AuthSession builders. + AuthSession *AuthSessionClient // Connector is the client for interacting with the Connector builders. Connector *ConnectorClient // DeviceRequest is the client for interacting with the DeviceRequest builders. @@ -67,6 +70,7 @@ func (c *Client) init() { c.Schema = migrate.NewSchema(c.driver) c.AuthCode = NewAuthCodeClient(c.config) c.AuthRequest = NewAuthRequestClient(c.config) + c.AuthSession = NewAuthSessionClient(c.config) c.Connector = NewConnectorClient(c.config) c.DeviceRequest = NewDeviceRequestClient(c.config) c.DeviceToken = NewDeviceTokenClient(c.config) @@ -170,6 +174,7 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { config: cfg, AuthCode: NewAuthCodeClient(cfg), AuthRequest: NewAuthRequestClient(cfg), + AuthSession: NewAuthSessionClient(cfg), Connector: NewConnectorClient(cfg), DeviceRequest: NewDeviceRequestClient(cfg), DeviceToken: NewDeviceTokenClient(cfg), @@ -200,6 +205,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) config: cfg, AuthCode: NewAuthCodeClient(cfg), AuthRequest: NewAuthRequestClient(cfg), + AuthSession: NewAuthSessionClient(cfg), Connector: NewConnectorClient(cfg), DeviceRequest: NewDeviceRequestClient(cfg), DeviceToken: NewDeviceTokenClient(cfg), @@ -238,8 +244,9 @@ func (c *Client) Close() error { // In order to add hooks to a specific client, call: `client.Node.Use(...)`. func (c *Client) Use(hooks ...Hook) { for _, n := range []interface{ Use(...Hook) }{ - c.AuthCode, c.AuthRequest, c.Connector, c.DeviceRequest, c.DeviceToken, c.Keys, - c.OAuth2Client, c.OfflineSession, c.Password, c.RefreshToken, c.UserIdentity, + c.AuthCode, c.AuthRequest, c.AuthSession, c.Connector, c.DeviceRequest, + c.DeviceToken, c.Keys, c.OAuth2Client, c.OfflineSession, c.Password, + c.RefreshToken, c.UserIdentity, } { n.Use(hooks...) } @@ -249,8 +256,9 @@ func (c *Client) Use(hooks ...Hook) { // In order to add interceptors to a specific client, call: `client.Node.Intercept(...)`. func (c *Client) Intercept(interceptors ...Interceptor) { for _, n := range []interface{ Intercept(...Interceptor) }{ - c.AuthCode, c.AuthRequest, c.Connector, c.DeviceRequest, c.DeviceToken, c.Keys, - c.OAuth2Client, c.OfflineSession, c.Password, c.RefreshToken, c.UserIdentity, + c.AuthCode, c.AuthRequest, c.AuthSession, c.Connector, c.DeviceRequest, + c.DeviceToken, c.Keys, c.OAuth2Client, c.OfflineSession, c.Password, + c.RefreshToken, c.UserIdentity, } { n.Intercept(interceptors...) } @@ -263,6 +271,8 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { return c.AuthCode.mutate(ctx, m) case *AuthRequestMutation: return c.AuthRequest.mutate(ctx, m) + case *AuthSessionMutation: + return c.AuthSession.mutate(ctx, m) case *ConnectorMutation: return c.Connector.mutate(ctx, m) case *DeviceRequestMutation: @@ -552,6 +562,139 @@ func (c *AuthRequestClient) mutate(ctx context.Context, m *AuthRequestMutation) } } +// AuthSessionClient is a client for the AuthSession schema. +type AuthSessionClient struct { + config +} + +// NewAuthSessionClient returns a client for the AuthSession from the given config. +func NewAuthSessionClient(c config) *AuthSessionClient { + return &AuthSessionClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `authsession.Hooks(f(g(h())))`. +func (c *AuthSessionClient) Use(hooks ...Hook) { + c.hooks.AuthSession = append(c.hooks.AuthSession, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `authsession.Intercept(f(g(h())))`. +func (c *AuthSessionClient) Intercept(interceptors ...Interceptor) { + c.inters.AuthSession = append(c.inters.AuthSession, interceptors...) +} + +// Create returns a builder for creating a AuthSession entity. +func (c *AuthSessionClient) Create() *AuthSessionCreate { + mutation := newAuthSessionMutation(c.config, OpCreate) + return &AuthSessionCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of AuthSession entities. +func (c *AuthSessionClient) CreateBulk(builders ...*AuthSessionCreate) *AuthSessionCreateBulk { + return &AuthSessionCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *AuthSessionClient) MapCreateBulk(slice any, setFunc func(*AuthSessionCreate, int)) *AuthSessionCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &AuthSessionCreateBulk{err: fmt.Errorf("calling to AuthSessionClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*AuthSessionCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &AuthSessionCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for AuthSession. +func (c *AuthSessionClient) Update() *AuthSessionUpdate { + mutation := newAuthSessionMutation(c.config, OpUpdate) + return &AuthSessionUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *AuthSessionClient) UpdateOne(_m *AuthSession) *AuthSessionUpdateOne { + mutation := newAuthSessionMutation(c.config, OpUpdateOne, withAuthSession(_m)) + return &AuthSessionUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *AuthSessionClient) UpdateOneID(id string) *AuthSessionUpdateOne { + mutation := newAuthSessionMutation(c.config, OpUpdateOne, withAuthSessionID(id)) + return &AuthSessionUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for AuthSession. +func (c *AuthSessionClient) Delete() *AuthSessionDelete { + mutation := newAuthSessionMutation(c.config, OpDelete) + return &AuthSessionDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *AuthSessionClient) DeleteOne(_m *AuthSession) *AuthSessionDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *AuthSessionClient) DeleteOneID(id string) *AuthSessionDeleteOne { + builder := c.Delete().Where(authsession.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &AuthSessionDeleteOne{builder} +} + +// Query returns a query builder for AuthSession. +func (c *AuthSessionClient) Query() *AuthSessionQuery { + return &AuthSessionQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeAuthSession}, + inters: c.Interceptors(), + } +} + +// Get returns a AuthSession entity by its id. +func (c *AuthSessionClient) Get(ctx context.Context, id string) (*AuthSession, error) { + return c.Query().Where(authsession.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *AuthSessionClient) GetX(ctx context.Context, id string) *AuthSession { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// Hooks returns the client hooks. +func (c *AuthSessionClient) Hooks() []Hook { + return c.hooks.AuthSession +} + +// Interceptors returns the client interceptors. +func (c *AuthSessionClient) Interceptors() []Interceptor { + return c.inters.AuthSession +} + +func (c *AuthSessionClient) mutate(ctx context.Context, m *AuthSessionMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&AuthSessionCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&AuthSessionUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&AuthSessionUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&AuthSessionDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("db: unknown AuthSession mutation op: %q", m.Op()) + } +} + // ConnectorClient is a client for the Connector schema. type ConnectorClient struct { config @@ -1752,11 +1895,11 @@ func (c *UserIdentityClient) mutate(ctx context.Context, m *UserIdentityMutation // hooks and interceptors per client, for fast access. type ( hooks struct { - AuthCode, AuthRequest, Connector, DeviceRequest, DeviceToken, Keys, + AuthCode, AuthRequest, AuthSession, Connector, DeviceRequest, DeviceToken, Keys, OAuth2Client, OfflineSession, Password, RefreshToken, UserIdentity []ent.Hook } inters struct { - AuthCode, AuthRequest, Connector, DeviceRequest, DeviceToken, Keys, + AuthCode, AuthRequest, AuthSession, Connector, DeviceRequest, DeviceToken, Keys, OAuth2Client, OfflineSession, Password, RefreshToken, UserIdentity []ent.Interceptor } diff --git a/storage/ent/db/ent.go b/storage/ent/db/ent.go index fe965a79..f73e0090 100644 --- a/storage/ent/db/ent.go +++ b/storage/ent/db/ent.go @@ -14,6 +14,7 @@ import ( "entgo.io/ent/dialect/sql/sqlgraph" "github.com/dexidp/dex/storage/ent/db/authcode" "github.com/dexidp/dex/storage/ent/db/authrequest" + "github.com/dexidp/dex/storage/ent/db/authsession" "github.com/dexidp/dex/storage/ent/db/connector" "github.com/dexidp/dex/storage/ent/db/devicerequest" "github.com/dexidp/dex/storage/ent/db/devicetoken" @@ -85,6 +86,7 @@ func checkColumn(t, c string) error { columnCheck = sql.NewColumnCheck(map[string]func(string) bool{ authcode.Table: authcode.ValidColumn, authrequest.Table: authrequest.ValidColumn, + authsession.Table: authsession.ValidColumn, connector.Table: connector.ValidColumn, devicerequest.Table: devicerequest.ValidColumn, devicetoken.Table: devicetoken.ValidColumn, diff --git a/storage/ent/db/hook/hook.go b/storage/ent/db/hook/hook.go index 008dc7ee..1c8780d7 100644 --- a/storage/ent/db/hook/hook.go +++ b/storage/ent/db/hook/hook.go @@ -33,6 +33,18 @@ func (f AuthRequestFunc) Mutate(ctx context.Context, m db.Mutation) (db.Value, e return nil, fmt.Errorf("unexpected mutation type %T. expect *db.AuthRequestMutation", m) } +// The AuthSessionFunc type is an adapter to allow the use of ordinary +// function as AuthSession mutator. +type AuthSessionFunc func(context.Context, *db.AuthSessionMutation) (db.Value, error) + +// Mutate calls f(ctx, m). +func (f AuthSessionFunc) Mutate(ctx context.Context, m db.Mutation) (db.Value, error) { + if mv, ok := m.(*db.AuthSessionMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *db.AuthSessionMutation", m) +} + // The ConnectorFunc type is an adapter to allow the use of ordinary // function as Connector mutator. type ConnectorFunc func(context.Context, *db.ConnectorMutation) (db.Value, error) diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index 1b9b61c9..786598c0 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -63,6 +63,21 @@ var ( Columns: AuthRequestsColumns, PrimaryKey: []*schema.Column{AuthRequestsColumns[0]}, } + // AuthSessionsColumns holds the columns for the "auth_sessions" table. + AuthSessionsColumns = []*schema.Column{ + {Name: "id", Type: field.TypeString, Unique: true, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "client_states", Type: field.TypeBytes}, + {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, + {Name: "last_activity", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, + {Name: "ip_address", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "user_agent", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + } + // AuthSessionsTable holds the schema information for the "auth_sessions" table. + AuthSessionsTable = &schema.Table{ + Name: "auth_sessions", + Columns: AuthSessionsColumns, + PrimaryKey: []*schema.Column{AuthSessionsColumns[0]}, + } // ConnectorsColumns holds the columns for the "connectors" table. ConnectorsColumns = []*schema.Column{ {Name: "id", Type: field.TypeString, Unique: true, Size: 100, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, @@ -226,6 +241,7 @@ var ( Tables = []*schema.Table{ AuthCodesTable, AuthRequestsTable, + AuthSessionsTable, ConnectorsTable, DeviceRequestsTable, DeviceTokensTable, diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 8625471a..748022c9 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -14,6 +14,7 @@ import ( "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/ent/db/authcode" "github.com/dexidp/dex/storage/ent/db/authrequest" + "github.com/dexidp/dex/storage/ent/db/authsession" "github.com/dexidp/dex/storage/ent/db/connector" "github.com/dexidp/dex/storage/ent/db/devicerequest" "github.com/dexidp/dex/storage/ent/db/devicetoken" @@ -38,6 +39,7 @@ const ( // Node types. TypeAuthCode = "AuthCode" TypeAuthRequest = "AuthRequest" + TypeAuthSession = "AuthSession" TypeConnector = "Connector" TypeDeviceRequest = "DeviceRequest" TypeDeviceToken = "DeviceToken" @@ -2719,6 +2721,554 @@ func (m *AuthRequestMutation) ResetEdge(name string) error { return fmt.Errorf("unknown AuthRequest edge %s", name) } +// AuthSessionMutation represents an operation that mutates the AuthSession nodes in the graph. +type AuthSessionMutation struct { + config + op Op + typ string + id *string + client_states *[]byte + created_at *time.Time + last_activity *time.Time + ip_address *string + user_agent *string + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*AuthSession, error) + predicates []predicate.AuthSession +} + +var _ ent.Mutation = (*AuthSessionMutation)(nil) + +// authsessionOption allows management of the mutation configuration using functional options. +type authsessionOption func(*AuthSessionMutation) + +// newAuthSessionMutation creates new mutation for the AuthSession entity. +func newAuthSessionMutation(c config, op Op, opts ...authsessionOption) *AuthSessionMutation { + m := &AuthSessionMutation{ + config: c, + op: op, + typ: TypeAuthSession, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withAuthSessionID sets the ID field of the mutation. +func withAuthSessionID(id string) authsessionOption { + return func(m *AuthSessionMutation) { + var ( + err error + once sync.Once + value *AuthSession + ) + m.oldValue = func(ctx context.Context) (*AuthSession, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().AuthSession.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withAuthSession sets the old AuthSession of the mutation. +func withAuthSession(node *AuthSession) authsessionOption { + return func(m *AuthSessionMutation) { + m.oldValue = func(context.Context) (*AuthSession, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m AuthSessionMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m AuthSessionMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("db: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// SetID sets the value of the id field. Note that this +// operation is only accepted on creation of AuthSession entities. +func (m *AuthSessionMutation) SetID(id string) { + m.id = &id +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *AuthSessionMutation) ID() (id string, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *AuthSessionMutation) IDs(ctx context.Context) ([]string, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []string{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().AuthSession.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetClientStates sets the "client_states" field. +func (m *AuthSessionMutation) SetClientStates(b []byte) { + m.client_states = &b +} + +// ClientStates returns the value of the "client_states" field in the mutation. +func (m *AuthSessionMutation) ClientStates() (r []byte, exists bool) { + v := m.client_states + if v == nil { + return + } + return *v, true +} + +// OldClientStates returns the old "client_states" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldClientStates(ctx context.Context) (v []byte, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldClientStates is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldClientStates requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldClientStates: %w", err) + } + return oldValue.ClientStates, nil +} + +// ResetClientStates resets all changes to the "client_states" field. +func (m *AuthSessionMutation) ResetClientStates() { + m.client_states = nil +} + +// SetCreatedAt sets the "created_at" field. +func (m *AuthSessionMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *AuthSessionMutation) CreatedAt() (r time.Time, exists bool) { + v := m.created_at + if v == nil { + return + } + return *v, true +} + +// OldCreatedAt returns the old "created_at" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) + } + return oldValue.CreatedAt, nil +} + +// ResetCreatedAt resets all changes to the "created_at" field. +func (m *AuthSessionMutation) ResetCreatedAt() { + m.created_at = nil +} + +// SetLastActivity sets the "last_activity" field. +func (m *AuthSessionMutation) SetLastActivity(t time.Time) { + m.last_activity = &t +} + +// LastActivity returns the value of the "last_activity" field in the mutation. +func (m *AuthSessionMutation) LastActivity() (r time.Time, exists bool) { + v := m.last_activity + if v == nil { + return + } + return *v, true +} + +// OldLastActivity returns the old "last_activity" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldLastActivity(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldLastActivity is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldLastActivity requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldLastActivity: %w", err) + } + return oldValue.LastActivity, nil +} + +// ResetLastActivity resets all changes to the "last_activity" field. +func (m *AuthSessionMutation) ResetLastActivity() { + m.last_activity = nil +} + +// SetIPAddress sets the "ip_address" field. +func (m *AuthSessionMutation) SetIPAddress(s string) { + m.ip_address = &s +} + +// IPAddress returns the value of the "ip_address" field in the mutation. +func (m *AuthSessionMutation) IPAddress() (r string, exists bool) { + v := m.ip_address + if v == nil { + return + } + return *v, true +} + +// OldIPAddress returns the old "ip_address" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldIPAddress(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldIPAddress is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldIPAddress requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldIPAddress: %w", err) + } + return oldValue.IPAddress, nil +} + +// ResetIPAddress resets all changes to the "ip_address" field. +func (m *AuthSessionMutation) ResetIPAddress() { + m.ip_address = nil +} + +// SetUserAgent sets the "user_agent" field. +func (m *AuthSessionMutation) SetUserAgent(s string) { + m.user_agent = &s +} + +// UserAgent returns the value of the "user_agent" field in the mutation. +func (m *AuthSessionMutation) UserAgent() (r string, exists bool) { + v := m.user_agent + if v == nil { + return + } + return *v, true +} + +// OldUserAgent returns the old "user_agent" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldUserAgent(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserAgent is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserAgent requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserAgent: %w", err) + } + return oldValue.UserAgent, nil +} + +// ResetUserAgent resets all changes to the "user_agent" field. +func (m *AuthSessionMutation) ResetUserAgent() { + m.user_agent = nil +} + +// Where appends a list predicates to the AuthSessionMutation builder. +func (m *AuthSessionMutation) Where(ps ...predicate.AuthSession) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the AuthSessionMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *AuthSessionMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.AuthSession, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *AuthSessionMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *AuthSessionMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (AuthSession). +func (m *AuthSessionMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *AuthSessionMutation) Fields() []string { + fields := make([]string, 0, 5) + if m.client_states != nil { + fields = append(fields, authsession.FieldClientStates) + } + if m.created_at != nil { + fields = append(fields, authsession.FieldCreatedAt) + } + if m.last_activity != nil { + fields = append(fields, authsession.FieldLastActivity) + } + if m.ip_address != nil { + fields = append(fields, authsession.FieldIPAddress) + } + if m.user_agent != nil { + fields = append(fields, authsession.FieldUserAgent) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *AuthSessionMutation) Field(name string) (ent.Value, bool) { + switch name { + case authsession.FieldClientStates: + return m.ClientStates() + case authsession.FieldCreatedAt: + return m.CreatedAt() + case authsession.FieldLastActivity: + return m.LastActivity() + case authsession.FieldIPAddress: + return m.IPAddress() + case authsession.FieldUserAgent: + return m.UserAgent() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *AuthSessionMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case authsession.FieldClientStates: + return m.OldClientStates(ctx) + case authsession.FieldCreatedAt: + return m.OldCreatedAt(ctx) + case authsession.FieldLastActivity: + return m.OldLastActivity(ctx) + case authsession.FieldIPAddress: + return m.OldIPAddress(ctx) + case authsession.FieldUserAgent: + return m.OldUserAgent(ctx) + } + return nil, fmt.Errorf("unknown AuthSession field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *AuthSessionMutation) SetField(name string, value ent.Value) error { + switch name { + case authsession.FieldClientStates: + v, ok := value.([]byte) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetClientStates(v) + return nil + case authsession.FieldCreatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedAt(v) + return nil + case authsession.FieldLastActivity: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetLastActivity(v) + return nil + case authsession.FieldIPAddress: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetIPAddress(v) + return nil + case authsession.FieldUserAgent: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserAgent(v) + return nil + } + return fmt.Errorf("unknown AuthSession field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *AuthSessionMutation) AddedFields() []string { + return nil +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *AuthSessionMutation) AddedField(name string) (ent.Value, bool) { + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *AuthSessionMutation) AddField(name string, value ent.Value) error { + switch name { + } + return fmt.Errorf("unknown AuthSession numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *AuthSessionMutation) ClearedFields() []string { + return nil +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *AuthSessionMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *AuthSessionMutation) ClearField(name string) error { + return fmt.Errorf("unknown AuthSession nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *AuthSessionMutation) ResetField(name string) error { + switch name { + case authsession.FieldClientStates: + m.ResetClientStates() + return nil + case authsession.FieldCreatedAt: + m.ResetCreatedAt() + return nil + case authsession.FieldLastActivity: + m.ResetLastActivity() + return nil + case authsession.FieldIPAddress: + m.ResetIPAddress() + return nil + case authsession.FieldUserAgent: + m.ResetUserAgent() + return nil + } + return fmt.Errorf("unknown AuthSession field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *AuthSessionMutation) AddedEdges() []string { + edges := make([]string, 0, 0) + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *AuthSessionMutation) AddedIDs(name string) []ent.Value { + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *AuthSessionMutation) RemovedEdges() []string { + edges := make([]string, 0, 0) + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *AuthSessionMutation) RemovedIDs(name string) []ent.Value { + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *AuthSessionMutation) ClearedEdges() []string { + edges := make([]string, 0, 0) + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *AuthSessionMutation) EdgeCleared(name string) bool { + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *AuthSessionMutation) ClearEdge(name string) error { + return fmt.Errorf("unknown AuthSession unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *AuthSessionMutation) ResetEdge(name string) error { + return fmt.Errorf("unknown AuthSession edge %s", name) +} + // ConnectorMutation represents an operation that mutates the Connector nodes in the graph. type ConnectorMutation struct { config diff --git a/storage/ent/db/predicate/predicate.go b/storage/ent/db/predicate/predicate.go index 90ddffcb..9e977a2a 100644 --- a/storage/ent/db/predicate/predicate.go +++ b/storage/ent/db/predicate/predicate.go @@ -12,6 +12,9 @@ type AuthCode func(*sql.Selector) // AuthRequest is the predicate function for authrequest builders. type AuthRequest func(*sql.Selector) +// AuthSession is the predicate function for authsession builders. +type AuthSession func(*sql.Selector) + // Connector is the predicate function for connector builders. type Connector func(*sql.Selector) diff --git a/storage/ent/db/runtime.go b/storage/ent/db/runtime.go index b6f34aef..98c12ecc 100644 --- a/storage/ent/db/runtime.go +++ b/storage/ent/db/runtime.go @@ -7,6 +7,7 @@ import ( "github.com/dexidp/dex/storage/ent/db/authcode" "github.com/dexidp/dex/storage/ent/db/authrequest" + "github.com/dexidp/dex/storage/ent/db/authsession" "github.com/dexidp/dex/storage/ent/db/connector" "github.com/dexidp/dex/storage/ent/db/devicerequest" "github.com/dexidp/dex/storage/ent/db/devicetoken" @@ -87,6 +88,20 @@ func init() { authrequestDescID := authrequestFields[0].Descriptor() // authrequest.IDValidator is a validator for the "id" field. It is called by the builders before save. authrequest.IDValidator = authrequestDescID.Validators[0].(func(string) error) + authsessionFields := schema.AuthSession{}.Fields() + _ = authsessionFields + // authsessionDescIPAddress is the schema descriptor for ip_address field. + authsessionDescIPAddress := authsessionFields[4].Descriptor() + // authsession.DefaultIPAddress holds the default value on creation for the ip_address field. + authsession.DefaultIPAddress = authsessionDescIPAddress.Default.(string) + // authsessionDescUserAgent is the schema descriptor for user_agent field. + authsessionDescUserAgent := authsessionFields[5].Descriptor() + // authsession.DefaultUserAgent holds the default value on creation for the user_agent field. + authsession.DefaultUserAgent = authsessionDescUserAgent.Default.(string) + // authsessionDescID is the schema descriptor for id field. + authsessionDescID := authsessionFields[0].Descriptor() + // authsession.IDValidator is a validator for the "id" field. It is called by the builders before save. + authsession.IDValidator = authsessionDescID.Validators[0].(func(string) error) connectorFields := schema.Connector{}.Fields() _ = connectorFields // connectorDescType is the schema descriptor for type field. diff --git a/storage/ent/db/tx.go b/storage/ent/db/tx.go index 77be8a8f..94f27935 100644 --- a/storage/ent/db/tx.go +++ b/storage/ent/db/tx.go @@ -16,6 +16,8 @@ type Tx struct { AuthCode *AuthCodeClient // AuthRequest is the client for interacting with the AuthRequest builders. AuthRequest *AuthRequestClient + // AuthSession is the client for interacting with the AuthSession builders. + AuthSession *AuthSessionClient // Connector is the client for interacting with the Connector builders. Connector *ConnectorClient // DeviceRequest is the client for interacting with the DeviceRequest builders. @@ -167,6 +169,7 @@ func (tx *Tx) Client() *Client { func (tx *Tx) init() { tx.AuthCode = NewAuthCodeClient(tx.config) tx.AuthRequest = NewAuthRequestClient(tx.config) + tx.AuthSession = NewAuthSessionClient(tx.config) tx.Connector = NewConnectorClient(tx.config) tx.DeviceRequest = NewDeviceRequestClient(tx.config) tx.DeviceToken = NewDeviceTokenClient(tx.config) diff --git a/storage/ent/schema/authsession.go b/storage/ent/schema/authsession.go new file mode 100644 index 00000000..f0e57110 --- /dev/null +++ b/storage/ent/schema/authsession.go @@ -0,0 +1,37 @@ +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema/field" +) + +// AuthSession holds the schema definition for the AuthSession entity. +type AuthSession struct { + ent.Schema +} + +// Fields of the AuthSession. +func (AuthSession) Fields() []ent.Field { + return []ent.Field{ + field.Text("id"). + SchemaType(textSchema). + NotEmpty(). + Unique(), + field.Bytes("client_states"), + field.Time("created_at"). + SchemaType(timeSchema), + field.Time("last_activity"). + SchemaType(timeSchema), + field.Text("ip_address"). + SchemaType(textSchema). + Default(""), + field.Text("user_agent"). + SchemaType(textSchema). + Default(""), + } +} + +// Edges of the AuthSession. +func (AuthSession) Edges() []ent.Edge { + return []ent.Edge{} +} diff --git a/storage/etcd/etcd.go b/storage/etcd/etcd.go index d2248ea2..c05f5631 100644 --- a/storage/etcd/etcd.go +++ b/storage/etcd/etcd.go @@ -25,6 +25,7 @@ const ( deviceRequestPrefix = "device_req/" deviceTokenPrefix = "device_token/" userIdentityPrefix = "user_identity/" + authSessionPrefix = "auth_session/" // defaultStorageTimeout will be applied to all storage's operations. defaultStorageTimeout = 5 * time.Second @@ -422,6 +423,61 @@ func (c *conn) ListUserIdentities(ctx context.Context) (identities []storage.Use return identities, nil } +func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) error { + return c.txnCreate(ctx, keyAuthSession(s.ID), fromStorageAuthSession(s)) +} + +func (c *conn) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { + ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) + defer cancel() + var s AuthSession + if err := c.getKey(ctx, keyAuthSession(sessionID), &s); err != nil { + return storage.AuthSession{}, err + } + return toStorageAuthSession(s), nil +} + +func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { + ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) + defer cancel() + return c.txnUpdate(ctx, keyAuthSession(sessionID), func(currentValue []byte) ([]byte, error) { + var current AuthSession + if len(currentValue) > 0 { + if err := json.Unmarshal(currentValue, ¤t); err != nil { + return nil, err + } + } + updated, err := updater(toStorageAuthSession(current)) + if err != nil { + return nil, err + } + return json.Marshal(fromStorageAuthSession(updated)) + }) +} + +func (c *conn) ListAuthSessions(ctx context.Context) (sessions []storage.AuthSession, err error) { + ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) + defer cancel() + res, err := c.db.Get(ctx, authSessionPrefix, clientv3.WithPrefix()) + if err != nil { + return sessions, err + } + for _, v := range res.Kvs { + var s AuthSession + if err = json.Unmarshal(v.Value, &s); err != nil { + return sessions, err + } + sessions = append(sessions, toStorageAuthSession(s)) + } + return sessions, nil +} + +func (c *conn) DeleteAuthSession(ctx context.Context, sessionID string) error { + ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) + defer cancel() + return c.deleteKey(ctx, keyAuthSession(sessionID)) +} + func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) error { return c.txnCreate(ctx, keyID(connectorPrefix, connector.ID), connector) } @@ -617,6 +673,10 @@ func keyUserIdentity(userID, connectorID string) string { return userIdentityPrefix + strings.ToLower(userID+"|"+connectorID) } +func keyAuthSession(sessionID string) string { + return strings.ToLower(authSessionPrefix + sessionID) +} + func (c *conn) CreateDeviceRequest(ctx context.Context, d storage.DeviceRequest) error { return c.txnCreate(ctx, keyID(deviceRequestPrefix, d.UserCode), fromStorageDeviceRequest(d)) } diff --git a/storage/etcd/types.go b/storage/etcd/types.go index ea4d216c..3624de32 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -296,6 +296,42 @@ func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { return s } +// AuthSession is a mirrored struct from storage with JSON struct tags. +type AuthSession struct { + ID string `json:"id,omitempty"` + ClientStates map[string]*storage.ClientAuthState `json:"client_states,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastActivity time.Time `json:"last_activity"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` +} + +func fromStorageAuthSession(s storage.AuthSession) AuthSession { + return AuthSession{ + ID: s.ID, + ClientStates: s.ClientStates, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + } +} + +func toStorageAuthSession(s AuthSession) storage.AuthSession { + result := storage.AuthSession{ + ID: s.ID, + ClientStates: s.ClientStates, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + } + if result.ClientStates == nil { + result.ClientStates = make(map[string]*storage.ClientAuthState) + } + return result +} + // DeviceRequest is a mirrored struct from storage with JSON struct tags type DeviceRequest struct { UserCode string `json:"user_code"` diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go index fd756845..55ea7455 100644 --- a/storage/kubernetes/storage.go +++ b/storage/kubernetes/storage.go @@ -26,6 +26,7 @@ const ( kindDeviceRequest = "DeviceRequest" kindDeviceToken = "DeviceToken" kindUserIdentity = "UserIdentity" + kindAuthSession = "AuthSession" ) const ( @@ -40,6 +41,7 @@ const ( resourceDeviceRequest = "devicerequests" resourceDeviceToken = "devicetokens" resourceUserIdentity = "useridentities" + resourceAuthSession = "authsessions" ) const ( @@ -809,6 +811,54 @@ func (cli *client) ListUserIdentities(ctx context.Context) ([]storage.UserIdenti return userIdentities, nil } +func (cli *client) CreateAuthSession(ctx context.Context, s storage.AuthSession) error { + return cli.post(resourceAuthSession, cli.fromStorageAuthSession(s)) +} + +func (cli *client) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { + var s AuthSession + if err := cli.get(resourceAuthSession, sessionID, &s); err != nil { + return storage.AuthSession{}, err + } + return toStorageAuthSession(s), nil +} + +func (cli *client) UpdateAuthSession(ctx context.Context, sessionID string, updater func(old storage.AuthSession) (storage.AuthSession, error)) error { + return retryOnConflict(ctx, func() error { + var s AuthSession + if err := cli.get(resourceAuthSession, sessionID, &s); err != nil { + return err + } + + updated, err := updater(toStorageAuthSession(s)) + if err != nil { + return err + } + + newSession := cli.fromStorageAuthSession(updated) + newSession.ObjectMeta = s.ObjectMeta + return cli.put(resourceAuthSession, sessionID, newSession) + }) +} + +func (cli *client) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, error) { + var authSessionList AuthSessionList + if err := cli.list(resourceAuthSession, &authSessionList); err != nil { + return nil, fmt.Errorf("failed to list auth sessions: %v", err) + } + + sessions := make([]storage.AuthSession, len(authSessionList.AuthSessions)) + for i, s := range authSessionList.AuthSessions { + sessions[i] = toStorageAuthSession(s) + } + + return sessions, nil +} + +func (cli *client) DeleteAuthSession(ctx context.Context, sessionID string) error { + return cli.delete(resourceAuthSession, sessionID) +} + func isKubernetesAPIConflictError(err error) bool { if httpErr, ok := err.(httpError); ok { if httpErr.StatusCode() == http.StatusConflict { diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 44ab4943..473f59cc 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -243,6 +243,23 @@ func customResourceDefinitions(apiVersion string) []k8sapi.CustomResourceDefinit }, }, }, + { + ObjectMeta: k8sapi.ObjectMeta{ + Name: "authsessions.dex.coreos.com", + }, + TypeMeta: crdMeta, + Spec: k8sapi.CustomResourceDefinitionSpec{ + Group: apiGroup, + Version: version, + Versions: versions, + Scope: scope, + Names: k8sapi.CustomResourceDefinitionNames{ + Plural: "authsessions", + Singular: "authsession", + Kind: "AuthSession", + }, + }, + }, } } @@ -948,3 +965,55 @@ func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { } return s } + +// AuthSession is a Kubernetes representation of a storage AuthSession. +type AuthSession struct { + k8sapi.TypeMeta `json:",inline"` + k8sapi.ObjectMeta `json:"metadata,omitempty"` + + ClientStates map[string]*storage.ClientAuthState `json:"clientStates,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + LastActivity time.Time `json:"lastActivity,omitempty"` + IPAddress string `json:"ipAddress,omitempty"` + UserAgent string `json:"userAgent,omitempty"` +} + +// AuthSessionList is a list of AuthSessions. +type AuthSessionList struct { + k8sapi.TypeMeta `json:",inline"` + k8sapi.ListMeta `json:"metadata,omitempty"` + AuthSessions []AuthSession `json:"items"` +} + +func (cli *client) fromStorageAuthSession(s storage.AuthSession) AuthSession { + return AuthSession{ + TypeMeta: k8sapi.TypeMeta{ + Kind: kindAuthSession, + APIVersion: cli.apiVersion, + }, + ObjectMeta: k8sapi.ObjectMeta{ + Name: s.ID, + Namespace: cli.namespace, + }, + ClientStates: s.ClientStates, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + } +} + +func toStorageAuthSession(s AuthSession) storage.AuthSession { + result := storage.AuthSession{ + ID: s.ObjectMeta.Name, + ClientStates: s.ClientStates, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + } + if result.ClientStates == nil { + result.ClientStates = make(map[string]*storage.ClientAuthState) + } + return result +} diff --git a/storage/memory/memory.go b/storage/memory/memory.go index ecf3a410..483ed246 100644 --- a/storage/memory/memory.go +++ b/storage/memory/memory.go @@ -23,6 +23,7 @@ func New(logger *slog.Logger) storage.Storage { passwords: make(map[string]storage.Password), offlineSessions: make(map[compositeKeyID]storage.OfflineSessions), userIdentities: make(map[compositeKeyID]storage.UserIdentity), + authSessions: make(map[string]storage.AuthSession), connectors: make(map[string]storage.Connector), deviceRequests: make(map[string]storage.DeviceRequest), deviceTokens: make(map[string]storage.DeviceToken), @@ -51,6 +52,7 @@ type memStorage struct { passwords map[string]storage.Password offlineSessions map[compositeKeyID]storage.OfflineSessions userIdentities map[compositeKeyID]storage.UserIdentity + authSessions map[string]storage.AuthSession connectors map[string]storage.Connector deviceRequests map[string]storage.DeviceRequest deviceTokens map[string]storage.DeviceToken @@ -246,6 +248,61 @@ func (s *memStorage) ListUserIdentities(ctx context.Context) (identities []stora return } +func (s *memStorage) ListAuthSessions(ctx context.Context) (sessions []storage.AuthSession, err error) { + s.tx(func() { + for _, session := range s.authSessions { + sessions = append(sessions, session) + } + }) + return +} + +func (s *memStorage) CreateAuthSession(ctx context.Context, session storage.AuthSession) (err error) { + s.tx(func() { + if _, ok := s.authSessions[session.ID]; ok { + err = storage.ErrAlreadyExists + } else { + s.authSessions[session.ID] = session + } + }) + return +} + +func (s *memStorage) GetAuthSession(ctx context.Context, sessionID string) (session storage.AuthSession, err error) { + s.tx(func() { + var ok bool + if session, ok = s.authSessions[sessionID]; !ok { + err = storage.ErrNotFound + } + }) + return +} + +func (s *memStorage) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) (err error) { + s.tx(func() { + r, ok := s.authSessions[sessionID] + if !ok { + err = storage.ErrNotFound + return + } + if r, err = updater(r); err == nil { + s.authSessions[sessionID] = r + } + }) + return +} + +func (s *memStorage) DeleteAuthSession(ctx context.Context, sessionID string) (err error) { + s.tx(func() { + if _, ok := s.authSessions[sessionID]; !ok { + err = storage.ErrNotFound + return + } + delete(s.authSessions, sessionID) + }) + return +} + func (s *memStorage) CreateConnector(ctx context.Context, connector storage.Connector) (err error) { s.tx(func() { if _, ok := s.connectors[connector.ID]; ok { diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 04c9be3b..ab11713a 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -926,6 +926,136 @@ func (c *conn) DeleteUserIdentity(ctx context.Context, userID, connectorID strin return nil } +func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) error { + _, err := c.Exec(` + insert into auth_session ( + id, client_states, + created_at, last_activity, + ip_address, user_agent + ) + values ($1, $2, $3, $4, $5, $6); + `, + s.ID, encoder(s.ClientStates), + s.CreatedAt, s.LastActivity, + s.IPAddress, s.UserAgent, + ) + if err != nil { + if c.alreadyExistsCheck(err) { + return storage.ErrAlreadyExists + } + return fmt.Errorf("insert auth session: %v", err) + } + return nil +} + +func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { + return c.ExecTx(func(tx *trans) error { + s, err := getAuthSession(ctx, tx, sessionID) + if err != nil { + return err + } + + newSession, err := updater(s) + if err != nil { + return err + } + _, err = tx.Exec(` + update auth_session + set + client_states = $1, + last_activity = $2, + ip_address = $3, + user_agent = $4 + where id = $5; + `, + encoder(newSession.ClientStates), + newSession.LastActivity, + newSession.IPAddress, newSession.UserAgent, + sessionID, + ) + if err != nil { + return fmt.Errorf("update auth session: %v", err) + } + return nil + }) +} + +func (c *conn) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { + return getAuthSession(ctx, c, sessionID) +} + +func getAuthSession(ctx context.Context, q querier, sessionID string) (storage.AuthSession, error) { + return scanAuthSession(q.QueryRow(` + select + id, client_states, + created_at, last_activity, + ip_address, user_agent + from auth_session + where id = $1; + `, sessionID)) +} + +func scanAuthSession(s scanner) (session storage.AuthSession, err error) { + err = s.Scan( + &session.ID, decoder(&session.ClientStates), + &session.CreatedAt, &session.LastActivity, + &session.IPAddress, &session.UserAgent, + ) + if err != nil { + if err == sql.ErrNoRows { + return session, storage.ErrNotFound + } + return session, fmt.Errorf("select auth session: %v", err) + } + if session.ClientStates == nil { + session.ClientStates = make(map[string]*storage.ClientAuthState) + } + return session, nil +} + +func (c *conn) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, error) { + rows, err := c.Query(` + select + id, client_states, + created_at, last_activity, + ip_address, user_agent + from auth_session; + `) + if err != nil { + return nil, fmt.Errorf("query: %v", err) + } + defer rows.Close() + + var sessions []storage.AuthSession + for rows.Next() { + s, err := scanAuthSession(rows) + if err != nil { + return nil, err + } + sessions = append(sessions, s) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan: %v", err) + } + return sessions, nil +} + +func (c *conn) DeleteAuthSession(ctx context.Context, sessionID string) error { + result, err := c.Exec(`delete from auth_session where id = $1`, sessionID) + if err != nil { + return fmt.Errorf("delete auth_session: id = %s: %w", sessionID, err) + } + + n, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %v", err) + } + if n < 1 { + return storage.ErrNotFound + } + return nil +} + func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) error { grantTypes, err := json.Marshal(connector.GrantTypes) if err != nil { diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 053e9e6a..7561d146 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -409,4 +409,17 @@ var migrations = []migration{ );`, }, }, + { + stmts: []string{ + ` + create table auth_session ( + id text not null primary key, + client_states bytea not null, + created_at timestamptz not null, + last_activity timestamptz not null, + ip_address text not null default '', + user_agent text not null default '' + );`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index 3e332a80..963c7c67 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -84,6 +84,7 @@ type Storage interface { CreatePassword(ctx context.Context, p Password) error CreateOfflineSessions(ctx context.Context, s OfflineSessions) error CreateUserIdentity(ctx context.Context, u UserIdentity) error + CreateAuthSession(ctx context.Context, s AuthSession) error CreateConnector(ctx context.Context, c Connector) error CreateDeviceRequest(ctx context.Context, d DeviceRequest) error CreateDeviceToken(ctx context.Context, d DeviceToken) error @@ -98,6 +99,7 @@ type Storage interface { GetPassword(ctx context.Context, email string) (Password, error) GetOfflineSessions(ctx context.Context, userID string, connID string) (OfflineSessions, error) GetUserIdentity(ctx context.Context, userID, connectorID string) (UserIdentity, error) + GetAuthSession(ctx context.Context, sessionID string) (AuthSession, error) GetConnector(ctx context.Context, id string) (Connector, error) GetDeviceRequest(ctx context.Context, userCode string) (DeviceRequest, error) GetDeviceToken(ctx context.Context, deviceCode string) (DeviceToken, error) @@ -107,6 +109,7 @@ type Storage interface { ListPasswords(ctx context.Context) ([]Password, error) ListConnectors(ctx context.Context) ([]Connector, error) ListUserIdentities(ctx context.Context) ([]UserIdentity, error) + ListAuthSessions(ctx context.Context) ([]AuthSession, error) // Delete methods MUST be atomic. DeleteAuthRequest(ctx context.Context, id string) error @@ -116,6 +119,7 @@ type Storage interface { DeletePassword(ctx context.Context, email string) error DeleteOfflineSessions(ctx context.Context, userID string, connID string) error DeleteUserIdentity(ctx context.Context, userID, connectorID string) error + DeleteAuthSession(ctx context.Context, sessionID string) error DeleteConnector(ctx context.Context, id string) error // Update methods take a function for updating an object then performs that update within @@ -139,6 +143,7 @@ type Storage interface { UpdatePassword(ctx context.Context, email string, updater func(p Password) (Password, error)) error UpdateOfflineSessions(ctx context.Context, userID string, connID string, updater func(s OfflineSessions) (OfflineSessions, error)) error UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u UserIdentity) (UserIdentity, error)) error + UpdateAuthSession(ctx context.Context, sessionID string, updater func(s AuthSession) (AuthSession, error)) error UpdateConnector(ctx context.Context, id string, updater func(c Connector) (Connector, error)) error UpdateDeviceToken(ctx context.Context, deviceCode string, updater func(t DeviceToken) (DeviceToken, error)) error @@ -336,6 +341,26 @@ type UserIdentity struct { BlockedUntil time.Time } +// ClientAuthState represents the authentication state for a specific client within a session. +type ClientAuthState struct { + UserID string + ConnectorID string + Active bool + ExpiresAt time.Time + LastActivity time.Time + LastTokenIssuedAt time.Time +} + +// AuthSession represents a browser-bound authentication session. +type AuthSession struct { + ID string + ClientStates map[string]*ClientAuthState // clientID -> auth state + CreatedAt time.Time + LastActivity time.Time + IPAddress string + UserAgent string +} + // OfflineSessions objects are sessions pertaining to users with refresh tokens. type OfflineSessions struct { // UserID of an end user who has logged into the server. From de1e85a872ec6b89d04e45173e251f23290a75d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:12:37 +0100 Subject: [PATCH 164/222] build(deps): bump github/codeql-action from 4.32.6 to 4.33.0 (#4651) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.32.6 to 4.33.0. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/0d579ffd059c29b07949a3cce3983f0780820c98...b1bff81932f5cdfc8695c7752dcee935dcd061c8) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.33.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/analysis-scorecard.yaml | 2 +- .github/workflows/artifacts.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index 56306a64..258ec01b 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -42,6 +42,6 @@ jobs: retention-days: 5 - name: Upload results to GitHub Security tab - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 7169a50f..c0a4d113 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -264,6 +264,6 @@ jobs: overwrite: true - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v3.29.5 with: sarif_file: trivy-results.sarif From 72e63fa158a59ddb963e4a35cfe92bbffd90a85f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:14:30 +0100 Subject: [PATCH 165/222] build(deps): bump google.golang.org/api from 0.271.0 to 0.272.0 (#4652) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.271.0 to 0.272.0. - [Release notes](https://github.com/googleapis/google-api-go-client/releases) - [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md) - [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.271.0...v0.272.0) --- updated-dependencies: - dependency-name: google.golang.org/api dependency-version: 0.272.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index a9f5c352..10a040d1 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 - google.golang.org/api v0.271.0 + google.golang.org/api v0.272.0 google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 ) @@ -74,7 +74,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/googleapis/gax-go/v2 v2.18.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 @@ -121,8 +121,8 @@ require ( golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.42.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b8f97985..5abb122a 100644 --- a/go.sum +++ b/go.sum @@ -103,8 +103,8 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= -github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= +github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= 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= @@ -323,14 +323,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= -google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From d31ed974302d447f08ee87cef10f5c61f5b0e7a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:16:38 +0100 Subject: [PATCH 166/222] build(deps): bump github.com/mattn/go-sqlite3 from 1.14.34 to 1.14.37 (#4653) Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.34 to 1.14.37. - [Release notes](https://github.com/mattn/go-sqlite3/releases) - [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.34...v1.14.37) --- updated-dependencies: - dependency-name: github.com/mattn/go-sqlite3 dependency-version: 1.14.37 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 10a040d1..fcd42fdc 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/kylelemons/godebug v1.1.0 github.com/lib/pq v1.11.2 github.com/mattermost/xml-roundtrip-validator v0.1.0 - github.com/mattn/go-sqlite3 v1.14.34 + github.com/mattn/go-sqlite3 v1.14.37 github.com/oklog/run v1.2.0 github.com/openbao/openbao/api/v2 v2.5.1 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 5abb122a..8acb1fef 100644 --- a/go.sum +++ b/go.sum @@ -177,8 +177,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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= From 90fd51b81e170b869ac4eb680ad3eefe8ca9cd80 Mon Sep 17 00:00:00 2001 From: Yarden Shoham Date: Tue, 17 Mar 2026 12:19:31 +0200 Subject: [PATCH 167/222] feat(ldap): allow specifying multiple attributes on username input (#4061) In some use-cases, one would like to login using either their username or email. Administrators now don't have to choose a single field but may specify multiple fields to count as "username". Signed-off-by: Yarden Shoham --- connector/ldap/ldap.go | 59 ++++++++++++++++--- connector/ldap/ldap_test.go | 91 +++++++++++++++++++++++++---- connector/ldap/testdata/schema.ldif | 22 +++++++ 3 files changed, 152 insertions(+), 20 deletions(-) diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go index 4cb7180e..e01e2df8 100644 --- a/connector/ldap/ldap.go +++ b/connector/ldap/ldap.go @@ -34,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 @@ -58,6 +60,27 @@ 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"` @@ -110,9 +133,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 @@ -242,7 +266,6 @@ func (c *Config) openConnector(logger *slog.Logger) (*ldapConnector, error) { }{ {"host", c.Host}, {"userSearch.baseDN", c.UserSearch.BaseDN}, - {"userSearch.username", c.UserSearch.Username}, } for _, field := range requiredFields { @@ -251,6 +274,10 @@ func (c *Config) openConnector(logger *slog.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 @@ -298,7 +325,7 @@ func (c *Config) openConnector(logger *slog.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 + return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig, c.UserSearch.Username, logger}, nil } var ( @@ -314,6 +341,8 @@ type ldapConnector struct { tlsConfig *tls.Config + usernameAttrs []string + logger *slog.Logger } @@ -421,7 +450,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) } @@ -439,6 +480,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) } diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go index a9665e12..3335d56b 100644 --- a/connector/ldap/ldap_test.go +++ b/connector/ldap/ldap_test.go @@ -45,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{ { @@ -105,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{ { @@ -141,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{ @@ -184,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{ { @@ -238,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{ { @@ -284,7 +321,7 @@ 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{ { @@ -333,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{ { @@ -389,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" @@ -434,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{ { @@ -458,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{ { @@ -482,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{ { @@ -525,13 +562,43 @@ 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 = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=TestNestedGroups,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ diff --git a/connector/ldap/testdata/schema.ldif b/connector/ldap/testdata/schema.ldif index a7f1393d..ab133543 100644 --- a/connector/ldap/testdata/schema.ldif +++ b/connector/ldap/testdata/schema.ldif @@ -505,3 +505,25 @@ 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 From 1e65dda440cf2f7a5b190e4822b5408923972b5e Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Tue, 17 Mar 2026 14:13:27 +0100 Subject: [PATCH 168/222] fix(localSigner): simplify Algorithm method to always return RSA algorithm (#4655) Signed-off-by: maksim.nabokikh --- server/signer/local.go | 13 +++----- server/signer/local_test.go | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 server/signer/local_test.go diff --git a/server/signer/local.go b/server/signer/local.go index a210aaa0..da9f99fb 100644 --- a/server/signer/local.go +++ b/server/signer/local.go @@ -105,13 +105,8 @@ func (l *localSigner) ValidationKeys(ctx context.Context) ([]*jose.JSONWebKey, e return jwks, nil } -func (l *localSigner) Algorithm(ctx context.Context) (jose.SignatureAlgorithm, error) { - keys, err := l.storage.GetKeys(ctx) - if err != nil { - return "", fmt.Errorf("failed to get keys: %v", err) - } - if keys.SigningKey == nil { - return "", fmt.Errorf("no signing key found") - } - return signatureAlgorithm(keys.SigningKey) +func (l *localSigner) Algorithm(_ context.Context) (jose.SignatureAlgorithm, error) { + // Local signer always uses RSA keys (see rotationStrategy.key). + // TODO(nabokihms): add support for other key types and algorithms in the future. + return jose.RS256, nil } diff --git a/server/signer/local_test.go b/server/signer/local_test.go new file mode 100644 index 00000000..9e0f7e8d --- /dev/null +++ b/server/signer/local_test.go @@ -0,0 +1,60 @@ +package signer + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/storage/memory" +) + +func newTestLocalSigner(t *testing.T) *localSigner { + t.Helper() + + logger := slog.New(slog.DiscardHandler) + s := memory.New(logger) + r := &keyRotator{ + Storage: s, + strategy: defaultRotationStrategy(time.Hour, time.Hour), + now: time.Now, + logger: logger, + } + + return &localSigner{ + storage: s, + rotator: r, + logger: logger, + } +} + +func TestLocalSignerAlgorithm(t *testing.T) { + ls := newTestLocalSigner(t) + + // Algorithm should return RS256 even before keys are rotated (empty storage). + alg, err := ls.Algorithm(context.Background()) + require.NoError(t, err) + assert.Equal(t, jose.RS256, alg) +} + +func TestLocalSignerSignAndValidate(t *testing.T) { + ls := newTestLocalSigner(t) + ctx := context.Background() + + // Rotate keys so we have a signing key. + require.NoError(t, ls.rotator.rotate()) + + payload := []byte(`{"sub":"test-user"}`) + signed, err := ls.Sign(ctx, payload) + require.NoError(t, err) + assert.NotEmpty(t, signed) + + // Validation keys should be available. + keys, err := ls.ValidationKeys(ctx) + require.NoError(t, err) + assert.NotEmpty(t, keys) +} From 285d83b15a033788939a4e7d7d23fc347e800a8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:12:46 +0100 Subject: [PATCH 169/222] build(deps): bump google.golang.org/grpc from 1.79.2 to 1.79.3 (#4658) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.2 to 1.79.3. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.2...v1.79.3) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fcd42fdc..828101be 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.272.0 - google.golang.org/grpc v1.79.2 + google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 8acb1fef..f83c38d4 100644 --- a/go.sum +++ b/go.sum @@ -331,8 +331,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1: google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= 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= From 7f4a5a755b07ad66e5e532d5e734444ad0898898 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:13:32 +0100 Subject: [PATCH 170/222] build(deps): bump github.com/go-ldap/ldap/v3 from 3.4.12 to 3.4.13 (#4659) Bumps [github.com/go-ldap/ldap/v3](https://github.com/go-ldap/ldap) from 3.4.12 to 3.4.13. - [Release notes](https://github.com/go-ldap/ldap/releases) - [Commits](https://github.com/go-ldap/ldap/compare/v3.4.12...v3.4.13) --- updated-dependencies: - dependency-name: github.com/go-ldap/ldap/v3 dependency-version: 3.4.13 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 828101be..e28ab513 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/ghodss/yaml v1.0.0 github.com/go-jose/go-jose/v4 v4.1.3 - github.com/go-ldap/ldap/v3 v3.4.12 + github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-sql-driver/mysql v1.9.3 github.com/google/cel-go v0.27.0 github.com/google/uuid v1.6.0 @@ -50,7 +50,7 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.1 // indirect - github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect diff --git a/go.sum b/go.sum index f83c38d4..9b62623d 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= -github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= +github.com/Azure/go-ntlmssp v0.1.0/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= @@ -73,8 +73,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= -github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= +github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= +github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= 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= From 8af6d3c4be711f5fb97ef37116cab2803b6f0c0b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:20:31 +0100 Subject: [PATCH 171/222] build(deps): bump google.golang.org/grpc in /examples (#4661) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.2 to 1.79.3. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.2...v1.79.3) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/go.mod | 2 +- examples/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 1a6756f6..3ca1c1a1 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -7,7 +7,7 @@ require ( github.com/dexidp/dex/api/v2 v2.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/oauth2 v0.36.0 - google.golang.org/grpc v1.79.2 + google.golang.org/grpc v1.79.3 ) require ( diff --git a/examples/go.sum b/examples/go.sum index c0aa9ceb..a92e0dbc 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -49,8 +49,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From cbd7dd7f5a12c21017f74538cb4780370048beb4 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Wed, 18 Mar 2026 11:58:18 +0100 Subject: [PATCH 172/222] feat: Create AuthSessions and set cookies (#4650) Signed-off-by: maksim.nabokikh --- cmd/dex/config.go | 21 + cmd/dex/serve.go | 54 ++ examples/config-dev.yaml | 8 + server/handlers.go | 59 ++- server/server.go | 14 + server/session.go | 339 +++++++++++++ server/session_test.go | 586 ++++++++++++++++++++++ server/templates.go | 29 +- storage/conformance/conformance.go | 21 +- storage/ent/client/authsession.go | 27 +- storage/ent/client/types.go | 4 +- storage/ent/db/authsession.go | 35 +- storage/ent/db/authsession/authsession.go | 30 ++ storage/ent/db/authsession/where.go | 210 ++++++++ storage/ent/db/authsession_create.go | 54 ++ storage/ent/db/authsession_query.go | 8 +- storage/ent/db/authsession_update.go | 148 ++++++ storage/ent/db/migrate/schema.go | 3 + storage/ent/db/mutation.go | 164 +++++- storage/ent/db/runtime.go | 16 +- storage/ent/schema/authsession.go | 9 + storage/etcd/etcd.go | 18 +- storage/etcd/types.go | 12 +- storage/kubernetes/storage.go | 29 +- storage/kubernetes/types.go | 12 +- storage/memory/memory.go | 28 +- storage/sql/crud.go | 41 +- storage/sql/migrate.go | 7 +- storage/storage.go | 22 +- web/templates/password.html | 11 +- web/themes/dark/styles.css | 10 + web/themes/light/styles.css | 10 + 32 files changed, 1922 insertions(+), 117 deletions(-) create mode 100644 server/session.go create mode 100644 server/session_test.go diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 8efc3c38..32f8e832 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -65,6 +65,10 @@ 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"` } // Validate the configuration @@ -103,6 +107,11 @@ func (c Config) Validate() error { 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)") + } + return nil } @@ -585,3 +594,15 @@ 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"` +} diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 5cc0877a..27d2502c 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -415,6 +415,19 @@ 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 { @@ -759,3 +772,44 @@ func loadTLSConfig(certFile, keyFile, caFile string, baseConfig *tls.Config) (*t func recordBuildInfo() { buildInfo.WithLabelValues(version, runtime.Version(), fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)).Set(1) } + +func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { + sc := &server.SessionConfig{ + 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 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) + } + return sc, nil +} diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 0e8bb575..fe66df8e 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -95,6 +95,14 @@ telemetry: # validIfNotUsedFor: "2160h" # 90 days # absoluteLifetime: "3960h" # 165 days +# Authentication sessions configuration. +# Requires DEX_SESSIONS_ENABLED=true feature flag. +# sessions: +# cookieName: "dex_session" +# absoluteLifetime: "24h" +# validIfNotUsedFor: "1h" +# rememberMeCheckedByDefault: false + # Options for controlling the logger. # logger: # level: "debug" diff --git a/server/handlers.go b/server/handlers.go index 20fd85bf..360c46ea 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -365,6 +365,16 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } + // Check if there's a valid session that can skip login for this client. + if s.sessionConfig != nil { + if redirectURL, ok := s.trySessionLogin(ctx, r, w, authReq); ok { + if redirectURL != "" { + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + } + return + } + } + scopes := parseScopes(authReq.Scopes) // Work out where the "Select another login method" link should go. @@ -493,9 +503,11 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { return } + rememberMe := s.rememberMeDefault() + switch r.Method { case http.MethodGet: - if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink); err != nil { + if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink, rememberMe); err != nil { s.logger.ErrorContext(r.Context(), "server template error", "err", err) } case http.MethodPost: @@ -510,7 +522,7 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { return } if !ok { - if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink); err != nil { + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink, rememberMe); err != nil { s.logger.ErrorContext(r.Context(), "server template error", "err", err) } s.logger.ErrorContext(r.Context(), "failed login attempt: Invalid credentials.", "user", username) @@ -523,13 +535,21 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { return } + // Re-read auth request after finalizeLogin populated Claims. + authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + rememberMe := r.FormValue("remember_me") == "on" + if err := s.createOrUpdateAuthSession(ctx, r, w, authReq, rememberMe); err != nil { + s.logger.ErrorContext(ctx, "failed to create/update auth session", "err", err) + } + if canSkipApproval { - authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) - if err != nil { - s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } + // authReq was already re-read after finalizeLogin above. s.sendCodeResponse(w, r, authReq) return } @@ -628,13 +648,22 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) return } + // Re-read auth request after finalizeLogin populated Claims. + authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) + s.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. + if err := s.createOrUpdateAuthSession(ctx, r, w, authReq, s.sessionConfig != nil && s.sessionConfig.RememberMeCheckedByDefault); err != nil { + s.logger.ErrorContext(ctx, "failed to create/update auth session", "err", err) + } + if canSkipApproval { - authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) - if err != nil { - s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } + // authReq was already re-read after finalizeLogin above. s.sendCodeResponse(w, r, authReq) return } @@ -854,6 +883,8 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { } func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) { + s.updateSessionTokenIssuedAt(r, authReq.ClientID) + ctx := r.Context() if s.now().After(authReq.Expiry) { s.renderError(r, w, http.StatusBadRequest, "User session has expired.") diff --git a/server/server.go b/server/server.go index e63cb278..4acf6eb8 100644 --- a/server/server.go +++ b/server/server.go @@ -137,6 +137,17 @@ type Config struct { // 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 *SessionConfig +} + +// SessionConfig holds resolved session configuration. +type SessionConfig struct { + CookieName string + AbsoluteLifetime time.Duration + ValidIfNotUsedFor time.Duration + RememberMeCheckedByDefault bool } // WebConfig holds the server's frontend templates and asset configuration. @@ -226,6 +237,8 @@ type Server struct { logger *slog.Logger signer signer.Signer + + sessionConfig *SessionConfig } // NewServer constructs a server from the provided config. @@ -349,6 +362,7 @@ func newServer(ctx context.Context, c Config) (*Server, error) { passwordConnector: c.PasswordConnector, logger: c.Logger, signer: c.Signer, + sessionConfig: c.SessionConfig, } // Retrieves connector objects in backend storage. This list includes the static connectors diff --git a/server/session.go b/server/session.go new file mode 100644 index 00000000..f6355abf --- /dev/null +++ b/server/session.go @@ -0,0 +1,339 @@ +package server + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "path" + "strings" + + "github.com/dexidp/dex/storage" +) + +// rememberMeDefault returns a pointer to the default remember-me value if sessions are enabled, nil otherwise. +func (s *Server) rememberMeDefault() *bool { + if s.sessionConfig == nil { + return nil + } + v := s.sessionConfig.RememberMeCheckedByDefault + return &v +} + +// remoteIP returns the real IP from context (set by parseRealIP middleware) or falls back to r.RemoteAddr. +func remoteIP(r *http.Request) string { + if ip, ok := r.Context().Value(RequestKeyRemoteIP).(string); ok && ip != "" { + return ip + } + return r.RemoteAddr +} + +// sessionCookieValue encodes session identity into a cookie value. +// Format: base64url(userID) + "." + base64url(connectorID) + "." + nonce +// TODO(nabokihms): consider cookie encoding +func sessionCookieValue(userID, connectorID, nonce string) string { + return base64.RawURLEncoding.EncodeToString([]byte(userID)) + + "." + base64.RawURLEncoding.EncodeToString([]byte(connectorID)) + + "." + nonce +} + +// parseSessionCookie decodes a session cookie value into its components. +func parseSessionCookie(value string) (userID, connectorID, nonce string, err error) { + parts := strings.SplitN(value, ".", 3) + if len(parts) != 3 { + return "", "", "", fmt.Errorf("invalid session cookie format") + } + + userIDBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", "", "", fmt.Errorf("decode userID: %w", err) + } + + connectorIDBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", "", "", fmt.Errorf("decode connectorID: %w", err) + } + + return string(userIDBytes), string(connectorIDBytes), parts[2], nil +} + +func (s *Server) sessionCookiePath() string { + if s.issuerURL.Path == "" { + return "/" + } + return s.issuerURL.Path +} + +func (s *Server) setSessionCookie(w http.ResponseWriter, userID, connectorID, nonce string, rememberMe bool) { + cookie := &http.Cookie{ + Name: s.sessionConfig.CookieName, + Value: sessionCookieValue(userID, connectorID, nonce), + Path: s.sessionCookiePath(), + HttpOnly: true, + Secure: s.issuerURL.Scheme == "https", + SameSite: http.SameSiteLaxMode, + } + if rememberMe { + cookie.MaxAge = int(s.sessionConfig.AbsoluteLifetime.Seconds()) + } + http.SetCookie(w, cookie) +} + +func (s *Server) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: s.sessionConfig.CookieName, + Value: "", + Path: s.sessionCookiePath(), + HttpOnly: true, + Secure: s.issuerURL.Scheme == "https", + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// getValidAuthSession returns a valid, non-expired session or nil. +// It parses the session cookie to extract (userID, connectorID, nonce), +// looks up the session by composite key, and verifies the nonce. +// Invalid or expired session cookies are cleared automatically. +func (s *Server) getValidAuthSession(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq *storage.AuthRequest) *storage.AuthSession { + if s.sessionConfig == nil { + return nil + } + + cookie, err := r.Cookie(s.sessionConfig.CookieName) + if err != nil || cookie.Value == "" { + return nil + } + + userID, connectorID, nonce, err := parseSessionCookie(cookie.Value) + if err != nil { + s.logger.DebugContext(ctx, "invalid session cookie format", "err", err) + s.clearSessionCookie(w) + return nil + } + + session, err := s.storage.GetAuthSession(ctx, userID, connectorID) + if err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "failed to get auth session", "err", err) + } + s.clearSessionCookie(w) + return nil + } + + // Verify nonce to prevent cookie forgery. + if session.Nonce != nonce { + s.logger.DebugContext(ctx, "auth session nonce mismatch") + s.clearSessionCookie(w) + return nil + } + + now := s.now() + + // Check absolute lifetime. + if now.After(session.CreatedAt.Add(s.sessionConfig.AbsoluteLifetime)) { + s.logger.InfoContext(ctx, "auth session expired (absolute lifetime)", + "user_id", session.UserID, "connector_id", session.ConnectorID) + if err := s.storage.DeleteAuthSession(ctx, session.UserID, session.ConnectorID); err != nil { + s.logger.DebugContext(ctx, "failed to delete expired auth session", "err", err) + } + s.clearSessionCookie(w) + return nil + } + + // Check idle timeout. + if now.After(session.LastActivity.Add(s.sessionConfig.ValidIfNotUsedFor)) { + s.logger.InfoContext(ctx, "auth session expired (idle timeout)", + "user_id", session.UserID, "connector_id", session.ConnectorID) + if err := s.storage.DeleteAuthSession(ctx, session.UserID, session.ConnectorID); err != nil { + s.logger.DebugContext(ctx, "failed to delete expired auth session", "err", err) + } + s.clearSessionCookie(w) + return nil + } + + // Only reuse sessions from the same connector. + if session.ConnectorID != authReq.ConnectorID { + return nil + } + + return &session +} + +// createOrUpdateAuthSession creates a new session or updates an existing one +// after a successful login, and sets the session cookie. +// rememberMe controls whether the cookie is persistent (survives browser close). +func (s *Server) createOrUpdateAuthSession(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq storage.AuthRequest, rememberMe bool) error { + if s.sessionConfig == nil { + return nil + } + + now := s.now() + userID := authReq.Claims.UserID + connectorID := authReq.ConnectorID + + clientState := &storage.ClientAuthState{ + Active: true, + ExpiresAt: now.Add(s.sessionConfig.AbsoluteLifetime), + LastActivity: now, + } + + // Try to reuse existing session for this (userID, connectorID). + session, err := s.storage.GetAuthSession(ctx, userID, connectorID) + if err == nil { + // Session exists, update it. + s.logger.DebugContext(ctx, "updating existing auth session", + "user_id", userID, "connector_id", connectorID, "client_id", authReq.ClientID) + + if err := s.storage.UpdateAuthSession(ctx, userID, connectorID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.LastActivity = now + if old.ClientStates == nil { + old.ClientStates = make(map[string]*storage.ClientAuthState) + } + old.ClientStates[authReq.ClientID] = clientState + return old, nil + }); err != nil { + return fmt.Errorf("update auth session: %w", err) + } + + s.setSessionCookie(w, userID, connectorID, session.Nonce, rememberMe) + return nil + } + + // Unexpected error, exit the method. + if !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("get auth session: %w", err) + } + + nonce := storage.NewID() + newSession := storage.AuthSession{ + UserID: userID, + ConnectorID: connectorID, + Nonce: nonce, + ClientStates: map[string]*storage.ClientAuthState{ + authReq.ClientID: clientState, + }, + CreatedAt: now, + LastActivity: now, + IPAddress: remoteIP(r), + UserAgent: r.UserAgent(), + } + + if err := s.storage.CreateAuthSession(ctx, newSession); err != nil { + return fmt.Errorf("create auth session: %w", err) + } + + s.logger.DebugContext(ctx, "created new auth session", + "user_id", userID, "connector_id", connectorID, "client_id", authReq.ClientID) + s.setSessionCookie(w, userID, connectorID, nonce, rememberMe) + return nil +} + +// trySessionLogin checks if the user has a valid session for the same connector. +// If so, it finalizes login from the stored identity and returns a redirect URL. +// Returns ("", false) if session-based login is not possible. +func (s *Server) trySessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest) (string, bool) { + session := s.getValidAuthSession(ctx, w, r, authReq) + if session == nil { + return "", false + } + + clientState, ok := session.ClientStates[authReq.ClientID] + if !ok || !clientState.Active { + return "", false + } + + now := s.now() + if now.After(clientState.ExpiresAt) { + return "", false + } + + // Load identity from storage. + ui, err := s.storage.GetUserIdentity(ctx, session.UserID, session.ConnectorID) + if err != nil { + s.logger.ErrorContext(ctx, "session: failed to get user identity", "err", err) + return "", false + } + + 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 (without logging "login successful"). + if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.LoggedIn = true + a.Claims = claims + a.ConnectorID = session.ConnectorID + return a, nil + }); err != nil { + s.logger.ErrorContext(ctx, "session: failed to update auth request", "err", err) + return "", false + } + + s.logger.DebugContext(ctx, "session: re-authenticated from session", + "user_id", session.UserID, "connector_id", session.ConnectorID) + + // Update session activity. + _ = s.storage.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.LastActivity = now + if cs, ok := old.ClientStates[authReq.ClientID]; ok { + cs.LastActivity = now + } + return old, nil + }) + + // Build HMAC for approval URL. + h := hmac.New(sha256.New, authReq.HMACKey) + h.Write([]byte(authReq.ID)) + mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + // Skip approval if globally configured or user already consented to the requested scopes. + if !authReq.ForceApprovalPrompt && (s.skipApproval || scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes)) { + // Re-read to get the updated AuthRequest (LoggedIn, Claims, ConnectorID set above). + updated, err := s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(ctx, "session: failed to get auth request", "err", err) + return "", false + } + s.sendCodeResponse(w, r, updated) + return "", true + } + + returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + mac + return returnURL, true +} + +// updateSessionTokenIssuedAt updates the session's LastTokenIssuedAt for the given client. +func (s *Server) updateSessionTokenIssuedAt(r *http.Request, clientID string) { + if s.sessionConfig == nil { + return + } + + cookie, err := r.Cookie(s.sessionConfig.CookieName) + if err != nil || cookie.Value == "" { + return + } + + userID, connectorID, _, err := parseSessionCookie(cookie.Value) + if err != nil { + return + } + + now := s.now() + _ = s.storage.UpdateAuthSession(r.Context(), userID, connectorID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.LastActivity = now + if cs, ok := old.ClientStates[clientID]; ok { + cs.LastTokenIssuedAt = now + cs.LastActivity = now + } + return old, nil + }) +} diff --git a/server/session_test.go b/server/session_test.go new file mode 100644 index 00000000..757c11b3 --- /dev/null +++ b/server/session_test.go @@ -0,0 +1,586 @@ +package server + +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/storage" + "github.com/dexidp/dex/storage/memory" +) + +func newTestSessionServer(t *testing.T) *Server { + 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) + + return &Server{ + storage: memory.New(nil), + logger: slog.Default(), + now: func() time.Time { return now }, + sessionConfig: &SessionConfig{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: 1 * time.Hour, + }, + issuerURL: *issuerURL, + } +} + +func TestSetSessionCookie(t *testing.T) { + s := newTestSessionServer(t) + w := httptest.NewRecorder() + + s.setSessionCookie(w, "user1", "conn1", "nonce123", false) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + + c := cookies[0] + assert.Equal(t, "dex_session", c.Name) + assert.Equal(t, sessionCookieValue("user1", "conn1", "nonce123"), 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") + s.issuerURL = *u + w := httptest.NewRecorder() + + s.setSessionCookie(w, "user1", "conn1", "nonce123", 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.clearSessionCookie(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 + userID string + connectorID string + nonce string + }{ + {"simple", "user1", "ldap", "abc123"}, + {"with special chars", "user@example.com", "oidc-provider", "xyz789"}, + {"unicode", "юзер", "коннектор", "nonce"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := sessionCookieValue(tt.userID, tt.connectorID, tt.nonce) + gotUser, gotConn, gotNonce, err := parseSessionCookie(value) + require.NoError(t, err) + assert.Equal(t, tt.userID, gotUser) + assert.Equal(t, tt.connectorID, gotConn) + assert.Equal(t, tt.nonce, gotNonce) + }) + } +} + +func TestParseSessionCookie_Invalid(t *testing.T) { + //nolint:dogsled // only for tests + _, _, _, err := parseSessionCookie("invalid") + assert.Error(t, err) + //nolint:dogsled // only for tests + _, _, _, err = parseSessionCookie("a.b") + 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) + s.sessionConfig = nil + r := httptest.NewRequest(http.MethodGet, "/", nil) + assert.Nil(t, s.getValidAuthSession(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.getValidAuthSession(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.getValidAuthSession(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: sessionCookieValue("nouser", "noconn", "nonce")}) + w := httptest.NewRecorder() + assert.Nil(t, s.getValidAuthSession(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", + Nonce: 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", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "conn1", nonce)}) + + result := s.getValidAuthSession(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", + Nonce: 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", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "ldap", nonce)}) + + githubReq := &storage.AuthRequest{ConnectorID: "github"} + assert.Nil(t, s.getValidAuthSession(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", + Nonce: "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", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user2", "conn2", "wrong-nonce")}) + + conn2Req := &storage.AuthRequest{ConnectorID: "conn2"} + w := httptest.NewRecorder() + assert.Nil(t, s.getValidAuthSession(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", + Nonce: 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", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user3", "conn3", nonce)}) + + conn3Req := &storage.AuthRequest{ConnectorID: "conn3"} + w := httptest.NewRecorder() + assert.Nil(t, s.getValidAuthSession(ctx, w, r, conn3Req)) + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + + // Session should be deleted. + _, err := s.storage.GetAuthSession(ctx, "user3", "conn3") + 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", + Nonce: 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", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user4", "conn4", nonce)}) + + conn4Req := &storage.AuthRequest{ConnectorID: "conn4"} + w := httptest.NewRecorder() + assert.Nil(t, s.getValidAuthSession(ctx, w, r, conn4Req)) + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + + // Session should be deleted. + _, err := s.storage.GetAuthSession(ctx, "user4", "conn4") + 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.createOrUpdateAuthSession(ctx, r, w, authReq, false) + require.NoError(t, err) + + // Cookie should be set. + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + + userID, connectorID, nonce, err := parseSessionCookie(cookies[0].Value) + require.NoError(t, err) + assert.Equal(t, "user-1", userID) + assert.Equal(t, "mock", connectorID) + assert.NotEmpty(t, nonce) + + // Session should exist in storage. + session, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + 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.True(t, session.ClientStates["client-1"].Active) + }) + + 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", + Nonce: nonce, + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + 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", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, existingSession)) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + authReq := storage.AuthRequest{ + ID: "auth-2", + ClientID: "client-2", + Claims: storage.Claims{UserID: "user-1"}, + ConnectorID: "mock", + } + + err := s.createOrUpdateAuthSession(ctx, r, w, authReq, false) + require.NoError(t, err) + + // Cookie should be set with existing nonce. + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + _, _, gotNonce, err := parseSessionCookie(cookies[0].Value) + require.NoError(t, err) + assert.Equal(t, nonce, gotNonce) + + // Session should have both clients. + session, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + require.NoError(t, err) + assert.Len(t, session.ClientStates, 2) + assert.Contains(t, session.ClientStates, "client-1") + assert.Contains(t, session.ClientStates, "client-2") + }) + + t.Run("nil session config", func(t *testing.T) { + s := newTestSessionServer(t) + s.sessionConfig = nil + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + err := s.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 *Server) storage.AuthRequest { + t.Helper() + ctx := t.Context() + now := s.now() + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + 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.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", + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + return authReq +} + +func sessionCookieRequest(userID, connectorID, nonce string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue(userID, connectorID, nonce)}) + 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("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.True(t, ok) + // sendCodeResponse deletes the AuthRequest after processing, + // so we can't verify it here. The fact that ok=true is sufficient. + }) + + 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("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.True(t, ok) + assert.Contains(t, redirectURL, "/approval") + 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) + // Scopes match stored consent: {"client-1": {"openid", "email"}} + + r := sessionCookieRequest("user-1", "mock", "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("user-1", "mock", "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("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok) + }) + + t.Run("expired client state returns false", func(t *testing.T) { + s := newTestSessionServer(t) + ctx := t.Context() + now := s.now() + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-exp", + ConnectorID: "mock", + Nonce: "nonce-exp", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + Active: true, + ExpiresAt: now.Add(-1 * time.Hour), // expired + }, + }, + CreatedAt: now.Add(-2 * time.Hour), + LastActivity: now.Add(-1 * time.Minute), + })) + + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-exp", + ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-exp"}, + Consents: make(map[string][]string), + CreatedAt: now, + LastLogin: now, + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-1", + ConnectorID: "mock", + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("user-exp", "mock", "nonce-exp") + 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("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + require.True(t, ok) + + session, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + require.NoError(t, err) + assert.Equal(t, s.now(), session.LastActivity) + }) +} diff --git a/server/templates.go b/server/templates.go index b77663e1..b377d056 100644 --- a/server/templates.go +++ b/server/templates.go @@ -291,18 +291,31 @@ func (t *templates) login(r *http.Request, w http.ResponseWriter, connectors []c return renderTemplate(w, t.loginTmpl, data) } -func (t *templates) password(r *http.Request, w http.ResponseWriter, postURL, lastUsername, usernamePrompt string, lastWasInvalid bool, backLink string) error { +func (t *templates) password(r *http.Request, w http.ResponseWriter, postURL, lastUsername, usernamePrompt string, lastWasInvalid bool, backLink string, rememberMe *bool) error { if lastWasInvalid { w.WriteHeader(http.StatusUnauthorized) } data := struct { - PostURL string - BackLink string - Username string - UsernamePrompt string - Invalid bool - ReqPath string - }{postURL, backLink, lastUsername, usernamePrompt, lastWasInvalid, r.URL.Path} + PostURL string + BackLink string + Username string + UsernamePrompt string + Invalid bool + ReqPath string + ShowRememberMe bool + RememberMeChecked bool + }{ + PostURL: postURL, + BackLink: backLink, + Username: lastUsername, + UsernamePrompt: usernamePrompt, + Invalid: lastWasInvalid, + ReqPath: r.URL.Path, + ShowRememberMe: rememberMe != nil, + } + if rememberMe != nil { + data.RememberMeChecked = *rememberMe + } return renderTemplate(w, t.passwordTmpl, data) } diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index 94b23745..e4e30307 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -1174,11 +1174,11 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { now := time.Now().UTC().Round(time.Millisecond) session := storage.AuthSession{ - ID: storage.NewID(), + UserID: "user1", + ConnectorID: "conn1", + Nonce: storage.NewID(), ClientStates: map[string]*storage.ClientAuthState{ "client1": { - UserID: "user1", - ConnectorID: "conn1", Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now, @@ -1201,7 +1201,7 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { mustBeErrAlreadyExists(t, "auth session", err) // Get and compare. - got, err := s.GetAuthSession(ctx, session.ID) + got, err := s.GetAuthSession(ctx, session.UserID, session.ConnectorID) if err != nil { t.Fatalf("get auth session: %v", err) } @@ -1219,10 +1219,8 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { // Update: add a new client state. newNow := now.Add(time.Minute) - if err := s.UpdateAuthSession(ctx, session.ID, func(old storage.AuthSession) (storage.AuthSession, error) { + if err := s.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) { old.ClientStates["client2"] = &storage.ClientAuthState{ - UserID: "user2", - ConnectorID: "conn2", Active: true, ExpiresAt: newNow.Add(24 * time.Hour), LastActivity: newNow, @@ -1234,7 +1232,7 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { } // Get and verify update. - got, err = s.GetAuthSession(ctx, session.ID) + got, err = s.GetAuthSession(ctx, session.UserID, session.ConnectorID) if err != nil { t.Fatalf("get auth session after update: %v", err) } @@ -1244,9 +1242,6 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { if got.ClientStates["client2"] == nil { t.Fatal("expected client2 state to exist") } - if got.ClientStates["client2"].UserID != "user2" { - t.Errorf("expected client2 user_id to be user2, got %s", got.ClientStates["client2"].UserID) - } // List and verify. sessions, err := s.ListAuthSessions(ctx) @@ -1258,11 +1253,11 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { } // Delete. - if err := s.DeleteAuthSession(ctx, session.ID); err != nil { + if err := s.DeleteAuthSession(ctx, session.UserID, session.ConnectorID); err != nil { t.Fatalf("delete auth session: %v", err) } // Get deleted should return ErrNotFound. - _, err = s.GetAuthSession(ctx, session.ID) + _, err = s.GetAuthSession(ctx, session.UserID, session.ConnectorID) mustBeErrNotFound(t, "auth session", err) } diff --git a/storage/ent/client/authsession.go b/storage/ent/client/authsession.go index 439bdbe3..14120f1b 100644 --- a/storage/ent/client/authsession.go +++ b/storage/ent/client/authsession.go @@ -18,8 +18,12 @@ func (d *Database) CreateAuthSession(ctx context.Context, session storage.AuthSe return fmt.Errorf("encode client states auth session: %w", err) } + id := compositeKeyID(session.UserID, session.ConnectorID, d.hasher) _, err = d.client.AuthSession.Create(). - SetID(session.ID). + SetID(id). + SetUserID(session.UserID). + SetConnectorID(session.ConnectorID). + SetNonce(session.Nonce). SetClientStates(encodedStates). SetCreatedAt(session.CreatedAt). SetLastActivity(session.LastActivity). @@ -32,9 +36,10 @@ func (d *Database) CreateAuthSession(ctx context.Context, session storage.AuthSe return nil } -// GetAuthSession extracts an auth session from the database by session ID. -func (d *Database) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { - authSession, err := d.client.AuthSession.Get(ctx, sessionID) +// GetAuthSession extracts an auth session from the database by user ID and connector ID. +func (d *Database) GetAuthSession(ctx context.Context, userID, connectorID string) (storage.AuthSession, error) { + id := compositeKeyID(userID, connectorID, d.hasher) + authSession, err := d.client.AuthSession.Get(ctx, id) if err != nil { return storage.AuthSession{}, convertDBError("get auth session: %w", err) } @@ -55,9 +60,10 @@ func (d *Database) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, return storageAuthSessions, nil } -// DeleteAuthSession deletes an auth session from the database by session ID. -func (d *Database) DeleteAuthSession(ctx context.Context, sessionID string) error { - err := d.client.AuthSession.DeleteOneID(sessionID).Exec(ctx) +// DeleteAuthSession deletes an auth session from the database by user ID and connector ID. +func (d *Database) DeleteAuthSession(ctx context.Context, userID, connectorID string) error { + id := compositeKeyID(userID, connectorID, d.hasher) + err := d.client.AuthSession.DeleteOneID(id).Exec(ctx) if err != nil { return convertDBError("delete auth session: %w", err) } @@ -65,13 +71,14 @@ func (d *Database) DeleteAuthSession(ctx context.Context, sessionID string) erro } // UpdateAuthSession changes an auth session using an updater function. -func (d *Database) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { +func (d *Database) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { + id := compositeKeyID(userID, connectorID, d.hasher) tx, err := d.BeginTx(ctx) if err != nil { return convertDBError("update auth session tx: %w", err) } - authSession, err := tx.AuthSession.Get(ctx, sessionID) + authSession, err := tx.AuthSession.Get(ctx, id) if err != nil { return rollback(tx, "update auth session database: %w", err) } @@ -90,7 +97,7 @@ func (d *Database) UpdateAuthSession(ctx context.Context, sessionID string, upda return rollback(tx, "encode client states auth session: %w", err) } - _, err = tx.AuthSession.UpdateOneID(sessionID). + _, err = tx.AuthSession.UpdateOneID(id). SetClientStates(encodedStates). SetLastActivity(newSession.LastActivity). SetIPAddress(newSession.IPAddress). diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index f8e99c4a..14bcdfc3 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -198,7 +198,9 @@ func toStorageUserIdentity(u *db.UserIdentity) storage.UserIdentity { func toStorageAuthSession(s *db.AuthSession) storage.AuthSession { result := storage.AuthSession{ - ID: s.ID, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, IPAddress: s.IPAddress, diff --git a/storage/ent/db/authsession.go b/storage/ent/db/authsession.go index b81479c7..26882da8 100644 --- a/storage/ent/db/authsession.go +++ b/storage/ent/db/authsession.go @@ -17,6 +17,12 @@ type AuthSession struct { config `json:"-"` // ID of the ent. ID string `json:"id,omitempty"` + // UserID holds the value of the "user_id" field. + UserID string `json:"user_id,omitempty"` + // ConnectorID holds the value of the "connector_id" field. + ConnectorID string `json:"connector_id,omitempty"` + // Nonce holds the value of the "nonce" field. + Nonce string `json:"nonce,omitempty"` // ClientStates holds the value of the "client_states" field. ClientStates []byte `json:"client_states,omitempty"` // CreatedAt holds the value of the "created_at" field. @@ -37,7 +43,7 @@ func (*AuthSession) scanValues(columns []string) ([]any, error) { switch columns[i] { case authsession.FieldClientStates: values[i] = new([]byte) - case authsession.FieldID, authsession.FieldIPAddress, authsession.FieldUserAgent: + case authsession.FieldID, authsession.FieldUserID, authsession.FieldConnectorID, authsession.FieldNonce, authsession.FieldIPAddress, authsession.FieldUserAgent: values[i] = new(sql.NullString) case authsession.FieldCreatedAt, authsession.FieldLastActivity: values[i] = new(sql.NullTime) @@ -62,6 +68,24 @@ func (_m *AuthSession) assignValues(columns []string, values []any) error { } else if value.Valid { _m.ID = value.String } + case authsession.FieldUserID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field user_id", values[i]) + } else if value.Valid { + _m.UserID = value.String + } + case authsession.FieldConnectorID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field connector_id", values[i]) + } else if value.Valid { + _m.ConnectorID = value.String + } + case authsession.FieldNonce: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field nonce", values[i]) + } else if value.Valid { + _m.Nonce = value.String + } case authsession.FieldClientStates: if value, ok := values[i].(*[]byte); !ok { return fmt.Errorf("unexpected type %T for field client_states", values[i]) @@ -128,6 +152,15 @@ func (_m *AuthSession) String() string { var builder strings.Builder builder.WriteString("AuthSession(") builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("user_id=") + builder.WriteString(_m.UserID) + builder.WriteString(", ") + builder.WriteString("connector_id=") + builder.WriteString(_m.ConnectorID) + builder.WriteString(", ") + builder.WriteString("nonce=") + builder.WriteString(_m.Nonce) + builder.WriteString(", ") builder.WriteString("client_states=") builder.WriteString(fmt.Sprintf("%v", _m.ClientStates)) builder.WriteString(", ") diff --git a/storage/ent/db/authsession/authsession.go b/storage/ent/db/authsession/authsession.go index e2548f90..8e5bdfc2 100644 --- a/storage/ent/db/authsession/authsession.go +++ b/storage/ent/db/authsession/authsession.go @@ -11,6 +11,12 @@ const ( Label = "auth_session" // FieldID holds the string denoting the id field in the database. FieldID = "id" + // FieldUserID holds the string denoting the user_id field in the database. + FieldUserID = "user_id" + // FieldConnectorID holds the string denoting the connector_id field in the database. + FieldConnectorID = "connector_id" + // FieldNonce holds the string denoting the nonce field in the database. + FieldNonce = "nonce" // FieldClientStates holds the string denoting the client_states field in the database. FieldClientStates = "client_states" // FieldCreatedAt holds the string denoting the created_at field in the database. @@ -28,6 +34,9 @@ const ( // Columns holds all SQL columns for authsession fields. var Columns = []string{ FieldID, + FieldUserID, + FieldConnectorID, + FieldNonce, FieldClientStates, FieldCreatedAt, FieldLastActivity, @@ -46,6 +55,12 @@ func ValidColumn(column string) bool { } var ( + // UserIDValidator is a validator for the "user_id" field. It is called by the builders before save. + UserIDValidator func(string) error + // ConnectorIDValidator is a validator for the "connector_id" field. It is called by the builders before save. + ConnectorIDValidator func(string) error + // NonceValidator is a validator for the "nonce" field. It is called by the builders before save. + NonceValidator func(string) error // DefaultIPAddress holds the default value on creation for the "ip_address" field. DefaultIPAddress string // DefaultUserAgent holds the default value on creation for the "user_agent" field. @@ -62,6 +77,21 @@ func ByID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldID, opts...).ToFunc() } +// ByUserID orders the results by the user_id field. +func ByUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserID, opts...).ToFunc() +} + +// ByConnectorID orders the results by the connector_id field. +func ByConnectorID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldConnectorID, opts...).ToFunc() +} + +// ByNonce orders the results by the nonce field. +func ByNonce(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldNonce, opts...).ToFunc() +} + // ByCreatedAt orders the results by the created_at field. func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() diff --git a/storage/ent/db/authsession/where.go b/storage/ent/db/authsession/where.go index a4f52894..cdda7fb8 100644 --- a/storage/ent/db/authsession/where.go +++ b/storage/ent/db/authsession/where.go @@ -64,6 +64,21 @@ func IDContainsFold(id string) predicate.AuthSession { return predicate.AuthSession(sql.FieldContainsFold(FieldID, id)) } +// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. +func UserID(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldUserID, v)) +} + +// ConnectorID applies equality check predicate on the "connector_id" field. It's identical to ConnectorIDEQ. +func ConnectorID(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldConnectorID, v)) +} + +// Nonce applies equality check predicate on the "nonce" field. It's identical to NonceEQ. +func Nonce(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldNonce, v)) +} + // ClientStates applies equality check predicate on the "client_states" field. It's identical to ClientStatesEQ. func ClientStates(v []byte) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldClientStates, v)) @@ -89,6 +104,201 @@ func UserAgent(v string) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldUserAgent, v)) } +// UserIDEQ applies the EQ predicate on the "user_id" field. +func UserIDEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldUserID, v)) +} + +// UserIDNEQ applies the NEQ predicate on the "user_id" field. +func UserIDNEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldUserID, v)) +} + +// UserIDIn applies the In predicate on the "user_id" field. +func UserIDIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldUserID, vs...)) +} + +// UserIDNotIn applies the NotIn predicate on the "user_id" field. +func UserIDNotIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldUserID, vs...)) +} + +// UserIDGT applies the GT predicate on the "user_id" field. +func UserIDGT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldUserID, v)) +} + +// UserIDGTE applies the GTE predicate on the "user_id" field. +func UserIDGTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldUserID, v)) +} + +// UserIDLT applies the LT predicate on the "user_id" field. +func UserIDLT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldUserID, v)) +} + +// UserIDLTE applies the LTE predicate on the "user_id" field. +func UserIDLTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldUserID, v)) +} + +// UserIDContains applies the Contains predicate on the "user_id" field. +func UserIDContains(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContains(FieldUserID, v)) +} + +// UserIDHasPrefix applies the HasPrefix predicate on the "user_id" field. +func UserIDHasPrefix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasPrefix(FieldUserID, v)) +} + +// UserIDHasSuffix applies the HasSuffix predicate on the "user_id" field. +func UserIDHasSuffix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasSuffix(FieldUserID, v)) +} + +// UserIDEqualFold applies the EqualFold predicate on the "user_id" field. +func UserIDEqualFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEqualFold(FieldUserID, v)) +} + +// UserIDContainsFold applies the ContainsFold predicate on the "user_id" field. +func UserIDContainsFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContainsFold(FieldUserID, v)) +} + +// ConnectorIDEQ applies the EQ predicate on the "connector_id" field. +func ConnectorIDEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldConnectorID, v)) +} + +// ConnectorIDNEQ applies the NEQ predicate on the "connector_id" field. +func ConnectorIDNEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldConnectorID, v)) +} + +// ConnectorIDIn applies the In predicate on the "connector_id" field. +func ConnectorIDIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldConnectorID, vs...)) +} + +// ConnectorIDNotIn applies the NotIn predicate on the "connector_id" field. +func ConnectorIDNotIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldConnectorID, vs...)) +} + +// ConnectorIDGT applies the GT predicate on the "connector_id" field. +func ConnectorIDGT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldConnectorID, v)) +} + +// ConnectorIDGTE applies the GTE predicate on the "connector_id" field. +func ConnectorIDGTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldConnectorID, v)) +} + +// ConnectorIDLT applies the LT predicate on the "connector_id" field. +func ConnectorIDLT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldConnectorID, v)) +} + +// ConnectorIDLTE applies the LTE predicate on the "connector_id" field. +func ConnectorIDLTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldConnectorID, v)) +} + +// ConnectorIDContains applies the Contains predicate on the "connector_id" field. +func ConnectorIDContains(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContains(FieldConnectorID, v)) +} + +// ConnectorIDHasPrefix applies the HasPrefix predicate on the "connector_id" field. +func ConnectorIDHasPrefix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasPrefix(FieldConnectorID, v)) +} + +// ConnectorIDHasSuffix applies the HasSuffix predicate on the "connector_id" field. +func ConnectorIDHasSuffix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasSuffix(FieldConnectorID, v)) +} + +// ConnectorIDEqualFold applies the EqualFold predicate on the "connector_id" field. +func ConnectorIDEqualFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEqualFold(FieldConnectorID, v)) +} + +// ConnectorIDContainsFold applies the ContainsFold predicate on the "connector_id" field. +func ConnectorIDContainsFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContainsFold(FieldConnectorID, v)) +} + +// NonceEQ applies the EQ predicate on the "nonce" field. +func NonceEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldNonce, v)) +} + +// NonceNEQ applies the NEQ predicate on the "nonce" field. +func NonceNEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldNonce, v)) +} + +// NonceIn applies the In predicate on the "nonce" field. +func NonceIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldNonce, vs...)) +} + +// NonceNotIn applies the NotIn predicate on the "nonce" field. +func NonceNotIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldNonce, vs...)) +} + +// NonceGT applies the GT predicate on the "nonce" field. +func NonceGT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldNonce, v)) +} + +// NonceGTE applies the GTE predicate on the "nonce" field. +func NonceGTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldNonce, v)) +} + +// NonceLT applies the LT predicate on the "nonce" field. +func NonceLT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldNonce, v)) +} + +// NonceLTE applies the LTE predicate on the "nonce" field. +func NonceLTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldNonce, v)) +} + +// NonceContains applies the Contains predicate on the "nonce" field. +func NonceContains(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContains(FieldNonce, v)) +} + +// NonceHasPrefix applies the HasPrefix predicate on the "nonce" field. +func NonceHasPrefix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasPrefix(FieldNonce, v)) +} + +// NonceHasSuffix applies the HasSuffix predicate on the "nonce" field. +func NonceHasSuffix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasSuffix(FieldNonce, v)) +} + +// NonceEqualFold applies the EqualFold predicate on the "nonce" field. +func NonceEqualFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEqualFold(FieldNonce, v)) +} + +// NonceContainsFold applies the ContainsFold predicate on the "nonce" field. +func NonceContainsFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContainsFold(FieldNonce, v)) +} + // ClientStatesEQ applies the EQ predicate on the "client_states" field. func ClientStatesEQ(v []byte) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldClientStates, v)) diff --git a/storage/ent/db/authsession_create.go b/storage/ent/db/authsession_create.go index a680d675..080b094c 100644 --- a/storage/ent/db/authsession_create.go +++ b/storage/ent/db/authsession_create.go @@ -20,6 +20,24 @@ type AuthSessionCreate struct { hooks []Hook } +// SetUserID sets the "user_id" field. +func (_c *AuthSessionCreate) SetUserID(v string) *AuthSessionCreate { + _c.mutation.SetUserID(v) + return _c +} + +// SetConnectorID sets the "connector_id" field. +func (_c *AuthSessionCreate) SetConnectorID(v string) *AuthSessionCreate { + _c.mutation.SetConnectorID(v) + return _c +} + +// SetNonce sets the "nonce" field. +func (_c *AuthSessionCreate) SetNonce(v string) *AuthSessionCreate { + _c.mutation.SetNonce(v) + return _c +} + // SetClientStates sets the "client_states" field. func (_c *AuthSessionCreate) SetClientStates(v []byte) *AuthSessionCreate { _c.mutation.SetClientStates(v) @@ -119,6 +137,30 @@ func (_c *AuthSessionCreate) defaults() { // check runs all checks and user-defined validators on the builder. func (_c *AuthSessionCreate) check() error { + if _, ok := _c.mutation.UserID(); !ok { + return &ValidationError{Name: "user_id", err: errors.New(`db: missing required field "AuthSession.user_id"`)} + } + if v, ok := _c.mutation.UserID(); ok { + if err := authsession.UserIDValidator(v); err != nil { + return &ValidationError{Name: "user_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.user_id": %w`, err)} + } + } + if _, ok := _c.mutation.ConnectorID(); !ok { + return &ValidationError{Name: "connector_id", err: errors.New(`db: missing required field "AuthSession.connector_id"`)} + } + if v, ok := _c.mutation.ConnectorID(); ok { + if err := authsession.ConnectorIDValidator(v); err != nil { + return &ValidationError{Name: "connector_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.connector_id": %w`, err)} + } + } + if _, ok := _c.mutation.Nonce(); !ok { + return &ValidationError{Name: "nonce", err: errors.New(`db: missing required field "AuthSession.nonce"`)} + } + if v, ok := _c.mutation.Nonce(); ok { + if err := authsession.NonceValidator(v); err != nil { + return &ValidationError{Name: "nonce", err: fmt.Errorf(`db: validator failed for field "AuthSession.nonce": %w`, err)} + } + } if _, ok := _c.mutation.ClientStates(); !ok { return &ValidationError{Name: "client_states", err: errors.New(`db: missing required field "AuthSession.client_states"`)} } @@ -174,6 +216,18 @@ func (_c *AuthSessionCreate) createSpec() (*AuthSession, *sqlgraph.CreateSpec) { _node.ID = id _spec.ID.Value = id } + if value, ok := _c.mutation.UserID(); ok { + _spec.SetField(authsession.FieldUserID, field.TypeString, value) + _node.UserID = value + } + if value, ok := _c.mutation.ConnectorID(); ok { + _spec.SetField(authsession.FieldConnectorID, field.TypeString, value) + _node.ConnectorID = value + } + if value, ok := _c.mutation.Nonce(); ok { + _spec.SetField(authsession.FieldNonce, field.TypeString, value) + _node.Nonce = value + } if value, ok := _c.mutation.ClientStates(); ok { _spec.SetField(authsession.FieldClientStates, field.TypeBytes, value) _node.ClientStates = value diff --git a/storage/ent/db/authsession_query.go b/storage/ent/db/authsession_query.go index dc3528f9..6550b55d 100644 --- a/storage/ent/db/authsession_query.go +++ b/storage/ent/db/authsession_query.go @@ -262,12 +262,12 @@ func (_q *AuthSessionQuery) Clone() *AuthSessionQuery { // Example: // // var v []struct { -// ClientStates []byte `json:"client_states,omitempty"` +// UserID string `json:"user_id,omitempty"` // Count int `json:"count,omitempty"` // } // // client.AuthSession.Query(). -// GroupBy(authsession.FieldClientStates). +// GroupBy(authsession.FieldUserID). // Aggregate(db.Count()). // Scan(ctx, &v) func (_q *AuthSessionQuery) GroupBy(field string, fields ...string) *AuthSessionGroupBy { @@ -285,11 +285,11 @@ func (_q *AuthSessionQuery) GroupBy(field string, fields ...string) *AuthSession // Example: // // var v []struct { -// ClientStates []byte `json:"client_states,omitempty"` +// UserID string `json:"user_id,omitempty"` // } // // client.AuthSession.Query(). -// Select(authsession.FieldClientStates). +// Select(authsession.FieldUserID). // Scan(ctx, &v) func (_q *AuthSessionQuery) Select(fields ...string) *AuthSessionSelect { _q.ctx.Fields = append(_q.ctx.Fields, fields...) diff --git a/storage/ent/db/authsession_update.go b/storage/ent/db/authsession_update.go index e91999bd..5457b04c 100644 --- a/storage/ent/db/authsession_update.go +++ b/storage/ent/db/authsession_update.go @@ -28,6 +28,48 @@ func (_u *AuthSessionUpdate) Where(ps ...predicate.AuthSession) *AuthSessionUpda return _u } +// SetUserID sets the "user_id" field. +func (_u *AuthSessionUpdate) SetUserID(v string) *AuthSessionUpdate { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableUserID(v *string) *AuthSessionUpdate { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetConnectorID sets the "connector_id" field. +func (_u *AuthSessionUpdate) SetConnectorID(v string) *AuthSessionUpdate { + _u.mutation.SetConnectorID(v) + return _u +} + +// SetNillableConnectorID sets the "connector_id" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableConnectorID(v *string) *AuthSessionUpdate { + if v != nil { + _u.SetConnectorID(*v) + } + return _u +} + +// SetNonce sets the "nonce" field. +func (_u *AuthSessionUpdate) SetNonce(v string) *AuthSessionUpdate { + _u.mutation.SetNonce(v) + return _u +} + +// SetNillableNonce sets the "nonce" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableNonce(v *string) *AuthSessionUpdate { + if v != nil { + _u.SetNonce(*v) + } + return _u +} + // SetClientStates sets the "client_states" field. func (_u *AuthSessionUpdate) SetClientStates(v []byte) *AuthSessionUpdate { _u.mutation.SetClientStates(v) @@ -122,7 +164,30 @@ func (_u *AuthSessionUpdate) ExecX(ctx context.Context) { } } +// check runs all checks and user-defined validators on the builder. +func (_u *AuthSessionUpdate) check() error { + if v, ok := _u.mutation.UserID(); ok { + if err := authsession.UserIDValidator(v); err != nil { + return &ValidationError{Name: "user_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.user_id": %w`, err)} + } + } + if v, ok := _u.mutation.ConnectorID(); ok { + if err := authsession.ConnectorIDValidator(v); err != nil { + return &ValidationError{Name: "connector_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.connector_id": %w`, err)} + } + } + if v, ok := _u.mutation.Nonce(); ok { + if err := authsession.NonceValidator(v); err != nil { + return &ValidationError{Name: "nonce", err: fmt.Errorf(`db: validator failed for field "AuthSession.nonce": %w`, err)} + } + } + return nil +} + func (_u *AuthSessionUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } _spec := sqlgraph.NewUpdateSpec(authsession.Table, authsession.Columns, sqlgraph.NewFieldSpec(authsession.FieldID, field.TypeString)) if ps := _u.mutation.predicates; len(ps) > 0 { _spec.Predicate = func(selector *sql.Selector) { @@ -131,6 +196,15 @@ func (_u *AuthSessionUpdate) sqlSave(ctx context.Context) (_node int, err error) } } } + if value, ok := _u.mutation.UserID(); ok { + _spec.SetField(authsession.FieldUserID, field.TypeString, value) + } + if value, ok := _u.mutation.ConnectorID(); ok { + _spec.SetField(authsession.FieldConnectorID, field.TypeString, value) + } + if value, ok := _u.mutation.Nonce(); ok { + _spec.SetField(authsession.FieldNonce, field.TypeString, value) + } if value, ok := _u.mutation.ClientStates(); ok { _spec.SetField(authsession.FieldClientStates, field.TypeBytes, value) } @@ -166,6 +240,48 @@ type AuthSessionUpdateOne struct { mutation *AuthSessionMutation } +// SetUserID sets the "user_id" field. +func (_u *AuthSessionUpdateOne) SetUserID(v string) *AuthSessionUpdateOne { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableUserID(v *string) *AuthSessionUpdateOne { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetConnectorID sets the "connector_id" field. +func (_u *AuthSessionUpdateOne) SetConnectorID(v string) *AuthSessionUpdateOne { + _u.mutation.SetConnectorID(v) + return _u +} + +// SetNillableConnectorID sets the "connector_id" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableConnectorID(v *string) *AuthSessionUpdateOne { + if v != nil { + _u.SetConnectorID(*v) + } + return _u +} + +// SetNonce sets the "nonce" field. +func (_u *AuthSessionUpdateOne) SetNonce(v string) *AuthSessionUpdateOne { + _u.mutation.SetNonce(v) + return _u +} + +// SetNillableNonce sets the "nonce" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableNonce(v *string) *AuthSessionUpdateOne { + if v != nil { + _u.SetNonce(*v) + } + return _u +} + // SetClientStates sets the "client_states" field. func (_u *AuthSessionUpdateOne) SetClientStates(v []byte) *AuthSessionUpdateOne { _u.mutation.SetClientStates(v) @@ -273,7 +389,30 @@ func (_u *AuthSessionUpdateOne) ExecX(ctx context.Context) { } } +// check runs all checks and user-defined validators on the builder. +func (_u *AuthSessionUpdateOne) check() error { + if v, ok := _u.mutation.UserID(); ok { + if err := authsession.UserIDValidator(v); err != nil { + return &ValidationError{Name: "user_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.user_id": %w`, err)} + } + } + if v, ok := _u.mutation.ConnectorID(); ok { + if err := authsession.ConnectorIDValidator(v); err != nil { + return &ValidationError{Name: "connector_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.connector_id": %w`, err)} + } + } + if v, ok := _u.mutation.Nonce(); ok { + if err := authsession.NonceValidator(v); err != nil { + return &ValidationError{Name: "nonce", err: fmt.Errorf(`db: validator failed for field "AuthSession.nonce": %w`, err)} + } + } + return nil +} + func (_u *AuthSessionUpdateOne) sqlSave(ctx context.Context) (_node *AuthSession, err error) { + if err := _u.check(); err != nil { + return _node, err + } _spec := sqlgraph.NewUpdateSpec(authsession.Table, authsession.Columns, sqlgraph.NewFieldSpec(authsession.FieldID, field.TypeString)) id, ok := _u.mutation.ID() if !ok { @@ -299,6 +438,15 @@ func (_u *AuthSessionUpdateOne) sqlSave(ctx context.Context) (_node *AuthSession } } } + if value, ok := _u.mutation.UserID(); ok { + _spec.SetField(authsession.FieldUserID, field.TypeString, value) + } + if value, ok := _u.mutation.ConnectorID(); ok { + _spec.SetField(authsession.FieldConnectorID, field.TypeString, value) + } + if value, ok := _u.mutation.Nonce(); ok { + _spec.SetField(authsession.FieldNonce, field.TypeString, value) + } if value, ok := _u.mutation.ClientStates(); ok { _spec.SetField(authsession.FieldClientStates, field.TypeBytes, value) } diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index 786598c0..31c0d211 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -66,6 +66,9 @@ var ( // AuthSessionsColumns holds the columns for the "auth_sessions" table. AuthSessionsColumns = []*schema.Column{ {Name: "id", Type: field.TypeString, Unique: true, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "user_id", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "connector_id", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "nonce", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "client_states", Type: field.TypeBytes}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "last_activity", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 748022c9..ffbb9b2e 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -2727,6 +2727,9 @@ type AuthSessionMutation struct { op Op typ string id *string + user_id *string + connector_id *string + nonce *string client_states *[]byte created_at *time.Time last_activity *time.Time @@ -2842,6 +2845,114 @@ func (m *AuthSessionMutation) IDs(ctx context.Context) ([]string, error) { } } +// SetUserID sets the "user_id" field. +func (m *AuthSessionMutation) SetUserID(s string) { + m.user_id = &s +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *AuthSessionMutation) UserID() (r string, exists bool) { + v := m.user_id + if v == nil { + return + } + return *v, true +} + +// OldUserID returns the old "user_id" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldUserID(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserID: %w", err) + } + return oldValue.UserID, nil +} + +// ResetUserID resets all changes to the "user_id" field. +func (m *AuthSessionMutation) ResetUserID() { + m.user_id = nil +} + +// SetConnectorID sets the "connector_id" field. +func (m *AuthSessionMutation) SetConnectorID(s string) { + m.connector_id = &s +} + +// ConnectorID returns the value of the "connector_id" field in the mutation. +func (m *AuthSessionMutation) ConnectorID() (r string, exists bool) { + v := m.connector_id + if v == nil { + return + } + return *v, true +} + +// OldConnectorID returns the old "connector_id" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldConnectorID(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldConnectorID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldConnectorID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldConnectorID: %w", err) + } + return oldValue.ConnectorID, nil +} + +// ResetConnectorID resets all changes to the "connector_id" field. +func (m *AuthSessionMutation) ResetConnectorID() { + m.connector_id = nil +} + +// SetNonce sets the "nonce" field. +func (m *AuthSessionMutation) SetNonce(s string) { + m.nonce = &s +} + +// Nonce returns the value of the "nonce" field in the mutation. +func (m *AuthSessionMutation) Nonce() (r string, exists bool) { + v := m.nonce + if v == nil { + return + } + return *v, true +} + +// OldNonce returns the old "nonce" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldNonce(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldNonce is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldNonce requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldNonce: %w", err) + } + return oldValue.Nonce, nil +} + +// ResetNonce resets all changes to the "nonce" field. +func (m *AuthSessionMutation) ResetNonce() { + m.nonce = nil +} + // SetClientStates sets the "client_states" field. func (m *AuthSessionMutation) SetClientStates(b []byte) { m.client_states = &b @@ -3056,7 +3167,16 @@ func (m *AuthSessionMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AuthSessionMutation) Fields() []string { - fields := make([]string, 0, 5) + fields := make([]string, 0, 8) + if m.user_id != nil { + fields = append(fields, authsession.FieldUserID) + } + if m.connector_id != nil { + fields = append(fields, authsession.FieldConnectorID) + } + if m.nonce != nil { + fields = append(fields, authsession.FieldNonce) + } if m.client_states != nil { fields = append(fields, authsession.FieldClientStates) } @@ -3080,6 +3200,12 @@ func (m *AuthSessionMutation) Fields() []string { // schema. func (m *AuthSessionMutation) Field(name string) (ent.Value, bool) { switch name { + case authsession.FieldUserID: + return m.UserID() + case authsession.FieldConnectorID: + return m.ConnectorID() + case authsession.FieldNonce: + return m.Nonce() case authsession.FieldClientStates: return m.ClientStates() case authsession.FieldCreatedAt: @@ -3099,6 +3225,12 @@ func (m *AuthSessionMutation) Field(name string) (ent.Value, bool) { // database failed. func (m *AuthSessionMutation) OldField(ctx context.Context, name string) (ent.Value, error) { switch name { + case authsession.FieldUserID: + return m.OldUserID(ctx) + case authsession.FieldConnectorID: + return m.OldConnectorID(ctx) + case authsession.FieldNonce: + return m.OldNonce(ctx) case authsession.FieldClientStates: return m.OldClientStates(ctx) case authsession.FieldCreatedAt: @@ -3118,6 +3250,27 @@ func (m *AuthSessionMutation) OldField(ctx context.Context, name string) (ent.Va // type. func (m *AuthSessionMutation) SetField(name string, value ent.Value) error { switch name { + case authsession.FieldUserID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserID(v) + return nil + case authsession.FieldConnectorID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetConnectorID(v) + return nil + case authsession.FieldNonce: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetNonce(v) + return nil case authsession.FieldClientStates: v, ok := value.([]byte) if !ok { @@ -3202,6 +3355,15 @@ func (m *AuthSessionMutation) ClearField(name string) error { // It returns an error if the field is not defined in the schema. func (m *AuthSessionMutation) ResetField(name string) error { switch name { + case authsession.FieldUserID: + m.ResetUserID() + return nil + case authsession.FieldConnectorID: + m.ResetConnectorID() + return nil + case authsession.FieldNonce: + m.ResetNonce() + return nil case authsession.FieldClientStates: m.ResetClientStates() return nil diff --git a/storage/ent/db/runtime.go b/storage/ent/db/runtime.go index 98c12ecc..3973c7e7 100644 --- a/storage/ent/db/runtime.go +++ b/storage/ent/db/runtime.go @@ -90,12 +90,24 @@ func init() { authrequest.IDValidator = authrequestDescID.Validators[0].(func(string) error) authsessionFields := schema.AuthSession{}.Fields() _ = authsessionFields + // authsessionDescUserID is the schema descriptor for user_id field. + authsessionDescUserID := authsessionFields[1].Descriptor() + // authsession.UserIDValidator is a validator for the "user_id" field. It is called by the builders before save. + authsession.UserIDValidator = authsessionDescUserID.Validators[0].(func(string) error) + // authsessionDescConnectorID is the schema descriptor for connector_id field. + authsessionDescConnectorID := authsessionFields[2].Descriptor() + // authsession.ConnectorIDValidator is a validator for the "connector_id" field. It is called by the builders before save. + authsession.ConnectorIDValidator = authsessionDescConnectorID.Validators[0].(func(string) error) + // authsessionDescNonce is the schema descriptor for nonce field. + authsessionDescNonce := authsessionFields[3].Descriptor() + // authsession.NonceValidator is a validator for the "nonce" field. It is called by the builders before save. + authsession.NonceValidator = authsessionDescNonce.Validators[0].(func(string) error) // authsessionDescIPAddress is the schema descriptor for ip_address field. - authsessionDescIPAddress := authsessionFields[4].Descriptor() + authsessionDescIPAddress := authsessionFields[7].Descriptor() // authsession.DefaultIPAddress holds the default value on creation for the ip_address field. authsession.DefaultIPAddress = authsessionDescIPAddress.Default.(string) // authsessionDescUserAgent is the schema descriptor for user_agent field. - authsessionDescUserAgent := authsessionFields[5].Descriptor() + authsessionDescUserAgent := authsessionFields[8].Descriptor() // authsession.DefaultUserAgent holds the default value on creation for the user_agent field. authsession.DefaultUserAgent = authsessionDescUserAgent.Default.(string) // authsessionDescID is the schema descriptor for id field. diff --git a/storage/ent/schema/authsession.go b/storage/ent/schema/authsession.go index f0e57110..ff76ab61 100644 --- a/storage/ent/schema/authsession.go +++ b/storage/ent/schema/authsession.go @@ -17,6 +17,15 @@ func (AuthSession) Fields() []ent.Field { SchemaType(textSchema). NotEmpty(). Unique(), + field.Text("user_id"). + SchemaType(textSchema). + NotEmpty(), + field.Text("connector_id"). + SchemaType(textSchema). + NotEmpty(), + field.Text("nonce"). + SchemaType(textSchema). + NotEmpty(), field.Bytes("client_states"), field.Time("created_at"). SchemaType(timeSchema), diff --git a/storage/etcd/etcd.go b/storage/etcd/etcd.go index c05f5631..e21acc9b 100644 --- a/storage/etcd/etcd.go +++ b/storage/etcd/etcd.go @@ -424,23 +424,23 @@ func (c *conn) ListUserIdentities(ctx context.Context) (identities []storage.Use } func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) error { - return c.txnCreate(ctx, keyAuthSession(s.ID), fromStorageAuthSession(s)) + return c.txnCreate(ctx, keyAuthSession(s.UserID, s.ConnectorID), fromStorageAuthSession(s)) } -func (c *conn) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { +func (c *conn) GetAuthSession(ctx context.Context, userID, connectorID string) (storage.AuthSession, error) { ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) defer cancel() var s AuthSession - if err := c.getKey(ctx, keyAuthSession(sessionID), &s); err != nil { + if err := c.getKey(ctx, keyAuthSession(userID, connectorID), &s); err != nil { return storage.AuthSession{}, err } return toStorageAuthSession(s), nil } -func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { +func (c *conn) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) defer cancel() - return c.txnUpdate(ctx, keyAuthSession(sessionID), func(currentValue []byte) ([]byte, error) { + return c.txnUpdate(ctx, keyAuthSession(userID, connectorID), func(currentValue []byte) ([]byte, error) { var current AuthSession if len(currentValue) > 0 { if err := json.Unmarshal(currentValue, ¤t); err != nil { @@ -472,10 +472,10 @@ func (c *conn) ListAuthSessions(ctx context.Context) (sessions []storage.AuthSes return sessions, nil } -func (c *conn) DeleteAuthSession(ctx context.Context, sessionID string) error { +func (c *conn) DeleteAuthSession(ctx context.Context, userID, connectorID string) error { ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) defer cancel() - return c.deleteKey(ctx, keyAuthSession(sessionID)) + return c.deleteKey(ctx, keyAuthSession(userID, connectorID)) } func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) error { @@ -673,8 +673,8 @@ func keyUserIdentity(userID, connectorID string) string { return userIdentityPrefix + strings.ToLower(userID+"|"+connectorID) } -func keyAuthSession(sessionID string) string { - return strings.ToLower(authSessionPrefix + sessionID) +func keyAuthSession(userID, connectorID string) string { + return authSessionPrefix + strings.ToLower(userID+"|"+connectorID) } func (c *conn) CreateDeviceRequest(ctx context.Context, d storage.DeviceRequest) error { diff --git a/storage/etcd/types.go b/storage/etcd/types.go index 3624de32..3dc8ef27 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -298,7 +298,9 @@ func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { // AuthSession is a mirrored struct from storage with JSON struct tags. type AuthSession struct { - ID string `json:"id,omitempty"` + UserID string `json:"user_id,omitempty"` + ConnectorID string `json:"connector_id,omitempty"` + Nonce string `json:"nonce,omitempty"` ClientStates map[string]*storage.ClientAuthState `json:"client_states,omitempty"` CreatedAt time.Time `json:"created_at"` LastActivity time.Time `json:"last_activity"` @@ -308,7 +310,9 @@ type AuthSession struct { func fromStorageAuthSession(s storage.AuthSession) AuthSession { return AuthSession{ - ID: s.ID, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, ClientStates: s.ClientStates, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, @@ -319,7 +323,9 @@ func fromStorageAuthSession(s storage.AuthSession) AuthSession { func toStorageAuthSession(s AuthSession) storage.AuthSession { result := storage.AuthSession{ - ID: s.ID, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, ClientStates: s.ClientStates, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go index 55ea7455..b6d2990b 100644 --- a/storage/kubernetes/storage.go +++ b/storage/kubernetes/storage.go @@ -815,18 +815,27 @@ func (cli *client) CreateAuthSession(ctx context.Context, s storage.AuthSession) return cli.post(resourceAuthSession, cli.fromStorageAuthSession(s)) } -func (cli *client) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { +func (cli *client) getAuthSession(userID, connectorID string) (AuthSession, error) { var s AuthSession - if err := cli.get(resourceAuthSession, sessionID, &s); err != nil { + name := offlineTokenName(userID, connectorID, cli.hash) + if err := cli.get(resourceAuthSession, name, &s); err != nil { + return AuthSession{}, err + } + return s, nil +} + +func (cli *client) GetAuthSession(ctx context.Context, userID, connectorID string) (storage.AuthSession, error) { + s, err := cli.getAuthSession(userID, connectorID) + if err != nil { return storage.AuthSession{}, err } return toStorageAuthSession(s), nil } -func (cli *client) UpdateAuthSession(ctx context.Context, sessionID string, updater func(old storage.AuthSession) (storage.AuthSession, error)) error { +func (cli *client) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(old storage.AuthSession) (storage.AuthSession, error)) error { return retryOnConflict(ctx, func() error { - var s AuthSession - if err := cli.get(resourceAuthSession, sessionID, &s); err != nil { + s, err := cli.getAuthSession(userID, connectorID) + if err != nil { return err } @@ -837,7 +846,7 @@ func (cli *client) UpdateAuthSession(ctx context.Context, sessionID string, upda newSession := cli.fromStorageAuthSession(updated) newSession.ObjectMeta = s.ObjectMeta - return cli.put(resourceAuthSession, sessionID, newSession) + return cli.put(resourceAuthSession, s.ObjectMeta.Name, newSession) }) } @@ -855,8 +864,12 @@ func (cli *client) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, return sessions, nil } -func (cli *client) DeleteAuthSession(ctx context.Context, sessionID string) error { - return cli.delete(resourceAuthSession, sessionID) +func (cli *client) DeleteAuthSession(ctx context.Context, userID, connectorID string) error { + s, err := cli.getAuthSession(userID, connectorID) + if err != nil { + return err + } + return cli.delete(resourceAuthSession, s.ObjectMeta.Name) } func isKubernetesAPIConflictError(err error) bool { diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 473f59cc..074c6815 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -971,6 +971,9 @@ type AuthSession struct { k8sapi.TypeMeta `json:",inline"` k8sapi.ObjectMeta `json:"metadata,omitempty"` + UserID string `json:"userID,omitempty"` + ConnectorID string `json:"connectorID,omitempty"` + Nonce string `json:"nonce,omitempty"` ClientStates map[string]*storage.ClientAuthState `json:"clientStates,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` LastActivity time.Time `json:"lastActivity,omitempty"` @@ -992,9 +995,12 @@ func (cli *client) fromStorageAuthSession(s storage.AuthSession) AuthSession { APIVersion: cli.apiVersion, }, ObjectMeta: k8sapi.ObjectMeta{ - Name: s.ID, + Name: offlineTokenName(s.UserID, s.ConnectorID, cli.hash), Namespace: cli.namespace, }, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, ClientStates: s.ClientStates, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, @@ -1005,7 +1011,9 @@ func (cli *client) fromStorageAuthSession(s storage.AuthSession) AuthSession { func toStorageAuthSession(s AuthSession) storage.AuthSession { result := storage.AuthSession{ - ID: s.ObjectMeta.Name, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, ClientStates: s.ClientStates, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, diff --git a/storage/memory/memory.go b/storage/memory/memory.go index 483ed246..e507340c 100644 --- a/storage/memory/memory.go +++ b/storage/memory/memory.go @@ -23,7 +23,7 @@ func New(logger *slog.Logger) storage.Storage { passwords: make(map[string]storage.Password), offlineSessions: make(map[compositeKeyID]storage.OfflineSessions), userIdentities: make(map[compositeKeyID]storage.UserIdentity), - authSessions: make(map[string]storage.AuthSession), + authSessions: make(map[compositeKeyID]storage.AuthSession), connectors: make(map[string]storage.Connector), deviceRequests: make(map[string]storage.DeviceRequest), deviceTokens: make(map[string]storage.DeviceToken), @@ -52,7 +52,7 @@ type memStorage struct { passwords map[string]storage.Password offlineSessions map[compositeKeyID]storage.OfflineSessions userIdentities map[compositeKeyID]storage.UserIdentity - authSessions map[string]storage.AuthSession + authSessions map[compositeKeyID]storage.AuthSession connectors map[string]storage.Connector deviceRequests map[string]storage.DeviceRequest deviceTokens map[string]storage.DeviceToken @@ -258,47 +258,51 @@ func (s *memStorage) ListAuthSessions(ctx context.Context) (sessions []storage.A } func (s *memStorage) CreateAuthSession(ctx context.Context, session storage.AuthSession) (err error) { + id := compositeKeyID{userID: session.UserID, connID: session.ConnectorID} s.tx(func() { - if _, ok := s.authSessions[session.ID]; ok { + if _, ok := s.authSessions[id]; ok { err = storage.ErrAlreadyExists } else { - s.authSessions[session.ID] = session + s.authSessions[id] = session } }) return } -func (s *memStorage) GetAuthSession(ctx context.Context, sessionID string) (session storage.AuthSession, err error) { +func (s *memStorage) GetAuthSession(ctx context.Context, userID, connectorID string) (session storage.AuthSession, err error) { + id := compositeKeyID{userID: userID, connID: connectorID} s.tx(func() { var ok bool - if session, ok = s.authSessions[sessionID]; !ok { + if session, ok = s.authSessions[id]; !ok { err = storage.ErrNotFound } }) return } -func (s *memStorage) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) (err error) { +func (s *memStorage) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) (err error) { + id := compositeKeyID{userID: userID, connID: connectorID} s.tx(func() { - r, ok := s.authSessions[sessionID] + r, ok := s.authSessions[id] if !ok { err = storage.ErrNotFound return } if r, err = updater(r); err == nil { - s.authSessions[sessionID] = r + s.authSessions[id] = r } }) return } -func (s *memStorage) DeleteAuthSession(ctx context.Context, sessionID string) (err error) { +func (s *memStorage) DeleteAuthSession(ctx context.Context, userID, connectorID string) (err error) { + id := compositeKeyID{userID: userID, connID: connectorID} s.tx(func() { - if _, ok := s.authSessions[sessionID]; !ok { + if _, ok := s.authSessions[id]; !ok { err = storage.ErrNotFound return } - delete(s.authSessions, sessionID) + delete(s.authSessions, id) }) return } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index ab11713a..91213d76 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -929,13 +929,15 @@ func (c *conn) DeleteUserIdentity(ctx context.Context, userID, connectorID strin func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) error { _, err := c.Exec(` insert into auth_session ( - id, client_states, + user_id, connector_id, nonce, + client_states, created_at, last_activity, ip_address, user_agent ) - values ($1, $2, $3, $4, $5, $6); + values ($1, $2, $3, $4, $5, $6, $7, $8); `, - s.ID, encoder(s.ClientStates), + s.UserID, s.ConnectorID, s.Nonce, + encoder(s.ClientStates), s.CreatedAt, s.LastActivity, s.IPAddress, s.UserAgent, ) @@ -948,9 +950,9 @@ func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) err return nil } -func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { +func (c *conn) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { return c.ExecTx(func(tx *trans) error { - s, err := getAuthSession(ctx, tx, sessionID) + s, err := getAuthSession(ctx, tx, userID, connectorID) if err != nil { return err } @@ -966,12 +968,12 @@ func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater last_activity = $2, ip_address = $3, user_agent = $4 - where id = $5; + where user_id = $5 AND connector_id = $6; `, encoder(newSession.ClientStates), newSession.LastActivity, newSession.IPAddress, newSession.UserAgent, - sessionID, + userID, connectorID, ) if err != nil { return fmt.Errorf("update auth session: %v", err) @@ -980,24 +982,26 @@ func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater }) } -func (c *conn) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { - return getAuthSession(ctx, c, sessionID) +func (c *conn) GetAuthSession(ctx context.Context, userID, connectorID string) (storage.AuthSession, error) { + return getAuthSession(ctx, c, userID, connectorID) } -func getAuthSession(ctx context.Context, q querier, sessionID string) (storage.AuthSession, error) { +func getAuthSession(ctx context.Context, q querier, userID, connectorID string) (storage.AuthSession, error) { return scanAuthSession(q.QueryRow(` select - id, client_states, + user_id, connector_id, nonce, + client_states, created_at, last_activity, ip_address, user_agent from auth_session - where id = $1; - `, sessionID)) + where user_id = $1 AND connector_id = $2; + `, userID, connectorID)) } func scanAuthSession(s scanner) (session storage.AuthSession, err error) { err = s.Scan( - &session.ID, decoder(&session.ClientStates), + &session.UserID, &session.ConnectorID, &session.Nonce, + decoder(&session.ClientStates), &session.CreatedAt, &session.LastActivity, &session.IPAddress, &session.UserAgent, ) @@ -1016,7 +1020,8 @@ func scanAuthSession(s scanner) (session storage.AuthSession, err error) { func (c *conn) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, error) { rows, err := c.Query(` select - id, client_states, + user_id, connector_id, nonce, + client_states, created_at, last_activity, ip_address, user_agent from auth_session; @@ -1040,10 +1045,10 @@ func (c *conn) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, err return sessions, nil } -func (c *conn) DeleteAuthSession(ctx context.Context, sessionID string) error { - result, err := c.Exec(`delete from auth_session where id = $1`, sessionID) +func (c *conn) DeleteAuthSession(ctx context.Context, userID, connectorID string) error { + result, err := c.Exec(`delete from auth_session where user_id = $1 AND connector_id = $2`, userID, connectorID) if err != nil { - return fmt.Errorf("delete auth_session: id = %s: %w", sessionID, err) + return fmt.Errorf("delete auth_session: user_id = %s, connector_id = %s: %w", userID, connectorID, err) } n, err := result.RowsAffected() diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 7561d146..b296ebee 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -413,12 +413,15 @@ var migrations = []migration{ stmts: []string{ ` create table auth_session ( - id text not null primary key, + user_id text not null, + connector_id text not null, + nonce text not null default '', client_states bytea not null, created_at timestamptz not null, last_activity timestamptz not null, ip_address text not null default '', - user_agent text not null default '' + user_agent text not null default '', + PRIMARY KEY (user_id, connector_id) );`, }, }, diff --git a/storage/storage.go b/storage/storage.go index 963c7c67..1d8da99d 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -99,7 +99,7 @@ type Storage interface { GetPassword(ctx context.Context, email string) (Password, error) GetOfflineSessions(ctx context.Context, userID string, connID string) (OfflineSessions, error) GetUserIdentity(ctx context.Context, userID, connectorID string) (UserIdentity, error) - GetAuthSession(ctx context.Context, sessionID string) (AuthSession, error) + GetAuthSession(ctx context.Context, userID, connectorID string) (AuthSession, error) GetConnector(ctx context.Context, id string) (Connector, error) GetDeviceRequest(ctx context.Context, userCode string) (DeviceRequest, error) GetDeviceToken(ctx context.Context, deviceCode string) (DeviceToken, error) @@ -119,7 +119,7 @@ type Storage interface { DeletePassword(ctx context.Context, email string) error DeleteOfflineSessions(ctx context.Context, userID string, connID string) error DeleteUserIdentity(ctx context.Context, userID, connectorID string) error - DeleteAuthSession(ctx context.Context, sessionID string) error + DeleteAuthSession(ctx context.Context, userID, connectorID string) error DeleteConnector(ctx context.Context, id string) error // Update methods take a function for updating an object then performs that update within @@ -143,7 +143,7 @@ type Storage interface { UpdatePassword(ctx context.Context, email string, updater func(p Password) (Password, error)) error UpdateOfflineSessions(ctx context.Context, userID string, connID string, updater func(s OfflineSessions) (OfflineSessions, error)) error UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u UserIdentity) (UserIdentity, error)) error - UpdateAuthSession(ctx context.Context, sessionID string, updater func(s AuthSession) (AuthSession, error)) error + UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s AuthSession) (AuthSession, error)) error UpdateConnector(ctx context.Context, id string, updater func(c Connector) (Connector, error)) error UpdateDeviceToken(ctx context.Context, deviceCode string, updater func(t DeviceToken) (DeviceToken, error)) error @@ -341,19 +341,25 @@ type UserIdentity struct { BlockedUntil time.Time } -// ClientAuthState represents the authentication state for a specific client within a session. +// ClientAuthState represents authentication state for a specific client within an auth session. type ClientAuthState struct { - UserID string - ConnectorID string Active bool ExpiresAt time.Time LastActivity time.Time LastTokenIssuedAt time.Time } -// AuthSession represents a browser-bound authentication session. +// AuthSession represents a user's authentication session from a specific connector. +// Keyed by composite (UserID, ConnectorID), similar to OfflineSessions. +// The Nonce field is a random value included in the session cookie to prevent forgery. +// +// TODO(nabokihms): support multiple sessions in one browser by storing multiple +// session references in the cookie (e.g. "ref1|ref2") so that different users +// can maintain independent sessions in the same browser. type AuthSession struct { - ID string + UserID string + ConnectorID string + Nonce string // random, included in cookie for verification ClientStates map[string]*ClientAuthState // clientID -> auth state CreatedAt time.Time LastActivity time.Time diff --git a/web/templates/password.html b/web/templates/password.html index a6d8b667..ed529a2e 100644 --- a/web/templates/password.html +++ b/web/templates/password.html @@ -22,7 +22,16 @@
{{ end }} - + {{ if .ShowRememberMe }} +
+ +
+ {{ end }} + + {{ if .BackLink }} diff --git a/web/themes/dark/styles.css b/web/themes/dark/styles.css index 153f17eb..d6cb393c 100644 --- a/web/themes/dark/styles.css +++ b/web/themes/dark/styles.css @@ -122,6 +122,16 @@ margin-top: 8px; } +.theme-remember-me { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + width: 260px; + margin: 4px auto; +} + .dex-container { color: #b8bcc4; } diff --git a/web/themes/light/styles.css b/web/themes/light/styles.css index 61586722..37001aed 100644 --- a/web/themes/light/styles.css +++ b/web/themes/light/styles.css @@ -117,3 +117,13 @@ .theme-link-back { margin-top: 8px; } + +.theme-remember-me { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + width: 260px; + margin: 4px auto; +} From 503ddcaeea0b5630e2ec462d15126d4d70e80334 Mon Sep 17 00:00:00 2001 From: kt <45947799+kanywst@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:30:56 +0900 Subject: [PATCH 173/222] DEP for Identity Assertion JWT Authorization Grant (ID-JAG) / requested_token_type urn:ietf:params:oauth:token-type:id-jag (#4600) Signed-off-by: kanywst --- docs/enhancements/id-jag-2026-03-02#4600.md | 283 ++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/enhancements/id-jag-2026-03-02#4600.md 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 00000000..231921c8 --- /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 From 86abd336f8a96688fbc7e69e2406536b0d323fcf Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Wed, 18 Mar 2026 18:20:20 +0100 Subject: [PATCH 174/222] Two-Factor authentication (TOTP) (#3712) Signed-off-by: maksim.nabokikh --- cmd/dex/config.go | 78 +++++ cmd/dex/serve.go | 26 ++ examples/config-dev.yaml | 24 ++ go.mod | 2 + go.sum | 4 + server/handlers.go | 69 +++- server/handlers_approval_test.go | 1 + server/mfa.go | 364 ++++++++++++++++++++ server/server.go | 14 + server/templates.go | 18 + storage/ent/client/authrequest.go | 2 + storage/ent/client/client.go | 2 + storage/ent/client/types.go | 16 +- storage/ent/client/useridentity.go | 19 + storage/ent/db/authrequest.go | 15 +- storage/ent/db/authrequest/authrequest.go | 10 + storage/ent/db/authrequest/where.go | 15 + storage/ent/db/authrequest_create.go | 25 ++ storage/ent/db/authrequest_update.go | 34 ++ storage/ent/db/migrate/schema.go | 3 + storage/ent/db/mutation.go | 223 +++++++++++- storage/ent/db/oauth2client.go | 17 +- storage/ent/db/oauth2client/oauth2client.go | 3 + storage/ent/db/oauth2client/where.go | 10 + storage/ent/db/oauth2client_create.go | 10 + storage/ent/db/oauth2client_update.go | 58 ++++ storage/ent/db/runtime.go | 4 + storage/ent/db/useridentity.go | 15 +- storage/ent/db/useridentity/useridentity.go | 3 + storage/ent/db/useridentity/where.go | 55 +++ storage/ent/db/useridentity_create.go | 10 + storage/ent/db/useridentity_update.go | 36 ++ storage/ent/schema/authrequest.go | 2 + storage/ent/schema/client.go | 2 + storage/ent/schema/useridentity.go | 3 + storage/etcd/types.go | 23 +- storage/kubernetes/types.go | 27 +- storage/sql/crud.go | 70 ++-- storage/sql/migrate.go | 13 + storage/storage.go | 21 +- web/templates/totp_verify.html | 36 ++ 41 files changed, 1322 insertions(+), 60 deletions(-) create mode 100644 server/mfa.go create mode 100644 web/templates/totp_verify.html diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 32f8e832..48f2c056 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -69,6 +69,19 @@ type Config struct { // 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 @@ -112,6 +125,54 @@ func (c Config) Validate() error { return fmt.Errorf("sessions config requires sessions to be enabled (DEX_SESSIONS_ENABLED=true)") } + if err := c.validateMFA(); err != nil { + return 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} + 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 } @@ -606,3 +667,20 @@ type Sessions struct { // RememberMeCheckedByDefault controls the default state of the "remember me" checkbox. RememberMeCheckedByDefault *bool `json:"rememberMeCheckedByDefault"` } + +// 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"` +} diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 27d2502c..995ae0fc 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/json" "errors" "fmt" "log/slog" @@ -384,6 +385,8 @@ func runServe(options serveOptions) error { ContinueOnConnectorFailure: featureflags.ContinueOnConnectorFailure.Enabled(), Signer: signerInstance, IDTokensValidFor: idTokensValidFor, + MFAProviders: buildMFAProviders(c.MFA.Authenticators, logger), + DefaultMFAChain: c.MFA.DefaultMFAChain, } if c.Expiry.AuthRequests != "" { @@ -813,3 +816,26 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { } return sc, nil } + +func buildMFAProviders(authenticators []MFAAuthenticator, logger *slog.Logger) map[string]server.MFAProvider { + if len(authenticators) == 0 { + return nil + } + + providers := make(map[string]server.MFAProvider, 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] = server.NewTOTPProvider(cfg.Issuer, auth.ConnectorTypes) + 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/examples/config-dev.yaml b/examples/config-dev.yaml index fe66df8e..434c8938 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -138,6 +138,25 @@ telemetry: # # 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 +# # Default MFA chain applied to clients that don't specify their own mfaChain. +# # If omitted or empty, no MFA is required by default. +# defaultMFAChain: +# - totp-1 + # Instead of reading from an external storage, use this list of clients. # # If this option isn't chosen clients may be added through the gRPC API. @@ -152,6 +171,11 @@ staticClients: # 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 # Example using environment variables # Set DEX_CLIENT_ID and DEX_SECURE_CLIENT_SECRET before starting Dex diff --git a/go.mod b/go.mod index e28ab513..ba668e8f 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/oklog/run v1.2.0 github.com/openbao/openbao/api/v2 v2.5.1 github.com/pkg/errors v0.9.1 + github.com/pquerna/otp v1.5.0 github.com/prometheus/client_golang v1.23.2 github.com/russellhaering/goxmldsig v1.5.0 github.com/spf13/cobra v1.10.2 @@ -58,6 +59,7 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-semver v0.3.1 // indirect diff --git a/go.sum b/go.sum index 9b62623d..6562da2d 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ 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/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/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -201,6 +203,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= diff --git a/server/handlers.go b/server/handlers.go index 360c46ea..31a160f2 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -789,7 +789,41 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, userIdentity = &ui } - // we can skip the redirect to /approval and go ahead and send code if it's not required + // an HMAC is used here to ensure that the request ID is unpredictable, ensuring that an attacker who intercepted the original + // flow would be unable to poll for the result at the /approval endpoint + h := hmac.New(sha256.New, authReq.HMACKey) + h.Write([]byte(authReq.ID)) + mac := h.Sum(nil) + hmacParam := base64.RawURLEncoding.EncodeToString(mac) + + // Check if the client requires MFA. + mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) + if err != nil { + return "", false, fmt.Errorf("failed to get MFA chain for client: %v", err) + } + if len(mfaChain) > 0 { + // Redirect to MFA verification starting with the first authenticator. + // Each authenticator redirects to the next one in the chain upon success. + // HMAC includes authenticatorID to prevent skipping steps by URL manipulation. + h.Reset() + h.Write([]byte(authReq.ID + "|" + mfaChain[0])) + v := url.Values{} + v.Set("req", authReq.ID) + v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil))) + v.Set("authenticator", mfaChain[0]) + returnURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode() + return returnURL, false, nil + } + + // No MFA required — mark as validated. + if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.MFAValidated = true + return a, nil + }); err != nil { + return "", false, fmt.Errorf("failed to update auth request MFA status: %v", err) + } + + // Skip approval if globally configured. if s.skipApproval && !authReq.ForceApprovalPrompt { return "", true, nil } @@ -801,13 +835,7 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, } } - // an HMAC is used here to ensure that the request ID is unpredictable, ensuring that an attacker who intercepted the original - // flow would be unable to poll for the result at the /approval endpoint - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID)) - mac := h.Sum(nil) - - returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + base64.RawURLEncoding.EncodeToString(mac) + returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + hmacParam return returnURL, false, nil } @@ -840,8 +868,31 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { return } - // build expected hmac with secret key h := hmac.New(sha256.New, authReq.HMACKey) + if !authReq.MFAValidated { + // Check if MFA is actually required — if so, redirect to TOTP instead of blocking. + // This handles the case where MFA was enabled after the auth flow started. + mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) + if err != nil { + s.logger.ErrorContext(ctx, "failed to get MFA chain", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + if len(mfaChain) > 0 { + h.Write([]byte(authReq.ID + "|" + mfaChain[0])) + v := url.Values{} + v.Set("req", authReq.ID) + v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil))) + v.Set("authenticator", mfaChain[0]) + h.Reset() + totpURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode() + http.Redirect(w, r, totpURL, http.StatusSeeOther) + return + } + // No MFA required but flag not set — allow through (backward compat). + } + + // build expected hmac with secret key h.Write([]byte(authReq.ID)) expectedMAC := h.Sum(nil) // constant time comparison diff --git a/server/handlers_approval_test.go b/server/handlers_approval_test.go index 5ab80fc5..24a72b83 100644 --- a/server/handlers_approval_test.go +++ b/server/handlers_approval_test.go @@ -84,6 +84,7 @@ func TestHandleApprovalDoubleSubmitPOST(t *testing.T) { RedirectURI: "https://client.example/callback", Expiry: time.Now().Add(time.Minute), LoggedIn: true, + MFAValidated: true, HMACKey: []byte("approval-double-submit-key"), } require.NoError(t, server.storage.CreateAuthRequest(ctx, authReq)) diff --git a/server/mfa.go b/server/mfa.go new file mode 100644 index 00000000..7d2417f6 --- /dev/null +++ b/server/mfa.go @@ -0,0 +1,364 @@ +package server + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "image/png" + "net/http" + "net/url" + "path" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + + "github.com/dexidp/dex/storage" +) + +// MFAProvider is a pluggable multi-factor authentication method. +type MFAProvider interface { + // Type returns the authenticator type identifier (e.g., "TOTP"). + Type() string + // EnabledForConnectorType returns true if this provider applies to the given connector type. + // If no connector types are configured, the provider applies to all. + EnabledForConnectorType(connectorType string) bool +} + +// TOTPProvider implements TOTP-based multi-factor authentication. +type TOTPProvider struct { + issuer string + connectorTypes map[string]struct{} +} + +// NewTOTPProvider creates a new TOTP MFA provider. +func NewTOTPProvider(issuer string, connectorTypes []string) *TOTPProvider { + m := make(map[string]struct{}, len(connectorTypes)) + for _, t := range connectorTypes { + m[t] = struct{}{} + } + return &TOTPProvider{issuer: issuer, connectorTypes: m} +} + +func (p *TOTPProvider) EnabledForConnectorType(connectorType string) bool { + if len(p.connectorTypes) == 0 { + return true + } + _, ok := p.connectorTypes[connectorType] + return ok +} + +func (p *TOTPProvider) Type() string { return "TOTP" } + +func (p *TOTPProvider) generate(connID, email string) (*otp.Key, error) { + return totp.Generate(totp.GenerateOpts{ + Issuer: p.issuer, + AccountName: fmt.Sprintf("(%s) %s", connID, email), + }) +} + +func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) { + macEncoded := r.FormValue("hmac") + if macEncoded == "" { + s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request.") + return + } + mac, err := base64.RawURLEncoding.DecodeString(macEncoded) + if err != nil { + s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request.") + return + } + + ctx := r.Context() + + authReq, err := s.storage.GetAuthRequest(ctx, r.FormValue("req")) + if err != nil { + s.logger.ErrorContext(ctx, "failed to get auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + if !authReq.LoggedIn { + s.logger.ErrorContext(ctx, "auth request does not have an identity for MFA verification") + s.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.") + return + } + + authenticatorID := r.FormValue("authenticator") + + // Verify HMAC — includes authenticatorID to prevent skipping steps in the MFA chain. + h := hmac.New(sha256.New, authReq.HMACKey) + h.Write([]byte(authReq.ID + "|" + authenticatorID)) + if !hmac.Equal(mac, h.Sum(nil)) { + s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request.") + return + } + provider, ok := s.mfaProviders[authenticatorID] + if !ok { + s.renderError(r, w, http.StatusBadRequest, "Unknown authenticator.") + return + } + + totpProvider, ok := provider.(*TOTPProvider) + if !ok { + s.renderError(r, w, http.StatusInternalServerError, "Unsupported authenticator type.") + return + } + + identity, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) + if err != nil { + s.logger.ErrorContext(ctx, "failed to get user identity", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + + // Build approval URL with an HMAC that covers only the request ID + // (MFA HMAC includes authenticatorID and is not valid for approval). + approvalH := hmac.New(sha256.New, authReq.HMACKey) + approvalH.Write([]byte(authReq.ID)) + returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + + "&hmac=" + base64.RawURLEncoding.EncodeToString(approvalH.Sum(nil)) + + if authReq.MFAValidated { + http.Redirect(w, r, returnURL, http.StatusSeeOther) + return + } + + secret := identity.MFASecrets[authenticatorID] + + switch r.Method { + case http.MethodGet: + if secret == nil { + // First-time enrollment: generate a new TOTP key. + // TODO(nabokihms): clean up stale unconfirmed secrets. If a user starts + // enrollment multiple times without completing it, old secrets accumulate. + generated, err := totpProvider.generate(authReq.ConnectorID, authReq.Claims.Email) + if err != nil { + s.logger.ErrorContext(ctx, "failed to generate TOTP key", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + secret = &storage.MFASecret{ + AuthenticatorID: authenticatorID, + Type: "TOTP", + Secret: generated.String(), + Confirmed: false, + CreatedAt: s.now(), + } + + if err := s.storage.UpdateUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + if old.MFASecrets == nil { + old.MFASecrets = make(map[string]*storage.MFASecret) + } + old.MFASecrets[authenticatorID] = secret + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to store MFA secret", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + } + + s.renderTOTPPage(secret, false, totpProvider.issuer, authReq.ConnectorID, w, r) + + case http.MethodPost: + // TODO(nabokihms): this endpoint should be protected with a rate limit (like the auth endpoint). + // TOTP has a limited keyspace (6 digits) with a 30-second validity window, + // making it particularly vulnerable to brute-force without rate limiting. + // + // For now the best way is to use external rate limiting solutions. + if secret == nil || secret.Secret == "" { + s.renderError(r, w, http.StatusBadRequest, "MFA not enrolled.") + return + } + + code := r.FormValue("totp") + generated, err := otp.NewKeyFromURL(secret.Secret) + if err != nil { + s.logger.ErrorContext(ctx, "failed to load TOTP key", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + if !totp.Validate(code, generated.Secret()) { + s.renderTOTPPage(secret, true, totpProvider.issuer, authReq.ConnectorID, w, r) + return + } + + // Mark MFA secret as confirmed. + if !secret.Confirmed { + if err := s.storage.UpdateUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + if s := old.MFASecrets[authenticatorID]; s != nil { + s.Confirmed = true + } + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to confirm MFA secret", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + } + + // Check if there are more authenticators in the MFA chain. + mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) + if err != nil { + s.logger.ErrorContext(ctx, "failed to get MFA chain", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + // Find the next authenticator in the chain after the current one. + var nextAuthenticator string + for i, id := range mfaChain { + if id == authenticatorID && i+1 < len(mfaChain) { + nextAuthenticator = mfaChain[i+1] + break + } + } + + if nextAuthenticator != "" { + // Redirect to the next authenticator in the chain. + h := hmac.New(sha256.New, authReq.HMACKey) + h.Write([]byte(authReq.ID + "|" + nextAuthenticator)) + v := url.Values{} + v.Set("req", authReq.ID) + v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil))) + v.Set("authenticator", nextAuthenticator) + nextURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode() + http.Redirect(w, r, nextURL, http.StatusSeeOther) + return + } + + // All authenticators in the chain completed — mark as validated. + if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, func(old storage.AuthRequest) (storage.AuthRequest, error) { + old.MFAValidated = true + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to update auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + s.sendCodeOrRedirectToApproval(w, r, authReq, returnURL) + + default: + s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") + } +} + +func (s *Server) renderTOTPPage(secret *storage.MFASecret, lastFail bool, issuer, connectorID string, w http.ResponseWriter, r *http.Request) { + // Prevent browser from caching the TOTP page (contains QR code with secret). + w.Header().Set("Cache-Control", "no-store") + var qrCode string + if !secret.Confirmed { + var err error + qrCode, err = generateTOTPQRCode(secret.Secret) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to generate QR code", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + } + if err := s.templates.totpVerify(r, w, r.URL.String(), issuer, connectorID, qrCode, lastFail); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } +} + +// sendCodeOrRedirectToApproval checks skipApproval and stored consent, +// sending a code response directly if possible, or redirecting to the approval page. +func (s *Server) sendCodeOrRedirectToApproval(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, approvalURL string) { + ctx := r.Context() + + if !authReq.ForceApprovalPrompt { + if s.skipApproval { + authReq, err := s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(ctx, "failed to get auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + s.sendCodeResponse(w, r, authReq) + return + } + + ui, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) + if err == nil && scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes) { + authReq, err := s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(ctx, "failed to get auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + s.sendCodeResponse(w, r, authReq) + return + } + } + + http.Redirect(w, r, approvalURL, http.StatusSeeOther) +} + +func generateTOTPQRCode(keyURL string) (string, error) { + generated, err := otp.NewKeyFromURL(keyURL) + if err != nil { + return "", fmt.Errorf("failed to load TOTP key: %w", err) + } + + qrCodeImage, err := generated.Image(300, 300) + if err != nil { + return "", fmt.Errorf("failed to generate TOTP QR code: %w", err) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, qrCodeImage); err != nil { + return "", fmt.Errorf("failed to encode TOTP QR code: %w", err) + } + + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// mfaChainForClient returns the MFA chain for a client filtered by connector type, +// falling back to the server's defaultMFAChain if the client has none. +// Returns nil if no MFA is configured/applicable. +func (s *Server) mfaChainForClient(ctx context.Context, clientID, connectorID string) ([]string, error) { + if len(s.mfaProviders) == 0 { + return nil, nil + } + + client, err := s.storage.GetClient(ctx, clientID) + if err != nil { + return nil, err + } + + // nil means "not set" — fall back to default. + // Explicit empty slice ([]string{}) means "no MFA" — don't fall back. + source := client.MFAChain + if source == nil { + source = s.defaultMFAChain + } + + // Resolve connector type from connector ID. + connectorType, err := s.getConnectorType(ctx, connectorID) + if err != nil { + return nil, err + } + + var chain []string + for _, authID := range source { + provider, ok := s.mfaProviders[authID] + if ok && provider.EnabledForConnectorType(connectorType) { + chain = append(chain, authID) + } + } + return chain, nil +} + +// getConnectorType returns the type of the connector with the given ID. +func (s *Server) getConnectorType(ctx context.Context, connectorID string) (string, error) { + conn, err := s.getConnector(ctx, connectorID) + if err != nil { + return "", fmt.Errorf("get connector %q: %w", connectorID, err) + } + return conn.Type, nil +} diff --git a/server/server.go b/server/server.go index 4acf6eb8..669c16a0 100644 --- a/server/server.go +++ b/server/server.go @@ -57,6 +57,7 @@ const LocalConnector = "local" // Connector is a connector with resource version metadata. type Connector struct { + Type string ResourceVersion string Connector connector.Connector GrantTypes []string @@ -140,6 +141,12 @@ type Config struct { // SessionConfig holds session settings. Nil when sessions are disabled. SessionConfig *SessionConfig + + // MFAProviders maps authenticator IDs to their provider implementations. + MFAProviders map[string]MFAProvider + + // DefaultMFAChain is applied to clients that don't specify their own mfaChain. + DefaultMFAChain []string } // SessionConfig holds resolved session configuration. @@ -239,6 +246,9 @@ type Server struct { signer signer.Signer sessionConfig *SessionConfig + + mfaProviders map[string]MFAProvider + defaultMFAChain []string } // NewServer constructs a server from the provided config. @@ -363,6 +373,8 @@ func newServer(ctx context.Context, c Config) (*Server, error) { logger: c.Logger, signer: c.Signer, sessionConfig: c.SessionConfig, + mfaProviders: c.MFAProviders, + defaultMFAChain: c.DefaultMFAChain, } // Retrieves connector objects in backend storage. This list includes the static connectors @@ -551,6 +563,7 @@ func newServer(ctx context.Context, c Config) (*Server, error) { // "authproxy" connector. handleFunc("/callback/{connector}", s.handleConnectorCallback) handleFunc("/approval", s.handleApproval) + handleFunc("/mfa/verify", s.handleMFAVerify) handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !c.HealthChecker.IsHealthy() { s.renderError(r, w, http.StatusInternalServerError, "Health check failed.") @@ -791,6 +804,7 @@ func (s *Server) OpenConnector(conn storage.Connector) (Connector, error) { } connector := Connector{ + Type: conn.Type, ResourceVersion: conn.ResourceVersion, Connector: c, GrantTypes: conn.GrantTypes, diff --git a/server/templates.go b/server/templates.go index b377d056..693f0f1c 100644 --- a/server/templates.go +++ b/server/templates.go @@ -22,6 +22,7 @@ const ( tmplError = "error.html" tmplDevice = "device.html" tmplDeviceSuccess = "device_success.html" + tmplTOTPVerify = "totp_verify.html" ) var requiredTmpls = []string{ @@ -42,6 +43,7 @@ type templates struct { errorTmpl *template.Template deviceTmpl *template.Template deviceSuccessTmpl *template.Template + totpVerifyTmpl *template.Template } type webConfig struct { @@ -169,6 +171,7 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { errorTmpl: tmpls.Lookup(tmplError), deviceTmpl: tmpls.Lookup(tmplDevice), deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess), + totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify), }, nil } @@ -338,6 +341,21 @@ func (t *templates) approval(r *http.Request, w http.ResponseWriter, authReqID, return renderTemplate(w, t.approvalTmpl, data) } +func (t *templates) totpVerify(r *http.Request, w http.ResponseWriter, postURL, issuer, connector, qrCode string, lastWasInvalid bool) error { + if lastWasInvalid { + w.WriteHeader(http.StatusUnauthorized) + } + data := struct { + PostURL string + Invalid bool + Issuer string + Connector string + QRCode string + ReqPath string + }{postURL, lastWasInvalid, issuer, connector, qrCode, r.URL.Path} + return renderTemplate(w, t.totpVerifyTmpl, data) +} + func (t *templates) oob(r *http.Request, w http.ResponseWriter, code string) error { data := struct { Code string diff --git a/storage/ent/client/authrequest.go b/storage/ent/client/authrequest.go index 25d3e415..86c71056 100644 --- a/storage/ent/client/authrequest.go +++ b/storage/ent/client/authrequest.go @@ -32,6 +32,7 @@ func (d *Database) CreateAuthRequest(ctx context.Context, authRequest storage.Au SetConnectorID(authRequest.ConnectorID). SetConnectorData(authRequest.ConnectorData). SetHmacKey(authRequest.HMACKey). + SetMfaValidated(authRequest.MFAValidated). Save(ctx) if err != nil { return convertDBError("create auth request: %w", err) @@ -96,6 +97,7 @@ func (d *Database) UpdateAuthRequest(ctx context.Context, id string, updater fun SetConnectorID(newAuthRequest.ConnectorID). SetConnectorData(newAuthRequest.ConnectorData). SetHmacKey(newAuthRequest.HMACKey). + SetMfaValidated(newAuthRequest.MFAValidated). Save(context.TODO()) if err != nil { return rollback(tx, "update auth request uploading: %w", err) diff --git a/storage/ent/client/client.go b/storage/ent/client/client.go index a4f0d942..4c18939d 100644 --- a/storage/ent/client/client.go +++ b/storage/ent/client/client.go @@ -17,6 +17,7 @@ func (d *Database) CreateClient(ctx context.Context, client storage.Client) erro SetRedirectUris(client.RedirectURIs). SetTrustedPeers(client.TrustedPeers). SetAllowedConnectors(client.AllowedConnectors). + SetMfaChain(client.MFAChain). Save(ctx) if err != nil { return convertDBError("create oauth2 client: %w", err) @@ -81,6 +82,7 @@ func (d *Database) UpdateClient(ctx context.Context, id string, updater func(old SetRedirectUris(newClient.RedirectURIs). SetTrustedPeers(newClient.TrustedPeers). SetAllowedConnectors(newClient.AllowedConnectors). + SetMfaChain(newClient.MFAChain). Save(ctx) if err != nil { return rollback(tx, "update client uploading: %w", err) diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 14bcdfc3..7ae9390d 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -45,7 +45,8 @@ func toStorageAuthRequest(a *db.AuthRequest) storage.AuthRequest { CodeChallenge: a.CodeChallenge, CodeChallengeMethod: a.CodeChallengeMethod, }, - HMACKey: a.HmacKey, + HMACKey: a.HmacKey, + MFAValidated: a.MfaValidated, } } @@ -84,6 +85,7 @@ func toStorageClient(c *db.OAuth2Client) storage.Client { Name: c.Name, LogoURL: c.LogoURL, AllowedConnectors: c.AllowedConnectors, + MFAChain: c.MfaChain, } } @@ -193,6 +195,18 @@ func toStorageUserIdentity(u *db.UserIdentity) storage.UserIdentity { // Server code assumes this will be non-nil. s.Consents = make(map[string][]string) } + + if u.MfaSecrets != nil { + if err := json.Unmarshal(*u.MfaSecrets, &s.MFASecrets); err != nil { + // Correctness of json structure is guaranteed on uploading + panic(err) + } + if s.MFASecrets == nil { + s.MFASecrets = make(map[string]*storage.MFASecret) + } + } else { + s.MFASecrets = make(map[string]*storage.MFASecret) + } return s } diff --git a/storage/ent/client/useridentity.go b/storage/ent/client/useridentity.go index 1cf87919..31e9d049 100644 --- a/storage/ent/client/useridentity.go +++ b/storage/ent/client/useridentity.go @@ -18,6 +18,14 @@ func (d *Database) CreateUserIdentity(ctx context.Context, identity storage.User return fmt.Errorf("encode consents user identity: %w", err) } + if identity.MFASecrets == nil { + identity.MFASecrets = make(map[string]*storage.MFASecret) + } + encodedMFASecrets, err := json.Marshal(identity.MFASecrets) + if err != nil { + return fmt.Errorf("encode mfa secrets user identity: %w", err) + } + id := compositeKeyID(identity.UserID, identity.ConnectorID, d.hasher) _, err = d.client.UserIdentity.Create(). SetID(id). @@ -30,6 +38,7 @@ func (d *Database) CreateUserIdentity(ctx context.Context, identity storage.User SetClaimsEmailVerified(identity.Claims.EmailVerified). SetClaimsGroups(identity.Claims.Groups). SetConsents(encodedConsents). + SetMfaSecrets(encodedMFASecrets). SetCreatedAt(identity.CreatedAt). SetLastLogin(identity.LastLogin). SetBlockedUntil(identity.BlockedUntil). @@ -90,6 +99,15 @@ func (d *Database) UpdateUserIdentity(ctx context.Context, userID string, connec return rollback(tx, "encode consents user identity: %w", err) } + if newUserIdentity.MFASecrets == nil { + newUserIdentity.MFASecrets = make(map[string]*storage.MFASecret) + } + + encodedMFASecrets, err := json.Marshal(newUserIdentity.MFASecrets) + if err != nil { + return rollback(tx, "encode mfa secrets user identity: %w", err) + } + _, err = tx.UserIdentity.UpdateOneID(id). SetUserID(newUserIdentity.UserID). SetConnectorID(newUserIdentity.ConnectorID). @@ -100,6 +118,7 @@ func (d *Database) UpdateUserIdentity(ctx context.Context, userID string, connec SetClaimsEmailVerified(newUserIdentity.Claims.EmailVerified). SetClaimsGroups(newUserIdentity.Claims.Groups). SetConsents(encodedConsents). + SetMfaSecrets(encodedMFASecrets). SetCreatedAt(newUserIdentity.CreatedAt). SetLastLogin(newUserIdentity.LastLogin). SetBlockedUntil(newUserIdentity.BlockedUntil). diff --git a/storage/ent/db/authrequest.go b/storage/ent/db/authrequest.go index ac5b550a..02ead496 100644 --- a/storage/ent/db/authrequest.go +++ b/storage/ent/db/authrequest.go @@ -57,7 +57,9 @@ type AuthRequest struct { // CodeChallengeMethod holds the value of the "code_challenge_method" field. CodeChallengeMethod string `json:"code_challenge_method,omitempty"` // HmacKey holds the value of the "hmac_key" field. - HmacKey []byte `json:"hmac_key,omitempty"` + HmacKey []byte `json:"hmac_key,omitempty"` + // MfaValidated holds the value of the "mfa_validated" field. + MfaValidated bool `json:"mfa_validated,omitempty"` selectValues sql.SelectValues } @@ -68,7 +70,7 @@ func (*AuthRequest) scanValues(columns []string) ([]any, error) { switch columns[i] { case authrequest.FieldScopes, authrequest.FieldResponseTypes, authrequest.FieldClaimsGroups, authrequest.FieldConnectorData, authrequest.FieldHmacKey: values[i] = new([]byte) - case authrequest.FieldForceApprovalPrompt, authrequest.FieldLoggedIn, authrequest.FieldClaimsEmailVerified: + case authrequest.FieldForceApprovalPrompt, authrequest.FieldLoggedIn, authrequest.FieldClaimsEmailVerified, authrequest.FieldMfaValidated: values[i] = new(sql.NullBool) case authrequest.FieldID, authrequest.FieldClientID, authrequest.FieldRedirectURI, authrequest.FieldNonce, authrequest.FieldState, authrequest.FieldClaimsUserID, authrequest.FieldClaimsUsername, authrequest.FieldClaimsEmail, authrequest.FieldClaimsPreferredUsername, authrequest.FieldConnectorID, authrequest.FieldCodeChallenge, authrequest.FieldCodeChallengeMethod: values[i] = new(sql.NullString) @@ -221,6 +223,12 @@ func (_m *AuthRequest) assignValues(columns []string, values []any) error { } else if value != nil { _m.HmacKey = *value } + case authrequest.FieldMfaValidated: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field mfa_validated", values[i]) + } else if value.Valid { + _m.MfaValidated = value.Bool + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -318,6 +326,9 @@ func (_m *AuthRequest) String() string { builder.WriteString(", ") builder.WriteString("hmac_key=") builder.WriteString(fmt.Sprintf("%v", _m.HmacKey)) + builder.WriteString(", ") + builder.WriteString("mfa_validated=") + builder.WriteString(fmt.Sprintf("%v", _m.MfaValidated)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/authrequest/authrequest.go b/storage/ent/db/authrequest/authrequest.go index 0998c799..4fda6633 100644 --- a/storage/ent/db/authrequest/authrequest.go +++ b/storage/ent/db/authrequest/authrequest.go @@ -51,6 +51,8 @@ const ( FieldCodeChallengeMethod = "code_challenge_method" // FieldHmacKey holds the string denoting the hmac_key field in the database. FieldHmacKey = "hmac_key" + // FieldMfaValidated holds the string denoting the mfa_validated field in the database. + FieldMfaValidated = "mfa_validated" // Table holds the table name of the authrequest in the database. Table = "auth_requests" ) @@ -78,6 +80,7 @@ var Columns = []string{ FieldCodeChallenge, FieldCodeChallengeMethod, FieldHmacKey, + FieldMfaValidated, } // ValidColumn reports if the column name is valid (part of the table columns). @@ -97,6 +100,8 @@ var ( DefaultCodeChallenge string // DefaultCodeChallengeMethod holds the default value on creation for the "code_challenge_method" field. DefaultCodeChallengeMethod string + // DefaultMfaValidated holds the default value on creation for the "mfa_validated" field. + DefaultMfaValidated bool // IDValidator is a validator for the "id" field. It is called by the builders before save. IDValidator func(string) error ) @@ -183,3 +188,8 @@ func ByCodeChallenge(opts ...sql.OrderTermOption) OrderOption { func ByCodeChallengeMethod(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCodeChallengeMethod, opts...).ToFunc() } + +// ByMfaValidated orders the results by the mfa_validated field. +func ByMfaValidated(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldMfaValidated, opts...).ToFunc() +} diff --git a/storage/ent/db/authrequest/where.go b/storage/ent/db/authrequest/where.go index 4d3a39be..2f679bb3 100644 --- a/storage/ent/db/authrequest/where.go +++ b/storage/ent/db/authrequest/where.go @@ -149,6 +149,11 @@ func HmacKey(v []byte) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldHmacKey, v)) } +// MfaValidated applies equality check predicate on the "mfa_validated" field. It's identical to MfaValidatedEQ. +func MfaValidated(v bool) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldMfaValidated, v)) +} + // ClientIDEQ applies the EQ predicate on the "client_id" field. func ClientIDEQ(v string) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldClientID, v)) @@ -1054,6 +1059,16 @@ func HmacKeyLTE(v []byte) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldLTE(FieldHmacKey, v)) } +// MfaValidatedEQ applies the EQ predicate on the "mfa_validated" field. +func MfaValidatedEQ(v bool) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldMfaValidated, v)) +} + +// MfaValidatedNEQ applies the NEQ predicate on the "mfa_validated" field. +func MfaValidatedNEQ(v bool) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNEQ(FieldMfaValidated, v)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.AuthRequest) predicate.AuthRequest { return predicate.AuthRequest(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/authrequest_create.go b/storage/ent/db/authrequest_create.go index 6224ef8e..c36648a1 100644 --- a/storage/ent/db/authrequest_create.go +++ b/storage/ent/db/authrequest_create.go @@ -164,6 +164,20 @@ func (_c *AuthRequestCreate) SetHmacKey(v []byte) *AuthRequestCreate { return _c } +// SetMfaValidated sets the "mfa_validated" field. +func (_c *AuthRequestCreate) SetMfaValidated(v bool) *AuthRequestCreate { + _c.mutation.SetMfaValidated(v) + return _c +} + +// SetNillableMfaValidated sets the "mfa_validated" field if the given value is not nil. +func (_c *AuthRequestCreate) SetNillableMfaValidated(v *bool) *AuthRequestCreate { + if v != nil { + _c.SetMfaValidated(*v) + } + return _c +} + // SetID sets the "id" field. func (_c *AuthRequestCreate) SetID(v string) *AuthRequestCreate { _c.mutation.SetID(v) @@ -217,6 +231,10 @@ func (_c *AuthRequestCreate) defaults() { v := authrequest.DefaultCodeChallengeMethod _c.mutation.SetCodeChallengeMethod(v) } + if _, ok := _c.mutation.MfaValidated(); !ok { + v := authrequest.DefaultMfaValidated + _c.mutation.SetMfaValidated(v) + } } // check runs all checks and user-defined validators on the builder. @@ -269,6 +287,9 @@ func (_c *AuthRequestCreate) check() error { if _, ok := _c.mutation.HmacKey(); !ok { return &ValidationError{Name: "hmac_key", err: errors.New(`db: missing required field "AuthRequest.hmac_key"`)} } + if _, ok := _c.mutation.MfaValidated(); !ok { + return &ValidationError{Name: "mfa_validated", err: errors.New(`db: missing required field "AuthRequest.mfa_validated"`)} + } if v, ok := _c.mutation.ID(); ok { if err := authrequest.IDValidator(v); err != nil { return &ValidationError{Name: "id", err: fmt.Errorf(`db: validator failed for field "AuthRequest.id": %w`, err)} @@ -389,6 +410,10 @@ func (_c *AuthRequestCreate) createSpec() (*AuthRequest, *sqlgraph.CreateSpec) { _spec.SetField(authrequest.FieldHmacKey, field.TypeBytes, value) _node.HmacKey = value } + if value, ok := _c.mutation.MfaValidated(); ok { + _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) + _node.MfaValidated = value + } return _node, _spec } diff --git a/storage/ent/db/authrequest_update.go b/storage/ent/db/authrequest_update.go index e1fa678a..e512b2d9 100644 --- a/storage/ent/db/authrequest_update.go +++ b/storage/ent/db/authrequest_update.go @@ -311,6 +311,20 @@ func (_u *AuthRequestUpdate) SetHmacKey(v []byte) *AuthRequestUpdate { return _u } +// SetMfaValidated sets the "mfa_validated" field. +func (_u *AuthRequestUpdate) SetMfaValidated(v bool) *AuthRequestUpdate { + _u.mutation.SetMfaValidated(v) + return _u +} + +// SetNillableMfaValidated sets the "mfa_validated" field if the given value is not nil. +func (_u *AuthRequestUpdate) SetNillableMfaValidated(v *bool) *AuthRequestUpdate { + if v != nil { + _u.SetMfaValidated(*v) + } + return _u +} + // Mutation returns the AuthRequestMutation object of the builder. func (_u *AuthRequestUpdate) Mutation() *AuthRequestMutation { return _u.mutation @@ -439,6 +453,9 @@ func (_u *AuthRequestUpdate) sqlSave(ctx context.Context) (_node int, err error) if value, ok := _u.mutation.HmacKey(); ok { _spec.SetField(authrequest.FieldHmacKey, field.TypeBytes, value) } + if value, ok := _u.mutation.MfaValidated(); ok { + _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{authrequest.Label} @@ -741,6 +758,20 @@ func (_u *AuthRequestUpdateOne) SetHmacKey(v []byte) *AuthRequestUpdateOne { return _u } +// SetMfaValidated sets the "mfa_validated" field. +func (_u *AuthRequestUpdateOne) SetMfaValidated(v bool) *AuthRequestUpdateOne { + _u.mutation.SetMfaValidated(v) + return _u +} + +// SetNillableMfaValidated sets the "mfa_validated" field if the given value is not nil. +func (_u *AuthRequestUpdateOne) SetNillableMfaValidated(v *bool) *AuthRequestUpdateOne { + if v != nil { + _u.SetMfaValidated(*v) + } + return _u +} + // Mutation returns the AuthRequestMutation object of the builder. func (_u *AuthRequestUpdateOne) Mutation() *AuthRequestMutation { return _u.mutation @@ -899,6 +930,9 @@ func (_u *AuthRequestUpdateOne) sqlSave(ctx context.Context) (_node *AuthRequest if value, ok := _u.mutation.HmacKey(); ok { _spec.SetField(authrequest.FieldHmacKey, field.TypeBytes, value) } + if value, ok := _u.mutation.MfaValidated(); ok { + _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) + } _node = &AuthRequest{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index 31c0d211..36db86e3 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -56,6 +56,7 @@ var ( {Name: "code_challenge", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "code_challenge_method", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "hmac_key", Type: field.TypeBytes}, + {Name: "mfa_validated", Type: field.TypeBool, Default: false}, } // AuthRequestsTable holds the schema information for the "auth_requests" table. AuthRequestsTable = &schema.Table{ @@ -154,6 +155,7 @@ var ( {Name: "name", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "logo_url", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "allowed_connectors", Type: field.TypeJSON, Nullable: true}, + {Name: "mfa_chain", Type: field.TypeJSON, Nullable: true}, } // Oauth2clientsTable holds the schema information for the "oauth2clients" table. Oauth2clientsTable = &schema.Table{ @@ -230,6 +232,7 @@ var ( {Name: "claims_email_verified", Type: field.TypeBool, Default: false}, {Name: "claims_groups", Type: field.TypeJSON, Nullable: true}, {Name: "consents", Type: field.TypeBytes}, + {Name: "mfa_secrets", Type: field.TypeBytes, Nullable: true}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "last_login", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "blocked_until", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index ffbb9b2e..151443f9 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -1262,6 +1262,7 @@ type AuthRequestMutation struct { code_challenge *string code_challenge_method *string hmac_key *[]byte + mfa_validated *bool clearedFields map[string]struct{} done bool oldValue func(context.Context) (*AuthRequest, error) @@ -2192,6 +2193,42 @@ func (m *AuthRequestMutation) ResetHmacKey() { m.hmac_key = nil } +// SetMfaValidated sets the "mfa_validated" field. +func (m *AuthRequestMutation) SetMfaValidated(b bool) { + m.mfa_validated = &b +} + +// MfaValidated returns the value of the "mfa_validated" field in the mutation. +func (m *AuthRequestMutation) MfaValidated() (r bool, exists bool) { + v := m.mfa_validated + if v == nil { + return + } + return *v, true +} + +// OldMfaValidated returns the old "mfa_validated" field's value of the AuthRequest entity. +// If the AuthRequest object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthRequestMutation) OldMfaValidated(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldMfaValidated is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldMfaValidated requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldMfaValidated: %w", err) + } + return oldValue.MfaValidated, nil +} + +// ResetMfaValidated resets all changes to the "mfa_validated" field. +func (m *AuthRequestMutation) ResetMfaValidated() { + m.mfa_validated = nil +} + // Where appends a list predicates to the AuthRequestMutation builder. func (m *AuthRequestMutation) Where(ps ...predicate.AuthRequest) { m.predicates = append(m.predicates, ps...) @@ -2226,7 +2263,7 @@ func (m *AuthRequestMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AuthRequestMutation) Fields() []string { - fields := make([]string, 0, 20) + fields := make([]string, 0, 21) if m.client_id != nil { fields = append(fields, authrequest.FieldClientID) } @@ -2287,6 +2324,9 @@ func (m *AuthRequestMutation) Fields() []string { if m.hmac_key != nil { fields = append(fields, authrequest.FieldHmacKey) } + if m.mfa_validated != nil { + fields = append(fields, authrequest.FieldMfaValidated) + } return fields } @@ -2335,6 +2375,8 @@ func (m *AuthRequestMutation) Field(name string) (ent.Value, bool) { return m.CodeChallengeMethod() case authrequest.FieldHmacKey: return m.HmacKey() + case authrequest.FieldMfaValidated: + return m.MfaValidated() } return nil, false } @@ -2384,6 +2426,8 @@ func (m *AuthRequestMutation) OldField(ctx context.Context, name string) (ent.Va return m.OldCodeChallengeMethod(ctx) case authrequest.FieldHmacKey: return m.OldHmacKey(ctx) + case authrequest.FieldMfaValidated: + return m.OldMfaValidated(ctx) } return nil, fmt.Errorf("unknown AuthRequest field %s", name) } @@ -2533,6 +2577,13 @@ func (m *AuthRequestMutation) SetField(name string, value ent.Value) error { } m.SetHmacKey(v) return nil + case authrequest.FieldMfaValidated: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetMfaValidated(v) + return nil } return fmt.Errorf("unknown AuthRequest field %s", name) } @@ -2669,6 +2720,9 @@ func (m *AuthRequestMutation) ResetField(name string) error { case authrequest.FieldHmacKey: m.ResetHmacKey() return nil + case authrequest.FieldMfaValidated: + m.ResetMfaValidated() + return nil } return fmt.Errorf("unknown AuthRequest field %s", name) } @@ -5941,6 +5995,8 @@ type OAuth2ClientMutation struct { logo_url *string allowed_connectors *[]string appendallowed_connectors []string + mfa_chain *[]string + appendmfa_chain []string clearedFields map[string]struct{} done bool oldValue func(context.Context) (*OAuth2Client, error) @@ -6390,6 +6446,71 @@ func (m *OAuth2ClientMutation) ResetAllowedConnectors() { delete(m.clearedFields, oauth2client.FieldAllowedConnectors) } +// SetMfaChain sets the "mfa_chain" field. +func (m *OAuth2ClientMutation) SetMfaChain(s []string) { + m.mfa_chain = &s + m.appendmfa_chain = nil +} + +// MfaChain returns the value of the "mfa_chain" field in the mutation. +func (m *OAuth2ClientMutation) MfaChain() (r []string, exists bool) { + v := m.mfa_chain + if v == nil { + return + } + return *v, true +} + +// OldMfaChain returns the old "mfa_chain" field's value of the OAuth2Client entity. +// If the OAuth2Client object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *OAuth2ClientMutation) OldMfaChain(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldMfaChain is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldMfaChain requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldMfaChain: %w", err) + } + return oldValue.MfaChain, nil +} + +// AppendMfaChain adds s to the "mfa_chain" field. +func (m *OAuth2ClientMutation) AppendMfaChain(s []string) { + m.appendmfa_chain = append(m.appendmfa_chain, s...) +} + +// AppendedMfaChain returns the list of values that were appended to the "mfa_chain" field in this mutation. +func (m *OAuth2ClientMutation) AppendedMfaChain() ([]string, bool) { + if len(m.appendmfa_chain) == 0 { + return nil, false + } + return m.appendmfa_chain, true +} + +// ClearMfaChain clears the value of the "mfa_chain" field. +func (m *OAuth2ClientMutation) ClearMfaChain() { + m.mfa_chain = nil + m.appendmfa_chain = nil + m.clearedFields[oauth2client.FieldMfaChain] = struct{}{} +} + +// MfaChainCleared returns if the "mfa_chain" field was cleared in this mutation. +func (m *OAuth2ClientMutation) MfaChainCleared() bool { + _, ok := m.clearedFields[oauth2client.FieldMfaChain] + return ok +} + +// ResetMfaChain resets all changes to the "mfa_chain" field. +func (m *OAuth2ClientMutation) ResetMfaChain() { + m.mfa_chain = nil + m.appendmfa_chain = nil + delete(m.clearedFields, oauth2client.FieldMfaChain) +} + // Where appends a list predicates to the OAuth2ClientMutation builder. func (m *OAuth2ClientMutation) Where(ps ...predicate.OAuth2Client) { m.predicates = append(m.predicates, ps...) @@ -6424,7 +6545,7 @@ func (m *OAuth2ClientMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *OAuth2ClientMutation) Fields() []string { - fields := make([]string, 0, 7) + fields := make([]string, 0, 8) if m.secret != nil { fields = append(fields, oauth2client.FieldSecret) } @@ -6446,6 +6567,9 @@ func (m *OAuth2ClientMutation) Fields() []string { if m.allowed_connectors != nil { fields = append(fields, oauth2client.FieldAllowedConnectors) } + if m.mfa_chain != nil { + fields = append(fields, oauth2client.FieldMfaChain) + } return fields } @@ -6468,6 +6592,8 @@ func (m *OAuth2ClientMutation) Field(name string) (ent.Value, bool) { return m.LogoURL() case oauth2client.FieldAllowedConnectors: return m.AllowedConnectors() + case oauth2client.FieldMfaChain: + return m.MfaChain() } return nil, false } @@ -6491,6 +6617,8 @@ func (m *OAuth2ClientMutation) OldField(ctx context.Context, name string) (ent.V return m.OldLogoURL(ctx) case oauth2client.FieldAllowedConnectors: return m.OldAllowedConnectors(ctx) + case oauth2client.FieldMfaChain: + return m.OldMfaChain(ctx) } return nil, fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -6549,6 +6677,13 @@ func (m *OAuth2ClientMutation) SetField(name string, value ent.Value) error { } m.SetAllowedConnectors(v) return nil + case oauth2client.FieldMfaChain: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetMfaChain(v) + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -6588,6 +6723,9 @@ func (m *OAuth2ClientMutation) ClearedFields() []string { if m.FieldCleared(oauth2client.FieldAllowedConnectors) { fields = append(fields, oauth2client.FieldAllowedConnectors) } + if m.FieldCleared(oauth2client.FieldMfaChain) { + fields = append(fields, oauth2client.FieldMfaChain) + } return fields } @@ -6611,6 +6749,9 @@ func (m *OAuth2ClientMutation) ClearField(name string) error { case oauth2client.FieldAllowedConnectors: m.ClearAllowedConnectors() return nil + case oauth2client.FieldMfaChain: + m.ClearMfaChain() + return nil } return fmt.Errorf("unknown OAuth2Client nullable field %s", name) } @@ -6640,6 +6781,9 @@ func (m *OAuth2ClientMutation) ResetField(name string) error { case oauth2client.FieldAllowedConnectors: m.ResetAllowedConnectors() return nil + case oauth2client.FieldMfaChain: + m.ResetMfaChain() + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -9168,6 +9312,7 @@ type UserIdentityMutation struct { claims_groups *[]string appendclaims_groups []string consents *[]byte + mfa_secrets *[]byte created_at *time.Time last_login *time.Time blocked_until *time.Time @@ -9634,6 +9779,55 @@ func (m *UserIdentityMutation) ResetConsents() { m.consents = nil } +// SetMfaSecrets sets the "mfa_secrets" field. +func (m *UserIdentityMutation) SetMfaSecrets(b []byte) { + m.mfa_secrets = &b +} + +// MfaSecrets returns the value of the "mfa_secrets" field in the mutation. +func (m *UserIdentityMutation) MfaSecrets() (r []byte, exists bool) { + v := m.mfa_secrets + if v == nil { + return + } + return *v, true +} + +// OldMfaSecrets returns the old "mfa_secrets" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldMfaSecrets(ctx context.Context) (v *[]byte, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldMfaSecrets is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldMfaSecrets requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldMfaSecrets: %w", err) + } + return oldValue.MfaSecrets, nil +} + +// ClearMfaSecrets clears the value of the "mfa_secrets" field. +func (m *UserIdentityMutation) ClearMfaSecrets() { + m.mfa_secrets = nil + m.clearedFields[useridentity.FieldMfaSecrets] = struct{}{} +} + +// MfaSecretsCleared returns if the "mfa_secrets" field was cleared in this mutation. +func (m *UserIdentityMutation) MfaSecretsCleared() bool { + _, ok := m.clearedFields[useridentity.FieldMfaSecrets] + return ok +} + +// ResetMfaSecrets resets all changes to the "mfa_secrets" field. +func (m *UserIdentityMutation) ResetMfaSecrets() { + m.mfa_secrets = nil + delete(m.clearedFields, useridentity.FieldMfaSecrets) +} + // SetCreatedAt sets the "created_at" field. func (m *UserIdentityMutation) SetCreatedAt(t time.Time) { m.created_at = &t @@ -9776,7 +9970,7 @@ func (m *UserIdentityMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserIdentityMutation) Fields() []string { - fields := make([]string, 0, 12) + fields := make([]string, 0, 13) if m.user_id != nil { fields = append(fields, useridentity.FieldUserID) } @@ -9804,6 +9998,9 @@ func (m *UserIdentityMutation) Fields() []string { if m.consents != nil { fields = append(fields, useridentity.FieldConsents) } + if m.mfa_secrets != nil { + fields = append(fields, useridentity.FieldMfaSecrets) + } if m.created_at != nil { fields = append(fields, useridentity.FieldCreatedAt) } @@ -9839,6 +10036,8 @@ func (m *UserIdentityMutation) Field(name string) (ent.Value, bool) { return m.ClaimsGroups() case useridentity.FieldConsents: return m.Consents() + case useridentity.FieldMfaSecrets: + return m.MfaSecrets() case useridentity.FieldCreatedAt: return m.CreatedAt() case useridentity.FieldLastLogin: @@ -9872,6 +10071,8 @@ func (m *UserIdentityMutation) OldField(ctx context.Context, name string) (ent.V return m.OldClaimsGroups(ctx) case useridentity.FieldConsents: return m.OldConsents(ctx) + case useridentity.FieldMfaSecrets: + return m.OldMfaSecrets(ctx) case useridentity.FieldCreatedAt: return m.OldCreatedAt(ctx) case useridentity.FieldLastLogin: @@ -9950,6 +10151,13 @@ func (m *UserIdentityMutation) SetField(name string, value ent.Value) error { } m.SetConsents(v) return nil + case useridentity.FieldMfaSecrets: + v, ok := value.([]byte) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetMfaSecrets(v) + return nil case useridentity.FieldCreatedAt: v, ok := value.(time.Time) if !ok { @@ -10004,6 +10212,9 @@ func (m *UserIdentityMutation) ClearedFields() []string { if m.FieldCleared(useridentity.FieldClaimsGroups) { fields = append(fields, useridentity.FieldClaimsGroups) } + if m.FieldCleared(useridentity.FieldMfaSecrets) { + fields = append(fields, useridentity.FieldMfaSecrets) + } return fields } @@ -10021,6 +10232,9 @@ func (m *UserIdentityMutation) ClearField(name string) error { case useridentity.FieldClaimsGroups: m.ClearClaimsGroups() return nil + case useridentity.FieldMfaSecrets: + m.ClearMfaSecrets() + return nil } return fmt.Errorf("unknown UserIdentity nullable field %s", name) } @@ -10056,6 +10270,9 @@ func (m *UserIdentityMutation) ResetField(name string) error { case useridentity.FieldConsents: m.ResetConsents() return nil + case useridentity.FieldMfaSecrets: + m.ResetMfaSecrets() + return nil case useridentity.FieldCreatedAt: m.ResetCreatedAt() return nil diff --git a/storage/ent/db/oauth2client.go b/storage/ent/db/oauth2client.go index c6671aba..81a87afc 100644 --- a/storage/ent/db/oauth2client.go +++ b/storage/ent/db/oauth2client.go @@ -31,7 +31,9 @@ type OAuth2Client struct { LogoURL string `json:"logo_url,omitempty"` // AllowedConnectors holds the value of the "allowed_connectors" field. AllowedConnectors []string `json:"allowed_connectors,omitempty"` - selectValues sql.SelectValues + // MfaChain holds the value of the "mfa_chain" field. + MfaChain []string `json:"mfa_chain,omitempty"` + selectValues sql.SelectValues } // scanValues returns the types for scanning values from sql.Rows. @@ -39,7 +41,7 @@ func (*OAuth2Client) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors: + case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors, oauth2client.FieldMfaChain: values[i] = new([]byte) case oauth2client.FieldPublic: values[i] = new(sql.NullBool) @@ -114,6 +116,14 @@ func (_m *OAuth2Client) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field allowed_connectors: %w", err) } } + case oauth2client.FieldMfaChain: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field mfa_chain", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.MfaChain); err != nil { + return fmt.Errorf("unmarshal field mfa_chain: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -170,6 +180,9 @@ func (_m *OAuth2Client) String() string { builder.WriteString(", ") builder.WriteString("allowed_connectors=") builder.WriteString(fmt.Sprintf("%v", _m.AllowedConnectors)) + builder.WriteString(", ") + builder.WriteString("mfa_chain=") + builder.WriteString(fmt.Sprintf("%v", _m.MfaChain)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/oauth2client/oauth2client.go b/storage/ent/db/oauth2client/oauth2client.go index 529f4c8d..0143eb6e 100644 --- a/storage/ent/db/oauth2client/oauth2client.go +++ b/storage/ent/db/oauth2client/oauth2client.go @@ -25,6 +25,8 @@ const ( FieldLogoURL = "logo_url" // FieldAllowedConnectors holds the string denoting the allowed_connectors field in the database. FieldAllowedConnectors = "allowed_connectors" + // FieldMfaChain holds the string denoting the mfa_chain field in the database. + FieldMfaChain = "mfa_chain" // Table holds the table name of the oauth2client in the database. Table = "oauth2clients" ) @@ -39,6 +41,7 @@ var Columns = []string{ FieldName, FieldLogoURL, FieldAllowedConnectors, + FieldMfaChain, } // ValidColumn reports if the column name is valid (part of the table columns). diff --git a/storage/ent/db/oauth2client/where.go b/storage/ent/db/oauth2client/where.go index 1425bf7e..d8ce00d6 100644 --- a/storage/ent/db/oauth2client/where.go +++ b/storage/ent/db/oauth2client/where.go @@ -317,6 +317,16 @@ func AllowedConnectorsNotNil() predicate.OAuth2Client { return predicate.OAuth2Client(sql.FieldNotNull(FieldAllowedConnectors)) } +// MfaChainIsNil applies the IsNil predicate on the "mfa_chain" field. +func MfaChainIsNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldIsNull(FieldMfaChain)) +} + +// MfaChainNotNil applies the NotNil predicate on the "mfa_chain" field. +func MfaChainNotNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldNotNull(FieldMfaChain)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.OAuth2Client) predicate.OAuth2Client { return predicate.OAuth2Client(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/oauth2client_create.go b/storage/ent/db/oauth2client_create.go index fe29ab2a..1e8d2a54 100644 --- a/storage/ent/db/oauth2client_create.go +++ b/storage/ent/db/oauth2client_create.go @@ -61,6 +61,12 @@ func (_c *OAuth2ClientCreate) SetAllowedConnectors(v []string) *OAuth2ClientCrea return _c } +// SetMfaChain sets the "mfa_chain" field. +func (_c *OAuth2ClientCreate) SetMfaChain(v []string) *OAuth2ClientCreate { + _c.mutation.SetMfaChain(v) + return _c +} + // SetID sets the "id" field. func (_c *OAuth2ClientCreate) SetID(v string) *OAuth2ClientCreate { _c.mutation.SetID(v) @@ -196,6 +202,10 @@ func (_c *OAuth2ClientCreate) createSpec() (*OAuth2Client, *sqlgraph.CreateSpec) _spec.SetField(oauth2client.FieldAllowedConnectors, field.TypeJSON, value) _node.AllowedConnectors = value } + if value, ok := _c.mutation.MfaChain(); ok { + _spec.SetField(oauth2client.FieldMfaChain, field.TypeJSON, value) + _node.MfaChain = value + } return _node, _spec } diff --git a/storage/ent/db/oauth2client_update.go b/storage/ent/db/oauth2client_update.go index 3fdbdbdc..7e9f753c 100644 --- a/storage/ent/db/oauth2client_update.go +++ b/storage/ent/db/oauth2client_update.go @@ -138,6 +138,24 @@ func (_u *OAuth2ClientUpdate) ClearAllowedConnectors() *OAuth2ClientUpdate { return _u } +// SetMfaChain sets the "mfa_chain" field. +func (_u *OAuth2ClientUpdate) SetMfaChain(v []string) *OAuth2ClientUpdate { + _u.mutation.SetMfaChain(v) + return _u +} + +// AppendMfaChain appends value to the "mfa_chain" field. +func (_u *OAuth2ClientUpdate) AppendMfaChain(v []string) *OAuth2ClientUpdate { + _u.mutation.AppendMfaChain(v) + return _u +} + +// ClearMfaChain clears the value of the "mfa_chain" field. +func (_u *OAuth2ClientUpdate) ClearMfaChain() *OAuth2ClientUpdate { + _u.mutation.ClearMfaChain() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdate) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -247,6 +265,17 @@ func (_u *OAuth2ClientUpdate) sqlSave(ctx context.Context) (_node int, err error if _u.mutation.AllowedConnectorsCleared() { _spec.ClearField(oauth2client.FieldAllowedConnectors, field.TypeJSON) } + if value, ok := _u.mutation.MfaChain(); ok { + _spec.SetField(oauth2client.FieldMfaChain, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedMfaChain(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldMfaChain, value) + }) + } + if _u.mutation.MfaChainCleared() { + _spec.ClearField(oauth2client.FieldMfaChain, field.TypeJSON) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{oauth2client.Label} @@ -377,6 +406,24 @@ func (_u *OAuth2ClientUpdateOne) ClearAllowedConnectors() *OAuth2ClientUpdateOne return _u } +// SetMfaChain sets the "mfa_chain" field. +func (_u *OAuth2ClientUpdateOne) SetMfaChain(v []string) *OAuth2ClientUpdateOne { + _u.mutation.SetMfaChain(v) + return _u +} + +// AppendMfaChain appends value to the "mfa_chain" field. +func (_u *OAuth2ClientUpdateOne) AppendMfaChain(v []string) *OAuth2ClientUpdateOne { + _u.mutation.AppendMfaChain(v) + return _u +} + +// ClearMfaChain clears the value of the "mfa_chain" field. +func (_u *OAuth2ClientUpdateOne) ClearMfaChain() *OAuth2ClientUpdateOne { + _u.mutation.ClearMfaChain() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdateOne) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -516,6 +563,17 @@ func (_u *OAuth2ClientUpdateOne) sqlSave(ctx context.Context) (_node *OAuth2Clie if _u.mutation.AllowedConnectorsCleared() { _spec.ClearField(oauth2client.FieldAllowedConnectors, field.TypeJSON) } + if value, ok := _u.mutation.MfaChain(); ok { + _spec.SetField(oauth2client.FieldMfaChain, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedMfaChain(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldMfaChain, value) + }) + } + if _u.mutation.MfaChainCleared() { + _spec.ClearField(oauth2client.FieldMfaChain, field.TypeJSON) + } _node = &OAuth2Client{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/db/runtime.go b/storage/ent/db/runtime.go index 3973c7e7..df011f55 100644 --- a/storage/ent/db/runtime.go +++ b/storage/ent/db/runtime.go @@ -84,6 +84,10 @@ func init() { authrequestDescCodeChallengeMethod := authrequestFields[19].Descriptor() // authrequest.DefaultCodeChallengeMethod holds the default value on creation for the code_challenge_method field. authrequest.DefaultCodeChallengeMethod = authrequestDescCodeChallengeMethod.Default.(string) + // authrequestDescMfaValidated is the schema descriptor for mfa_validated field. + authrequestDescMfaValidated := authrequestFields[21].Descriptor() + // authrequest.DefaultMfaValidated holds the default value on creation for the mfa_validated field. + authrequest.DefaultMfaValidated = authrequestDescMfaValidated.Default.(bool) // authrequestDescID is the schema descriptor for id field. authrequestDescID := authrequestFields[0].Descriptor() // authrequest.IDValidator is a validator for the "id" field. It is called by the builders before save. diff --git a/storage/ent/db/useridentity.go b/storage/ent/db/useridentity.go index 7127299b..91f291de 100644 --- a/storage/ent/db/useridentity.go +++ b/storage/ent/db/useridentity.go @@ -36,6 +36,8 @@ type UserIdentity struct { ClaimsGroups []string `json:"claims_groups,omitempty"` // Consents holds the value of the "consents" field. Consents []byte `json:"consents,omitempty"` + // MfaSecrets holds the value of the "mfa_secrets" field. + MfaSecrets *[]byte `json:"mfa_secrets,omitempty"` // CreatedAt holds the value of the "created_at" field. CreatedAt time.Time `json:"created_at,omitempty"` // LastLogin holds the value of the "last_login" field. @@ -50,7 +52,7 @@ func (*UserIdentity) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case useridentity.FieldClaimsGroups, useridentity.FieldConsents: + case useridentity.FieldClaimsGroups, useridentity.FieldConsents, useridentity.FieldMfaSecrets: values[i] = new([]byte) case useridentity.FieldClaimsEmailVerified: values[i] = new(sql.NullBool) @@ -135,6 +137,12 @@ func (_m *UserIdentity) assignValues(columns []string, values []any) error { } else if value != nil { _m.Consents = *value } + case useridentity.FieldMfaSecrets: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field mfa_secrets", values[i]) + } else if value != nil { + _m.MfaSecrets = value + } case useridentity.FieldCreatedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created_at", values[i]) @@ -216,6 +224,11 @@ func (_m *UserIdentity) String() string { builder.WriteString("consents=") builder.WriteString(fmt.Sprintf("%v", _m.Consents)) builder.WriteString(", ") + if v := _m.MfaSecrets; v != nil { + builder.WriteString("mfa_secrets=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("created_at=") builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) builder.WriteString(", ") diff --git a/storage/ent/db/useridentity/useridentity.go b/storage/ent/db/useridentity/useridentity.go index f08d74ec..9fae1444 100644 --- a/storage/ent/db/useridentity/useridentity.go +++ b/storage/ent/db/useridentity/useridentity.go @@ -29,6 +29,8 @@ const ( FieldClaimsGroups = "claims_groups" // FieldConsents holds the string denoting the consents field in the database. FieldConsents = "consents" + // FieldMfaSecrets holds the string denoting the mfa_secrets field in the database. + FieldMfaSecrets = "mfa_secrets" // FieldCreatedAt holds the string denoting the created_at field in the database. FieldCreatedAt = "created_at" // FieldLastLogin holds the string denoting the last_login field in the database. @@ -51,6 +53,7 @@ var Columns = []string{ FieldClaimsEmailVerified, FieldClaimsGroups, FieldConsents, + FieldMfaSecrets, FieldCreatedAt, FieldLastLogin, FieldBlockedUntil, diff --git a/storage/ent/db/useridentity/where.go b/storage/ent/db/useridentity/where.go index 201d340f..c3e3d911 100644 --- a/storage/ent/db/useridentity/where.go +++ b/storage/ent/db/useridentity/where.go @@ -104,6 +104,11 @@ func Consents(v []byte) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldEQ(FieldConsents, v)) } +// MfaSecrets applies equality check predicate on the "mfa_secrets" field. It's identical to MfaSecretsEQ. +func MfaSecrets(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldMfaSecrets, v)) +} + // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. func CreatedAt(v time.Time) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldEQ(FieldCreatedAt, v)) @@ -569,6 +574,56 @@ func ConsentsLTE(v []byte) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldLTE(FieldConsents, v)) } +// MfaSecretsEQ applies the EQ predicate on the "mfa_secrets" field. +func MfaSecretsEQ(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldMfaSecrets, v)) +} + +// MfaSecretsNEQ applies the NEQ predicate on the "mfa_secrets" field. +func MfaSecretsNEQ(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldMfaSecrets, v)) +} + +// MfaSecretsIn applies the In predicate on the "mfa_secrets" field. +func MfaSecretsIn(vs ...[]byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldMfaSecrets, vs...)) +} + +// MfaSecretsNotIn applies the NotIn predicate on the "mfa_secrets" field. +func MfaSecretsNotIn(vs ...[]byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldMfaSecrets, vs...)) +} + +// MfaSecretsGT applies the GT predicate on the "mfa_secrets" field. +func MfaSecretsGT(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldMfaSecrets, v)) +} + +// MfaSecretsGTE applies the GTE predicate on the "mfa_secrets" field. +func MfaSecretsGTE(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldMfaSecrets, v)) +} + +// MfaSecretsLT applies the LT predicate on the "mfa_secrets" field. +func MfaSecretsLT(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldMfaSecrets, v)) +} + +// MfaSecretsLTE applies the LTE predicate on the "mfa_secrets" field. +func MfaSecretsLTE(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldMfaSecrets, v)) +} + +// MfaSecretsIsNil applies the IsNil predicate on the "mfa_secrets" field. +func MfaSecretsIsNil() predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIsNull(FieldMfaSecrets)) +} + +// MfaSecretsNotNil applies the NotNil predicate on the "mfa_secrets" field. +func MfaSecretsNotNil() predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotNull(FieldMfaSecrets)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldEQ(FieldCreatedAt, v)) diff --git a/storage/ent/db/useridentity_create.go b/storage/ent/db/useridentity_create.go index 336d5c30..0f4b355a 100644 --- a/storage/ent/db/useridentity_create.go +++ b/storage/ent/db/useridentity_create.go @@ -114,6 +114,12 @@ func (_c *UserIdentityCreate) SetConsents(v []byte) *UserIdentityCreate { return _c } +// SetMfaSecrets sets the "mfa_secrets" field. +func (_c *UserIdentityCreate) SetMfaSecrets(v []byte) *UserIdentityCreate { + _c.mutation.SetMfaSecrets(v) + return _c +} + // SetCreatedAt sets the "created_at" field. func (_c *UserIdentityCreate) SetCreatedAt(v time.Time) *UserIdentityCreate { _c.mutation.SetCreatedAt(v) @@ -316,6 +322,10 @@ func (_c *UserIdentityCreate) createSpec() (*UserIdentity, *sqlgraph.CreateSpec) _spec.SetField(useridentity.FieldConsents, field.TypeBytes, value) _node.Consents = value } + if value, ok := _c.mutation.MfaSecrets(); ok { + _spec.SetField(useridentity.FieldMfaSecrets, field.TypeBytes, value) + _node.MfaSecrets = &value + } if value, ok := _c.mutation.CreatedAt(); ok { _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) _node.CreatedAt = value diff --git a/storage/ent/db/useridentity_update.go b/storage/ent/db/useridentity_update.go index 27ee0d3a..9de8e51f 100644 --- a/storage/ent/db/useridentity_update.go +++ b/storage/ent/db/useridentity_update.go @@ -151,6 +151,18 @@ func (_u *UserIdentityUpdate) SetConsents(v []byte) *UserIdentityUpdate { return _u } +// SetMfaSecrets sets the "mfa_secrets" field. +func (_u *UserIdentityUpdate) SetMfaSecrets(v []byte) *UserIdentityUpdate { + _u.mutation.SetMfaSecrets(v) + return _u +} + +// ClearMfaSecrets clears the value of the "mfa_secrets" field. +func (_u *UserIdentityUpdate) ClearMfaSecrets() *UserIdentityUpdate { + _u.mutation.ClearMfaSecrets() + return _u +} + // SetCreatedAt sets the "created_at" field. func (_u *UserIdentityUpdate) SetCreatedAt(v time.Time) *UserIdentityUpdate { _u.mutation.SetCreatedAt(v) @@ -287,6 +299,12 @@ func (_u *UserIdentityUpdate) sqlSave(ctx context.Context) (_node int, err error if value, ok := _u.mutation.Consents(); ok { _spec.SetField(useridentity.FieldConsents, field.TypeBytes, value) } + if value, ok := _u.mutation.MfaSecrets(); ok { + _spec.SetField(useridentity.FieldMfaSecrets, field.TypeBytes, value) + } + if _u.mutation.MfaSecretsCleared() { + _spec.ClearField(useridentity.FieldMfaSecrets, field.TypeBytes) + } if value, ok := _u.mutation.CreatedAt(); ok { _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) } @@ -438,6 +456,18 @@ func (_u *UserIdentityUpdateOne) SetConsents(v []byte) *UserIdentityUpdateOne { return _u } +// SetMfaSecrets sets the "mfa_secrets" field. +func (_u *UserIdentityUpdateOne) SetMfaSecrets(v []byte) *UserIdentityUpdateOne { + _u.mutation.SetMfaSecrets(v) + return _u +} + +// ClearMfaSecrets clears the value of the "mfa_secrets" field. +func (_u *UserIdentityUpdateOne) ClearMfaSecrets() *UserIdentityUpdateOne { + _u.mutation.ClearMfaSecrets() + return _u +} + // SetCreatedAt sets the "created_at" field. func (_u *UserIdentityUpdateOne) SetCreatedAt(v time.Time) *UserIdentityUpdateOne { _u.mutation.SetCreatedAt(v) @@ -604,6 +634,12 @@ func (_u *UserIdentityUpdateOne) sqlSave(ctx context.Context) (_node *UserIdenti if value, ok := _u.mutation.Consents(); ok { _spec.SetField(useridentity.FieldConsents, field.TypeBytes, value) } + if value, ok := _u.mutation.MfaSecrets(); ok { + _spec.SetField(useridentity.FieldMfaSecrets, field.TypeBytes, value) + } + if _u.mutation.MfaSecretsCleared() { + _spec.ClearField(useridentity.FieldMfaSecrets, field.TypeBytes) + } if value, ok := _u.mutation.CreatedAt(); ok { _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) } diff --git a/storage/ent/schema/authrequest.go b/storage/ent/schema/authrequest.go index 2b75927b..905c73ab 100644 --- a/storage/ent/schema/authrequest.go +++ b/storage/ent/schema/authrequest.go @@ -88,6 +88,8 @@ func (AuthRequest) Fields() []ent.Field { SchemaType(textSchema). Default(""), field.Bytes("hmac_key"), + field.Bool("mfa_validated"). + Default(false), } } diff --git a/storage/ent/schema/client.go b/storage/ent/schema/client.go index f0e10606..c7737d70 100644 --- a/storage/ent/schema/client.go +++ b/storage/ent/schema/client.go @@ -47,6 +47,8 @@ func (OAuth2Client) Fields() []ent.Field { NotEmpty(), field.JSON("allowed_connectors", []string{}). Optional(), + field.JSON("mfa_chain", []string{}). + Optional(), } } diff --git a/storage/ent/schema/useridentity.go b/storage/ent/schema/useridentity.go index a4928240..f8a4f2b8 100644 --- a/storage/ent/schema/useridentity.go +++ b/storage/ent/schema/useridentity.go @@ -41,6 +41,9 @@ func (UserIdentity) Fields() []ent.Field { field.JSON("claims_groups", []string{}). Optional(), field.Bytes("consents"), + field.Bytes("mfa_secrets"). + Nillable(). + Optional(), field.Time("created_at"). SchemaType(timeSchema), field.Time("last_login"). diff --git a/storage/etcd/types.go b/storage/etcd/types.go index 3dc8ef27..b76727e2 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -86,6 +86,8 @@ type AuthRequest struct { CodeChallengeMethod string `json:"code_challenge_method,omitempty"` HMACKey []byte `json:"hmac_key"` + + MFAValidated bool `json:"mfa_validated"` } func fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { @@ -106,6 +108,7 @@ func fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { CodeChallenge: a.PKCE.CodeChallenge, CodeChallengeMethod: a.PKCE.CodeChallengeMethod, HMACKey: a.HMACKey, + MFAValidated: a.MFAValidated, } } @@ -128,7 +131,8 @@ func toStorageAuthRequest(a AuthRequest) storage.AuthRequest { CodeChallenge: a.CodeChallenge, CodeChallengeMethod: a.CodeChallengeMethod, }, - HMACKey: a.HMACKey, + HMACKey: a.HMACKey, + MFAValidated: a.MFAValidated, } } @@ -258,13 +262,14 @@ func toStorageOfflineSessions(o OfflineSessions) storage.OfflineSessions { // UserIdentity is a mirrored struct from storage with JSON struct tags type UserIdentity struct { - UserID string `json:"user_id,omitempty"` - ConnectorID string `json:"connector_id,omitempty"` - Claims Claims `json:"claims,omitempty"` - Consents map[string][]string `json:"consents,omitempty"` - CreatedAt time.Time `json:"created_at"` - LastLogin time.Time `json:"last_login"` - BlockedUntil time.Time `json:"blocked_until"` + UserID string `json:"user_id,omitempty"` + ConnectorID string `json:"connector_id,omitempty"` + Claims Claims `json:"claims,omitempty"` + Consents map[string][]string `json:"consents,omitempty"` + MFASecrets map[string]*storage.MFASecret `json:"mfa_secrets,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastLogin time.Time `json:"last_login"` + BlockedUntil time.Time `json:"blocked_until"` } func fromStorageUserIdentity(u storage.UserIdentity) UserIdentity { @@ -273,6 +278,7 @@ func fromStorageUserIdentity(u storage.UserIdentity) UserIdentity { ConnectorID: u.ConnectorID, Claims: fromStorageClaims(u.Claims), Consents: u.Consents, + MFASecrets: u.MFASecrets, CreatedAt: u.CreatedAt, LastLogin: u.LastLogin, BlockedUntil: u.BlockedUntil, @@ -285,6 +291,7 @@ func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { ConnectorID: u.ConnectorID, Claims: toStorageClaims(u.Claims), Consents: u.Consents, + MFASecrets: u.MFASecrets, CreatedAt: u.CreatedAt, LastLogin: u.LastLogin, BlockedUntil: u.BlockedUntil, diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 074c6815..c0245dad 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -287,6 +287,8 @@ type Client struct { LogoURL string `json:"logoURL,omitempty"` AllowedConnectors []string `json:"allowedConnectors,omitempty"` + + MFAChain []string `json:"mfaChain,omitempty"` } // ClientList is a list of Clients. @@ -314,6 +316,7 @@ func (cli *client) fromStorageClient(c storage.Client) Client { Name: c.Name, LogoURL: c.LogoURL, AllowedConnectors: c.AllowedConnectors, + MFAChain: c.MFAChain, } } @@ -327,6 +330,7 @@ func toStorageClient(c Client) storage.Client { Name: c.Name, LogoURL: c.LogoURL, AllowedConnectors: c.AllowedConnectors, + MFAChain: c.MFAChain, } } @@ -396,6 +400,8 @@ type AuthRequest struct { CodeChallengeMethod string `json:"code_challenge_method,omitempty"` HMACKey []byte `json:"hmac_key"` + + MFAValidated bool `json:"mfa_validated"` } // AuthRequestList is a list of AuthRequests. @@ -424,7 +430,8 @@ func toStorageAuthRequest(req AuthRequest) storage.AuthRequest { CodeChallenge: req.CodeChallenge, CodeChallengeMethod: req.CodeChallengeMethod, }, - HMACKey: req.HMACKey, + HMACKey: req.HMACKey, + MFAValidated: req.MFAValidated, } return a } @@ -454,6 +461,7 @@ func (cli *client) fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { CodeChallenge: a.PKCE.CodeChallenge, CodeChallengeMethod: a.PKCE.CodeChallengeMethod, HMACKey: a.HMACKey, + MFAValidated: a.MFAValidated, } return req } @@ -913,13 +921,14 @@ type UserIdentity struct { k8sapi.TypeMeta `json:",inline"` k8sapi.ObjectMeta `json:"metadata,omitempty"` - UserID string `json:"userID,omitempty"` - ConnectorID string `json:"connectorID,omitempty"` - Claims Claims `json:"claims,omitempty"` - Consents map[string][]string `json:"consents,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - LastLogin time.Time `json:"lastLogin,omitempty"` - BlockedUntil time.Time `json:"blockedUntil,omitempty"` + UserID string `json:"userID,omitempty"` + ConnectorID string `json:"connectorID,omitempty"` + Claims Claims `json:"claims,omitempty"` + Consents map[string][]string `json:"consents,omitempty"` + MFASecrets map[string]*storage.MFASecret `json:"mfaSecrets,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + LastLogin time.Time `json:"lastLogin,omitempty"` + BlockedUntil time.Time `json:"blockedUntil,omitempty"` } // UserIdentityList is a list of UserIdentities. @@ -943,6 +952,7 @@ func (cli *client) fromStorageUserIdentity(u storage.UserIdentity) UserIdentity ConnectorID: u.ConnectorID, Claims: fromStorageClaims(u.Claims), Consents: u.Consents, + MFASecrets: u.MFASecrets, CreatedAt: u.CreatedAt, LastLogin: u.LastLogin, BlockedUntil: u.BlockedUntil, @@ -955,6 +965,7 @@ func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { ConnectorID: u.ConnectorID, Claims: toStorageClaims(u.Claims), Consents: u.Consents, + MFASecrets: u.MFASecrets, CreatedAt: u.CreatedAt, LastLogin: u.LastLogin, BlockedUntil: u.BlockedUntil, diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 91213d76..d77ce4e0 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -134,10 +134,11 @@ func (c *conn) CreateAuthRequest(ctx context.Context, a storage.AuthRequest) err connector_id, connector_data, expiry, code_challenge, code_challenge_method, - hmac_key + hmac_key, + mfa_validated ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22 ); `, a.ID, a.ClientID, encoder(a.ResponseTypes), encoder(a.Scopes), a.RedirectURI, a.Nonce, a.State, @@ -148,6 +149,7 @@ func (c *conn) CreateAuthRequest(ctx context.Context, a storage.AuthRequest) err a.Expiry, a.PKCE.CodeChallenge, a.PKCE.CodeChallengeMethod, a.HMACKey, + a.MFAValidated, ) if err != nil { if c.alreadyExistsCheck(err) { @@ -180,8 +182,9 @@ func (c *conn) UpdateAuthRequest(ctx context.Context, id string, updater func(a connector_id = $15, connector_data = $16, expiry = $17, code_challenge = $18, code_challenge_method = $19, - hmac_key = $20 - where id = $21; + hmac_key = $20, + mfa_validated = $21 + where id = $22; `, a.ClientID, encoder(a.ResponseTypes), encoder(a.Scopes), a.RedirectURI, a.Nonce, a.State, a.ForceApprovalPrompt, a.LoggedIn, @@ -191,6 +194,7 @@ func (c *conn) UpdateAuthRequest(ctx context.Context, id string, updater func(a a.ConnectorID, a.ConnectorData, a.Expiry, a.PKCE.CodeChallenge, a.PKCE.CodeChallengeMethod, a.HMACKey, + a.MFAValidated, r.ID, ) if err != nil { @@ -212,7 +216,8 @@ func getAuthRequest(ctx context.Context, q querier, id string) (a storage.AuthRe claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, connector_id, connector_data, expiry, - code_challenge, code_challenge_method, hmac_key + code_challenge, code_challenge_method, hmac_key, + mfa_validated from auth_request where id = $1; `, id).Scan( &a.ID, &a.ClientID, decoder(&a.ResponseTypes), decoder(&a.Scopes), &a.RedirectURI, &a.Nonce, &a.State, @@ -222,6 +227,7 @@ func getAuthRequest(ctx context.Context, q querier, id string) (a storage.AuthRe decoder(&a.Claims.Groups), &a.ConnectorID, &a.ConnectorData, &a.Expiry, &a.PKCE.CodeChallenge, &a.PKCE.CodeChallengeMethod, &a.HMACKey, + &a.MFAValidated, ) if err != nil { if err == sql.ErrNoRows { @@ -514,9 +520,10 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto public = $4, name = $5, logo_url = $6, - allowed_connectors = $7 - where id = $8; - `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, encoder(nc.AllowedConnectors), id, + allowed_connectors = $7, + mfa_chain = $8 + where id = $9; + `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, encoder(nc.AllowedConnectors), encoder(nc.MFAChain), id, ) if err != nil { return fmt.Errorf("update client: %v", err) @@ -528,12 +535,12 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { _, err := c.Exec(` insert into client ( - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain ) - values ($1, $2, $3, $4, $5, $6, $7, $8); + values ($1, $2, $3, $4, $5, $6, $7, $8, $9); `, cli.ID, cli.Secret, encoder(cli.RedirectURIs), encoder(cli.TrustedPeers), - cli.Public, cli.Name, cli.LogoURL, encoder(cli.AllowedConnectors), + cli.Public, cli.Name, cli.LogoURL, encoder(cli.AllowedConnectors), encoder(cli.MFAChain), ) if err != nil { if c.alreadyExistsCheck(err) { @@ -547,7 +554,7 @@ func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { func getClient(ctx context.Context, q querier, id string) (storage.Client, error) { return scanClient(q.QueryRow(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain from client where id = $1; `, id)) } @@ -559,7 +566,7 @@ func (c *conn) GetClient(ctx context.Context, id string) (storage.Client, error) func (c *conn) ListClients(ctx context.Context) ([]storage.Client, error) { rows, err := c.Query(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain from client; `) if err != nil { @@ -583,9 +590,10 @@ func (c *conn) ListClients(ctx context.Context) ([]storage.Client, error) { func scanClient(s scanner) (cli storage.Client, err error) { var allowedConnectors []byte + var mfaChain []byte err = s.Scan( &cli.ID, &cli.Secret, decoder(&cli.RedirectURIs), decoder(&cli.TrustedPeers), - &cli.Public, &cli.Name, &cli.LogoURL, &allowedConnectors, + &cli.Public, &cli.Name, &cli.LogoURL, &allowedConnectors, &mfaChain, ) if err != nil { if err == sql.ErrNoRows { @@ -598,6 +606,11 @@ func scanClient(s scanner) (cli storage.Client, err error) { return cli, fmt.Errorf("unmarshal client allowed connectors: %v", err) } } + if len(mfaChain) > 0 { + if err := json.Unmarshal(mfaChain, &cli.MFAChain); err != nil { + return cli, fmt.Errorf("unmarshal client mfa chain: %v", err) + } + } return cli, nil } @@ -781,17 +794,17 @@ func (c *conn) CreateUserIdentity(ctx context.Context, u storage.UserIdentity) e user_id, connector_id, claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, - consents, + consents, mfa_secrets, created_at, last_login, blocked_until ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 ); `, u.UserID, u.ConnectorID, u.Claims.UserID, u.Claims.Username, u.Claims.PreferredUsername, u.Claims.Email, u.Claims.EmailVerified, encoder(u.Claims.Groups), - encoder(u.Consents), + encoder(u.Consents), encoder(u.MFASecrets), u.CreatedAt, u.LastLogin, u.BlockedUntil, ) if err != nil { @@ -824,14 +837,15 @@ func (c *conn) UpdateUserIdentity(ctx context.Context, userID, connectorID strin claims_email_verified = $5, claims_groups = $6, consents = $7, - created_at = $8, - last_login = $9, - blocked_until = $10 - where user_id = $11 AND connector_id = $12; + mfa_secrets = $8, + created_at = $9, + last_login = $10, + blocked_until = $11 + where user_id = $12 AND connector_id = $13; `, newIdentity.Claims.UserID, newIdentity.Claims.Username, newIdentity.Claims.PreferredUsername, newIdentity.Claims.Email, newIdentity.Claims.EmailVerified, encoder(newIdentity.Claims.Groups), - encoder(newIdentity.Consents), + encoder(newIdentity.Consents), encoder(newIdentity.MFASecrets), newIdentity.CreatedAt, newIdentity.LastLogin, newIdentity.BlockedUntil, u.UserID, u.ConnectorID, ) @@ -852,7 +866,7 @@ func getUserIdentity(ctx context.Context, q querier, userID, connectorID string) user_id, connector_id, claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, - consents, + consents, mfa_secrets, created_at, last_login, blocked_until from user_identity where user_id = $1 AND connector_id = $2; @@ -865,7 +879,7 @@ func (c *conn) ListUserIdentities(ctx context.Context) ([]storage.UserIdentity, user_id, connector_id, claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, - consents, + consents, mfa_secrets, created_at, last_login, blocked_until from user_identity; `) @@ -889,11 +903,12 @@ func (c *conn) ListUserIdentities(ctx context.Context) ([]storage.UserIdentity, } func scanUserIdentity(s scanner) (u storage.UserIdentity, err error) { + var mfaSecrets []byte err = s.Scan( &u.UserID, &u.ConnectorID, &u.Claims.UserID, &u.Claims.Username, &u.Claims.PreferredUsername, &u.Claims.Email, &u.Claims.EmailVerified, decoder(&u.Claims.Groups), - decoder(&u.Consents), + decoder(&u.Consents), &mfaSecrets, &u.CreatedAt, &u.LastLogin, &u.BlockedUntil, ) if err != nil { @@ -905,6 +920,11 @@ func scanUserIdentity(s scanner) (u storage.UserIdentity, err error) { if u.Consents == nil { u.Consents = make(map[string][]string) } + if len(mfaSecrets) > 0 { + if err := json.Unmarshal(mfaSecrets, &u.MFASecrets); err != nil { + return u, fmt.Errorf("unmarshal user identity mfa secrets: %v", err) + } + } return u, nil } diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index b296ebee..0e44e52b 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -425,4 +425,17 @@ var migrations = []migration{ );`, }, }, + { + stmts: []string{ + ` + alter table auth_request + add column mfa_validated boolean not null default false;`, + ` + alter table user_identity + add column mfa_secrets bytea;`, + ` + alter table client + add column mfa_chain bytea;`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index 1d8da99d..57c4823b 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -185,6 +185,10 @@ type Client struct { // AllowedConnectors is a list of connector IDs that the client is allowed to use for authentication. // If empty, all connectors are allowed. AllowedConnectors []string `json:"allowedConnectors"` + + // MFAChain is an ordered list of MFA authenticator IDs that a user must complete + // during login. Empty means no MFA required. + MFAChain []string `json:"mfaChain"` } // Claims represents the ID Token claims supported by the server. @@ -247,6 +251,9 @@ type AuthRequest struct { // HMACKey is used when generating an AuthRequest-specific HMAC HMACKey []byte + + // MFAValidated is set to true if the user has completed multi-factor authentication. + MFAValidated bool } // AuthCode represents a code which can be exchanged for an OAuth2 token response. @@ -330,12 +337,24 @@ type RefreshTokenRef struct { LastUsed time.Time } +// MFASecret stores the enrollment state and secret for an MFA authenticator. +// Note: Secret is stored without encryption. Encrypting secrets at rest is the +// responsibility of the storage backend (e.g., encrypted etcd, disk encryption). +type MFASecret struct { + AuthenticatorID string `json:"authenticatorID"` + Type string `json:"type"` + Secret string `json:"secret"` + Confirmed bool `json:"confirmed"` + CreatedAt time.Time `json:"createdAt"` +} + // UserIdentity represents persistent per-user identity data. type UserIdentity struct { UserID string ConnectorID string Claims Claims - Consents map[string][]string // clientID -> approved scopes + Consents map[string][]string // clientID -> approved scopes + MFASecrets map[string]*MFASecret // authenticatorID -> secret CreatedAt time.Time LastLogin time.Time BlockedUntil time.Time diff --git a/web/templates/totp_verify.html b/web/templates/totp_verify.html new file mode 100644 index 00000000..2e00c326 --- /dev/null +++ b/web/templates/totp_verify.html @@ -0,0 +1,36 @@ +{{ template "header.html" . }} + +
+

Two-factor authentication

+ {{ if not (eq .QRCode "") }} +

Scan the QR code below using your authenticator app, then enter the code.

+
+ QR code +
+ {{ else }} +

Enter the code from your authenticator app.

+

{{ .Issuer }}

+ {{ end }} +
+
+
+ +
+ +
+ + {{ if .Invalid }} +
+ Invalid code. Please try again. +
+ {{ end }} + + +
+
+ +{{ template "footer.html" . }} From 8938c98eded97e097dd08bf193a243ccdf164d1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:43:02 +0100 Subject: [PATCH 175/222] build(deps): bump github.com/russellhaering/goxmldsig (#4664) Bumps [github.com/russellhaering/goxmldsig](https://github.com/russellhaering/goxmldsig) from 1.5.0 to 1.6.0. - [Release notes](https://github.com/russellhaering/goxmldsig/releases) - [Commits](https://github.com/russellhaering/goxmldsig/compare/v1.5.0...v1.6.0) --- updated-dependencies: - dependency-name: github.com/russellhaering/goxmldsig dependency-version: 1.6.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ba668e8f..0c9d4242 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.5.0 github.com/prometheus/client_golang v1.23.2 - github.com/russellhaering/goxmldsig v1.5.0 + 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.8 diff --git a/go.sum b/go.sum index 6562da2d..d41ec1af 100644 --- a/go.sum +++ b/go.sum @@ -215,8 +215,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 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.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw= -github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk= +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/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= From 56914a8ad6d753b7d84f5445f4e262383d57bb68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:00:59 +0100 Subject: [PATCH 176/222] build(deps): bump github.com/lib/pq from 1.11.2 to 1.12.0 (#4666) Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.11.2 to 1.12.0. - [Release notes](https://github.com/lib/pq/releases) - [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md) - [Commits](https://github.com/lib/pq/compare/v1.11.2...v1.12.0) --- updated-dependencies: - dependency-name: github.com/lib/pq dependency-version: 1.12.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0c9d4242..9d706aaa 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 github.com/kylelemons/godebug v1.1.0 - github.com/lib/pq v1.11.2 + github.com/lib/pq v1.12.0 github.com/mattermost/xml-roundtrip-validator v0.1.0 github.com/mattn/go-sqlite3 v1.14.37 github.com/oklog/run v1.2.0 diff --git a/go.sum b/go.sum index d41ec1af..3bad638c 100644 --- a/go.sum +++ b/go.sum @@ -169,8 +169,8 @@ 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 v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= -github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= +github.com/lib/pq v1.12.0/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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= From ff5bc7c269af495ad54d6daa79056c1e669d7087 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:01:16 +0100 Subject: [PATCH 177/222] build(deps): bump actions/cache from 5.0.3 to 5.0.4 (#4665) Bumps [actions/cache](https://github.com/actions/cache) from 5.0.3 to 5.0.4. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/cdf6c1fa76f9f475f3d7449005a359c84ca0f306...668228422ae6a00e4ad889ee87cd7109ec5666a7) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 5.0.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 2 +- .github/workflows/trivydb-cache.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index c0a4d113..4c2e74f1 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -226,7 +226,7 @@ jobs: run: echo "date=$(date +%Y-%m-%d)" >> $GITHUB_OUTPUT - name: Restore trivy cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: cache/db key: trivy-cache-${{ steps.date.outputs.date }} diff --git a/.github/workflows/trivydb-cache.yaml b/.github/workflows/trivydb-cache.yaml index fc912b2a..b64bd38e 100644 --- a/.github/workflows/trivydb-cache.yaml +++ b/.github/workflows/trivydb-cache.yaml @@ -36,7 +36,7 @@ jobs: rm javadb.tar.gz - name: Cache DBs - uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ github.workspace }}/.cache/trivy key: cache-trivy-${{ steps.date.outputs.date }} From 7ec1760c6b912678fdd657a368cada2ed2d4edae Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Thu, 19 Mar 2026 11:21:28 +0100 Subject: [PATCH 178/222] feat: Add OIDC conformance testing scripts and configuration (#4663) Signed-off-by: maksim.nabokikh --- examples/oidc-conformance/config.yaml.tmpl | 36 +++++ examples/oidc-conformance/run.sh | 147 +++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 examples/oidc-conformance/config.yaml.tmpl create mode 100755 examples/oidc-conformance/run.sh diff --git a/examples/oidc-conformance/config.yaml.tmpl b/examples/oidc-conformance/config.yaml.tmpl new file mode 100644 index 00000000..1f89afc6 --- /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 00000000..702928bd --- /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" From c3bc1d7466e02e696fd65e4e7d7c9631732fd142 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Thu, 19 Mar 2026 15:53:15 +0100 Subject: [PATCH 179/222] feat: add auth_time, prompt, and max_age fields (#4662) Signed-off-by: maksim.nabokikh --- .gitignore | 1 + server/handlers.go | 60 +++-- server/introspectionhandler_test.go | 4 +- server/oauth2.go | 49 +++- server/prompt.go | 77 ++++++ server/prompt_test.go | 64 +++++ server/refreshhandlers.go | 15 +- server/refreshhandlers_test.go | 138 ++++++++++ server/session.go | 11 +- server/session_test.go | 181 ++++++++++++- storage/conformance/conformance.go | 14 +- storage/conformance/transactions.go | 1 + storage/ent/client/authcode.go | 1 + storage/ent/client/authrequest.go | 6 + storage/ent/client/types.go | 4 + storage/ent/db/authcode.go | 15 +- storage/ent/db/authcode/authcode.go | 8 + storage/ent/db/authcode/where.go | 55 ++++ storage/ent/db/authcode_create.go | 18 ++ storage/ent/db/authcode_update.go | 52 ++++ storage/ent/db/authrequest.go | 39 ++- storage/ent/db/authrequest/authrequest.go | 28 ++ storage/ent/db/authrequest/where.go | 170 +++++++++++++ storage/ent/db/authrequest_create.go | 68 +++++ storage/ent/db/authrequest_update.go | 140 ++++++++++ storage/ent/db/migrate/schema.go | 4 + storage/ent/db/mutation.go | 296 +++++++++++++++++++++- storage/ent/db/runtime.go | 8 + storage/ent/schema/authcode.go | 3 + storage/ent/schema/authrequest.go | 3 + storage/etcd/types.go | 14 + storage/kubernetes/types.go | 14 + storage/sql/crud.go | 26 +- storage/sql/migrate.go | 4 + storage/storage.go | 18 ++ 35 files changed, 1558 insertions(+), 51 deletions(-) create mode 100644 server/prompt.go create mode 100644 server/prompt_test.go diff --git a/.gitignore b/.gitignore index 11cfbe81..7e5ae46e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /docker-compose.override.yaml /var/ /vendor/ +*.db diff --git a/server/handlers.go b/server/handlers.go index 31a160f2..d810dbef 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -365,14 +365,39 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } - // Check if there's a valid session that can skip login for this client. + // Handle OIDC prompt parameter and session-based login. + prompt, err := ParsePrompt(authReq.Prompt) + if err != nil { + // Server error because authReq was validated before saving it to database. + s.redirectWithError(w, r, authReq, errServerError, "Invalid authentication request") + return + } + // handle prompt only if sessions are enabled if s.sessionConfig != nil { - if redirectURL, ok := s.trySessionLogin(ctx, r, w, authReq); ok { + // prompt=none: no UI allowed. + if prompt.None() { + redirectURL, ok := s.trySessionLogin(ctx, r, w, authReq) + if !ok { + s.redirectWithError(w, r, authReq, errLoginRequired, "User not authenticated") + return + } if redirectURL != "" { - http.Redirect(w, r, redirectURL, http.StatusSeeOther) + // Session found but consent required — no UI allowed. + s.redirectWithError(w, r, authReq, errInteractionRequired, "Consent required") + return } return } + + if !prompt.Login() { + // Normal flow: try session-based login (skip if prompt=login forces re-auth). + if redirectURL, ok := s.trySessionLogin(ctx, r, w, authReq); ok { + if redirectURL != "" { + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + } + return + } + } } scopes := parseScopes(authReq.Scopes) @@ -687,6 +712,7 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, a.LoggedIn = true a.Claims = claims a.ConnectorData = identity.ConnectorData + a.AuthTime = s.now() return a, nil } if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, updater); err != nil { @@ -769,11 +795,8 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, } case err == nil: if err := s.storage.UpdateUserIdentity(ctx, identity.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { - if len(identity.ConnectorData) > 0 { - old.Claims = claims - old.LastLogin = now - return old, nil - } + old.Claims = claims + old.LastLogin = now return old, nil }); err != nil { s.logger.ErrorContext(ctx, "failed to update user identity", "err", err) @@ -988,6 +1011,7 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe RedirectURI: authReq.RedirectURI, ConnectorData: authReq.ConnectorData, PKCE: authReq.PKCE, + AuthTime: authReq.AuthTime, } if err := s.storage.CreateAuthCode(ctx, code); err != nil { s.logger.ErrorContext(r.Context(), "Failed to create auth code", "err", err) @@ -1007,7 +1031,7 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe implicitOrHybrid = true var err error - accessToken, _, err = s.newAccessToken(r.Context(), authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, authReq.ConnectorID) + accessToken, _, err = s.newAccessToken(r.Context(), authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, authReq.ConnectorID, authReq.AuthTime) if err != nil { s.logger.ErrorContext(r.Context(), "failed to create new access token", "err", err) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) @@ -1017,7 +1041,7 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe implicitOrHybrid = true var err error - idToken, idTokenExpiry, err = s.newIDToken(r.Context(), authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, accessToken, code.ID, authReq.ConnectorID) + idToken, idTokenExpiry, err = s.newIDToken(r.Context(), authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, accessToken, code.ID, authReq.ConnectorID, authReq.AuthTime) if err != nil { s.logger.ErrorContext(r.Context(), "failed to create ID token", "err", err) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) @@ -1251,14 +1275,14 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s } func (s *Server) exchangeAuthCode(ctx context.Context, w http.ResponseWriter, authCode storage.AuthCode, client storage.Client) (*accessTokenResponse, error) { - accessToken, _, err := s.newAccessToken(ctx, client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID) + accessToken, _, err := s.newAccessToken(ctx, client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID, authCode.AuthTime) if err != nil { s.logger.ErrorContext(ctx, "failed to create new access token", "err", err) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) return nil, err } - idToken, expiry, err := s.newIDToken(ctx, client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, accessToken, authCode.ID, authCode.ConnectorID) + idToken, expiry, err := s.newIDToken(ctx, client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, accessToken, authCode.ID, authCode.ConnectorID, authCode.AuthTime) if err != nil { s.logger.ErrorContext(ctx, "failed to create ID token", "err", err) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) @@ -1539,14 +1563,14 @@ func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, cli Groups: identity.Groups, } - accessToken, _, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID) + accessToken, _, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID, time.Time{}) if err != nil { s.logger.ErrorContext(r.Context(), "password grant failed to create new access token", "err", err) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) return } - idToken, expiry, err := s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID) + idToken, expiry, err := s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID, time.Time{}) if err != nil { s.logger.ErrorContext(r.Context(), "password grant failed to create new ID token", "err", err) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) @@ -1752,9 +1776,9 @@ func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request, cli var expiry time.Time switch requestedTokenType { case tokenTypeID: - resp.AccessToken, expiry, err = s.newIDToken(r.Context(), client.ID, claims, scopes, "", "", "", connID) + resp.AccessToken, expiry, err = s.newIDToken(r.Context(), client.ID, claims, scopes, "", "", "", connID, time.Time{}) case tokenTypeAccess: - resp.AccessToken, expiry, err = s.newAccessToken(r.Context(), client.ID, claims, scopes, "", connID) + resp.AccessToken, expiry, err = s.newAccessToken(r.Context(), client.ID, claims, scopes, "", connID, time.Time{}) default: s.tokenErrHelper(w, errRequestNotSupported, "Invalid requested_token_type.", http.StatusBadRequest) return @@ -1854,7 +1878,7 @@ func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Req // Creating connectors with an empty ID with the config and API is prohibited connID := "" - accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID) + accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID, time.Time{}) if err != nil { s.logger.ErrorContext(ctx, "client_credentials grant failed to create new access token", "err", err) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) @@ -1863,7 +1887,7 @@ func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Req var idToken string if hasOpenIDScope { - idToken, expiry, err = s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID) + idToken, expiry, err = s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID, time.Time{}) if err != nil { s.logger.ErrorContext(ctx, "client_credentials grant failed to create new ID token", "err", err) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) diff --git a/server/introspectionhandler_test.go b/server/introspectionhandler_test.go index 799f0000..29db7d31 100644 --- a/server/introspectionhandler_test.go +++ b/server/introspectionhandler_test.go @@ -152,7 +152,7 @@ func TestGetTokenFromRequestSuccess(t *testing.T) { accessToken, _, err := s.newIDToken(ctx, "test", storage.Claims{ UserID: "1", Username: "jane", - }, []string{"openid"}, "nonce", "", "", "test") + }, []string{"openid"}, "nonce", "", "", "test", time.Time{}) require.NoError(t, err) tests := []struct { @@ -270,7 +270,7 @@ func TestHandleIntrospect(t *testing.T) { Email: "jane.doe@example.com", EmailVerified: true, Groups: []string{"a", "b"}, - }, []string{"openid", "email", "profile", "groups"}, "foo", "", "", "test") + }, []string{"openid", "email", "profile", "groups"}, "foo", "", "", "test", time.Time{}) require.NoError(t, err) activeRefreshToken, err := internal.Marshal(&internal.RefreshToken{RefreshId: "test", Token: "bar"}) diff --git a/server/oauth2.go b/server/oauth2.go index 9f12d1d0..3818c135 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -45,6 +45,18 @@ func newDisplayedErr(status int, format string, a ...interface{}) *displayedAuth return &displayedAuthErr{status, fmt.Sprintf(format, a...)} } +// redirectWithError redirects back to the client with an OAuth2 error response. +// Used for prompt=none when login or consent is required. +func (s *Server) 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) +} + // redirectedAuthErr is an error that should be reported back to the client by 302 redirect type redirectedAuthErr struct { State string @@ -117,6 +129,9 @@ const ( errInvalidGrant = "invalid_grant" errInvalidClient = "invalid_client" errInactiveToken = "inactive_token" + errLoginRequired = "login_required" + errInteractionRequired = "interaction_required" + errConsentRequired = "consent_required" ) const ( @@ -257,6 +272,7 @@ type idTokenClaims struct { IssuedAt int64 `json:"iat"` AuthorizingParty string `json:"azp,omitempty"` Nonce string `json:"nonce,omitempty"` + AuthTime int64 `json:"auth_time,omitempty"` AccessTokenHash string `json:"at_hash,omitempty"` CodeHash string `json:"c_hash,omitempty"` @@ -277,8 +293,8 @@ type federatedIDClaims struct { UserID string `json:"user_id,omitempty"` } -func (s *Server) newAccessToken(ctx context.Context, clientID string, claims storage.Claims, scopes []string, nonce, connID string) (accessToken string, expiry time.Time, err error) { - return s.newIDToken(ctx, clientID, claims, scopes, nonce, storage.NewID(), "", connID) +func (s *Server) newAccessToken(ctx context.Context, clientID string, claims storage.Claims, scopes []string, nonce, connID string, authTime time.Time) (accessToken string, expiry time.Time, err error) { + return s.newIDToken(ctx, clientID, claims, scopes, nonce, storage.NewID(), "", connID, authTime) } func getClientID(aud audience, azp string) (string, error) { @@ -324,7 +340,7 @@ func genSubject(userID string, connID string) (string, error) { return internal.Marshal(sub) } -func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage.Claims, scopes []string, nonce, accessToken, code, connID string) (idToken string, expiry time.Time, err error) { +func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage.Claims, scopes []string, nonce, accessToken, code, connID string, authTime time.Time) (idToken string, expiry time.Time, err error) { issuedAt := s.now() expiry = issuedAt.Add(s.idTokensValidFor) @@ -342,6 +358,11 @@ func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage IssuedAt: issuedAt.Unix(), } + // Include auth_time when sessions are enabled and the value is available. + if !authTime.IsZero() { + tok.AuthTime = authTime.Unix() + } + // Determine signing algorithm from signer signingAlg, err := s.signer.Algorithm(ctx) if err != nil { @@ -583,12 +604,32 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques } } + prompt, err := ParsePrompt(q.Get("prompt")) + if err != nil { + return nil, newRedirectedErr(errInvalidRequest, "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, newRedirectedErr(errInvalidRequest, "Invalid max_age value %q", maxAgeStr) + } + maxAge = v + } + + // OIDC prompt=consent implies force approval. + forceApproval := q.Get("approval_prompt") == "force" || prompt.Consent() + return &storage.AuthRequest{ ID: storage.NewID(), ClientID: client.ID, State: state, Nonce: nonce, - ForceApprovalPrompt: q.Get("approval_prompt") == "force", + ForceApprovalPrompt: forceApproval, + Prompt: prompt.String(), + MaxAge: maxAge, Scopes: scopes, RedirectURI: redirectURI, ResponseTypes: responseTypes, diff --git a/server/prompt.go b/server/prompt.go new file mode 100644 index 00000000..f4941745 --- /dev/null +++ b/server/prompt.go @@ -0,0 +1,77 @@ +package server + +import ( + "fmt" + "strings" +) + +// Prompt represents the parsed OIDC "prompt" parameter (RFC 6749 / OpenID Connect Core 3.1.2.1). +// The parameter is space-separated and may contain: "none", "login", "consent", "select_account". +// "none" must not be combined with any other value. +type Prompt struct { + none bool + login bool + consent bool +} + +// ParsePrompt parses and validates the raw prompt query parameter. +// Returns an error suitable for returning as an OAuth2 invalid_request if the value is invalid. +func ParsePrompt(raw string) (Prompt, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return Prompt{}, nil + } + + var p Prompt + seen := make(map[string]bool) + + for _, v := range strings.Fields(raw) { + if seen[v] { + continue + } + seen[v] = true + + switch v { + case "none": + p.none = true + case "login": + p.login = true + case "consent": + p.consent = true + case "select_account": + // Dex does not support account selection; ignore per spec recommendation. + default: + return Prompt{}, fmt.Errorf("invalid prompt value %q", v) + } + } + + if p.none && (p.login || p.consent) { + return Prompt{}, fmt.Errorf("prompt=none must not be combined with other values") + } + + return p, nil +} + +// None returns true if the caller requested no interactive UI. +func (p Prompt) None() bool { return p.none } + +// Login returns true if the caller requested forced re-authentication. +func (p Prompt) Login() bool { return p.login } + +// Consent returns true if the caller requested forced consent screen. +func (p Prompt) Consent() bool { return p.consent } + +// String returns the canonical space-separated representation stored in the database. +func (p Prompt) String() string { + var parts []string + if p.none { + return "none" + } + if p.login { + parts = append(parts, "login") + } + if p.consent { + parts = append(parts, "consent") + } + return strings.Join(parts, " ") +} diff --git a/server/prompt_test.go b/server/prompt_test.go new file mode 100644 index 00000000..00820ce8 --- /dev/null +++ b/server/prompt_test.go @@ -0,0 +1,64 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePrompt(t *testing.T) { + tests := []struct { + name string + raw string + want Prompt + wantErr bool + }{ + {name: "empty", raw: "", want: Prompt{}}, + {name: "none", raw: "none", want: Prompt{none: true}}, + {name: "login", raw: "login", want: Prompt{login: true}}, + {name: "consent", raw: "consent", want: Prompt{consent: true}}, + {name: "login consent", raw: "login consent", want: Prompt{login: true, consent: true}}, + {name: "consent login", raw: "consent login", want: Prompt{login: true, consent: true}}, + {name: "select_account ignored", raw: "select_account", want: Prompt{}}, + {name: "login select_account", raw: "login select_account", want: Prompt{login: true}}, + {name: "duplicate values", raw: "login login", want: Prompt{login: true}}, + {name: "whitespace padding", raw: " login ", want: Prompt{login: true}}, + + // Errors. + {name: "none with login", raw: "none login", wantErr: true}, + {name: "none with consent", raw: "none consent", wantErr: true}, + {name: "unknown value", raw: "bogus", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParsePrompt(tc.raw) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestPromptString(t *testing.T) { + tests := []struct { + prompt Prompt + want string + }{ + {Prompt{}, ""}, + {Prompt{none: true}, "none"}, + {Prompt{login: true}, "login"}, + {Prompt{consent: true}, "consent"}, + {Prompt{login: true, consent: true}, "login consent"}, + } + + for _, tc := range tests { + t.Run(tc.want, func(t *testing.T) { + assert.Equal(t, tc.want, tc.prompt.String()) + }) + } +} diff --git a/server/refreshhandlers.go b/server/refreshhandlers.go index a0807d07..48fc39f1 100644 --- a/server/refreshhandlers.go +++ b/server/refreshhandlers.go @@ -439,14 +439,25 @@ func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, clie Groups: ident.Groups, } - accessToken, _, err := s.newAccessToken(r.Context(), client.ID, claims, rCtx.scopes, rCtx.storageToken.Nonce, rCtx.storageToken.ConnectorID) + authTime := time.Time{} + if s.sessionConfig != nil { + ui, err := s.storage.GetUserIdentity(r.Context(), ident.UserID, rCtx.storageToken.ConnectorID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get user identity", "err", err) + s.refreshTokenErrHelper(w, newInternalServerError()) + return + } + authTime = ui.LastLogin + } + + accessToken, _, err := s.newAccessToken(r.Context(), client.ID, claims, rCtx.scopes, rCtx.storageToken.Nonce, rCtx.storageToken.ConnectorID, authTime) if err != nil { s.logger.ErrorContext(r.Context(), "failed to create new access token", "err", err) s.refreshTokenErrHelper(w, newInternalServerError()) return } - idToken, expiry, err := s.newIDToken(r.Context(), client.ID, claims, rCtx.scopes, rCtx.storageToken.Nonce, accessToken, "", rCtx.storageToken.ConnectorID) + idToken, expiry, err := s.newIDToken(r.Context(), client.ID, claims, rCtx.scopes, rCtx.storageToken.Nonce, accessToken, "", rCtx.storageToken.ConnectorID, authTime) if err != nil { s.logger.ErrorContext(r.Context(), "failed to create ID token", "err", err) s.refreshTokenErrHelper(w, newInternalServerError()) diff --git a/server/refreshhandlers_test.go b/server/refreshhandlers_test.go index 78bec27c..8db80c31 100644 --- a/server/refreshhandlers_test.go +++ b/server/refreshhandlers_test.go @@ -2,15 +2,18 @@ package server import ( "bytes" + "encoding/base64" "encoding/json" "log/slog" "net/http" "net/http/httptest" "net/url" "path" + "strings" "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/dexidp/dex/server/internal" @@ -209,6 +212,141 @@ func TestRefreshTokenExpirationScenarios(t *testing.T) { } } +// decodeJWTClaims decodes the payload of a JWT token without verifying the signature. +func decodeJWTClaims(t *testing.T, token string) map[string]any { + t.Helper() + parts := strings.SplitN(token, ".", 3) + require.Len(t, parts, 3, "JWT should have 3 parts") + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims map[string]any + err = json.Unmarshal(payload, &claims) + require.NoError(t, err) + return claims +} + +func TestRefreshTokenAuthTime(t *testing.T) { + t0 := time.Now().UTC().Round(time.Second) + loginTime := t0.Add(-10 * time.Minute) + + tests := []struct { + name string + sessionConfig *SessionConfig + createUserIdentity bool + wantAuthTime bool + wantHTTPError bool + }{ + { + name: "sessions enabled with user identity", + sessionConfig: &SessionConfig{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + }, + createUserIdentity: true, + wantAuthTime: true, + }, + { + name: "sessions disabled", + sessionConfig: nil, + createUserIdentity: false, + wantAuthTime: false, + }, + { + name: "sessions enabled but user identity missing", + sessionConfig: &SessionConfig{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + }, + createUserIdentity: false, + wantHTTPError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + httpServer, s := newTestServer(t, func(c *Config) { + c.Now = func() time.Time { return t0 } + }) + defer httpServer.Close() + + s.sessionConfig = tc.sessionConfig + + mockRefreshTokenTestStorage(t, s.storage, false) + + if tc.createUserIdentity { + // The mock connector returns UserID "0-385-28089-0" on Refresh, + // so the UserIdentity must use that ID to be found by handleRefreshToken. + err := s.storage.CreateUserIdentity(t.Context(), storage.UserIdentity{ + UserID: "0-385-28089-0", + ConnectorID: "test", + Claims: storage.Claims{ + UserID: "0-385-28089-0", + Username: "Kilgore Trout", + Email: "kilgore@kilgore.trout", + EmailVerified: true, + Groups: []string{"authors"}, + }, + CreatedAt: loginTime, + LastLogin: loginTime, + }) + require.NoError(t, err) + } + + u, err := url.Parse(s.issuerURL.String()) + require.NoError(t, err) + + tokenData, err := internal.Marshal(&internal.RefreshToken{RefreshId: "test", Token: "bar"}) + require.NoError(t, err) + + u.Path = path.Join(u.Path, "/token") + v := url.Values{} + v.Add("grant_type", "refresh_token") + v.Add("refresh_token", tokenData) + + 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) + + if tc.wantHTTPError { + assert.Equal(t, http.StatusInternalServerError, rr.Code) + return + } + require.Equal(t, http.StatusOK, rr.Code) + + var resp struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + } + err = json.Unmarshal(rr.Body.Bytes(), &resp) + require.NoError(t, err) + + accessClaims := decodeJWTClaims(t, resp.AccessToken) + + if tc.wantAuthTime { + assert.Equal(t, float64(loginTime.Unix()), accessClaims["auth_time"], + "access token auth_time should match UserIdentity.LastLogin") + } else { + assert.Nil(t, accessClaims["auth_time"], + "access token should not have auth_time when sessions are disabled") + } + + // TODO: newIDToken in handleRefreshToken is currently called with time.Time{}, + // so the ID token does not include auth_time. Once fixed, uncomment: + // if tc.wantAuthTime { + // idClaims := decodeJWTClaims(t, resp.IDToken) + // assert.Equal(t, float64(loginTime.Unix()), idClaims["auth_time"], + // "id token auth_time should match UserIdentity.LastLogin") + // } + }) + } +} + func TestRefreshTokenPolicy(t *testing.T) { lastTime := time.Now() l := slog.New(slog.DiscardHandler) diff --git a/server/session.go b/server/session.go index f6355abf..48e3fb6a 100644 --- a/server/session.go +++ b/server/session.go @@ -10,6 +10,7 @@ import ( "net/http" "path" "strings" + "time" "github.com/dexidp/dex/storage" ) @@ -258,6 +259,13 @@ func (s *Server) trySessionLogin(ctx context.Context, r *http.Request, w http.Re 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 + } + } + claims := storage.Claims{ UserID: ui.Claims.UserID, Username: ui.Claims.Username, @@ -267,11 +275,12 @@ func (s *Server) trySessionLogin(ctx context.Context, r *http.Request, w http.Re Groups: ui.Claims.Groups, } - // Update AuthRequest with stored identity (without logging "login successful"). + // Update AuthRequest with stored identity and auth_time from last login. if err := s.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 { s.logger.ErrorContext(ctx, "session: failed to update auth request", "err", err) diff --git a/server/session_test.go b/server/session_test.go index 757c11b3..7eac45b7 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -427,6 +427,7 @@ func setupSessionLoginFixture(t *testing.T, s *Server) storage.AuthRequest { ConnectorID: "mock", Scopes: []string{"openid", "email"}, RedirectURI: "http://localhost/callback", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), } @@ -463,8 +464,6 @@ func TestTrySessionLogin(t *testing.T) { _, ok := s.trySessionLogin(ctx, r, w, &authReq) assert.True(t, ok) - // sendCodeResponse deletes the AuthRequest after processing, - // so we can't verify it here. The fact that ok=true is sufficient. }) t.Run("successful login redirects to approval", func(t *testing.T) { @@ -491,7 +490,6 @@ func TestTrySessionLogin(t *testing.T) { s := newTestSessionServer(t) s.skipApproval = false authReq := setupSessionLoginFixture(t, s) - // Scopes match stored consent: {"client-1": {"openid", "email"}} r := sessionCookieRequest("user-1", "mock", "test-nonce") w := httptest.NewRecorder() @@ -526,24 +524,23 @@ func TestTrySessionLogin(t *testing.T) { t.Run("expired client state returns false", func(t *testing.T) { s := newTestSessionServer(t) - ctx := t.Context() now := s.now() - require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + require.NoError(t, s.storage.CreateAuthSession(t.Context(), storage.AuthSession{ UserID: "user-exp", ConnectorID: "mock", Nonce: "nonce-exp", ClientStates: map[string]*storage.ClientAuthState{ "client-1": { Active: true, - ExpiresAt: now.Add(-1 * time.Hour), // expired + ExpiresAt: now.Add(-1 * time.Hour), }, }, CreatedAt: now.Add(-2 * time.Hour), LastActivity: now.Add(-1 * time.Minute), })) - require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + require.NoError(t, s.storage.CreateUserIdentity(t.Context(), storage.UserIdentity{ UserID: "user-exp", ConnectorID: "mock", Claims: storage.Claims{UserID: "user-exp"}, @@ -556,10 +553,11 @@ func TestTrySessionLogin(t *testing.T) { ID: storage.NewID(), ClientID: "client-1", ConnectorID: "mock", + MaxAge: -1, HMACKey: storage.NewHMACKey(crypto.SHA256), Expiry: now.Add(10 * time.Minute), } - require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + require.NoError(t, s.storage.CreateAuthRequest(t.Context(), authReq)) r := sessionCookieRequest("user-exp", "mock", "nonce-exp") w := httptest.NewRecorder() @@ -584,3 +582,170 @@ func TestTrySessionLogin(t *testing.T) { 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 *Server, now time.Time, lastLogin time.Time) storage.AuthRequest { + t.Helper() + ctx := t.Context() + nonce := "test-nonce" + + session := storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + Nonce: nonce, + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + 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: sessionCookieValue("user-1", "mock", "test-nonce")}) + 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: sessionCookieValue("user-1", "mock", "test-nonce")}) + 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: sessionCookieValue("user-1", "mock", "test-nonce")}) + 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: sessionCookieValue("user-1", "mock", "test-nonce")}) + 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: sessionCookieValue("user-1", "mock", "test-nonce")}) + w := httptest.NewRecorder() + + redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq) + require.True(t, ok) + assert.Contains(t, redirectURL, "/approval") + + // 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 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) + }) +} diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index e4e30307..e2570b47 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -19,6 +19,10 @@ import ( // ensure that values being tested on never expire. var neverExpire = time.Now().UTC().Add(time.Hour * 24 * 365 * 100) +// defaultAuthTime is a non-zero time used as AuthTime default in tests. +// MySQL rejects Go's zero time (0001-01-01), so all test fixtures must use a real value. +var defaultAuthTime = time.Now().UTC() + type subTest struct { name string run func(t *testing.T, s storage.Storage) @@ -100,6 +104,7 @@ func testAuthRequestCRUD(t *testing.T, s storage.Storage) { ForceApprovalPrompt: true, LoggedIn: true, Expiry: neverExpire, + AuthTime: defaultAuthTime, ConnectorID: "ldap", ConnectorData: []byte(`{"some":"data"}`), Claims: storage.Claims{ @@ -134,6 +139,7 @@ func testAuthRequestCRUD(t *testing.T, s storage.Storage) { ForceApprovalPrompt: true, LoggedIn: true, Expiry: neverExpire, + AuthTime: defaultAuthTime, ConnectorID: "ldap", ConnectorData: []byte(`{"some":"data"}`), Claims: storage.Claims{ @@ -191,6 +197,7 @@ func testAuthCodeCRUD(t *testing.T, s storage.Storage) { Nonce: "foobar", Scopes: []string{"openid", "email"}, Expiry: neverExpire, + AuthTime: defaultAuthTime, ConnectorID: "ldap", ConnectorData: []byte(`{"some":"data"}`), PKCE: storage.PKCE{ @@ -217,6 +224,7 @@ func testAuthCodeCRUD(t *testing.T, s storage.Storage) { Nonce: "foobar", Scopes: []string{"openid", "email"}, Expiry: neverExpire, + AuthTime: defaultAuthTime, ConnectorID: "ldap", ConnectorData: []byte(`{"some":"data"}`), Claims: storage.Claims{ @@ -243,7 +251,8 @@ func testAuthCodeCRUD(t *testing.T, s storage.Storage) { if a1.Expiry.Unix() != got.Expiry.Unix() { t.Errorf("auth code expiry did not match want=%s vs got=%s", a1.Expiry, got.Expiry) } - got.Expiry = a1.Expiry // time fields do not compare well + got.Expiry = a1.Expiry // time fields do not compare well + got.AuthTime = a1.AuthTime // time fields do not compare well if diff := pretty.Compare(a1, got); diff != "" { t.Errorf("auth code retrieved from storage did not match: %s", diff) } @@ -790,6 +799,7 @@ func testGC(t *testing.T, s storage.Storage) { Nonce: "foobar", Scopes: []string{"openid", "email"}, Expiry: expiry, + AuthTime: defaultAuthTime, ConnectorID: "ldap", ConnectorData: []byte(`{"some":"data"}`), Claims: storage.Claims{ @@ -840,6 +850,7 @@ func testGC(t *testing.T, s storage.Storage) { ForceApprovalPrompt: true, LoggedIn: true, Expiry: expiry, + AuthTime: defaultAuthTime, ConnectorID: "ldap", ConnectorData: []byte(`{"some":"data"}`), Claims: storage.Claims{ @@ -976,6 +987,7 @@ func testTimezones(t *testing.T, s storage.Storage) { Nonce: "foobar", Scopes: []string{"openid", "email"}, Expiry: expiry, + AuthTime: defaultAuthTime, ConnectorID: "ldap", ConnectorData: []byte(`{"some":"data"}`), Claims: storage.Claims{ diff --git a/storage/conformance/transactions.go b/storage/conformance/transactions.go index 5889a024..f018224e 100644 --- a/storage/conformance/transactions.go +++ b/storage/conformance/transactions.go @@ -82,6 +82,7 @@ func testAuthRequestConcurrentUpdate(t *testing.T, s storage.Storage) { ForceApprovalPrompt: true, LoggedIn: true, Expiry: neverExpire, + AuthTime: defaultAuthTime, ConnectorID: "ldap", ConnectorData: []byte(`{"some":"data"}`), Claims: storage.Claims{ diff --git a/storage/ent/client/authcode.go b/storage/ent/client/authcode.go index aa5bd184..dfd5ca96 100644 --- a/storage/ent/client/authcode.go +++ b/storage/ent/client/authcode.go @@ -26,6 +26,7 @@ func (d *Database) CreateAuthCode(ctx context.Context, code storage.AuthCode) er SetExpiry(code.Expiry.UTC()). SetConnectorID(code.ConnectorID). SetConnectorData(code.ConnectorData). + SetAuthTime(code.AuthTime). Save(ctx) if err != nil { return convertDBError("create auth code: %w", err) diff --git a/storage/ent/client/authrequest.go b/storage/ent/client/authrequest.go index 86c71056..c1a3dfdb 100644 --- a/storage/ent/client/authrequest.go +++ b/storage/ent/client/authrequest.go @@ -33,6 +33,9 @@ func (d *Database) CreateAuthRequest(ctx context.Context, authRequest storage.Au SetConnectorData(authRequest.ConnectorData). SetHmacKey(authRequest.HMACKey). SetMfaValidated(authRequest.MFAValidated). + SetPrompt(authRequest.Prompt). + SetMaxAge(authRequest.MaxAge). + SetAuthTime(authRequest.AuthTime). Save(ctx) if err != nil { return convertDBError("create auth request: %w", err) @@ -98,6 +101,9 @@ func (d *Database) UpdateAuthRequest(ctx context.Context, id string, updater fun SetConnectorData(newAuthRequest.ConnectorData). SetHmacKey(newAuthRequest.HMACKey). SetMfaValidated(newAuthRequest.MFAValidated). + SetPrompt(newAuthRequest.Prompt). + SetMaxAge(newAuthRequest.MaxAge). + SetAuthTime(newAuthRequest.AuthTime). Save(context.TODO()) if err != nil { return rollback(tx, "update auth request uploading: %w", err) diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 7ae9390d..d58d9f41 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -47,6 +47,9 @@ func toStorageAuthRequest(a *db.AuthRequest) storage.AuthRequest { }, HMACKey: a.HmacKey, MFAValidated: a.MfaValidated, + Prompt: a.Prompt, + MaxAge: a.MaxAge, + AuthTime: a.AuthTime, } } @@ -72,6 +75,7 @@ func toStorageAuthCode(a *db.AuthCode) storage.AuthCode { CodeChallenge: a.CodeChallenge, CodeChallengeMethod: a.CodeChallengeMethod, }, + AuthTime: a.AuthTime, } } diff --git a/storage/ent/db/authcode.go b/storage/ent/db/authcode.go index 06ad7c8c..2b53e4da 100644 --- a/storage/ent/db/authcode.go +++ b/storage/ent/db/authcode.go @@ -48,7 +48,9 @@ type AuthCode struct { CodeChallenge string `json:"code_challenge,omitempty"` // CodeChallengeMethod holds the value of the "code_challenge_method" field. CodeChallengeMethod string `json:"code_challenge_method,omitempty"` - selectValues sql.SelectValues + // AuthTime holds the value of the "auth_time" field. + AuthTime time.Time `json:"auth_time,omitempty"` + selectValues sql.SelectValues } // scanValues returns the types for scanning values from sql.Rows. @@ -62,7 +64,7 @@ func (*AuthCode) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullBool) case authcode.FieldID, authcode.FieldClientID, authcode.FieldNonce, authcode.FieldRedirectURI, authcode.FieldClaimsUserID, authcode.FieldClaimsUsername, authcode.FieldClaimsEmail, authcode.FieldClaimsPreferredUsername, authcode.FieldConnectorID, authcode.FieldCodeChallenge, authcode.FieldCodeChallengeMethod: values[i] = new(sql.NullString) - case authcode.FieldExpiry: + case authcode.FieldExpiry, authcode.FieldAuthTime: values[i] = new(sql.NullTime) default: values[i] = new(sql.UnknownType) @@ -179,6 +181,12 @@ func (_m *AuthCode) assignValues(columns []string, values []any) error { } else if value.Valid { _m.CodeChallengeMethod = value.String } + case authcode.FieldAuthTime: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field auth_time", values[i]) + } else if value.Valid { + _m.AuthTime = value.Time + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -261,6 +269,9 @@ func (_m *AuthCode) String() string { builder.WriteString(", ") builder.WriteString("code_challenge_method=") builder.WriteString(_m.CodeChallengeMethod) + builder.WriteString(", ") + builder.WriteString("auth_time=") + builder.WriteString(_m.AuthTime.Format(time.ANSIC)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/authcode/authcode.go b/storage/ent/db/authcode/authcode.go index 6e056f15..570903e0 100644 --- a/storage/ent/db/authcode/authcode.go +++ b/storage/ent/db/authcode/authcode.go @@ -41,6 +41,8 @@ const ( FieldCodeChallenge = "code_challenge" // FieldCodeChallengeMethod holds the string denoting the code_challenge_method field in the database. FieldCodeChallengeMethod = "code_challenge_method" + // FieldAuthTime holds the string denoting the auth_time field in the database. + FieldAuthTime = "auth_time" // Table holds the table name of the authcode in the database. Table = "auth_codes" ) @@ -63,6 +65,7 @@ var Columns = []string{ FieldExpiry, FieldCodeChallenge, FieldCodeChallengeMethod, + FieldAuthTime, } // ValidColumn reports if the column name is valid (part of the table columns). @@ -167,3 +170,8 @@ func ByCodeChallenge(opts ...sql.OrderTermOption) OrderOption { func ByCodeChallengeMethod(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCodeChallengeMethod, opts...).ToFunc() } + +// ByAuthTime orders the results by the auth_time field. +func ByAuthTime(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAuthTime, opts...).ToFunc() +} diff --git a/storage/ent/db/authcode/where.go b/storage/ent/db/authcode/where.go index f8673fb0..41c590ca 100644 --- a/storage/ent/db/authcode/where.go +++ b/storage/ent/db/authcode/where.go @@ -129,6 +129,11 @@ func CodeChallengeMethod(v string) predicate.AuthCode { return predicate.AuthCode(sql.FieldEQ(FieldCodeChallengeMethod, v)) } +// AuthTime applies equality check predicate on the "auth_time" field. It's identical to AuthTimeEQ. +func AuthTime(v time.Time) predicate.AuthCode { + return predicate.AuthCode(sql.FieldEQ(FieldAuthTime, v)) +} + // ClientIDEQ applies the EQ predicate on the "client_id" field. func ClientIDEQ(v string) predicate.AuthCode { return predicate.AuthCode(sql.FieldEQ(FieldClientID, v)) @@ -899,6 +904,56 @@ func CodeChallengeMethodContainsFold(v string) predicate.AuthCode { return predicate.AuthCode(sql.FieldContainsFold(FieldCodeChallengeMethod, v)) } +// AuthTimeEQ applies the EQ predicate on the "auth_time" field. +func AuthTimeEQ(v time.Time) predicate.AuthCode { + return predicate.AuthCode(sql.FieldEQ(FieldAuthTime, v)) +} + +// AuthTimeNEQ applies the NEQ predicate on the "auth_time" field. +func AuthTimeNEQ(v time.Time) predicate.AuthCode { + return predicate.AuthCode(sql.FieldNEQ(FieldAuthTime, v)) +} + +// AuthTimeIn applies the In predicate on the "auth_time" field. +func AuthTimeIn(vs ...time.Time) predicate.AuthCode { + return predicate.AuthCode(sql.FieldIn(FieldAuthTime, vs...)) +} + +// AuthTimeNotIn applies the NotIn predicate on the "auth_time" field. +func AuthTimeNotIn(vs ...time.Time) predicate.AuthCode { + return predicate.AuthCode(sql.FieldNotIn(FieldAuthTime, vs...)) +} + +// AuthTimeGT applies the GT predicate on the "auth_time" field. +func AuthTimeGT(v time.Time) predicate.AuthCode { + return predicate.AuthCode(sql.FieldGT(FieldAuthTime, v)) +} + +// AuthTimeGTE applies the GTE predicate on the "auth_time" field. +func AuthTimeGTE(v time.Time) predicate.AuthCode { + return predicate.AuthCode(sql.FieldGTE(FieldAuthTime, v)) +} + +// AuthTimeLT applies the LT predicate on the "auth_time" field. +func AuthTimeLT(v time.Time) predicate.AuthCode { + return predicate.AuthCode(sql.FieldLT(FieldAuthTime, v)) +} + +// AuthTimeLTE applies the LTE predicate on the "auth_time" field. +func AuthTimeLTE(v time.Time) predicate.AuthCode { + return predicate.AuthCode(sql.FieldLTE(FieldAuthTime, v)) +} + +// AuthTimeIsNil applies the IsNil predicate on the "auth_time" field. +func AuthTimeIsNil() predicate.AuthCode { + return predicate.AuthCode(sql.FieldIsNull(FieldAuthTime)) +} + +// AuthTimeNotNil applies the NotNil predicate on the "auth_time" field. +func AuthTimeNotNil() predicate.AuthCode { + return predicate.AuthCode(sql.FieldNotNull(FieldAuthTime)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.AuthCode) predicate.AuthCode { return predicate.AuthCode(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/authcode_create.go b/storage/ent/db/authcode_create.go index ab4b9e4e..0e99e992 100644 --- a/storage/ent/db/authcode_create.go +++ b/storage/ent/db/authcode_create.go @@ -134,6 +134,20 @@ func (_c *AuthCodeCreate) SetNillableCodeChallengeMethod(v *string) *AuthCodeCre return _c } +// SetAuthTime sets the "auth_time" field. +func (_c *AuthCodeCreate) SetAuthTime(v time.Time) *AuthCodeCreate { + _c.mutation.SetAuthTime(v) + return _c +} + +// SetNillableAuthTime sets the "auth_time" field if the given value is not nil. +func (_c *AuthCodeCreate) SetNillableAuthTime(v *time.Time) *AuthCodeCreate { + if v != nil { + _c.SetAuthTime(*v) + } + return _c +} + // SetID sets the "id" field. func (_c *AuthCodeCreate) SetID(v string) *AuthCodeCreate { _c.mutation.SetID(v) @@ -362,6 +376,10 @@ func (_c *AuthCodeCreate) createSpec() (*AuthCode, *sqlgraph.CreateSpec) { _spec.SetField(authcode.FieldCodeChallengeMethod, field.TypeString, value) _node.CodeChallengeMethod = value } + if value, ok := _c.mutation.AuthTime(); ok { + _spec.SetField(authcode.FieldAuthTime, field.TypeTime, value) + _node.AuthTime = value + } return _node, _spec } diff --git a/storage/ent/db/authcode_update.go b/storage/ent/db/authcode_update.go index 7b7e186e..25027ca7 100644 --- a/storage/ent/db/authcode_update.go +++ b/storage/ent/db/authcode_update.go @@ -245,6 +245,26 @@ func (_u *AuthCodeUpdate) SetNillableCodeChallengeMethod(v *string) *AuthCodeUpd return _u } +// SetAuthTime sets the "auth_time" field. +func (_u *AuthCodeUpdate) SetAuthTime(v time.Time) *AuthCodeUpdate { + _u.mutation.SetAuthTime(v) + return _u +} + +// SetNillableAuthTime sets the "auth_time" field if the given value is not nil. +func (_u *AuthCodeUpdate) SetNillableAuthTime(v *time.Time) *AuthCodeUpdate { + if v != nil { + _u.SetAuthTime(*v) + } + return _u +} + +// ClearAuthTime clears the value of the "auth_time" field. +func (_u *AuthCodeUpdate) ClearAuthTime() *AuthCodeUpdate { + _u.mutation.ClearAuthTime() + return _u +} + // Mutation returns the AuthCodeMutation object of the builder. func (_u *AuthCodeUpdate) Mutation() *AuthCodeMutation { return _u.mutation @@ -393,6 +413,12 @@ func (_u *AuthCodeUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.CodeChallengeMethod(); ok { _spec.SetField(authcode.FieldCodeChallengeMethod, field.TypeString, value) } + if value, ok := _u.mutation.AuthTime(); ok { + _spec.SetField(authcode.FieldAuthTime, field.TypeTime, value) + } + if _u.mutation.AuthTimeCleared() { + _spec.ClearField(authcode.FieldAuthTime, field.TypeTime) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{authcode.Label} @@ -629,6 +655,26 @@ func (_u *AuthCodeUpdateOne) SetNillableCodeChallengeMethod(v *string) *AuthCode return _u } +// SetAuthTime sets the "auth_time" field. +func (_u *AuthCodeUpdateOne) SetAuthTime(v time.Time) *AuthCodeUpdateOne { + _u.mutation.SetAuthTime(v) + return _u +} + +// SetNillableAuthTime sets the "auth_time" field if the given value is not nil. +func (_u *AuthCodeUpdateOne) SetNillableAuthTime(v *time.Time) *AuthCodeUpdateOne { + if v != nil { + _u.SetAuthTime(*v) + } + return _u +} + +// ClearAuthTime clears the value of the "auth_time" field. +func (_u *AuthCodeUpdateOne) ClearAuthTime() *AuthCodeUpdateOne { + _u.mutation.ClearAuthTime() + return _u +} + // Mutation returns the AuthCodeMutation object of the builder. func (_u *AuthCodeUpdateOne) Mutation() *AuthCodeMutation { return _u.mutation @@ -807,6 +853,12 @@ func (_u *AuthCodeUpdateOne) sqlSave(ctx context.Context) (_node *AuthCode, err if value, ok := _u.mutation.CodeChallengeMethod(); ok { _spec.SetField(authcode.FieldCodeChallengeMethod, field.TypeString, value) } + if value, ok := _u.mutation.AuthTime(); ok { + _spec.SetField(authcode.FieldAuthTime, field.TypeTime, value) + } + if _u.mutation.AuthTimeCleared() { + _spec.ClearField(authcode.FieldAuthTime, field.TypeTime) + } _node = &AuthCode{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/db/authrequest.go b/storage/ent/db/authrequest.go index 02ead496..82619eeb 100644 --- a/storage/ent/db/authrequest.go +++ b/storage/ent/db/authrequest.go @@ -60,6 +60,12 @@ type AuthRequest struct { HmacKey []byte `json:"hmac_key,omitempty"` // MfaValidated holds the value of the "mfa_validated" field. MfaValidated bool `json:"mfa_validated,omitempty"` + // Prompt holds the value of the "prompt" field. + Prompt string `json:"prompt,omitempty"` + // MaxAge holds the value of the "max_age" field. + MaxAge int `json:"max_age,omitempty"` + // AuthTime holds the value of the "auth_time" field. + AuthTime time.Time `json:"auth_time,omitempty"` selectValues sql.SelectValues } @@ -72,9 +78,11 @@ func (*AuthRequest) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case authrequest.FieldForceApprovalPrompt, authrequest.FieldLoggedIn, authrequest.FieldClaimsEmailVerified, authrequest.FieldMfaValidated: values[i] = new(sql.NullBool) - case authrequest.FieldID, authrequest.FieldClientID, authrequest.FieldRedirectURI, authrequest.FieldNonce, authrequest.FieldState, authrequest.FieldClaimsUserID, authrequest.FieldClaimsUsername, authrequest.FieldClaimsEmail, authrequest.FieldClaimsPreferredUsername, authrequest.FieldConnectorID, authrequest.FieldCodeChallenge, authrequest.FieldCodeChallengeMethod: + case authrequest.FieldMaxAge: + values[i] = new(sql.NullInt64) + case authrequest.FieldID, authrequest.FieldClientID, authrequest.FieldRedirectURI, authrequest.FieldNonce, authrequest.FieldState, authrequest.FieldClaimsUserID, authrequest.FieldClaimsUsername, authrequest.FieldClaimsEmail, authrequest.FieldClaimsPreferredUsername, authrequest.FieldConnectorID, authrequest.FieldCodeChallenge, authrequest.FieldCodeChallengeMethod, authrequest.FieldPrompt: values[i] = new(sql.NullString) - case authrequest.FieldExpiry: + case authrequest.FieldExpiry, authrequest.FieldAuthTime: values[i] = new(sql.NullTime) default: values[i] = new(sql.UnknownType) @@ -229,6 +237,24 @@ func (_m *AuthRequest) assignValues(columns []string, values []any) error { } else if value.Valid { _m.MfaValidated = value.Bool } + case authrequest.FieldPrompt: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field prompt", values[i]) + } else if value.Valid { + _m.Prompt = value.String + } + case authrequest.FieldMaxAge: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field max_age", values[i]) + } else if value.Valid { + _m.MaxAge = int(value.Int64) + } + case authrequest.FieldAuthTime: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field auth_time", values[i]) + } else if value.Valid { + _m.AuthTime = value.Time + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -329,6 +355,15 @@ func (_m *AuthRequest) String() string { builder.WriteString(", ") builder.WriteString("mfa_validated=") builder.WriteString(fmt.Sprintf("%v", _m.MfaValidated)) + builder.WriteString(", ") + builder.WriteString("prompt=") + builder.WriteString(_m.Prompt) + builder.WriteString(", ") + builder.WriteString("max_age=") + builder.WriteString(fmt.Sprintf("%v", _m.MaxAge)) + builder.WriteString(", ") + builder.WriteString("auth_time=") + builder.WriteString(_m.AuthTime.Format(time.ANSIC)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/authrequest/authrequest.go b/storage/ent/db/authrequest/authrequest.go index 4fda6633..33c52c8f 100644 --- a/storage/ent/db/authrequest/authrequest.go +++ b/storage/ent/db/authrequest/authrequest.go @@ -53,6 +53,12 @@ const ( FieldHmacKey = "hmac_key" // FieldMfaValidated holds the string denoting the mfa_validated field in the database. FieldMfaValidated = "mfa_validated" + // FieldPrompt holds the string denoting the prompt field in the database. + FieldPrompt = "prompt" + // FieldMaxAge holds the string denoting the max_age field in the database. + FieldMaxAge = "max_age" + // FieldAuthTime holds the string denoting the auth_time field in the database. + FieldAuthTime = "auth_time" // Table holds the table name of the authrequest in the database. Table = "auth_requests" ) @@ -81,6 +87,9 @@ var Columns = []string{ FieldCodeChallengeMethod, FieldHmacKey, FieldMfaValidated, + FieldPrompt, + FieldMaxAge, + FieldAuthTime, } // ValidColumn reports if the column name is valid (part of the table columns). @@ -102,6 +111,10 @@ var ( DefaultCodeChallengeMethod string // DefaultMfaValidated holds the default value on creation for the "mfa_validated" field. DefaultMfaValidated bool + // DefaultPrompt holds the default value on creation for the "prompt" field. + DefaultPrompt string + // DefaultMaxAge holds the default value on creation for the "max_age" field. + DefaultMaxAge int // IDValidator is a validator for the "id" field. It is called by the builders before save. IDValidator func(string) error ) @@ -193,3 +206,18 @@ func ByCodeChallengeMethod(opts ...sql.OrderTermOption) OrderOption { func ByMfaValidated(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldMfaValidated, opts...).ToFunc() } + +// ByPrompt orders the results by the prompt field. +func ByPrompt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPrompt, opts...).ToFunc() +} + +// ByMaxAge orders the results by the max_age field. +func ByMaxAge(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldMaxAge, opts...).ToFunc() +} + +// ByAuthTime orders the results by the auth_time field. +func ByAuthTime(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAuthTime, opts...).ToFunc() +} diff --git a/storage/ent/db/authrequest/where.go b/storage/ent/db/authrequest/where.go index 2f679bb3..f87780b1 100644 --- a/storage/ent/db/authrequest/where.go +++ b/storage/ent/db/authrequest/where.go @@ -154,6 +154,21 @@ func MfaValidated(v bool) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldMfaValidated, v)) } +// Prompt applies equality check predicate on the "prompt" field. It's identical to PromptEQ. +func Prompt(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldPrompt, v)) +} + +// MaxAge applies equality check predicate on the "max_age" field. It's identical to MaxAgeEQ. +func MaxAge(v int) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldMaxAge, v)) +} + +// AuthTime applies equality check predicate on the "auth_time" field. It's identical to AuthTimeEQ. +func AuthTime(v time.Time) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldAuthTime, v)) +} + // ClientIDEQ applies the EQ predicate on the "client_id" field. func ClientIDEQ(v string) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldClientID, v)) @@ -1069,6 +1084,161 @@ func MfaValidatedNEQ(v bool) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldNEQ(FieldMfaValidated, v)) } +// PromptEQ applies the EQ predicate on the "prompt" field. +func PromptEQ(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldPrompt, v)) +} + +// PromptNEQ applies the NEQ predicate on the "prompt" field. +func PromptNEQ(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNEQ(FieldPrompt, v)) +} + +// PromptIn applies the In predicate on the "prompt" field. +func PromptIn(vs ...string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldIn(FieldPrompt, vs...)) +} + +// PromptNotIn applies the NotIn predicate on the "prompt" field. +func PromptNotIn(vs ...string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNotIn(FieldPrompt, vs...)) +} + +// PromptGT applies the GT predicate on the "prompt" field. +func PromptGT(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGT(FieldPrompt, v)) +} + +// PromptGTE applies the GTE predicate on the "prompt" field. +func PromptGTE(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGTE(FieldPrompt, v)) +} + +// PromptLT applies the LT predicate on the "prompt" field. +func PromptLT(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLT(FieldPrompt, v)) +} + +// PromptLTE applies the LTE predicate on the "prompt" field. +func PromptLTE(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLTE(FieldPrompt, v)) +} + +// PromptContains applies the Contains predicate on the "prompt" field. +func PromptContains(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldContains(FieldPrompt, v)) +} + +// PromptHasPrefix applies the HasPrefix predicate on the "prompt" field. +func PromptHasPrefix(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldHasPrefix(FieldPrompt, v)) +} + +// PromptHasSuffix applies the HasSuffix predicate on the "prompt" field. +func PromptHasSuffix(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldHasSuffix(FieldPrompt, v)) +} + +// PromptEqualFold applies the EqualFold predicate on the "prompt" field. +func PromptEqualFold(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEqualFold(FieldPrompt, v)) +} + +// PromptContainsFold applies the ContainsFold predicate on the "prompt" field. +func PromptContainsFold(v string) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldContainsFold(FieldPrompt, v)) +} + +// MaxAgeEQ applies the EQ predicate on the "max_age" field. +func MaxAgeEQ(v int) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldMaxAge, v)) +} + +// MaxAgeNEQ applies the NEQ predicate on the "max_age" field. +func MaxAgeNEQ(v int) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNEQ(FieldMaxAge, v)) +} + +// MaxAgeIn applies the In predicate on the "max_age" field. +func MaxAgeIn(vs ...int) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldIn(FieldMaxAge, vs...)) +} + +// MaxAgeNotIn applies the NotIn predicate on the "max_age" field. +func MaxAgeNotIn(vs ...int) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNotIn(FieldMaxAge, vs...)) +} + +// MaxAgeGT applies the GT predicate on the "max_age" field. +func MaxAgeGT(v int) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGT(FieldMaxAge, v)) +} + +// MaxAgeGTE applies the GTE predicate on the "max_age" field. +func MaxAgeGTE(v int) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGTE(FieldMaxAge, v)) +} + +// MaxAgeLT applies the LT predicate on the "max_age" field. +func MaxAgeLT(v int) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLT(FieldMaxAge, v)) +} + +// MaxAgeLTE applies the LTE predicate on the "max_age" field. +func MaxAgeLTE(v int) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLTE(FieldMaxAge, v)) +} + +// AuthTimeEQ applies the EQ predicate on the "auth_time" field. +func AuthTimeEQ(v time.Time) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldAuthTime, v)) +} + +// AuthTimeNEQ applies the NEQ predicate on the "auth_time" field. +func AuthTimeNEQ(v time.Time) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNEQ(FieldAuthTime, v)) +} + +// AuthTimeIn applies the In predicate on the "auth_time" field. +func AuthTimeIn(vs ...time.Time) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldIn(FieldAuthTime, vs...)) +} + +// AuthTimeNotIn applies the NotIn predicate on the "auth_time" field. +func AuthTimeNotIn(vs ...time.Time) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNotIn(FieldAuthTime, vs...)) +} + +// AuthTimeGT applies the GT predicate on the "auth_time" field. +func AuthTimeGT(v time.Time) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGT(FieldAuthTime, v)) +} + +// AuthTimeGTE applies the GTE predicate on the "auth_time" field. +func AuthTimeGTE(v time.Time) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGTE(FieldAuthTime, v)) +} + +// AuthTimeLT applies the LT predicate on the "auth_time" field. +func AuthTimeLT(v time.Time) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLT(FieldAuthTime, v)) +} + +// AuthTimeLTE applies the LTE predicate on the "auth_time" field. +func AuthTimeLTE(v time.Time) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLTE(FieldAuthTime, v)) +} + +// AuthTimeIsNil applies the IsNil predicate on the "auth_time" field. +func AuthTimeIsNil() predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldIsNull(FieldAuthTime)) +} + +// AuthTimeNotNil applies the NotNil predicate on the "auth_time" field. +func AuthTimeNotNil() predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNotNull(FieldAuthTime)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.AuthRequest) predicate.AuthRequest { return predicate.AuthRequest(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/authrequest_create.go b/storage/ent/db/authrequest_create.go index c36648a1..324c99d9 100644 --- a/storage/ent/db/authrequest_create.go +++ b/storage/ent/db/authrequest_create.go @@ -178,6 +178,48 @@ func (_c *AuthRequestCreate) SetNillableMfaValidated(v *bool) *AuthRequestCreate return _c } +// SetPrompt sets the "prompt" field. +func (_c *AuthRequestCreate) SetPrompt(v string) *AuthRequestCreate { + _c.mutation.SetPrompt(v) + return _c +} + +// SetNillablePrompt sets the "prompt" field if the given value is not nil. +func (_c *AuthRequestCreate) SetNillablePrompt(v *string) *AuthRequestCreate { + if v != nil { + _c.SetPrompt(*v) + } + return _c +} + +// SetMaxAge sets the "max_age" field. +func (_c *AuthRequestCreate) SetMaxAge(v int) *AuthRequestCreate { + _c.mutation.SetMaxAge(v) + return _c +} + +// SetNillableMaxAge sets the "max_age" field if the given value is not nil. +func (_c *AuthRequestCreate) SetNillableMaxAge(v *int) *AuthRequestCreate { + if v != nil { + _c.SetMaxAge(*v) + } + return _c +} + +// SetAuthTime sets the "auth_time" field. +func (_c *AuthRequestCreate) SetAuthTime(v time.Time) *AuthRequestCreate { + _c.mutation.SetAuthTime(v) + return _c +} + +// SetNillableAuthTime sets the "auth_time" field if the given value is not nil. +func (_c *AuthRequestCreate) SetNillableAuthTime(v *time.Time) *AuthRequestCreate { + if v != nil { + _c.SetAuthTime(*v) + } + return _c +} + // SetID sets the "id" field. func (_c *AuthRequestCreate) SetID(v string) *AuthRequestCreate { _c.mutation.SetID(v) @@ -235,6 +277,14 @@ func (_c *AuthRequestCreate) defaults() { v := authrequest.DefaultMfaValidated _c.mutation.SetMfaValidated(v) } + if _, ok := _c.mutation.Prompt(); !ok { + v := authrequest.DefaultPrompt + _c.mutation.SetPrompt(v) + } + if _, ok := _c.mutation.MaxAge(); !ok { + v := authrequest.DefaultMaxAge + _c.mutation.SetMaxAge(v) + } } // check runs all checks and user-defined validators on the builder. @@ -290,6 +340,12 @@ func (_c *AuthRequestCreate) check() error { if _, ok := _c.mutation.MfaValidated(); !ok { return &ValidationError{Name: "mfa_validated", err: errors.New(`db: missing required field "AuthRequest.mfa_validated"`)} } + if _, ok := _c.mutation.Prompt(); !ok { + return &ValidationError{Name: "prompt", err: errors.New(`db: missing required field "AuthRequest.prompt"`)} + } + if _, ok := _c.mutation.MaxAge(); !ok { + return &ValidationError{Name: "max_age", err: errors.New(`db: missing required field "AuthRequest.max_age"`)} + } if v, ok := _c.mutation.ID(); ok { if err := authrequest.IDValidator(v); err != nil { return &ValidationError{Name: "id", err: fmt.Errorf(`db: validator failed for field "AuthRequest.id": %w`, err)} @@ -414,6 +470,18 @@ func (_c *AuthRequestCreate) createSpec() (*AuthRequest, *sqlgraph.CreateSpec) { _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) _node.MfaValidated = value } + if value, ok := _c.mutation.Prompt(); ok { + _spec.SetField(authrequest.FieldPrompt, field.TypeString, value) + _node.Prompt = value + } + if value, ok := _c.mutation.MaxAge(); ok { + _spec.SetField(authrequest.FieldMaxAge, field.TypeInt, value) + _node.MaxAge = value + } + if value, ok := _c.mutation.AuthTime(); ok { + _spec.SetField(authrequest.FieldAuthTime, field.TypeTime, value) + _node.AuthTime = value + } return _node, _spec } diff --git a/storage/ent/db/authrequest_update.go b/storage/ent/db/authrequest_update.go index e512b2d9..7edece02 100644 --- a/storage/ent/db/authrequest_update.go +++ b/storage/ent/db/authrequest_update.go @@ -325,6 +325,61 @@ func (_u *AuthRequestUpdate) SetNillableMfaValidated(v *bool) *AuthRequestUpdate return _u } +// SetPrompt sets the "prompt" field. +func (_u *AuthRequestUpdate) SetPrompt(v string) *AuthRequestUpdate { + _u.mutation.SetPrompt(v) + return _u +} + +// SetNillablePrompt sets the "prompt" field if the given value is not nil. +func (_u *AuthRequestUpdate) SetNillablePrompt(v *string) *AuthRequestUpdate { + if v != nil { + _u.SetPrompt(*v) + } + return _u +} + +// SetMaxAge sets the "max_age" field. +func (_u *AuthRequestUpdate) SetMaxAge(v int) *AuthRequestUpdate { + _u.mutation.ResetMaxAge() + _u.mutation.SetMaxAge(v) + return _u +} + +// SetNillableMaxAge sets the "max_age" field if the given value is not nil. +func (_u *AuthRequestUpdate) SetNillableMaxAge(v *int) *AuthRequestUpdate { + if v != nil { + _u.SetMaxAge(*v) + } + return _u +} + +// AddMaxAge adds value to the "max_age" field. +func (_u *AuthRequestUpdate) AddMaxAge(v int) *AuthRequestUpdate { + _u.mutation.AddMaxAge(v) + return _u +} + +// SetAuthTime sets the "auth_time" field. +func (_u *AuthRequestUpdate) SetAuthTime(v time.Time) *AuthRequestUpdate { + _u.mutation.SetAuthTime(v) + return _u +} + +// SetNillableAuthTime sets the "auth_time" field if the given value is not nil. +func (_u *AuthRequestUpdate) SetNillableAuthTime(v *time.Time) *AuthRequestUpdate { + if v != nil { + _u.SetAuthTime(*v) + } + return _u +} + +// ClearAuthTime clears the value of the "auth_time" field. +func (_u *AuthRequestUpdate) ClearAuthTime() *AuthRequestUpdate { + _u.mutation.ClearAuthTime() + return _u +} + // Mutation returns the AuthRequestMutation object of the builder. func (_u *AuthRequestUpdate) Mutation() *AuthRequestMutation { return _u.mutation @@ -456,6 +511,21 @@ func (_u *AuthRequestUpdate) sqlSave(ctx context.Context) (_node int, err error) if value, ok := _u.mutation.MfaValidated(); ok { _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) } + if value, ok := _u.mutation.Prompt(); ok { + _spec.SetField(authrequest.FieldPrompt, field.TypeString, value) + } + if value, ok := _u.mutation.MaxAge(); ok { + _spec.SetField(authrequest.FieldMaxAge, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedMaxAge(); ok { + _spec.AddField(authrequest.FieldMaxAge, field.TypeInt, value) + } + if value, ok := _u.mutation.AuthTime(); ok { + _spec.SetField(authrequest.FieldAuthTime, field.TypeTime, value) + } + if _u.mutation.AuthTimeCleared() { + _spec.ClearField(authrequest.FieldAuthTime, field.TypeTime) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{authrequest.Label} @@ -772,6 +842,61 @@ func (_u *AuthRequestUpdateOne) SetNillableMfaValidated(v *bool) *AuthRequestUpd return _u } +// SetPrompt sets the "prompt" field. +func (_u *AuthRequestUpdateOne) SetPrompt(v string) *AuthRequestUpdateOne { + _u.mutation.SetPrompt(v) + return _u +} + +// SetNillablePrompt sets the "prompt" field if the given value is not nil. +func (_u *AuthRequestUpdateOne) SetNillablePrompt(v *string) *AuthRequestUpdateOne { + if v != nil { + _u.SetPrompt(*v) + } + return _u +} + +// SetMaxAge sets the "max_age" field. +func (_u *AuthRequestUpdateOne) SetMaxAge(v int) *AuthRequestUpdateOne { + _u.mutation.ResetMaxAge() + _u.mutation.SetMaxAge(v) + return _u +} + +// SetNillableMaxAge sets the "max_age" field if the given value is not nil. +func (_u *AuthRequestUpdateOne) SetNillableMaxAge(v *int) *AuthRequestUpdateOne { + if v != nil { + _u.SetMaxAge(*v) + } + return _u +} + +// AddMaxAge adds value to the "max_age" field. +func (_u *AuthRequestUpdateOne) AddMaxAge(v int) *AuthRequestUpdateOne { + _u.mutation.AddMaxAge(v) + return _u +} + +// SetAuthTime sets the "auth_time" field. +func (_u *AuthRequestUpdateOne) SetAuthTime(v time.Time) *AuthRequestUpdateOne { + _u.mutation.SetAuthTime(v) + return _u +} + +// SetNillableAuthTime sets the "auth_time" field if the given value is not nil. +func (_u *AuthRequestUpdateOne) SetNillableAuthTime(v *time.Time) *AuthRequestUpdateOne { + if v != nil { + _u.SetAuthTime(*v) + } + return _u +} + +// ClearAuthTime clears the value of the "auth_time" field. +func (_u *AuthRequestUpdateOne) ClearAuthTime() *AuthRequestUpdateOne { + _u.mutation.ClearAuthTime() + return _u +} + // Mutation returns the AuthRequestMutation object of the builder. func (_u *AuthRequestUpdateOne) Mutation() *AuthRequestMutation { return _u.mutation @@ -933,6 +1058,21 @@ func (_u *AuthRequestUpdateOne) sqlSave(ctx context.Context) (_node *AuthRequest if value, ok := _u.mutation.MfaValidated(); ok { _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) } + if value, ok := _u.mutation.Prompt(); ok { + _spec.SetField(authrequest.FieldPrompt, field.TypeString, value) + } + if value, ok := _u.mutation.MaxAge(); ok { + _spec.SetField(authrequest.FieldMaxAge, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedMaxAge(); ok { + _spec.AddField(authrequest.FieldMaxAge, field.TypeInt, value) + } + if value, ok := _u.mutation.AuthTime(); ok { + _spec.SetField(authrequest.FieldAuthTime, field.TypeTime, value) + } + if _u.mutation.AuthTimeCleared() { + _spec.ClearField(authrequest.FieldAuthTime, field.TypeTime) + } _node = &AuthRequest{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index 36db86e3..3fc0f834 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -26,6 +26,7 @@ var ( {Name: "expiry", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "code_challenge", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "code_challenge_method", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "auth_time", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, } // AuthCodesTable holds the schema information for the "auth_codes" table. AuthCodesTable = &schema.Table{ @@ -57,6 +58,9 @@ var ( {Name: "code_challenge_method", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "hmac_key", Type: field.TypeBytes}, {Name: "mfa_validated", Type: field.TypeBool, Default: false}, + {Name: "prompt", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "max_age", Type: field.TypeInt, Default: -1}, + {Name: "auth_time", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, } // AuthRequestsTable holds the schema information for the "auth_requests" table. AuthRequestsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 151443f9..46f204eb 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -74,6 +74,7 @@ type AuthCodeMutation struct { expiry *time.Time code_challenge *string code_challenge_method *string + auth_time *time.Time clearedFields map[string]struct{} done bool oldValue func(context.Context) (*AuthCode, error) @@ -795,6 +796,55 @@ func (m *AuthCodeMutation) ResetCodeChallengeMethod() { m.code_challenge_method = nil } +// SetAuthTime sets the "auth_time" field. +func (m *AuthCodeMutation) SetAuthTime(t time.Time) { + m.auth_time = &t +} + +// AuthTime returns the value of the "auth_time" field in the mutation. +func (m *AuthCodeMutation) AuthTime() (r time.Time, exists bool) { + v := m.auth_time + if v == nil { + return + } + return *v, true +} + +// OldAuthTime returns the old "auth_time" field's value of the AuthCode entity. +// If the AuthCode object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthCodeMutation) OldAuthTime(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAuthTime is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAuthTime requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAuthTime: %w", err) + } + return oldValue.AuthTime, nil +} + +// ClearAuthTime clears the value of the "auth_time" field. +func (m *AuthCodeMutation) ClearAuthTime() { + m.auth_time = nil + m.clearedFields[authcode.FieldAuthTime] = struct{}{} +} + +// AuthTimeCleared returns if the "auth_time" field was cleared in this mutation. +func (m *AuthCodeMutation) AuthTimeCleared() bool { + _, ok := m.clearedFields[authcode.FieldAuthTime] + return ok +} + +// ResetAuthTime resets all changes to the "auth_time" field. +func (m *AuthCodeMutation) ResetAuthTime() { + m.auth_time = nil + delete(m.clearedFields, authcode.FieldAuthTime) +} + // Where appends a list predicates to the AuthCodeMutation builder. func (m *AuthCodeMutation) Where(ps ...predicate.AuthCode) { m.predicates = append(m.predicates, ps...) @@ -829,7 +879,7 @@ func (m *AuthCodeMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AuthCodeMutation) Fields() []string { - fields := make([]string, 0, 15) + fields := make([]string, 0, 16) if m.client_id != nil { fields = append(fields, authcode.FieldClientID) } @@ -875,6 +925,9 @@ func (m *AuthCodeMutation) Fields() []string { if m.code_challenge_method != nil { fields = append(fields, authcode.FieldCodeChallengeMethod) } + if m.auth_time != nil { + fields = append(fields, authcode.FieldAuthTime) + } return fields } @@ -913,6 +966,8 @@ func (m *AuthCodeMutation) Field(name string) (ent.Value, bool) { return m.CodeChallenge() case authcode.FieldCodeChallengeMethod: return m.CodeChallengeMethod() + case authcode.FieldAuthTime: + return m.AuthTime() } return nil, false } @@ -952,6 +1007,8 @@ func (m *AuthCodeMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldCodeChallenge(ctx) case authcode.FieldCodeChallengeMethod: return m.OldCodeChallengeMethod(ctx) + case authcode.FieldAuthTime: + return m.OldAuthTime(ctx) } return nil, fmt.Errorf("unknown AuthCode field %s", name) } @@ -1066,6 +1123,13 @@ func (m *AuthCodeMutation) SetField(name string, value ent.Value) error { } m.SetCodeChallengeMethod(v) return nil + case authcode.FieldAuthTime: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAuthTime(v) + return nil } return fmt.Errorf("unknown AuthCode field %s", name) } @@ -1105,6 +1169,9 @@ func (m *AuthCodeMutation) ClearedFields() []string { if m.FieldCleared(authcode.FieldConnectorData) { fields = append(fields, authcode.FieldConnectorData) } + if m.FieldCleared(authcode.FieldAuthTime) { + fields = append(fields, authcode.FieldAuthTime) + } return fields } @@ -1128,6 +1195,9 @@ func (m *AuthCodeMutation) ClearField(name string) error { case authcode.FieldConnectorData: m.ClearConnectorData() return nil + case authcode.FieldAuthTime: + m.ClearAuthTime() + return nil } return fmt.Errorf("unknown AuthCode nullable field %s", name) } @@ -1181,6 +1251,9 @@ func (m *AuthCodeMutation) ResetField(name string) error { case authcode.FieldCodeChallengeMethod: m.ResetCodeChallengeMethod() return nil + case authcode.FieldAuthTime: + m.ResetAuthTime() + return nil } return fmt.Errorf("unknown AuthCode field %s", name) } @@ -1263,6 +1336,10 @@ type AuthRequestMutation struct { code_challenge_method *string hmac_key *[]byte mfa_validated *bool + prompt *string + max_age *int + addmax_age *int + auth_time *time.Time clearedFields map[string]struct{} done bool oldValue func(context.Context) (*AuthRequest, error) @@ -2229,6 +2306,147 @@ func (m *AuthRequestMutation) ResetMfaValidated() { m.mfa_validated = nil } +// SetPrompt sets the "prompt" field. +func (m *AuthRequestMutation) SetPrompt(s string) { + m.prompt = &s +} + +// Prompt returns the value of the "prompt" field in the mutation. +func (m *AuthRequestMutation) Prompt() (r string, exists bool) { + v := m.prompt + if v == nil { + return + } + return *v, true +} + +// OldPrompt returns the old "prompt" field's value of the AuthRequest entity. +// If the AuthRequest object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthRequestMutation) OldPrompt(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPrompt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPrompt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPrompt: %w", err) + } + return oldValue.Prompt, nil +} + +// ResetPrompt resets all changes to the "prompt" field. +func (m *AuthRequestMutation) ResetPrompt() { + m.prompt = nil +} + +// SetMaxAge sets the "max_age" field. +func (m *AuthRequestMutation) SetMaxAge(i int) { + m.max_age = &i + m.addmax_age = nil +} + +// MaxAge returns the value of the "max_age" field in the mutation. +func (m *AuthRequestMutation) MaxAge() (r int, exists bool) { + v := m.max_age + if v == nil { + return + } + return *v, true +} + +// OldMaxAge returns the old "max_age" field's value of the AuthRequest entity. +// If the AuthRequest object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthRequestMutation) OldMaxAge(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldMaxAge is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldMaxAge requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldMaxAge: %w", err) + } + return oldValue.MaxAge, nil +} + +// AddMaxAge adds i to the "max_age" field. +func (m *AuthRequestMutation) AddMaxAge(i int) { + if m.addmax_age != nil { + *m.addmax_age += i + } else { + m.addmax_age = &i + } +} + +// AddedMaxAge returns the value that was added to the "max_age" field in this mutation. +func (m *AuthRequestMutation) AddedMaxAge() (r int, exists bool) { + v := m.addmax_age + if v == nil { + return + } + return *v, true +} + +// ResetMaxAge resets all changes to the "max_age" field. +func (m *AuthRequestMutation) ResetMaxAge() { + m.max_age = nil + m.addmax_age = nil +} + +// SetAuthTime sets the "auth_time" field. +func (m *AuthRequestMutation) SetAuthTime(t time.Time) { + m.auth_time = &t +} + +// AuthTime returns the value of the "auth_time" field in the mutation. +func (m *AuthRequestMutation) AuthTime() (r time.Time, exists bool) { + v := m.auth_time + if v == nil { + return + } + return *v, true +} + +// OldAuthTime returns the old "auth_time" field's value of the AuthRequest entity. +// If the AuthRequest object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthRequestMutation) OldAuthTime(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAuthTime is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAuthTime requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAuthTime: %w", err) + } + return oldValue.AuthTime, nil +} + +// ClearAuthTime clears the value of the "auth_time" field. +func (m *AuthRequestMutation) ClearAuthTime() { + m.auth_time = nil + m.clearedFields[authrequest.FieldAuthTime] = struct{}{} +} + +// AuthTimeCleared returns if the "auth_time" field was cleared in this mutation. +func (m *AuthRequestMutation) AuthTimeCleared() bool { + _, ok := m.clearedFields[authrequest.FieldAuthTime] + return ok +} + +// ResetAuthTime resets all changes to the "auth_time" field. +func (m *AuthRequestMutation) ResetAuthTime() { + m.auth_time = nil + delete(m.clearedFields, authrequest.FieldAuthTime) +} + // Where appends a list predicates to the AuthRequestMutation builder. func (m *AuthRequestMutation) Where(ps ...predicate.AuthRequest) { m.predicates = append(m.predicates, ps...) @@ -2263,7 +2481,7 @@ func (m *AuthRequestMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AuthRequestMutation) Fields() []string { - fields := make([]string, 0, 21) + fields := make([]string, 0, 24) if m.client_id != nil { fields = append(fields, authrequest.FieldClientID) } @@ -2327,6 +2545,15 @@ func (m *AuthRequestMutation) Fields() []string { if m.mfa_validated != nil { fields = append(fields, authrequest.FieldMfaValidated) } + if m.prompt != nil { + fields = append(fields, authrequest.FieldPrompt) + } + if m.max_age != nil { + fields = append(fields, authrequest.FieldMaxAge) + } + if m.auth_time != nil { + fields = append(fields, authrequest.FieldAuthTime) + } return fields } @@ -2377,6 +2604,12 @@ func (m *AuthRequestMutation) Field(name string) (ent.Value, bool) { return m.HmacKey() case authrequest.FieldMfaValidated: return m.MfaValidated() + case authrequest.FieldPrompt: + return m.Prompt() + case authrequest.FieldMaxAge: + return m.MaxAge() + case authrequest.FieldAuthTime: + return m.AuthTime() } return nil, false } @@ -2428,6 +2661,12 @@ func (m *AuthRequestMutation) OldField(ctx context.Context, name string) (ent.Va return m.OldHmacKey(ctx) case authrequest.FieldMfaValidated: return m.OldMfaValidated(ctx) + case authrequest.FieldPrompt: + return m.OldPrompt(ctx) + case authrequest.FieldMaxAge: + return m.OldMaxAge(ctx) + case authrequest.FieldAuthTime: + return m.OldAuthTime(ctx) } return nil, fmt.Errorf("unknown AuthRequest field %s", name) } @@ -2584,6 +2823,27 @@ func (m *AuthRequestMutation) SetField(name string, value ent.Value) error { } m.SetMfaValidated(v) return nil + case authrequest.FieldPrompt: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPrompt(v) + return nil + case authrequest.FieldMaxAge: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetMaxAge(v) + return nil + case authrequest.FieldAuthTime: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAuthTime(v) + return nil } return fmt.Errorf("unknown AuthRequest field %s", name) } @@ -2591,13 +2851,21 @@ func (m *AuthRequestMutation) SetField(name string, value ent.Value) error { // AddedFields returns all numeric fields that were incremented/decremented during // this mutation. func (m *AuthRequestMutation) AddedFields() []string { - return nil + var fields []string + if m.addmax_age != nil { + fields = append(fields, authrequest.FieldMaxAge) + } + return fields } // AddedField returns the numeric value that was incremented/decremented on a field // with the given name. The second boolean return value indicates that this field // was not set, or was not defined in the schema. func (m *AuthRequestMutation) AddedField(name string) (ent.Value, bool) { + switch name { + case authrequest.FieldMaxAge: + return m.AddedMaxAge() + } return nil, false } @@ -2606,6 +2874,13 @@ func (m *AuthRequestMutation) AddedField(name string) (ent.Value, bool) { // type. func (m *AuthRequestMutation) AddField(name string, value ent.Value) error { switch name { + case authrequest.FieldMaxAge: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddMaxAge(v) + return nil } return fmt.Errorf("unknown AuthRequest numeric field %s", name) } @@ -2626,6 +2901,9 @@ func (m *AuthRequestMutation) ClearedFields() []string { if m.FieldCleared(authrequest.FieldConnectorData) { fields = append(fields, authrequest.FieldConnectorData) } + if m.FieldCleared(authrequest.FieldAuthTime) { + fields = append(fields, authrequest.FieldAuthTime) + } return fields } @@ -2652,6 +2930,9 @@ func (m *AuthRequestMutation) ClearField(name string) error { case authrequest.FieldConnectorData: m.ClearConnectorData() return nil + case authrequest.FieldAuthTime: + m.ClearAuthTime() + return nil } return fmt.Errorf("unknown AuthRequest nullable field %s", name) } @@ -2723,6 +3004,15 @@ func (m *AuthRequestMutation) ResetField(name string) error { case authrequest.FieldMfaValidated: m.ResetMfaValidated() return nil + case authrequest.FieldPrompt: + m.ResetPrompt() + return nil + case authrequest.FieldMaxAge: + m.ResetMaxAge() + return nil + case authrequest.FieldAuthTime: + m.ResetAuthTime() + return nil } return fmt.Errorf("unknown AuthRequest field %s", name) } diff --git a/storage/ent/db/runtime.go b/storage/ent/db/runtime.go index df011f55..2c1c5404 100644 --- a/storage/ent/db/runtime.go +++ b/storage/ent/db/runtime.go @@ -88,6 +88,14 @@ func init() { authrequestDescMfaValidated := authrequestFields[21].Descriptor() // authrequest.DefaultMfaValidated holds the default value on creation for the mfa_validated field. authrequest.DefaultMfaValidated = authrequestDescMfaValidated.Default.(bool) + // authrequestDescPrompt is the schema descriptor for prompt field. + authrequestDescPrompt := authrequestFields[22].Descriptor() + // authrequest.DefaultPrompt holds the default value on creation for the prompt field. + authrequest.DefaultPrompt = authrequestDescPrompt.Default.(string) + // authrequestDescMaxAge is the schema descriptor for max_age field. + authrequestDescMaxAge := authrequestFields[23].Descriptor() + // authrequest.DefaultMaxAge holds the default value on creation for the max_age field. + authrequest.DefaultMaxAge = authrequestDescMaxAge.Default.(int) // authrequestDescID is the schema descriptor for id field. authrequestDescID := authrequestFields[0].Descriptor() // authrequest.IDValidator is a validator for the "id" field. It is called by the builders before save. diff --git a/storage/ent/schema/authcode.go b/storage/ent/schema/authcode.go index 1574347b..b2669d08 100644 --- a/storage/ent/schema/authcode.go +++ b/storage/ent/schema/authcode.go @@ -81,6 +81,9 @@ func (AuthCode) Fields() []ent.Field { field.Text("code_challenge_method"). SchemaType(textSchema). Default(""), + field.Time("auth_time"). + SchemaType(timeSchema). + Optional(), } } diff --git a/storage/ent/schema/authrequest.go b/storage/ent/schema/authrequest.go index 905c73ab..be24c29f 100644 --- a/storage/ent/schema/authrequest.go +++ b/storage/ent/schema/authrequest.go @@ -90,6 +90,9 @@ func (AuthRequest) Fields() []ent.Field { field.Bytes("hmac_key"), field.Bool("mfa_validated"). Default(false), + field.Text("prompt").SchemaType(textSchema).Default(""), + field.Int("max_age").Default(-1), + field.Time("auth_time").SchemaType(timeSchema).Optional(), } } diff --git a/storage/etcd/types.go b/storage/etcd/types.go index b76727e2..0a73e9ea 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -24,6 +24,8 @@ type AuthCode struct { CodeChallenge string `json:"code_challenge,omitempty"` CodeChallengeMethod string `json:"code_challenge_method,omitempty"` + + AuthTime time.Time `json:"auth_time"` } func toStorageAuthCode(a AuthCode) storage.AuthCode { @@ -41,6 +43,7 @@ func toStorageAuthCode(a AuthCode) storage.AuthCode { CodeChallenge: a.CodeChallenge, CodeChallengeMethod: a.CodeChallengeMethod, }, + AuthTime: a.AuthTime, } } @@ -57,6 +60,7 @@ func fromStorageAuthCode(a storage.AuthCode) AuthCode { Expiry: a.Expiry, CodeChallenge: a.PKCE.CodeChallenge, CodeChallengeMethod: a.PKCE.CodeChallengeMethod, + AuthTime: a.AuthTime, } } @@ -88,6 +92,10 @@ type AuthRequest struct { HMACKey []byte `json:"hmac_key"` MFAValidated bool `json:"mfa_validated"` + + Prompt string `json:"prompt,omitempty"` + MaxAge int `json:"max_age"` + AuthTime time.Time `json:"auth_time"` } func fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { @@ -109,6 +117,9 @@ func fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { CodeChallengeMethod: a.PKCE.CodeChallengeMethod, HMACKey: a.HMACKey, MFAValidated: a.MFAValidated, + Prompt: a.Prompt, + MaxAge: a.MaxAge, + AuthTime: a.AuthTime, } } @@ -133,6 +144,9 @@ func toStorageAuthRequest(a AuthRequest) storage.AuthRequest { }, HMACKey: a.HMACKey, MFAValidated: a.MFAValidated, + Prompt: a.Prompt, + MaxAge: a.MaxAge, + AuthTime: a.AuthTime, } } diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index c0245dad..344ae75e 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -402,6 +402,10 @@ type AuthRequest struct { HMACKey []byte `json:"hmac_key"` MFAValidated bool `json:"mfa_validated"` + + Prompt string `json:"prompt,omitempty"` + MaxAge int `json:"maxAge"` + AuthTime time.Time `json:"authTime,omitempty"` } // AuthRequestList is a list of AuthRequests. @@ -432,6 +436,9 @@ func toStorageAuthRequest(req AuthRequest) storage.AuthRequest { }, HMACKey: req.HMACKey, MFAValidated: req.MFAValidated, + Prompt: req.Prompt, + MaxAge: req.MaxAge, + AuthTime: req.AuthTime, } return a } @@ -462,6 +469,9 @@ func (cli *client) fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { CodeChallengeMethod: a.PKCE.CodeChallengeMethod, HMACKey: a.HMACKey, MFAValidated: a.MFAValidated, + Prompt: a.Prompt, + MaxAge: a.MaxAge, + AuthTime: a.AuthTime, } return req } @@ -550,6 +560,8 @@ type AuthCode struct { CodeChallenge string `json:"code_challenge,omitempty"` CodeChallengeMethod string `json:"code_challenge_method,omitempty"` + + AuthTime time.Time `json:"authTime,omitempty"` } // AuthCodeList is a list of AuthCodes. @@ -579,6 +591,7 @@ func (cli *client) fromStorageAuthCode(a storage.AuthCode) AuthCode { Expiry: a.Expiry, CodeChallenge: a.PKCE.CodeChallenge, CodeChallengeMethod: a.PKCE.CodeChallengeMethod, + AuthTime: a.AuthTime, } } @@ -597,6 +610,7 @@ func toStorageAuthCode(a AuthCode) storage.AuthCode { CodeChallenge: a.CodeChallenge, CodeChallengeMethod: a.CodeChallengeMethod, }, + AuthTime: a.AuthTime, } } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index d77ce4e0..82b9bc9b 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -135,10 +135,11 @@ func (c *conn) CreateAuthRequest(ctx context.Context, a storage.AuthRequest) err expiry, code_challenge, code_challenge_method, hmac_key, - mfa_validated + mfa_validated, + prompt, max_age, auth_time ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25 ); `, a.ID, a.ClientID, encoder(a.ResponseTypes), encoder(a.Scopes), a.RedirectURI, a.Nonce, a.State, @@ -150,6 +151,7 @@ func (c *conn) CreateAuthRequest(ctx context.Context, a storage.AuthRequest) err a.PKCE.CodeChallenge, a.PKCE.CodeChallengeMethod, a.HMACKey, a.MFAValidated, + a.Prompt, a.MaxAge, a.AuthTime, ) if err != nil { if c.alreadyExistsCheck(err) { @@ -183,8 +185,9 @@ func (c *conn) UpdateAuthRequest(ctx context.Context, id string, updater func(a expiry = $17, code_challenge = $18, code_challenge_method = $19, hmac_key = $20, - mfa_validated = $21 - where id = $22; + mfa_validated = $21, + prompt = $22, max_age = $23, auth_time = $24 + where id = $25; `, a.ClientID, encoder(a.ResponseTypes), encoder(a.Scopes), a.RedirectURI, a.Nonce, a.State, a.ForceApprovalPrompt, a.LoggedIn, @@ -195,6 +198,7 @@ func (c *conn) UpdateAuthRequest(ctx context.Context, id string, updater func(a a.Expiry, a.PKCE.CodeChallenge, a.PKCE.CodeChallengeMethod, a.HMACKey, a.MFAValidated, + a.Prompt, a.MaxAge, a.AuthTime, r.ID, ) if err != nil { @@ -217,7 +221,8 @@ func getAuthRequest(ctx context.Context, q querier, id string) (a storage.AuthRe claims_email, claims_email_verified, claims_groups, connector_id, connector_data, expiry, code_challenge, code_challenge_method, hmac_key, - mfa_validated + mfa_validated, + prompt, max_age, auth_time from auth_request where id = $1; `, id).Scan( &a.ID, &a.ClientID, decoder(&a.ResponseTypes), decoder(&a.Scopes), &a.RedirectURI, &a.Nonce, &a.State, @@ -228,6 +233,7 @@ func getAuthRequest(ctx context.Context, q querier, id string) (a storage.AuthRe &a.ConnectorID, &a.ConnectorData, &a.Expiry, &a.PKCE.CodeChallenge, &a.PKCE.CodeChallengeMethod, &a.HMACKey, &a.MFAValidated, + &a.Prompt, &a.MaxAge, &a.AuthTime, ) if err != nil { if err == sql.ErrNoRows { @@ -246,14 +252,16 @@ func (c *conn) CreateAuthCode(ctx context.Context, a storage.AuthCode) error { claims_email, claims_email_verified, claims_groups, connector_id, connector_data, expiry, - code_challenge, code_challenge_method + code_challenge, code_challenge_method, + auth_time ) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16); + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17); `, a.ID, a.ClientID, encoder(a.Scopes), a.Nonce, a.RedirectURI, a.Claims.UserID, a.Claims.Username, a.Claims.PreferredUsername, a.Claims.Email, a.Claims.EmailVerified, encoder(a.Claims.Groups), a.ConnectorID, a.ConnectorData, a.Expiry, a.PKCE.CodeChallenge, a.PKCE.CodeChallengeMethod, + a.AuthTime, ) if err != nil { if c.alreadyExistsCheck(err) { @@ -272,13 +280,15 @@ func (c *conn) GetAuthCode(ctx context.Context, id string) (a storage.AuthCode, claims_email, claims_email_verified, claims_groups, connector_id, connector_data, expiry, - code_challenge, code_challenge_method + code_challenge, code_challenge_method, + auth_time from auth_code where id = $1; `, id).Scan( &a.ID, &a.ClientID, decoder(&a.Scopes), &a.Nonce, &a.RedirectURI, &a.Claims.UserID, &a.Claims.Username, &a.Claims.PreferredUsername, &a.Claims.Email, &a.Claims.EmailVerified, decoder(&a.Claims.Groups), &a.ConnectorID, &a.ConnectorData, &a.Expiry, &a.PKCE.CodeChallenge, &a.PKCE.CodeChallengeMethod, + &a.AuthTime, ) if err != nil { if err == sql.ErrNoRows { diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 0e44e52b..9131d284 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -436,6 +436,10 @@ var migrations = []migration{ ` alter table client add column mfa_chain bytea;`, + `alter table auth_request add column prompt text not null default '';`, + `alter table auth_request add column max_age integer not null default -1;`, + `alter table auth_request add column auth_time timestamptz not null default '1970-01-01 00:00:00';`, + `alter table auth_code add column auth_time timestamptz not null default '1970-01-01 00:00:00';`, }, }, } diff --git a/storage/storage.go b/storage/storage.go index 57c4823b..8161eb83 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -230,6 +230,20 @@ type AuthRequest struct { // attempts. ForceApprovalPrompt bool + // OIDC prompt parameter. Controls authentication and consent UI behavior. + // Values: "none", "login", "consent", "select_account". + Prompt string + + // MaxAge is the OIDC max_age parameter — maximum allowable elapsed time + // in seconds since the user last actively authenticated. + // -1 means not specified. + MaxAge int + + // AuthTime is when the user last actively authenticated (entered credentials). + // Set during finalizeLogin (= now) or trySessionLogin (= UserIdentity.LastLogin). + // Used in ID token as "auth_time" claim. + AuthTime time.Time + Expiry time.Time // Has the user proved their identity through a backing identity provider? @@ -290,6 +304,10 @@ type AuthCode struct { Expiry time.Time + // AuthTime is when the user last actively authenticated. + // Carried over from AuthRequest to include in ID tokens. + AuthTime time.Time + // PKCE CodeChallenge and CodeChallengeMethod PKCE PKCE } From 92f51f9d679ba9dc3a2d697ffad92fb3f71ed56c Mon Sep 17 00:00:00 2001 From: Taylor Silva Date: Fri, 20 Mar 2026 07:16:10 -0400 Subject: [PATCH 180/222] fix non-constant format string in call to newRedirectedErr (#4671) Signed-off-by: Taylor Silva --- server/oauth2.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/oauth2.go b/server/oauth2.go index 3818c135..d4105591 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -509,8 +509,7 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques } if codeChallenge != "" && !slices.Contains(s.pkce.CodeChallengeMethodsSupported, codeChallengeMethod) { - description := fmt.Sprintf("Unsupported PKCE challenge method (%q).", codeChallengeMethod) - return nil, newRedirectedErr(errInvalidRequest, description) + return nil, newRedirectedErr(errInvalidRequest, "Unsupported PKCE challenge method (%q).", codeChallengeMethod) } // Enforce PKCE if configured. @@ -599,8 +598,7 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques } if rt.token { if redirectURI == redirectURIOOB { - err := fmt.Sprintf("Cannot use response type 'token' with redirect_uri '%s'.", redirectURIOOB) - return nil, newRedirectedErr(errInvalidRequest, err) + return nil, newRedirectedErr(errInvalidRequest, "Cannot use response type 'token' with redirect_uri '%s'.", redirectURIOOB) } } From 3b5be6a876cee785c0466e8efe1a05fa45a2fc5d Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 20 Mar 2026 13:05:47 +0100 Subject: [PATCH 181/222] Disable MFA configuration in config-dev.yaml (#4672) Comment out multi-factor authentication configuration. Signed-off-by: Maksim Nabokikh --- examples/config-dev.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 434c8938..cc15daed 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -140,12 +140,12 @@ telemetry: # Multi-factor authentication configuration. # Requires DEX_SESSIONS_ENABLED=true feature flag. -mfa: - authenticators: - - id: totp-1 - type: TOTP - config: - issuer: "dex-1" +# 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 From 5bbc400c5ab6798d89f95094eaee0b5782d6cd77 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 20 Mar 2026 16:12:53 +0100 Subject: [PATCH 182/222] feat: implement id_token_hint (#4670) Signed-off-by: maksim.nabokikh Signed-off-by: Maksim Nabokikh --- server/handlers.go | 23 ++++- server/oauth2.go | 98 +++++++++++++++------ server/oauth2_test.go | 191 ++++++++++++++++++++++++++++++++++++++++- server/session.go | 7 ++ server/session_test.go | 77 +++++++++++++++++ 5 files changed, 366 insertions(+), 30 deletions(-) diff --git a/server/handlers.go b/server/handlers.go index d810dbef..8db9b05f 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -295,7 +295,7 @@ func (s *Server) getClientWithAuthError(ctx context.Context, clientID string) (s func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - authReq, err := s.parseAuthorizationRequest(r) + authReq, hintSubject, err := s.parseAuthorizationRequest(r) if err != nil { s.logger.ErrorContext(r.Context(), "failed to parse authorization request", "err", err) @@ -374,9 +374,26 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { } // handle prompt only if sessions are enabled if s.sessionConfig != nil { + // Retrieve the session once for use in both hint and prompt logic. + session := s.getValidAuthSession(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. + s.redirectWithError(w, r, authReq, errLoginRequired, "id_token_hint does not match authenticated user") + return + } + } + // prompt=none: no UI allowed. if prompt.None() { - redirectURL, ok := s.trySessionLogin(ctx, r, w, authReq) + redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, authReq, session) if !ok { s.redirectWithError(w, r, authReq, errLoginRequired, "User not authenticated") return @@ -391,7 +408,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { if !prompt.Login() { // Normal flow: try session-based login (skip if prompt=login forces re-auth). - if redirectURL, ok := s.trySessionLogin(ctx, r, w, authReq); ok { + if redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, authReq, session); ok { if redirectURL != "" { http.Redirect(w, r, redirectURL, http.StatusSeeOther) } diff --git a/server/oauth2.go b/server/oauth2.go index d4105591..7e102302 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -19,6 +19,7 @@ import ( "strings" "time" + "github.com/coreos/go-oidc/v3/oidc" "github.com/go-jose/go-jose/v4" "github.com/dexidp/dex/connector" @@ -438,16 +439,51 @@ func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage return idToken, expiry, nil } -// parse the initial request from the OAuth2 client. -func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthRequest, error) { +// validateIDTokenHint verifies the signature and issuer of an id_token_hint. +// Expired tokens are accepted per OIDC Core 1.0 §3.1.2.1. +// Returns the raw subject claim from the token. +func (s *Server) validateIDTokenHint(ctx context.Context, hint string) (string, error) { + verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.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 signerKeySet already guarantees the token was + // issued by this server, which is sufficient for a hint. + // Dex does the client id check later in the scope of the session validation. + SkipClientIDCheck: true, + }) + idToken, err := verifier.Verify(ctx, hint) + if err != nil { + return "", err + } + return idToken.Subject, nil +} + +// 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 := genSubject(session.UserID, session.ConnectorID) + if err != nil { + return false + } + return encoded == hintSubject +} + +// parseAuthorizationRequest parses the initial request from the OAuth2 client. +// Returns the auth request, the raw subject from id_token_hint (empty if not provided), and any error. +func (s *Server) 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.") + return nil, "", newDisplayedErr(http.StatusBadRequest, "Failed to parse request.") } q := r.Form redirectURI, err := url.QueryUnescape(q.Get("redirect_uri")) if err != nil { - return nil, newDisplayedErr(http.StatusBadRequest, "No redirect_uri provided.") + return nil, "", newDisplayedErr(http.StatusBadRequest, "No redirect_uri provided.") } clientID := q.Get("client_id") @@ -469,15 +505,15 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques if err != nil { if err == storage.ErrNotFound { s.logger.ErrorContext(r.Context(), "invalid client_id provided", "client_id", clientID) - return nil, newDisplayedErr(http.StatusNotFound, "Invalid client_id.") + return nil, "", newDisplayedErr(http.StatusNotFound, "Invalid client_id.") } s.logger.ErrorContext(r.Context(), "failed to get client", "err", err) - return nil, newDisplayedErr(http.StatusInternalServerError, "Database error.") + return nil, "", newDisplayedErr(http.StatusInternalServerError, "Database error.") } if !validateRedirectURI(client, redirectURI) { s.logger.ErrorContext(r.Context(), "unregistered redirect_uri", "redirect_uri", redirectURI, "client_id", clientID) - return nil, newDisplayedErr(http.StatusBadRequest, "Unregistered redirect_uri.") + return nil, "", newDisplayedErr(http.StatusBadRequest, "Unregistered redirect_uri.") } if redirectURI == deviceCallbackURI && client.Public { redirectURI = s.absPath(deviceCallbackURI) @@ -492,30 +528,30 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques connectors, err := s.storage.ListConnectors(ctx) if err != nil { s.logger.ErrorContext(r.Context(), "failed to list connectors", "err", err) - return nil, newRedirectedErr(errServerError, "Unable to retrieve connectors") + return nil, "", newRedirectedErr(errServerError, "Unable to retrieve connectors") } if !validateConnectorID(connectors, connectorID) { - return nil, newRedirectedErr(errInvalidRequest, "Invalid ConnectorID") + return nil, "", newRedirectedErr(errInvalidRequest, "Invalid ConnectorID") } if !isConnectorAllowed(client.AllowedConnectors, connectorID) { - return nil, newRedirectedErr(errInvalidRequest, "Connector not allowed for this client") + return nil, "", newRedirectedErr(errInvalidRequest, "Connector not allowed for this client") } } // dex doesn't support request parameter and must return request_not_supported error // https://openid.net/specs/openid-connect-core-1_0.html#6.1 if q.Get("request") != "" { - return nil, newRedirectedErr(errRequestNotSupported, "Server does not support request parameter.") + return nil, "", newRedirectedErr(errRequestNotSupported, "Server does not support request parameter.") } if codeChallenge != "" && !slices.Contains(s.pkce.CodeChallengeMethodsSupported, codeChallengeMethod) { - return nil, newRedirectedErr(errInvalidRequest, "Unsupported PKCE challenge method (%q).", codeChallengeMethod) + return nil, "", newRedirectedErr(errInvalidRequest, "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 s.pkce.Enforce && codeChallenge == "" { - return nil, newRedirectedErr(errInvalidRequest, "PKCE is required. The code_challenge parameter must be provided.") + return nil, "", newRedirectedErr(errInvalidRequest, "PKCE is required. The code_challenge parameter must be provided.") } var ( @@ -537,7 +573,7 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques isTrusted, err := s.validateCrossClientTrust(r.Context(), clientID, peerID) if err != nil { - return nil, newRedirectedErr(errServerError, "Internal server error.") + return nil, "", newRedirectedErr(errServerError, "Internal server error.") } if !isTrusted { invalidScopes = append(invalidScopes, scope) @@ -545,13 +581,13 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques } } if !hasOpenIDScope { - return nil, newRedirectedErr(errInvalidScope, `Missing required scope(s) ["openid"].`) + return nil, "", newRedirectedErr(errInvalidScope, `Missing required scope(s) ["openid"].`) } if len(unrecognized) > 0 { - return nil, newRedirectedErr(errInvalidScope, "Unrecognized scope(s) %q", unrecognized) + return nil, "", newRedirectedErr(errInvalidScope, "Unrecognized scope(s) %q", unrecognized) } if len(invalidScopes) > 0 { - return nil, newRedirectedErr(errInvalidScope, "Client can't request scope(s) %q", invalidScopes) + return nil, "", newRedirectedErr(errInvalidScope, "Client can't request scope(s) %q", invalidScopes) } var rt struct { @@ -569,23 +605,23 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques case responseTypeToken: rt.token = true default: - return nil, newRedirectedErr(errInvalidRequest, "Invalid response type %q", responseType) + return nil, "", newRedirectedErr(errInvalidRequest, "Invalid response type %q", responseType) } if !s.supportedResponseTypes[responseType] { - return nil, newRedirectedErr(errUnsupportedResponseType, "Unsupported response type %q", responseType) + return nil, "", newRedirectedErr(errUnsupportedResponseType, "Unsupported response type %q", responseType) } } if len(responseTypes) == 0 { - return nil, newRedirectedErr(errInvalidRequest, "No response_type provided") + return nil, "", newRedirectedErr(errInvalidRequest, "No response_type provided") } if rt.token && !rt.code && !rt.idToken { // "token" can't be provided by its own. // // https://openid.net/specs/openid-connect-core-1_0.html#Authentication - return nil, newRedirectedErr(errInvalidRequest, "Response type 'token' must be provided with type 'id_token' and/or 'code'") + return nil, "", newRedirectedErr(errInvalidRequest, "Response type 'token' must be provided with type 'id_token' and/or 'code'") } if !rt.code { // Either "id_token token" or "id_token" has been provided which implies the @@ -593,18 +629,18 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques // // https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest if nonce == "" { - return nil, newRedirectedErr(errInvalidRequest, "Response type 'token' requires a 'nonce' value.") + return nil, "", newRedirectedErr(errInvalidRequest, "Response type 'token' requires a 'nonce' value.") } } if rt.token { if redirectURI == redirectURIOOB { - return nil, newRedirectedErr(errInvalidRequest, "Cannot use response type 'token' with redirect_uri '%s'.", redirectURIOOB) + return nil, "", newRedirectedErr(errInvalidRequest, "Cannot use response type 'token' with redirect_uri '%s'.", redirectURIOOB) } } prompt, err := ParsePrompt(q.Get("prompt")) if err != nil { - return nil, newRedirectedErr(errInvalidRequest, "Invalid prompt parameter: %v", err) + return nil, "", newRedirectedErr(errInvalidRequest, "Invalid prompt parameter: %v", err) } // Parse max_age: -1 means not specified. @@ -612,7 +648,7 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques if maxAgeStr := q.Get("max_age"); maxAgeStr != "" { v, err := strconv.Atoi(maxAgeStr) if err != nil || v < 0 { - return nil, newRedirectedErr(errInvalidRequest, "Invalid max_age value %q", maxAgeStr) + return nil, "", newRedirectedErr(errInvalidRequest, "Invalid max_age value %q", maxAgeStr) } maxAge = v } @@ -620,6 +656,16 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques // 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 != "" { + sub, err := s.validateIDTokenHint(ctx, hint) + if err != nil { + return nil, "", newRedirectedErr(errInvalidRequest, "Invalid id_token_hint.") + } + idTokenHintSubject = sub + } + return &storage.AuthRequest{ ID: storage.NewID(), ClientID: client.ID, @@ -637,7 +683,7 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques CodeChallengeMethod: codeChallengeMethod, }, HMACKey: storage.NewHMACKey(crypto.SHA256), - }, nil + }, idTokenHintSubject, nil } func parseCrossClientScope(scope string) (peerID string, ok bool) { diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 6e1528ce..835868a1 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -3,13 +3,17 @@ package server import ( "crypto/rand" "crypto/rsa" + "encoding/json" + "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/dexidp/dex/server/signer" @@ -432,7 +436,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) @@ -883,3 +887,188 @@ func TestRedirectedAuthErrHandler(t *testing.T) { }) } } + +// 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 := &Server{ + signer: sig, + issuerURL: *issuerURL, + logger: slog.Default(), + } + + now := time.Now() + + t.Run("valid hint (not expired)", func(t *testing.T) { + token := signTestIDToken(t, idTokenClaims{ + Issuer: "https://issuer.example.com", + Subject: "CgNmb28SA2Jhcg", + Expiry: now.Add(1 * time.Hour).Unix(), + }) + sub, err := s.validateIDTokenHint(t.Context(), token) + require.NoError(t, err) + assert.Equal(t, "CgNmb28SA2Jhcg", sub) + }) + + t.Run("valid hint (expired)", func(t *testing.T) { + token := signTestIDToken(t, idTokenClaims{ + Issuer: "https://issuer.example.com", + Subject: "CgNmb28SA2Jhcg", + Expiry: now.Add(-1 * time.Hour).Unix(), + }) + sub, err := s.validateIDTokenHint(t.Context(), token) + require.NoError(t, err) + assert.Equal(t, "CgNmb28SA2Jhcg", sub) + }) + + t.Run("invalid signature", func(t *testing.T) { + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + payload, err := json.Marshal(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, 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) { + // 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 := newTestServerMultipleConnectors(t, func(c *Config) { + c.SupportedResponseTypes = []string{"code"} + 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, 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 := newTestServerMultipleConnectors(t, func(c *Config) { + c.SupportedResponseTypes = []string{"code"} + 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, errInvalidRequest, redirectErr.Type) + }) + + t.Run("no id_token_hint leaves subject empty", func(t *testing.T) { + httpServer, server := newTestServerMultipleConnectors(t, func(c *Config) { + c.SupportedResponseTypes = []string{"code"} + 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/session.go b/server/session.go index 48e3fb6a..750b970d 100644 --- a/server/session.go +++ b/server/session.go @@ -238,6 +238,13 @@ func (s *Server) createOrUpdateAuthSession(ctx context.Context, r *http.Request, // Returns ("", false) if session-based login is not possible. func (s *Server) trySessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest) (string, bool) { session := s.getValidAuthSession(ctx, w, r, authReq) + return s.trySessionLoginWithSession(ctx, r, w, authReq, session) +} + +// trySessionLoginWithSession is like trySessionLogin but accepts a pre-retrieved session. +// This allows callers to inspect the session (e.g., for id_token_hint comparison) before +// attempting session-based login. +func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest, session *storage.AuthSession) (string, bool) { if session == nil { return "", false } diff --git a/server/session_test.go b/server/session_test.go index 7eac45b7..6d32d123 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -732,6 +732,83 @@ func TestTrySessionLogin_MaxAge(t *testing.T) { }) } +func TestTrySessionLoginWithSession_IDTokenHint(t *testing.T) { + ctx := t.Context() + + // genSubject("user-1", "mock") produces a deterministic subject string. + hintSubjectForUser1Mock, err := genSubject("user-1", "mock") + require.NoError(t, err) + + hintSubjectOther, err := 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.getValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("user-1", "mock", "test-nonce"), &authReq) + require.NotNil(t, session) + + // Verify hint matches. + assert.True(t, sessionMatchesHint(session, hintSubjectForUser1Mock)) + + r := sessionCookieRequest("user-1", "mock", "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.getValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("user-1", "mock", "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("user-1", "mock", "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.getValidAuthSession(ctx, httptest.NewRecorder(), sessionCookieRequest("user-1", "mock", "test-nonce"), &authReq) + require.NotNil(t, session) + + r := sessionCookieRequest("user-1", "mock", "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{ From 449f66477c3d2f426162cdc99f15b3bbde474ee8 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 20 Mar 2026 20:06:43 +0100 Subject: [PATCH 183/222] feat: Add AuthSession GC (#4667) Signed-off-by: maksim.nabokikh --- server/server.go | 3 +- server/session.go | 21 ++-- server/session_test.go | 116 ++++++++++-------- storage/conformance/conformance.go | 106 +++++++++++++++- storage/ent/client/authsession.go | 4 + storage/ent/client/main.go | 14 +++ storage/ent/client/types.go | 16 +-- storage/ent/db/authsession.go | 26 +++- storage/ent/db/authsession/authsession.go | 16 +++ storage/ent/db/authsession/where.go | 90 ++++++++++++++ storage/ent/db/authsession_create.go | 26 ++++ storage/ent/db/authsession_update.go | 68 +++++++++++ storage/ent/db/migrate/schema.go | 2 + storage/ent/db/mutation.go | 140 +++++++++++++++++++--- storage/ent/schema/authsession.go | 4 + storage/etcd/etcd.go | 43 +++++-- storage/etcd/types.go | 54 +++++---- storage/kubernetes/storage.go | 16 +++ storage/kubernetes/types.go | 54 +++++---- storage/memory/memory.go | 6 + storage/sql/crud.go | 23 +++- storage/sql/migrate.go | 10 ++ storage/storage.go | 9 +- 23 files changed, 718 insertions(+), 149 deletions(-) diff --git a/server/server.go b/server/server.go index 669c16a0..53ef2d81 100644 --- a/server/server.go +++ b/server/server.go @@ -728,7 +728,8 @@ func (s *Server) startGarbageCollection(ctx context.Context, frequency time.Dura } else if !r.IsEmpty() { s.logger.InfoContext(ctx, "garbage collection run, delete auth", "requests", r.AuthRequests, "auth_codes", r.AuthCodes, - "device_requests", r.DeviceRequests, "device_tokens", r.DeviceTokens) + "device_requests", r.DeviceRequests, "device_tokens", r.DeviceTokens, + "auth_sessions", r.AuthSessions) } } } diff --git a/server/session.go b/server/session.go index 750b970d..5f65ea00 100644 --- a/server/session.go +++ b/server/session.go @@ -134,8 +134,8 @@ func (s *Server) getValidAuthSession(ctx context.Context, w http.ResponseWriter, now := s.now() - // Check absolute lifetime. - if now.After(session.CreatedAt.Add(s.sessionConfig.AbsoluteLifetime)) { + // Check absolute lifetime using the stored expiry (set once at creation). + if !session.AbsoluteExpiry.IsZero() && now.After(session.AbsoluteExpiry) { s.logger.InfoContext(ctx, "auth session expired (absolute lifetime)", "user_id", session.UserID, "connector_id", session.ConnectorID) if err := s.storage.DeleteAuthSession(ctx, session.UserID, session.ConnectorID); err != nil { @@ -145,8 +145,8 @@ func (s *Server) getValidAuthSession(ctx context.Context, w http.ResponseWriter, return nil } - // Check idle timeout. - if now.After(session.LastActivity.Add(s.sessionConfig.ValidIfNotUsedFor)) { + // Check idle timeout using the stored expiry (updated on every activity). + if !session.IdleExpiry.IsZero() && now.After(session.IdleExpiry) { s.logger.InfoContext(ctx, "auth session expired (idle timeout)", "user_id", session.UserID, "connector_id", session.ConnectorID) if err := s.storage.DeleteAuthSession(ctx, session.UserID, session.ConnectorID); err != nil { @@ -191,6 +191,7 @@ func (s *Server) createOrUpdateAuthSession(ctx context.Context, r *http.Request, if err := s.storage.UpdateAuthSession(ctx, userID, connectorID, func(old storage.AuthSession) (storage.AuthSession, error) { old.LastActivity = now + old.IdleExpiry = now.Add(s.sessionConfig.ValidIfNotUsedFor) if old.ClientStates == nil { old.ClientStates = make(map[string]*storage.ClientAuthState) } @@ -217,10 +218,12 @@ func (s *Server) createOrUpdateAuthSession(ctx context.Context, r *http.Request, ClientStates: map[string]*storage.ClientAuthState{ authReq.ClientID: clientState, }, - CreatedAt: now, - LastActivity: now, - IPAddress: remoteIP(r), - UserAgent: r.UserAgent(), + CreatedAt: now, + LastActivity: now, + IPAddress: remoteIP(r), + UserAgent: r.UserAgent(), + AbsoluteExpiry: now.Add(s.sessionConfig.AbsoluteLifetime), + IdleExpiry: now.Add(s.sessionConfig.ValidIfNotUsedFor), } if err := s.storage.CreateAuthSession(ctx, newSession); err != nil { @@ -300,6 +303,7 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request // Update session activity. _ = s.storage.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) { old.LastActivity = now + old.IdleExpiry = now.Add(s.sessionConfig.ValidIfNotUsedFor) if cs, ok := old.ClientStates[authReq.ClientID]; ok { cs.LastActivity = now } @@ -346,6 +350,7 @@ func (s *Server) updateSessionTokenIssuedAt(r *http.Request, clientID string) { now := s.now() _ = s.storage.UpdateAuthSession(r.Context(), userID, connectorID, func(old storage.AuthSession) (storage.AuthSession, error) { old.LastActivity = now + old.IdleExpiry = now.Add(s.sessionConfig.ValidIfNotUsedFor) if cs, ok := old.ClientStates[clientID]; ok { cs.LastTokenIssuedAt = now cs.LastActivity = now diff --git a/server/session_test.go b/server/session_test.go index 6d32d123..f00eed59 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -155,14 +155,16 @@ func TestGetValidAuthSession(t *testing.T) { nonce := "test-nonce" session := storage.AuthSession{ - UserID: "user1", - ConnectorID: "conn1", - Nonce: 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", + UserID: "user1", + ConnectorID: "conn1", + Nonce: 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)) @@ -181,14 +183,16 @@ func TestGetValidAuthSession(t *testing.T) { nonce := "test-nonce-conn" session := storage.AuthSession{ - UserID: "user1", - ConnectorID: "ldap", - Nonce: 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", + UserID: "user1", + ConnectorID: "ldap", + Nonce: 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)) @@ -204,14 +208,16 @@ func TestGetValidAuthSession(t *testing.T) { now := s.now() session := storage.AuthSession{ - UserID: "user2", - ConnectorID: "conn2", - Nonce: "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", + UserID: "user2", + ConnectorID: "conn2", + Nonce: "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)) @@ -230,14 +236,16 @@ func TestGetValidAuthSession(t *testing.T) { nonce := "expired-nonce" session := storage.AuthSession{ - UserID: "user3", - ConnectorID: "conn3", - Nonce: 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", + UserID: "user3", + ConnectorID: "conn3", + Nonce: 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)) @@ -260,14 +268,16 @@ func TestGetValidAuthSession(t *testing.T) { nonce := "idle-nonce" session := storage.AuthSession{ - UserID: "user4", - ConnectorID: "conn4", - Nonce: 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", + UserID: "user4", + ConnectorID: "conn4", + Nonce: 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)) @@ -338,10 +348,12 @@ func TestCreateOrUpdateAuthSession(t *testing.T) { 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", + 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)) @@ -402,10 +414,12 @@ func setupSessionLoginFixture(t *testing.T, s *Server) storage.AuthRequest { 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", + 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{ @@ -536,8 +550,10 @@ func TestTrySessionLogin(t *testing.T) { ExpiresAt: now.Add(-1 * time.Hour), }, }, - CreatedAt: now.Add(-2 * time.Hour), - LastActivity: now.Add(-1 * time.Minute), + CreatedAt: now.Add(-2 * time.Hour), + LastActivity: now.Add(-1 * time.Minute), + AbsoluteExpiry: now.Add(22 * time.Hour), + IdleExpiry: now.Add(59 * time.Minute), })) require.NoError(t, s.storage.CreateUserIdentity(t.Context(), storage.UserIdentity{ diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index e2570b47..c81c9484 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -966,6 +966,100 @@ func testGC(t *testing.T, s storage.Storage) { } else if err != storage.ErrNotFound { t.Errorf("expected storage.ErrNotFound, got %v", err) } + + // Test auth session GC. + authSession := storage.AuthSession{ + UserID: "gc-user", + ConnectorID: "gc-conn", + Nonce: storage.NewID(), + ClientStates: map[string]*storage.ClientAuthState{ + "client1": {Active: true, ExpiresAt: expiry.Add(time.Hour), LastActivity: expiry}, + }, + CreatedAt: expiry.Add(-time.Hour), + LastActivity: expiry.Add(-time.Hour), + AbsoluteExpiry: expiry, + IdleExpiry: expiry, + } + + if err := s.CreateAuthSession(ctx, authSession); err != nil { + t.Fatalf("failed creating auth session: %v", err) + } + + // GC before expiry should not delete. + for _, tz := range []*time.Location{time.UTC, est, pst} { + result, err := s.GarbageCollect(ctx, expiry.Add(-time.Hour).In(tz)) + if err != nil { + t.Errorf("garbage collection failed: %v", err) + } else if result.AuthSessions != 0 { + t.Errorf("expected no auth session garbage collection results, got %#v", result) + } + if _, err := s.GetAuthSession(ctx, authSession.UserID, authSession.ConnectorID); err != nil { + t.Errorf("expected to be able to get auth session after GC: %v", err) + } + } + + // GC after expiry should delete. + if r, err := s.GarbageCollect(ctx, expiry.Add(time.Hour)); err != nil { + t.Errorf("garbage collection failed: %v", err) + } else if r.AuthSessions != 1 { + t.Errorf("expected to garbage collect 1 auth session, got %d", r.AuthSessions) + } + + if _, err := s.GetAuthSession(ctx, authSession.UserID, authSession.ConnectorID); err == nil { + t.Errorf("expected auth session to be GC'd") + } else if err != storage.ErrNotFound { + t.Errorf("expected storage.ErrNotFound, got %v", err) + } + + // Test auth session GC: absolute expired, idle still valid. + absExpiredSession := storage.AuthSession{ + UserID: "gc-abs-expired", + ConnectorID: "gc-conn", + Nonce: storage.NewID(), + ClientStates: map[string]*storage.ClientAuthState{ + "client1": {Active: true, ExpiresAt: expiry.Add(time.Hour), LastActivity: expiry}, + }, + CreatedAt: expiry.Add(-25 * time.Hour), + LastActivity: expiry.Add(-time.Minute), + AbsoluteExpiry: expiry.Add(-time.Hour), // expired + IdleExpiry: expiry.Add(time.Hour), // still valid + } + if err := s.CreateAuthSession(ctx, absExpiredSession); err != nil { + t.Fatalf("failed creating abs-expired auth session: %v", err) + } + if r, err := s.GarbageCollect(ctx, expiry); err != nil { + t.Errorf("garbage collection failed: %v", err) + } else if r.AuthSessions != 1 { + t.Errorf("expected to garbage collect 1 auth session (absolute expired), got %d", r.AuthSessions) + } + if _, err := s.GetAuthSession(ctx, absExpiredSession.UserID, absExpiredSession.ConnectorID); err == nil { + t.Errorf("expected abs-expired auth session to be GC'd") + } + + // Test auth session GC: absolute still valid, idle expired. + idleExpiredSession := storage.AuthSession{ + UserID: "gc-idle-expired", + ConnectorID: "gc-conn", + Nonce: storage.NewID(), + ClientStates: map[string]*storage.ClientAuthState{ + "client1": {Active: true, ExpiresAt: expiry.Add(time.Hour), LastActivity: expiry}, + }, + CreatedAt: expiry.Add(-time.Hour), + LastActivity: expiry.Add(-2 * time.Hour), + AbsoluteExpiry: expiry.Add(23 * time.Hour), // still valid + IdleExpiry: expiry.Add(-time.Hour), // expired + } + if err := s.CreateAuthSession(ctx, idleExpiredSession); err != nil { + t.Fatalf("failed creating idle-expired auth session: %v", err) + } + if r, err := s.GarbageCollect(ctx, expiry); err != nil { + t.Errorf("garbage collection failed: %v", err) + } else if r.AuthSessions != 1 { + t.Errorf("expected to garbage collect 1 auth session (idle expired), got %d", r.AuthSessions) + } + if _, err := s.GetAuthSession(ctx, idleExpiredSession.UserID, idleExpiredSession.ConnectorID); err == nil { + t.Errorf("expected idle-expired auth session to be GC'd") + } } // testTimezones tests that backends either fully support timezones or @@ -1197,10 +1291,12 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { LastTokenIssuedAt: now, }, }, - CreatedAt: now, - LastActivity: now, - IPAddress: "192.168.1.1", - UserAgent: "TestBrowser/1.0", + CreatedAt: now, + LastActivity: now, + IPAddress: "192.168.1.1", + UserAgent: "TestBrowser/1.0", + AbsoluteExpiry: now.Add(24 * time.Hour), + IdleExpiry: now.Add(1 * time.Hour), } // Create. @@ -1220,6 +1316,8 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { got.CreatedAt = got.CreatedAt.UTC().Round(time.Millisecond) got.LastActivity = got.LastActivity.UTC().Round(time.Millisecond) + got.AbsoluteExpiry = got.AbsoluteExpiry.UTC().Round(time.Millisecond) + got.IdleExpiry = got.IdleExpiry.UTC().Round(time.Millisecond) for _, cs := range got.ClientStates { cs.ExpiresAt = cs.ExpiresAt.UTC().Round(time.Millisecond) cs.LastActivity = cs.LastActivity.UTC().Round(time.Millisecond) diff --git a/storage/ent/client/authsession.go b/storage/ent/client/authsession.go index 14120f1b..b4cdfe81 100644 --- a/storage/ent/client/authsession.go +++ b/storage/ent/client/authsession.go @@ -29,6 +29,8 @@ func (d *Database) CreateAuthSession(ctx context.Context, session storage.AuthSe SetLastActivity(session.LastActivity). SetIPAddress(session.IPAddress). SetUserAgent(session.UserAgent). + SetAbsoluteExpiry(session.AbsoluteExpiry.UTC()). + SetIdleExpiry(session.IdleExpiry.UTC()). Save(ctx) if err != nil { return convertDBError("create auth session: %w", err) @@ -102,6 +104,8 @@ func (d *Database) UpdateAuthSession(ctx context.Context, userID, connectorID st SetLastActivity(newSession.LastActivity). SetIPAddress(newSession.IPAddress). SetUserAgent(newSession.UserAgent). + SetAbsoluteExpiry(newSession.AbsoluteExpiry.UTC()). + SetIdleExpiry(newSession.IdleExpiry.UTC()). Save(ctx) if err != nil { return rollback(tx, "update auth session updating: %w", err) diff --git a/storage/ent/client/main.go b/storage/ent/client/main.go index a78830fc..ea9ca297 100644 --- a/storage/ent/client/main.go +++ b/storage/ent/client/main.go @@ -10,6 +10,7 @@ import ( "github.com/dexidp/dex/storage/ent/db" "github.com/dexidp/dex/storage/ent/db/authcode" "github.com/dexidp/dex/storage/ent/db/authrequest" + "github.com/dexidp/dex/storage/ent/db/authsession" "github.com/dexidp/dex/storage/ent/db/devicerequest" "github.com/dexidp/dex/storage/ent/db/devicetoken" "github.com/dexidp/dex/storage/ent/db/migrate" @@ -106,5 +107,18 @@ func (d *Database) GarbageCollect(ctx context.Context, now time.Time) (storage.G } result.DeviceTokens = int64(q) + q, err = d.client.AuthSession.Delete(). + Where( + authsession.Or( + authsession.AbsoluteExpiryLT(utcNow), + authsession.IdleExpiryLT(utcNow), + ), + ). + Exec(ctx) + if err != nil { + return result, convertDBError("gc auth session: %w", err) + } + result.AuthSessions = int64(q) + return result, err } diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index d58d9f41..91689619 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -216,13 +216,15 @@ func toStorageUserIdentity(u *db.UserIdentity) storage.UserIdentity { func toStorageAuthSession(s *db.AuthSession) storage.AuthSession { result := storage.AuthSession{ - UserID: s.UserID, - ConnectorID: s.ConnectorID, - Nonce: s.Nonce, - CreatedAt: s.CreatedAt, - LastActivity: s.LastActivity, - IPAddress: s.IPAddress, - UserAgent: s.UserAgent, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + AbsoluteExpiry: s.AbsoluteExpiry, + IdleExpiry: s.IdleExpiry, } if s.ClientStates != nil { diff --git a/storage/ent/db/authsession.go b/storage/ent/db/authsession.go index 26882da8..6ced0680 100644 --- a/storage/ent/db/authsession.go +++ b/storage/ent/db/authsession.go @@ -32,7 +32,11 @@ type AuthSession struct { // IPAddress holds the value of the "ip_address" field. IPAddress string `json:"ip_address,omitempty"` // UserAgent holds the value of the "user_agent" field. - UserAgent string `json:"user_agent,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + // AbsoluteExpiry holds the value of the "absolute_expiry" field. + AbsoluteExpiry time.Time `json:"absolute_expiry,omitempty"` + // IdleExpiry holds the value of the "idle_expiry" field. + IdleExpiry time.Time `json:"idle_expiry,omitempty"` selectValues sql.SelectValues } @@ -45,7 +49,7 @@ func (*AuthSession) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case authsession.FieldID, authsession.FieldUserID, authsession.FieldConnectorID, authsession.FieldNonce, authsession.FieldIPAddress, authsession.FieldUserAgent: values[i] = new(sql.NullString) - case authsession.FieldCreatedAt, authsession.FieldLastActivity: + case authsession.FieldCreatedAt, authsession.FieldLastActivity, authsession.FieldAbsoluteExpiry, authsession.FieldIdleExpiry: values[i] = new(sql.NullTime) default: values[i] = new(sql.UnknownType) @@ -116,6 +120,18 @@ func (_m *AuthSession) assignValues(columns []string, values []any) error { } else if value.Valid { _m.UserAgent = value.String } + case authsession.FieldAbsoluteExpiry: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field absolute_expiry", values[i]) + } else if value.Valid { + _m.AbsoluteExpiry = value.Time + } + case authsession.FieldIdleExpiry: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field idle_expiry", values[i]) + } else if value.Valid { + _m.IdleExpiry = value.Time + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -175,6 +191,12 @@ func (_m *AuthSession) String() string { builder.WriteString(", ") builder.WriteString("user_agent=") builder.WriteString(_m.UserAgent) + builder.WriteString(", ") + builder.WriteString("absolute_expiry=") + builder.WriteString(_m.AbsoluteExpiry.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("idle_expiry=") + builder.WriteString(_m.IdleExpiry.Format(time.ANSIC)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/authsession/authsession.go b/storage/ent/db/authsession/authsession.go index 8e5bdfc2..fc1cd5ed 100644 --- a/storage/ent/db/authsession/authsession.go +++ b/storage/ent/db/authsession/authsession.go @@ -27,6 +27,10 @@ const ( FieldIPAddress = "ip_address" // FieldUserAgent holds the string denoting the user_agent field in the database. FieldUserAgent = "user_agent" + // FieldAbsoluteExpiry holds the string denoting the absolute_expiry field in the database. + FieldAbsoluteExpiry = "absolute_expiry" + // FieldIdleExpiry holds the string denoting the idle_expiry field in the database. + FieldIdleExpiry = "idle_expiry" // Table holds the table name of the authsession in the database. Table = "auth_sessions" ) @@ -42,6 +46,8 @@ var Columns = []string{ FieldLastActivity, FieldIPAddress, FieldUserAgent, + FieldAbsoluteExpiry, + FieldIdleExpiry, } // ValidColumn reports if the column name is valid (part of the table columns). @@ -111,3 +117,13 @@ func ByIPAddress(opts ...sql.OrderTermOption) OrderOption { func ByUserAgent(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUserAgent, opts...).ToFunc() } + +// ByAbsoluteExpiry orders the results by the absolute_expiry field. +func ByAbsoluteExpiry(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAbsoluteExpiry, opts...).ToFunc() +} + +// ByIdleExpiry orders the results by the idle_expiry field. +func ByIdleExpiry(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldIdleExpiry, opts...).ToFunc() +} diff --git a/storage/ent/db/authsession/where.go b/storage/ent/db/authsession/where.go index cdda7fb8..193f1133 100644 --- a/storage/ent/db/authsession/where.go +++ b/storage/ent/db/authsession/where.go @@ -104,6 +104,16 @@ func UserAgent(v string) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldUserAgent, v)) } +// AbsoluteExpiry applies equality check predicate on the "absolute_expiry" field. It's identical to AbsoluteExpiryEQ. +func AbsoluteExpiry(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldAbsoluteExpiry, v)) +} + +// IdleExpiry applies equality check predicate on the "idle_expiry" field. It's identical to IdleExpiryEQ. +func IdleExpiry(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldIdleExpiry, v)) +} + // UserIDEQ applies the EQ predicate on the "user_id" field. func UserIDEQ(v string) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldUserID, v)) @@ -549,6 +559,86 @@ func UserAgentContainsFold(v string) predicate.AuthSession { return predicate.AuthSession(sql.FieldContainsFold(FieldUserAgent, v)) } +// AbsoluteExpiryEQ applies the EQ predicate on the "absolute_expiry" field. +func AbsoluteExpiryEQ(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldAbsoluteExpiry, v)) +} + +// AbsoluteExpiryNEQ applies the NEQ predicate on the "absolute_expiry" field. +func AbsoluteExpiryNEQ(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldAbsoluteExpiry, v)) +} + +// AbsoluteExpiryIn applies the In predicate on the "absolute_expiry" field. +func AbsoluteExpiryIn(vs ...time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldAbsoluteExpiry, vs...)) +} + +// AbsoluteExpiryNotIn applies the NotIn predicate on the "absolute_expiry" field. +func AbsoluteExpiryNotIn(vs ...time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldAbsoluteExpiry, vs...)) +} + +// AbsoluteExpiryGT applies the GT predicate on the "absolute_expiry" field. +func AbsoluteExpiryGT(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldAbsoluteExpiry, v)) +} + +// AbsoluteExpiryGTE applies the GTE predicate on the "absolute_expiry" field. +func AbsoluteExpiryGTE(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldAbsoluteExpiry, v)) +} + +// AbsoluteExpiryLT applies the LT predicate on the "absolute_expiry" field. +func AbsoluteExpiryLT(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldAbsoluteExpiry, v)) +} + +// AbsoluteExpiryLTE applies the LTE predicate on the "absolute_expiry" field. +func AbsoluteExpiryLTE(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldAbsoluteExpiry, v)) +} + +// IdleExpiryEQ applies the EQ predicate on the "idle_expiry" field. +func IdleExpiryEQ(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldIdleExpiry, v)) +} + +// IdleExpiryNEQ applies the NEQ predicate on the "idle_expiry" field. +func IdleExpiryNEQ(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldIdleExpiry, v)) +} + +// IdleExpiryIn applies the In predicate on the "idle_expiry" field. +func IdleExpiryIn(vs ...time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldIdleExpiry, vs...)) +} + +// IdleExpiryNotIn applies the NotIn predicate on the "idle_expiry" field. +func IdleExpiryNotIn(vs ...time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldIdleExpiry, vs...)) +} + +// IdleExpiryGT applies the GT predicate on the "idle_expiry" field. +func IdleExpiryGT(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldIdleExpiry, v)) +} + +// IdleExpiryGTE applies the GTE predicate on the "idle_expiry" field. +func IdleExpiryGTE(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldIdleExpiry, v)) +} + +// IdleExpiryLT applies the LT predicate on the "idle_expiry" field. +func IdleExpiryLT(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldIdleExpiry, v)) +} + +// IdleExpiryLTE applies the LTE predicate on the "idle_expiry" field. +func IdleExpiryLTE(v time.Time) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldIdleExpiry, v)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.AuthSession) predicate.AuthSession { return predicate.AuthSession(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/authsession_create.go b/storage/ent/db/authsession_create.go index 080b094c..0dc99e76 100644 --- a/storage/ent/db/authsession_create.go +++ b/storage/ent/db/authsession_create.go @@ -84,6 +84,18 @@ func (_c *AuthSessionCreate) SetNillableUserAgent(v *string) *AuthSessionCreate return _c } +// SetAbsoluteExpiry sets the "absolute_expiry" field. +func (_c *AuthSessionCreate) SetAbsoluteExpiry(v time.Time) *AuthSessionCreate { + _c.mutation.SetAbsoluteExpiry(v) + return _c +} + +// SetIdleExpiry sets the "idle_expiry" field. +func (_c *AuthSessionCreate) SetIdleExpiry(v time.Time) *AuthSessionCreate { + _c.mutation.SetIdleExpiry(v) + return _c +} + // SetID sets the "id" field. func (_c *AuthSessionCreate) SetID(v string) *AuthSessionCreate { _c.mutation.SetID(v) @@ -176,6 +188,12 @@ func (_c *AuthSessionCreate) check() error { if _, ok := _c.mutation.UserAgent(); !ok { return &ValidationError{Name: "user_agent", err: errors.New(`db: missing required field "AuthSession.user_agent"`)} } + if _, ok := _c.mutation.AbsoluteExpiry(); !ok { + return &ValidationError{Name: "absolute_expiry", err: errors.New(`db: missing required field "AuthSession.absolute_expiry"`)} + } + if _, ok := _c.mutation.IdleExpiry(); !ok { + return &ValidationError{Name: "idle_expiry", err: errors.New(`db: missing required field "AuthSession.idle_expiry"`)} + } if v, ok := _c.mutation.ID(); ok { if err := authsession.IDValidator(v); err != nil { return &ValidationError{Name: "id", err: fmt.Errorf(`db: validator failed for field "AuthSession.id": %w`, err)} @@ -248,6 +266,14 @@ func (_c *AuthSessionCreate) createSpec() (*AuthSession, *sqlgraph.CreateSpec) { _spec.SetField(authsession.FieldUserAgent, field.TypeString, value) _node.UserAgent = value } + if value, ok := _c.mutation.AbsoluteExpiry(); ok { + _spec.SetField(authsession.FieldAbsoluteExpiry, field.TypeTime, value) + _node.AbsoluteExpiry = value + } + if value, ok := _c.mutation.IdleExpiry(); ok { + _spec.SetField(authsession.FieldIdleExpiry, field.TypeTime, value) + _node.IdleExpiry = value + } return _node, _spec } diff --git a/storage/ent/db/authsession_update.go b/storage/ent/db/authsession_update.go index 5457b04c..d80e682b 100644 --- a/storage/ent/db/authsession_update.go +++ b/storage/ent/db/authsession_update.go @@ -132,6 +132,34 @@ func (_u *AuthSessionUpdate) SetNillableUserAgent(v *string) *AuthSessionUpdate return _u } +// SetAbsoluteExpiry sets the "absolute_expiry" field. +func (_u *AuthSessionUpdate) SetAbsoluteExpiry(v time.Time) *AuthSessionUpdate { + _u.mutation.SetAbsoluteExpiry(v) + return _u +} + +// SetNillableAbsoluteExpiry sets the "absolute_expiry" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableAbsoluteExpiry(v *time.Time) *AuthSessionUpdate { + if v != nil { + _u.SetAbsoluteExpiry(*v) + } + return _u +} + +// SetIdleExpiry sets the "idle_expiry" field. +func (_u *AuthSessionUpdate) SetIdleExpiry(v time.Time) *AuthSessionUpdate { + _u.mutation.SetIdleExpiry(v) + return _u +} + +// SetNillableIdleExpiry sets the "idle_expiry" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableIdleExpiry(v *time.Time) *AuthSessionUpdate { + if v != nil { + _u.SetIdleExpiry(*v) + } + return _u +} + // Mutation returns the AuthSessionMutation object of the builder. func (_u *AuthSessionUpdate) Mutation() *AuthSessionMutation { return _u.mutation @@ -220,6 +248,12 @@ func (_u *AuthSessionUpdate) sqlSave(ctx context.Context) (_node int, err error) if value, ok := _u.mutation.UserAgent(); ok { _spec.SetField(authsession.FieldUserAgent, field.TypeString, value) } + if value, ok := _u.mutation.AbsoluteExpiry(); ok { + _spec.SetField(authsession.FieldAbsoluteExpiry, field.TypeTime, value) + } + if value, ok := _u.mutation.IdleExpiry(); ok { + _spec.SetField(authsession.FieldIdleExpiry, field.TypeTime, value) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{authsession.Label} @@ -344,6 +378,34 @@ func (_u *AuthSessionUpdateOne) SetNillableUserAgent(v *string) *AuthSessionUpda return _u } +// SetAbsoluteExpiry sets the "absolute_expiry" field. +func (_u *AuthSessionUpdateOne) SetAbsoluteExpiry(v time.Time) *AuthSessionUpdateOne { + _u.mutation.SetAbsoluteExpiry(v) + return _u +} + +// SetNillableAbsoluteExpiry sets the "absolute_expiry" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableAbsoluteExpiry(v *time.Time) *AuthSessionUpdateOne { + if v != nil { + _u.SetAbsoluteExpiry(*v) + } + return _u +} + +// SetIdleExpiry sets the "idle_expiry" field. +func (_u *AuthSessionUpdateOne) SetIdleExpiry(v time.Time) *AuthSessionUpdateOne { + _u.mutation.SetIdleExpiry(v) + return _u +} + +// SetNillableIdleExpiry sets the "idle_expiry" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableIdleExpiry(v *time.Time) *AuthSessionUpdateOne { + if v != nil { + _u.SetIdleExpiry(*v) + } + return _u +} + // Mutation returns the AuthSessionMutation object of the builder. func (_u *AuthSessionUpdateOne) Mutation() *AuthSessionMutation { return _u.mutation @@ -462,6 +524,12 @@ func (_u *AuthSessionUpdateOne) sqlSave(ctx context.Context) (_node *AuthSession if value, ok := _u.mutation.UserAgent(); ok { _spec.SetField(authsession.FieldUserAgent, field.TypeString, value) } + if value, ok := _u.mutation.AbsoluteExpiry(); ok { + _spec.SetField(authsession.FieldAbsoluteExpiry, field.TypeTime, value) + } + if value, ok := _u.mutation.IdleExpiry(); ok { + _spec.SetField(authsession.FieldIdleExpiry, field.TypeTime, value) + } _node = &AuthSession{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index 3fc0f834..e8958ec9 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -79,6 +79,8 @@ var ( {Name: "last_activity", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "ip_address", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "user_agent", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "absolute_expiry", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, + {Name: "idle_expiry", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, } // AuthSessionsTable holds the schema information for the "auth_sessions" table. AuthSessionsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 46f204eb..4c7858f0 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -3068,21 +3068,23 @@ func (m *AuthRequestMutation) ResetEdge(name string) error { // AuthSessionMutation represents an operation that mutates the AuthSession nodes in the graph. type AuthSessionMutation struct { config - op Op - typ string - id *string - user_id *string - connector_id *string - nonce *string - client_states *[]byte - created_at *time.Time - last_activity *time.Time - ip_address *string - user_agent *string - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*AuthSession, error) - predicates []predicate.AuthSession + op Op + typ string + id *string + user_id *string + connector_id *string + nonce *string + client_states *[]byte + created_at *time.Time + last_activity *time.Time + ip_address *string + user_agent *string + absolute_expiry *time.Time + idle_expiry *time.Time + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*AuthSession, error) + predicates []predicate.AuthSession } var _ ent.Mutation = (*AuthSessionMutation)(nil) @@ -3477,6 +3479,78 @@ func (m *AuthSessionMutation) ResetUserAgent() { m.user_agent = nil } +// SetAbsoluteExpiry sets the "absolute_expiry" field. +func (m *AuthSessionMutation) SetAbsoluteExpiry(t time.Time) { + m.absolute_expiry = &t +} + +// AbsoluteExpiry returns the value of the "absolute_expiry" field in the mutation. +func (m *AuthSessionMutation) AbsoluteExpiry() (r time.Time, exists bool) { + v := m.absolute_expiry + if v == nil { + return + } + return *v, true +} + +// OldAbsoluteExpiry returns the old "absolute_expiry" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldAbsoluteExpiry(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAbsoluteExpiry is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAbsoluteExpiry requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAbsoluteExpiry: %w", err) + } + return oldValue.AbsoluteExpiry, nil +} + +// ResetAbsoluteExpiry resets all changes to the "absolute_expiry" field. +func (m *AuthSessionMutation) ResetAbsoluteExpiry() { + m.absolute_expiry = nil +} + +// SetIdleExpiry sets the "idle_expiry" field. +func (m *AuthSessionMutation) SetIdleExpiry(t time.Time) { + m.idle_expiry = &t +} + +// IdleExpiry returns the value of the "idle_expiry" field in the mutation. +func (m *AuthSessionMutation) IdleExpiry() (r time.Time, exists bool) { + v := m.idle_expiry + if v == nil { + return + } + return *v, true +} + +// OldIdleExpiry returns the old "idle_expiry" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldIdleExpiry(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldIdleExpiry is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldIdleExpiry requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldIdleExpiry: %w", err) + } + return oldValue.IdleExpiry, nil +} + +// ResetIdleExpiry resets all changes to the "idle_expiry" field. +func (m *AuthSessionMutation) ResetIdleExpiry() { + m.idle_expiry = nil +} + // Where appends a list predicates to the AuthSessionMutation builder. func (m *AuthSessionMutation) Where(ps ...predicate.AuthSession) { m.predicates = append(m.predicates, ps...) @@ -3511,7 +3585,7 @@ func (m *AuthSessionMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AuthSessionMutation) Fields() []string { - fields := make([]string, 0, 8) + fields := make([]string, 0, 10) if m.user_id != nil { fields = append(fields, authsession.FieldUserID) } @@ -3536,6 +3610,12 @@ func (m *AuthSessionMutation) Fields() []string { if m.user_agent != nil { fields = append(fields, authsession.FieldUserAgent) } + if m.absolute_expiry != nil { + fields = append(fields, authsession.FieldAbsoluteExpiry) + } + if m.idle_expiry != nil { + fields = append(fields, authsession.FieldIdleExpiry) + } return fields } @@ -3560,6 +3640,10 @@ func (m *AuthSessionMutation) Field(name string) (ent.Value, bool) { return m.IPAddress() case authsession.FieldUserAgent: return m.UserAgent() + case authsession.FieldAbsoluteExpiry: + return m.AbsoluteExpiry() + case authsession.FieldIdleExpiry: + return m.IdleExpiry() } return nil, false } @@ -3585,6 +3669,10 @@ func (m *AuthSessionMutation) OldField(ctx context.Context, name string) (ent.Va return m.OldIPAddress(ctx) case authsession.FieldUserAgent: return m.OldUserAgent(ctx) + case authsession.FieldAbsoluteExpiry: + return m.OldAbsoluteExpiry(ctx) + case authsession.FieldIdleExpiry: + return m.OldIdleExpiry(ctx) } return nil, fmt.Errorf("unknown AuthSession field %s", name) } @@ -3650,6 +3738,20 @@ func (m *AuthSessionMutation) SetField(name string, value ent.Value) error { } m.SetUserAgent(v) return nil + case authsession.FieldAbsoluteExpiry: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAbsoluteExpiry(v) + return nil + case authsession.FieldIdleExpiry: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetIdleExpiry(v) + return nil } return fmt.Errorf("unknown AuthSession field %s", name) } @@ -3723,6 +3825,12 @@ func (m *AuthSessionMutation) ResetField(name string) error { case authsession.FieldUserAgent: m.ResetUserAgent() return nil + case authsession.FieldAbsoluteExpiry: + m.ResetAbsoluteExpiry() + return nil + case authsession.FieldIdleExpiry: + m.ResetIdleExpiry() + return nil } return fmt.Errorf("unknown AuthSession field %s", name) } diff --git a/storage/ent/schema/authsession.go b/storage/ent/schema/authsession.go index ff76ab61..0b641b7f 100644 --- a/storage/ent/schema/authsession.go +++ b/storage/ent/schema/authsession.go @@ -37,6 +37,10 @@ func (AuthSession) Fields() []ent.Field { field.Text("user_agent"). SchemaType(textSchema). Default(""), + field.Time("absolute_expiry"). + SchemaType(timeSchema), + field.Time("idle_expiry"). + SchemaType(timeSchema), } } diff --git a/storage/etcd/etcd.go b/storage/etcd/etcd.go index e21acc9b..ad6244c3 100644 --- a/storage/etcd/etcd.go +++ b/storage/etcd/etcd.go @@ -108,6 +108,23 @@ func (c *conn) GarbageCollect(ctx context.Context, now time.Time) (result storag result.DeviceTokens++ } } + + authSessions, err := c.listAuthSessionsInternal(ctx) + if err != nil { + return result, err + } + + for _, authSession := range authSessions { + if now.After(authSession.AbsoluteExpiry) || now.After(authSession.IdleExpiry) { + if err := c.deleteKey(ctx, keyAuthSession(authSession.UserID, authSession.ConnectorID)); err != nil { + c.logger.Error("failed to delete auth session", "err", err) + delErr = fmt.Errorf("failed to delete auth session: %v", err) + } else { + result.AuthSessions++ + } + } + } + return result, delErr } @@ -458,16 +475,13 @@ func (c *conn) UpdateAuthSession(ctx context.Context, userID, connectorID string func (c *conn) ListAuthSessions(ctx context.Context) (sessions []storage.AuthSession, err error) { ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) defer cancel() - res, err := c.db.Get(ctx, authSessionPrefix, clientv3.WithPrefix()) + + res, err := c.listAuthSessionsInternal(ctx) if err != nil { return sessions, err } - for _, v := range res.Kvs { - var s AuthSession - if err = json.Unmarshal(v.Value, &s); err != nil { - return sessions, err - } - sessions = append(sessions, toStorageAuthSession(s)) + for _, v := range res { + sessions = append(sessions, toStorageAuthSession(v)) } return sessions, nil } @@ -613,6 +627,21 @@ func (c *conn) listAuthCodes(ctx context.Context) (codes []AuthCode, err error) return codes, nil } +func (c *conn) listAuthSessionsInternal(ctx context.Context) (sessions []AuthSession, err error) { + res, err := c.db.Get(ctx, authSessionPrefix, clientv3.WithPrefix()) + if err != nil { + return sessions, err + } + for _, v := range res.Kvs { + var s AuthSession + if err = json.Unmarshal(v.Value, &s); err != nil { + return sessions, err + } + sessions = append(sessions, s) + } + return sessions, nil +} + func (c *conn) txnCreate(ctx context.Context, key string, value interface{}) error { b, err := json.Marshal(value) if err != nil { diff --git a/storage/etcd/types.go b/storage/etcd/types.go index 0a73e9ea..7be8bcf8 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -319,39 +319,45 @@ func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { // AuthSession is a mirrored struct from storage with JSON struct tags. type AuthSession struct { - UserID string `json:"user_id,omitempty"` - ConnectorID string `json:"connector_id,omitempty"` - Nonce string `json:"nonce,omitempty"` - ClientStates map[string]*storage.ClientAuthState `json:"client_states,omitempty"` - CreatedAt time.Time `json:"created_at"` - LastActivity time.Time `json:"last_activity"` - IPAddress string `json:"ip_address,omitempty"` - UserAgent string `json:"user_agent,omitempty"` + UserID string `json:"user_id,omitempty"` + ConnectorID string `json:"connector_id,omitempty"` + Nonce string `json:"nonce,omitempty"` + ClientStates map[string]*storage.ClientAuthState `json:"client_states,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastActivity time.Time `json:"last_activity"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + AbsoluteExpiry time.Time `json:"absolute_expiry"` + IdleExpiry time.Time `json:"idle_expiry"` } func fromStorageAuthSession(s storage.AuthSession) AuthSession { return AuthSession{ - UserID: s.UserID, - ConnectorID: s.ConnectorID, - Nonce: s.Nonce, - ClientStates: s.ClientStates, - CreatedAt: s.CreatedAt, - LastActivity: s.LastActivity, - IPAddress: s.IPAddress, - UserAgent: s.UserAgent, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, + ClientStates: s.ClientStates, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + AbsoluteExpiry: s.AbsoluteExpiry, + IdleExpiry: s.IdleExpiry, } } func toStorageAuthSession(s AuthSession) storage.AuthSession { result := storage.AuthSession{ - UserID: s.UserID, - ConnectorID: s.ConnectorID, - Nonce: s.Nonce, - ClientStates: s.ClientStates, - CreatedAt: s.CreatedAt, - LastActivity: s.LastActivity, - IPAddress: s.IPAddress, - UserAgent: s.UserAgent, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, + ClientStates: s.ClientStates, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + AbsoluteExpiry: s.AbsoluteExpiry, + IdleExpiry: s.IdleExpiry, } if result.ClientStates == nil { result.ClientStates = make(map[string]*storage.ClientAuthState) diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go index b6d2990b..867cbed2 100644 --- a/storage/kubernetes/storage.go +++ b/storage/kubernetes/storage.go @@ -694,6 +694,22 @@ func (cli *client) GarbageCollect(ctx context.Context, now time.Time) (result st } } + var authSessions AuthSessionList + if err := cli.listN(resourceAuthSession, &authSessions, gcResultLimit); err != nil { + return result, fmt.Errorf("failed to list auth sessions: %v", err) + } + + for _, authSession := range authSessions.AuthSessions { + if now.After(authSession.AbsoluteExpiry) || now.After(authSession.IdleExpiry) { + if err := cli.delete(resourceAuthSession, authSession.ObjectMeta.Name); err != nil { + cli.logger.Error("failed to delete auth session", "err", err) + delErr = fmt.Errorf("failed to delete auth session: %v", err) + } else { + result.AuthSessions++ + } + } + } + if delErr != nil { return result, delErr } diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 344ae75e..c945874a 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -996,14 +996,16 @@ type AuthSession struct { k8sapi.TypeMeta `json:",inline"` k8sapi.ObjectMeta `json:"metadata,omitempty"` - UserID string `json:"userID,omitempty"` - ConnectorID string `json:"connectorID,omitempty"` - Nonce string `json:"nonce,omitempty"` - ClientStates map[string]*storage.ClientAuthState `json:"clientStates,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - LastActivity time.Time `json:"lastActivity,omitempty"` - IPAddress string `json:"ipAddress,omitempty"` - UserAgent string `json:"userAgent,omitempty"` + UserID string `json:"userID,omitempty"` + ConnectorID string `json:"connectorID,omitempty"` + Nonce string `json:"nonce,omitempty"` + ClientStates map[string]*storage.ClientAuthState `json:"clientStates,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + LastActivity time.Time `json:"lastActivity,omitempty"` + IPAddress string `json:"ipAddress,omitempty"` + UserAgent string `json:"userAgent,omitempty"` + AbsoluteExpiry time.Time `json:"absoluteExpiry,omitempty"` + IdleExpiry time.Time `json:"idleExpiry,omitempty"` } // AuthSessionList is a list of AuthSessions. @@ -1023,27 +1025,31 @@ func (cli *client) fromStorageAuthSession(s storage.AuthSession) AuthSession { Name: offlineTokenName(s.UserID, s.ConnectorID, cli.hash), Namespace: cli.namespace, }, - UserID: s.UserID, - ConnectorID: s.ConnectorID, - Nonce: s.Nonce, - ClientStates: s.ClientStates, - CreatedAt: s.CreatedAt, - LastActivity: s.LastActivity, - IPAddress: s.IPAddress, - UserAgent: s.UserAgent, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, + ClientStates: s.ClientStates, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + AbsoluteExpiry: s.AbsoluteExpiry, + IdleExpiry: s.IdleExpiry, } } func toStorageAuthSession(s AuthSession) storage.AuthSession { result := storage.AuthSession{ - UserID: s.UserID, - ConnectorID: s.ConnectorID, - Nonce: s.Nonce, - ClientStates: s.ClientStates, - CreatedAt: s.CreatedAt, - LastActivity: s.LastActivity, - IPAddress: s.IPAddress, - UserAgent: s.UserAgent, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, + ClientStates: s.ClientStates, + CreatedAt: s.CreatedAt, + LastActivity: s.LastActivity, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + AbsoluteExpiry: s.AbsoluteExpiry, + IdleExpiry: s.IdleExpiry, } if result.ClientStates == nil { result.ClientStates = make(map[string]*storage.ClientAuthState) diff --git a/storage/memory/memory.go b/storage/memory/memory.go index e507340c..495ce6bb 100644 --- a/storage/memory/memory.go +++ b/storage/memory/memory.go @@ -101,6 +101,12 @@ func (s *memStorage) GarbageCollect(ctx context.Context, now time.Time) (result result.DeviceTokens++ } } + for id, a := range s.authSessions { + if now.After(a.AbsoluteExpiry) || now.After(a.IdleExpiry) { + delete(s.authSessions, id) + result.AuthSessions++ + } + } }) return result, nil } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 82b9bc9b..83308672 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -121,7 +121,15 @@ func (c *conn) GarbageCollect(ctc context.Context, now time.Time) (storage.GCRes result.DeviceTokens = n } - return result, err + r, err = c.Exec(`delete from auth_session where absolute_expiry < $1 OR idle_expiry < $2`, now, now) + if err != nil { + return result, fmt.Errorf("gc auth_session: %v", err) + } + if n, err := r.RowsAffected(); err == nil { + result.AuthSessions = n + } + + return result, nil } func (c *conn) CreateAuthRequest(ctx context.Context, a storage.AuthRequest) error { @@ -962,14 +970,16 @@ func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) err user_id, connector_id, nonce, client_states, created_at, last_activity, - ip_address, user_agent + ip_address, user_agent, + absolute_expiry, idle_expiry ) - values ($1, $2, $3, $4, $5, $6, $7, $8); + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); `, s.UserID, s.ConnectorID, s.Nonce, encoder(s.ClientStates), s.CreatedAt, s.LastActivity, s.IPAddress, s.UserAgent, + s.AbsoluteExpiry, s.IdleExpiry, ) if err != nil { if c.alreadyExistsCheck(err) { @@ -1022,7 +1032,8 @@ func getAuthSession(ctx context.Context, q querier, userID, connectorID string) user_id, connector_id, nonce, client_states, created_at, last_activity, - ip_address, user_agent + ip_address, user_agent, + absolute_expiry, idle_expiry from auth_session where user_id = $1 AND connector_id = $2; `, userID, connectorID)) @@ -1034,6 +1045,7 @@ func scanAuthSession(s scanner) (session storage.AuthSession, err error) { decoder(&session.ClientStates), &session.CreatedAt, &session.LastActivity, &session.IPAddress, &session.UserAgent, + &session.AbsoluteExpiry, &session.IdleExpiry, ) if err != nil { if err == sql.ErrNoRows { @@ -1053,7 +1065,8 @@ func (c *conn) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, err user_id, connector_id, nonce, client_states, created_at, last_activity, - ip_address, user_agent + ip_address, user_agent, + absolute_expiry, idle_expiry from auth_session; `) if err != nil { diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 9131d284..80b81b4f 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -442,4 +442,14 @@ var migrations = []migration{ `alter table auth_code add column auth_time timestamptz not null default '1970-01-01 00:00:00';`, }, }, + { + stmts: []string{ + ` + alter table auth_session + add column absolute_expiry timestamptz not null default '1970-01-01 00:00:00';`, + ` + alter table auth_session + add column idle_expiry timestamptz not null default '1970-01-01 00:00:00';`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index 8161eb83..0889e6b6 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -60,6 +60,7 @@ type GCResult struct { AuthCodes int64 DeviceRequests int64 DeviceTokens int64 + AuthSessions int64 } // IsEmpty returns whether the garbage collection result is empty or not. @@ -67,7 +68,8 @@ func (g *GCResult) IsEmpty() bool { return g.AuthRequests == 0 && g.AuthCodes == 0 && g.DeviceRequests == 0 && - g.DeviceTokens == 0 + g.DeviceTokens == 0 && + g.AuthSessions == 0 } // Storage is the storage interface used by the server. Implementations are @@ -402,6 +404,11 @@ type AuthSession struct { LastActivity time.Time IPAddress string UserAgent string + + // AbsoluteExpiry is CreatedAt + AbsoluteLifetime, set once at creation. + AbsoluteExpiry time.Time + // IdleExpiry is LastActivity + ValidIfNotUsedFor, updated on every activity. + IdleExpiry time.Time } // OfflineSessions objects are sessions pertaining to users with refresh tokens. From 3c7e159750b9cfb3cbe2b693e429ef067d3e0675 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 20 Mar 2026 20:26:29 +0100 Subject: [PATCH 184/222] chore: update Go and gRPC dependencies to latest versions (#4673) Signed-off-by: maksim.nabokikh --- api/v2/go.mod | 12 ++++++------ api/v2/go.sum | 20 ++++++++++---------- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/api/v2/go.mod b/api/v2/go.mod index 289e3b81..56b281b8 100644 --- a/api/v2/go.mod +++ b/api/v2/go.mod @@ -1,15 +1,15 @@ module github.com/dexidp/dex/api/v2 -go 1.24.0 +go 1.25.0 require ( - google.golang.org/grpc v1.79.2 + google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 ) require ( - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect ) diff --git a/api/v2/go.sum b/api/v2/go.sum index e9906532..75e5902c 100644 --- a/api/v2/go.sum +++ b/api/v2/go.sum @@ -22,17 +22,17 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= 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/go.mod b/go.mod index 9d706aaa..9d997dcb 100644 --- a/go.mod +++ b/go.mod @@ -124,7 +124,7 @@ require ( golang.org/x/tools v0.42.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3bad638c..782597af 100644 --- a/go.sum +++ b/go.sum @@ -333,8 +333,8 @@ google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76 google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 363e9d522882ca6281e68ce87cf23566cce91398 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 20 Mar 2026 22:07:41 +0100 Subject: [PATCH 185/222] feat: use protobuf for session cookie (#4675) Signed-off-by: maksim.nabokikh --- server/internal/types.pb.go | 80 ++++++++++++++++++++++++++++++++++--- server/internal/types.proto | 7 ++++ server/session.go | 40 ++++++++----------- 3 files changed, 98 insertions(+), 29 deletions(-) diff --git a/server/internal/types.pb.go b/server/internal/types.pb.go index cabbea2e..8f9cbeb8 100644 --- a/server/internal/types.pb.go +++ b/server/internal/types.pb.go @@ -130,6 +130,67 @@ func (x *IDTokenSubject) GetConnId() string { return "" } +// SessionCookie holds session identity encoded into the session cookie value. +type SessionCookie 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"` + Nonce string `protobuf:"bytes,3,opt,name=nonce,proto3" json:"nonce,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionCookie) Reset() { + *x = SessionCookie{} + mi := &file_server_internal_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionCookie) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionCookie) ProtoMessage() {} + +func (x *SessionCookie) ProtoReflect() protoreflect.Message { + mi := &file_server_internal_types_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 SessionCookie.ProtoReflect.Descriptor instead. +func (*SessionCookie) Descriptor() ([]byte, []int) { + return file_server_internal_types_proto_rawDescGZIP(), []int{2} +} + +func (x *SessionCookie) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *SessionCookie) GetConnectorId() string { + if x != nil { + return x.ConnectorId + } + return "" +} + +func (x *SessionCookie) GetNonce() string { + if x != nil { + return x.Nonce + } + return "" +} + var File_server_internal_types_proto protoreflect.FileDescriptor var file_server_internal_types_proto_rawDesc = string([]byte{ @@ -144,10 +205,16 @@ var file_server_internal_types_proto_rawDesc = string([]byte{ 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x6e, 0x49, 0x64, - 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x64, - 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x22, 0x61, 0x0a, 0x0d, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6f, 0x6b, 0x69, + 0x65, 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, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x6f, + 0x6e, 0x63, 0x65, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -162,10 +229,11 @@ func file_server_internal_types_proto_rawDescGZIP() []byte { return file_server_internal_types_proto_rawDescData } -var file_server_internal_types_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_server_internal_types_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_server_internal_types_proto_goTypes = []any{ (*RefreshToken)(nil), // 0: internal.RefreshToken (*IDTokenSubject)(nil), // 1: internal.IDTokenSubject + (*SessionCookie)(nil), // 2: internal.SessionCookie } var file_server_internal_types_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type @@ -186,7 +254,7 @@ func file_server_internal_types_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_server_internal_types_proto_rawDesc), len(file_server_internal_types_proto_rawDesc)), NumEnums: 0, - NumMessages: 2, + NumMessages: 3, NumExtensions: 0, NumServices: 0, }, diff --git a/server/internal/types.proto b/server/internal/types.proto index 6a949fe8..324e8854 100644 --- a/server/internal/types.proto +++ b/server/internal/types.proto @@ -17,3 +17,10 @@ message IDTokenSubject { string user_id = 1; string conn_id = 2; } + +// SessionCookie holds session identity encoded into the session cookie value. +message SessionCookie { + string user_id = 1; + string connector_id = 2; + string nonce = 3; +} diff --git a/server/session.go b/server/session.go index 5f65ea00..52424c82 100644 --- a/server/session.go +++ b/server/session.go @@ -9,9 +9,9 @@ import ( "fmt" "net/http" "path" - "strings" "time" + "github.com/dexidp/dex/server/internal" "github.com/dexidp/dex/storage" ) @@ -32,33 +32,27 @@ func remoteIP(r *http.Request) string { return r.RemoteAddr } -// sessionCookieValue encodes session identity into a cookie value. -// Format: base64url(userID) + "." + base64url(connectorID) + "." + nonce -// TODO(nabokihms): consider cookie encoding +// sessionCookieValue encodes session identity into a cookie value using protobuf. func sessionCookieValue(userID, connectorID, nonce string) string { - return base64.RawURLEncoding.EncodeToString([]byte(userID)) + - "." + base64.RawURLEncoding.EncodeToString([]byte(connectorID)) + - "." + nonce + val, err := internal.Marshal(&internal.SessionCookie{ + UserId: userID, + ConnectorId: connectorID, + Nonce: nonce, + }) + if err != nil { + // Should never happen with valid string inputs. + panic(fmt.Sprintf("marshal session cookie: %v", err)) + } + return val } -// parseSessionCookie decodes a session cookie value into its components. +// parseSessionCookie decodes a protobuf-encoded session cookie value. func parseSessionCookie(value string) (userID, connectorID, nonce string, err error) { - parts := strings.SplitN(value, ".", 3) - if len(parts) != 3 { - return "", "", "", fmt.Errorf("invalid session cookie format") + var cookie internal.SessionCookie + if err := internal.Unmarshal(value, &cookie); err != nil { + return "", "", "", fmt.Errorf("decode session cookie: %w", err) } - - userIDBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - return "", "", "", fmt.Errorf("decode userID: %w", err) - } - - connectorIDBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return "", "", "", fmt.Errorf("decode connectorID: %w", err) - } - - return string(userIDBytes), string(connectorIDBytes), parts[2], nil + return cookie.UserId, cookie.ConnectorId, cookie.Nonce, nil } func (s *Server) sessionCookiePath() string { From 9d2274888a81c72e665b35364a9f08d331e7ffdb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:15:59 +0100 Subject: [PATCH 186/222] build(deps): bump github/codeql-action from 4.33.0 to 4.34.1 (#4679) --- .github/workflows/analysis-scorecard.yaml | 2 +- .github/workflows/artifacts.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index 258ec01b..4c4ebeb4 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -42,6 +42,6 @@ jobs: retention-days: 5 - name: Upload results to GitHub Security tab - uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v3.29.5 + uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 4c2e74f1..2d5ccf1f 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -264,6 +264,6 @@ jobs: overwrite: true - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v3.29.5 + uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v3.29.5 with: sarif_file: trivy-results.sarif From 2e41d5b211b820cc29b2614ab47be6a1fea5c133 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:16:25 +0100 Subject: [PATCH 187/222] build(deps): bump anchore/sbom-action from 0.23.1 to 0.24.0 (#4681) --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 2d5ccf1f..ac8d61e8 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -62,7 +62,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Set up Syft - uses: anchore/sbom-action/download-syft@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - name: Install cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 From 894f87dfea2acbc6aaa767f04fabeacf099639ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:16:55 +0100 Subject: [PATCH 188/222] build(deps): bump the etcd group with 2 updates (#4680) --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 9d997dcb..2c4d6bda 100644 --- a/go.mod +++ b/go.mod @@ -33,8 +33,8 @@ require ( 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.8 - go.etcd.io/etcd/client/v3 v3.6.8 + go.etcd.io/etcd/client/pkg/v3 v3.6.9 + go.etcd.io/etcd/client/v3 v3.6.9 golang.org/x/crypto v0.49.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.52.0 @@ -107,7 +107,7 @@ require ( github.com/spf13/pflag v1.0.9 // 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.8 // indirect + go.etcd.io/etcd/api/v3 v3.6.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect diff --git a/go.sum b/go.sum index 782597af..889d3840 100644 --- a/go.sum +++ b/go.sum @@ -244,12 +244,12 @@ 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.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= -go.etcd.io/etcd/api/v3 v3.6.8/go.mod h1:qyQj1HZPUV3B5cbAL8scG62+fyz5dSxxu0w8pn28N6Q= -go.etcd.io/etcd/client/pkg/v3 v3.6.8 h1:Qs/5C0LNFiqXxYf2GU8MVjYUEXJ6sZaYOz0zEqQgy50= -go.etcd.io/etcd/client/pkg/v3 v3.6.8/go.mod h1:GsiTRUZE2318PggZkAo6sWb6l8JLVrnckTNfbG8PWtw= -go.etcd.io/etcd/client/v3 v3.6.8 h1:B3G76t1UykqAOrbio7s/EPatixQDkQBevN8/mwiplrY= -go.etcd.io/etcd/client/v3 v3.6.8/go.mod h1:MVG4BpSIuumPi+ELF7wYtySETmoTWBHVcDoHdVupwt8= +go.etcd.io/etcd/api/v3 v3.6.9 h1:UA7iKfEW1AzgihcBSGXci2kDGQiokSq41F9HMCI/RTI= +go.etcd.io/etcd/api/v3 v3.6.9/go.mod h1:csEk/qTfxKL36NqJdU15Tgtl65A8dyEY2BYo7PRsIwk= +go.etcd.io/etcd/client/pkg/v3 v3.6.9 h1:T8nuk8Lz64C+Hzb0coBFLMSlVSQZBpAtFk46swdM1DA= +go.etcd.io/etcd/client/pkg/v3 v3.6.9/go.mod h1:WEy3PpwbbEBVRdh1NVJYsuUe/8eyI21PNJRazeD8z/Y= +go.etcd.io/etcd/client/v3 v3.6.9 h1:3X555hQXmhRr27O37wls53g68CpUiPOiHXrZfz2Al+o= +go.etcd.io/etcd/client/v3 v3.6.9/go.mod h1:KO7H1HLYh1qaljuVZJQwBFk1lRce6pJzt+C81GEnrlM= 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.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= From cf2c017038a041becc69ee60da33edba40c48eb2 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Tue, 24 Mar 2026 20:04:38 +0100 Subject: [PATCH 189/222] build(deps): update entgo.io/ent to v0.14.6 and ariga.io/atlas to v0.36.2 (#4684) Signed-off-by: maksim.nabokikh --- go.mod | 17 +++++++++++++---- go.sum | 29 +++++++++++++++++++++-------- storage/ent/db/runtime/runtime.go | 4 ++-- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index 2c4d6bda..ae1770db 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( cloud.google.com/go/compute/metadata v0.9.0 - entgo.io/ent v0.14.5 + 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.3.0 @@ -45,7 +45,7 @@ require ( ) require ( - ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect + ariga.io/atlas v0.36.2-0.20250730182955-2c6300d0a3e1 // indirect cel.dev/expr v0.25.1 // indirect cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect @@ -62,9 +62,13 @@ require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cenkalti/backoff/v4 v4.3.0 // 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.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -90,13 +94,18 @@ require ( github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // 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/tablewriter v0.0.5 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect diff --git a/go.sum b/go.sum index 889d3840..0936889f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc= -ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= +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.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= @@ -10,8 +10,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB 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.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= -entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U= +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.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk= @@ -48,6 +48,12 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= @@ -177,8 +183,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +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.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -193,8 +199,14 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq 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/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +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.5.1 h1:Br79D6L20SbAa5P7xqENxmvv8LyI4HoKosPy7klhn4o= github.com/openbao/openbao/api/v2 v2.5.1/go.mod h1:Dh5un77tqGgMbmlVEqjqN+8/dMyUohnkaQVg/wXW0Ig= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -303,6 +315,7 @@ golang.org/x/sync v0.20.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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/storage/ent/db/runtime/runtime.go b/storage/ent/db/runtime/runtime.go index ac832af9..634d2685 100644 --- a/storage/ent/db/runtime/runtime.go +++ b/storage/ent/db/runtime/runtime.go @@ -5,6 +5,6 @@ package runtime // The schema-stitching logic is generated in github.com/dexidp/dex/storage/ent/db/runtime.go const ( - Version = "v0.14.5" // Version of ent codegen. - Sum = "h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=" // Sum of ent codegen. + Version = "v0.14.6" // Version of ent codegen. + Sum = "h1:/f2696BpwuWAEEG6PVGWflg6+Inrpq4pRWuNlWz/Skk=" // Sum of ent codegen. ) From 08dc8eeb0ab70f0dd70c720086cde92e9ea600e5 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Wed, 25 Mar 2026 07:40:18 +0100 Subject: [PATCH 190/222] docs: add CONTRIBUTING.md (#4685) Signed-off-by: maksim.nabokikh --- CONTRIBUTING.md | 161 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1195805d --- /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 Gitpod](https://gitpod.io/#https://github.com/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). From 098ab6036e0f475a2ea682eae93f080662b393ff Mon Sep 17 00:00:00 2001 From: iliaandreevde Date: Wed, 25 Mar 2026 09:46:45 +0100 Subject: [PATCH 191/222] feat: support ES256 local signer (#4682) Signed-off-by: Ilia Andreev Co-authored-by: Ilia Andreev --- cmd/dex/config.go | 17 ++ cmd/dex/config_test.go | 95 ++++++++++- config.docker.yaml | 7 +- config.yaml.dist | 8 +- examples/config-dev.yaml | 9 +- server/handlers_test.go | 25 +++ server/oauth2_test.go | 119 ++++++++++++++ server/signer/local.go | 55 +++++-- server/signer/local_test.go | 246 +++++++++++++++++++++++++--- server/signer/rotation.go | 98 +++++++---- server/signer/rotation_test.go | 8 +- server/signer/utils.go | 50 ++++-- server/signer/utils_test.go | 50 ++++++ storage/conformance/conformance.go | 136 ++++++++++----- storage/conformance/gen_jwks.go | 100 +++++++---- storage/conformance/jwks.go | 135 ++++++++++----- storage/conformance/transactions.go | 96 ++++++++--- 17 files changed, 1024 insertions(+), 230 deletions(-) create mode 100644 server/signer/utils_test.go diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 48f2c056..846a924a 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -11,6 +11,7 @@ import ( "os" "strings" + "github.com/go-jose/go-jose/v4" "golang.org/x/crypto/bcrypt" "github.com/dexidp/dex/pkg/featureflags" @@ -525,6 +526,11 @@ func (s *Signer) UnmarshalJSON(b []byte) error { 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, @@ -533,6 +539,17 @@ func (s *Signer) UnmarshalJSON(b []byte) error { 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 { diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go index 26385f56..2a08ab1e 100644 --- a/cmd/dex/config_test.go +++ b/cmd/dex/config_test.go @@ -4,9 +4,11 @@ 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/dexidp/dex/connector/mock" @@ -481,10 +483,11 @@ logger: func TestSignerConfigUnmarshal(t *testing.T) { tests := []struct { - name string - config string - wantErr bool - check func(*Config) error + name string + config string + wantErr bool + errContains string + check func(*Config) error }{ { name: "local signer with rotation period", @@ -507,8 +510,84 @@ enablePasswordDB: true } 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) + } 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 }, @@ -583,6 +662,10 @@ enablePasswordDB: true 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 { diff --git a/config.docker.yaml b/config.docker.yaml index c5d2a47b..cc3a99ed 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 917f8d1f..669066c9 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -87,13 +87,19 @@ 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 # OAuth2 configuration # oauth2: diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index cc15daed..1b9da1f1 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -88,7 +88,7 @@ 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" @@ -239,13 +239,14 @@ staticPasswords: - "team-a/admins" userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" -# Settings for signing JWT tokens. Available options: -# - "local": use local keys (only RSA keys supported) -# - "vault": use Vault Transit backend (RSA and EC keys supported) +# 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: diff --git a/server/handlers_test.go b/server/handlers_test.go index 933e9e4d..dad1ab2a 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -20,12 +20,14 @@ import ( gosundheit "github.com/AppsFlyer/go-sundheit" "github.com/AppsFlyer/go-sundheit/checks" "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "github.com/dexidp/dex/connector" "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" ) @@ -112,6 +114,29 @@ func TestHandleDiscovery(t *testing.T) { }, res) } +func TestHandleDiscoveryWithES256LocalSigner(t *testing.T) { + httpServer, server := newTestServer(t, func(c *Config) { + localConfig := signer.LocalConfig{ + KeysRotationPeriod: time.Hour.String(), + Algorithm: jose.ES256, + } + + sig, err := localConfig.Open(context.Background(), c.Storage, time.Hour, time.Now, c.Logger) + require.NoError(t, err) + c.Signer = sig + }) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + var res discovery + err := json.NewDecoder(rr.Result().Body).Decode(&res) + require.NoError(t, err) + require.Equal(t, []string{string(jose.ES256)}, res.IDTokenAlgs) +} + func TestHandleHealthFailure(t *testing.T) { httpServer, server := newTestServer(t, func(c *Config) { c.HealthChecker = gosundheit.New() diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 835868a1..6df028e0 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "crypto/rand" "crypto/rsa" "encoding/json" @@ -781,6 +782,31 @@ func TestSignerKeySet(t *testing.T) { } } +func TestSignerKeySetWithES256LocalSigner(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := slog.New(slog.DiscardHandler) + store := memory.New(logger) + + localConfig := signer.LocalConfig{ + KeysRotationPeriod: time.Hour.String(), + Algorithm: jose.ES256, + } + sig, err := localConfig.Open(ctx, store, time.Hour, time.Now, logger) + require.NoError(t, err) + + sig.Start(ctx) + + jwt, err := sig.Sign(ctx, []byte("payload")) + require.NoError(t, err) + + keySet := &signerKeySet{signer: sig} + payload, err := keySet.VerifySignature(ctx, jwt) + require.NoError(t, err) + require.Equal(t, []byte("payload"), payload) +} + func TestRedirectedAuthErrHandler(t *testing.T) { tests := []struct { name string @@ -980,6 +1006,99 @@ func TestValidateIDTokenHint(t *testing.T) { }) } +func TestNewIDTokenUsesStoredAlgorithmUntilNextRotation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := slog.New(slog.DiscardHandler) + store := memory.New(logger) + + now := time.Now().UTC() + err := store.UpdateKeys(ctx, func(keys storage.Keys) (storage.Keys, error) { + keys.SigningKey = &jose.JSONWebKey{ + Key: testKey, + KeyID: "legacy-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.SigningKeyPub = &jose.JSONWebKey{ + Key: testKey.Public(), + KeyID: "legacy-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.NextRotation = now.Add(time.Hour) + return keys, nil + }) + require.NoError(t, err) + + localConfig := signer.LocalConfig{ + KeysRotationPeriod: time.Hour.String(), + Algorithm: jose.ES256, + } + sig, err := localConfig.Open(ctx, store, time.Hour, func() time.Time { return now }, logger) + require.NoError(t, err) + + sig.Start(ctx) + + alg, err := sig.Algorithm(ctx) + require.NoError(t, err) + require.Equal(t, jose.RS256, alg) + + issuerURL, err := url.Parse("https://issuer.example.com") + require.NoError(t, err) + + s := &Server{ + signer: sig, + issuerURL: *issuerURL, + logger: logger, + now: func() time.Time { return now }, + idTokensValidFor: time.Hour, + } + + accessToken := "test-access-token" + code := "test-auth-code" + idToken, _, err := s.newIDToken( + ctx, + "test-client", + storage.Claims{UserID: "1", Username: "jane"}, + []string{"openid"}, + "nonce", + accessToken, + code, + "test", + time.Time{}, + ) + require.NoError(t, err) + + keys, err := sig.ValidationKeys(ctx) + require.NoError(t, err) + require.NotEmpty(t, keys) + + jws, err := jose.ParseSigned(idToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + require.Len(t, jws.Signatures, 1) + require.Equal(t, string(jose.RS256), jws.Signatures[0].Protected.Algorithm) + + payload, err := jws.Verify(keys[0]) + require.NoError(t, err) + + var claims struct { + AccessTokenHash string `json:"at_hash"` + CodeHash string `json:"c_hash"` + } + err = json.Unmarshal(payload, &claims) + require.NoError(t, err) + + wantAtHash, err := accessTokenHash(jose.RS256, accessToken) + require.NoError(t, err) + require.Equal(t, wantAtHash, claims.AccessTokenHash) + + wantCodeHash, err := accessTokenHash(jose.RS256, code) + require.NoError(t, err) + require.Equal(t, wantCodeHash, claims.CodeHash) +} + func TestSessionMatchesHint(t *testing.T) { // genSubject("foo", "bar") == "CgNmb28SA2Jhcg" (from TestGetSubject) assert.True(t, sessionMatchesHint(&storage.AuthSession{UserID: "foo", ConnectorID: "bar"}, "CgNmb28SA2Jhcg")) diff --git a/server/signer/local.go b/server/signer/local.go index da9f99fb..30fd37d3 100644 --- a/server/signer/local.go +++ b/server/signer/local.go @@ -2,6 +2,7 @@ package signer import ( "context" + "errors" "fmt" "log/slog" "time" @@ -15,6 +16,21 @@ import ( type LocalConfig struct { // KeysRotationPeriod defines the duration of time after which the signing keys will be rotated. KeysRotationPeriod string `json:"keysRotationPeriod"` + // Algorithm defines the signing algorithm used for newly generated local keys. + // Changing it does not replace the current signing key immediately. The new + // algorithm is applied when Dex generates the next signing key during rotation. + // Supported values are RS256 and ES256. + Algorithm jose.SignatureAlgorithm `json:"algorithm"` +} + +func (c *LocalConfig) signingAlgorithm() (jose.SignatureAlgorithm, error) { + if c.Algorithm == "" { + return jose.RS256, nil + } + if c.Algorithm == jose.RS256 || c.Algorithm == jose.ES256 { + return c.Algorithm, nil + } + return "", fmt.Errorf("unsupported local signer algorithm %q", c.Algorithm) } // Open creates a new local signer. @@ -24,7 +40,15 @@ func (c *LocalConfig) Open(_ context.Context, s storage.Storage, idTokenValidFor return nil, fmt.Errorf("invalid config value %q for local signer rotation period: %v", c.KeysRotationPeriod, err) } - strategy := defaultRotationStrategy(rotateKeysAfter, idTokenValidFor) + alg, err := c.signingAlgorithm() + if err != nil { + return nil, fmt.Errorf("invalid config value %q for local signer algorithm: %v", c.Algorithm, err) + } + + strategy, err := rotationStrategyForAlgorithm(rotateKeysAfter, idTokenValidFor, alg) + if err != nil { + return nil, err + } r := &keyRotator{s, strategy, now, logger} return &localSigner{ storage: s, @@ -48,11 +72,7 @@ type localSigner struct { func (l *localSigner) Start(ctx context.Context) { // Try to rotate immediately so properly configured storages will have keys. if err := l.rotator.rotate(); err != nil { - if err == errAlreadyRotated { - l.logger.Info("key rotation not needed", "err", err) - } else { - l.logger.Error("failed to rotate keys", "err", err) - } + l.logRotateError(err) } go func() { @@ -62,13 +82,21 @@ func (l *localSigner) Start(ctx context.Context) { return case <-time.After(time.Second * 30): if err := l.rotator.rotate(); err != nil { - l.logger.Error("failed to rotate keys", "err", err) + l.logRotateError(err) } } } }() } +func (l *localSigner) logRotateError(err error) { + if errors.Is(err, errAlreadyRotated) { + l.logger.Info("key rotation not needed", "err", err) + return + } + l.logger.Error("failed to rotate keys", "err", err) +} + func (l *localSigner) Sign(ctx context.Context, payload []byte) (string, error) { keys, err := l.storage.GetKeys(ctx) if err != nil { @@ -105,8 +133,13 @@ func (l *localSigner) ValidationKeys(ctx context.Context) ([]*jose.JSONWebKey, e return jwks, nil } -func (l *localSigner) Algorithm(_ context.Context) (jose.SignatureAlgorithm, error) { - // Local signer always uses RSA keys (see rotationStrategy.key). - // TODO(nabokihms): add support for other key types and algorithms in the future. - return jose.RS256, nil +func (l *localSigner) Algorithm(ctx context.Context) (jose.SignatureAlgorithm, error) { + keys, err := l.storage.GetKeys(ctx) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return "", fmt.Errorf("failed to get keys: %v", err) + } + if keys.SigningKey == nil { + return l.rotator.strategy.algorithm, nil + } + return signatureAlgorithm(keys.SigningKey) } diff --git a/server/signer/local_test.go b/server/signer/local_test.go index 9e0f7e8d..dfd65763 100644 --- a/server/signer/local_test.go +++ b/server/signer/local_test.go @@ -2,6 +2,10 @@ package signer import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" "log/slog" "testing" "time" @@ -10,51 +14,245 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/memory" ) -func newTestLocalSigner(t *testing.T) *localSigner { +func newTestLocalSigner(t *testing.T, config LocalConfig, s storage.Storage, now func() time.Time) *localSigner { t.Helper() logger := slog.New(slog.DiscardHandler) - s := memory.New(logger) - r := &keyRotator{ - Storage: s, - strategy: defaultRotationStrategy(time.Hour, time.Hour), - now: time.Now, - logger: logger, + if s == nil { + s = memory.New(logger) + } + if config.KeysRotationPeriod == "" { + config.KeysRotationPeriod = time.Hour.String() + } + if now == nil { + now = time.Now } - return &localSigner{ - storage: s, - rotator: r, - logger: logger, + sig, err := config.Open(context.Background(), s, time.Hour, now, logger) + require.NoError(t, err) + + ls, ok := sig.(*localSigner) + require.True(t, ok) + return ls +} + +func newTestRSAJWKPair(t *testing.T) (*jose.JSONWebKey, *jose.JSONWebKey) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + priv, pub, err := newJWKPair(key, jose.RS256) + require.NoError(t, err) + + return priv, pub +} + +func newTestES256JWKPair(t *testing.T) (*jose.JSONWebKey, *jose.JSONWebKey) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + priv, pub, err := newJWKPair(key, jose.ES256) + require.NoError(t, err) + + return priv, pub +} + +func requireVerifiedByAnyKey(t *testing.T, token string, alg jose.SignatureAlgorithm, keys []*jose.JSONWebKey, wantPayload []byte) { + t.Helper() + + jws, err := jose.ParseSigned(token, []jose.SignatureAlgorithm{alg}) + require.NoError(t, err) + + for _, key := range keys { + payload, err := jws.Verify(key) + if err == nil { + require.Equal(t, wantPayload, payload) + return + } } + + t.Fatalf("token did not verify with any key for algorithm %s", alg) } func TestLocalSignerAlgorithm(t *testing.T) { - ls := newTestLocalSigner(t) + tests := []struct { + name string + cfg LocalConfig + want jose.SignatureAlgorithm + }{ + { + name: "default RS256 before first rotation", + cfg: LocalConfig{KeysRotationPeriod: time.Hour.String()}, + want: jose.RS256, + }, + { + name: "ES256 before first rotation", + cfg: LocalConfig{KeysRotationPeriod: time.Hour.String(), Algorithm: jose.ES256}, + want: jose.ES256, + }, + } - // Algorithm should return RS256 even before keys are rotated (empty storage). - alg, err := ls.Algorithm(context.Background()) - require.NoError(t, err) - assert.Equal(t, jose.RS256, alg) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ls := newTestLocalSigner(t, tt.cfg, nil, nil) + + alg, err := ls.Algorithm(context.Background()) + require.NoError(t, err) + assert.Equal(t, tt.want, alg) + }) + } } func TestLocalSignerSignAndValidate(t *testing.T) { - ls := newTestLocalSigner(t) - ctx := context.Background() + tests := []struct { + name string + cfg LocalConfig + want jose.SignatureAlgorithm + }{ + { + name: "RS256", + cfg: LocalConfig{KeysRotationPeriod: time.Hour.String()}, + want: jose.RS256, + }, + { + name: "ES256", + cfg: LocalConfig{KeysRotationPeriod: time.Hour.String(), Algorithm: jose.ES256}, + want: jose.ES256, + }, + } - // Rotate keys so we have a signing key. + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ls := newTestLocalSigner(t, tt.cfg, nil, nil) + ctx := context.Background() + + require.NoError(t, ls.rotator.rotate()) + + payload := []byte(`{"sub":"test-user"}`) + signed, err := ls.Sign(ctx, payload) + require.NoError(t, err) + assert.NotEmpty(t, signed) + + keys, err := ls.ValidationKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + assert.Equal(t, string(tt.want), keys[0].Algorithm) + + alg, err := ls.Algorithm(ctx) + require.NoError(t, err) + assert.Equal(t, tt.want, alg) + + requireVerifiedByAnyKey(t, signed, tt.want, keys, payload) + }) + } +} + +func TestLocalSignerAppliesConfiguredAlgorithmOnNextRotation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := slog.New(slog.DiscardHandler) + s := memory.New(logger) + + currentTime := time.Now().UTC() + + rsaPriv, rsaPub := newTestRSAJWKPair(t) + err := s.UpdateKeys(ctx, func(keys storage.Keys) (storage.Keys, error) { + keys.SigningKey = rsaPriv + keys.SigningKeyPub = rsaPub + keys.NextRotation = currentTime.Add(time.Hour) + return keys, nil + }) + require.NoError(t, err) + + ls := newTestLocalSigner( + t, + LocalConfig{KeysRotationPeriod: time.Hour.String(), Algorithm: jose.ES256}, + s, + func() time.Time { return currentTime }, + ) + + beforeAlg, err := ls.Algorithm(ctx) + require.NoError(t, err) + assert.Equal(t, jose.RS256, beforeAlg) + + beforePayload := []byte(`{"sub":"before-rotation"}`) + beforeToken, err := ls.Sign(ctx, beforePayload) + require.NoError(t, err) + + ls.Start(ctx) + + afterStartAlg, err := ls.Algorithm(ctx) + require.NoError(t, err) + assert.Equal(t, jose.RS256, afterStartAlg) + + currentTime = currentTime.Add(time.Hour + time.Second) require.NoError(t, ls.rotator.rotate()) - payload := []byte(`{"sub":"test-user"}`) - signed, err := ls.Sign(ctx, payload) + afterRotationAlg, err := ls.Algorithm(ctx) + require.NoError(t, err) + assert.Equal(t, jose.ES256, afterRotationAlg) + + afterPayload := []byte(`{"sub":"after-rotation"}`) + afterToken, err := ls.Sign(ctx, afterPayload) require.NoError(t, err) - assert.NotEmpty(t, signed) - // Validation keys should be available. keys, err := ls.ValidationKeys(ctx) require.NoError(t, err) - assert.NotEmpty(t, keys) + require.Len(t, keys, 2) + assert.Equal(t, string(jose.ES256), keys[0].Algorithm) + assert.Equal(t, string(jose.RS256), keys[1].Algorithm) + + requireVerifiedByAnyKey(t, beforeToken, jose.RS256, keys, beforePayload) + requireVerifiedByAnyKey(t, afterToken, jose.ES256, keys, afterPayload) +} + +func TestLocalSignerDoesNotRevertSharedKeyAlgorithmBeforeNextRotation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := slog.New(slog.DiscardHandler) + s := memory.New(logger) + + currentTime := time.Now().UTC() + + esPriv, esPub := newTestES256JWKPair(t) + err := s.UpdateKeys(ctx, func(keys storage.Keys) (storage.Keys, error) { + keys.SigningKey = esPriv + keys.SigningKeyPub = esPub + keys.NextRotation = currentTime.Add(time.Hour) + return keys, nil + }) + require.NoError(t, err) + + ls := newTestLocalSigner( + t, + LocalConfig{KeysRotationPeriod: time.Hour.String(), Algorithm: jose.RS256}, + s, + func() time.Time { return currentTime }, + ) + + require.NoError(t, ls.rotator.rotate()) + + alg, err := ls.Algorithm(ctx) + require.NoError(t, err) + assert.Equal(t, jose.ES256, alg) + + keys, err := ls.ValidationKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + assert.Equal(t, string(jose.ES256), keys[0].Algorithm) + assert.Equal(t, esPub.KeyID, keys[0].KeyID) + + payload := []byte(`{"sub":"shared-key"}`) + token, err := ls.Sign(ctx, payload) + require.NoError(t, err) + requireVerifiedByAnyKey(t, token, jose.ES256, keys, payload) } diff --git a/server/signer/rotation.go b/server/signer/rotation.go index f043ba72..b24fa1db 100644 --- a/server/signer/rotation.go +++ b/server/signer/rotation.go @@ -2,6 +2,9 @@ package signer import ( "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/hex" @@ -28,21 +31,55 @@ type rotationStrategy struct { // signatures? idTokenValidFor time.Duration - // Keys are always RSA keys. Though cryptopasta recommends ECDSA keys, not every - // client may support these (e.g. github.com/coreos/go-oidc/oidc). - key func() (*rsa.PrivateKey, error) + // Algorithm used for newly generated signing keys. + algorithm jose.SignatureAlgorithm + + // Local signer keys can be RSA or ECDSA depending on the configured algorithm. + key func() (crypto.Signer, error) } -// defaultRotationStrategy returns a strategy which rotates keys every provided period, -// holding onto the public parts for some specified amount of time. -func defaultRotationStrategy(rotationFrequency, idTokenValidFor time.Duration) rotationStrategy { - return rotationStrategy{ +// rotationStrategyForAlgorithm builds a key rotation strategy for the provided +// local signer algorithm. +func rotationStrategyForAlgorithm(rotationFrequency, idTokenValidFor time.Duration, algorithm jose.SignatureAlgorithm) (rotationStrategy, error) { + strategy := rotationStrategy{ rotationFrequency: rotationFrequency, idTokenValidFor: idTokenValidFor, - key: func() (*rsa.PrivateKey, error) { - return rsa.GenerateKey(rand.Reader, 2048) - }, + algorithm: algorithm, } + // Only RS256 and ES256 are supported for local key rotation; all other algorithms are handled by the default case. + switch algorithm { //nolint:exhaustive + case jose.RS256: + strategy.key = func() (crypto.Signer, error) { + return rsa.GenerateKey(rand.Reader, 2048) + } + case jose.ES256: + strategy.key = func() (crypto.Signer, error) { + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + } + default: + return rotationStrategy{}, fmt.Errorf("unsupported local signer algorithm %q", algorithm) + } + return strategy, nil +} + +func newJWKPair(key crypto.Signer, algorithm jose.SignatureAlgorithm) (priv, pub *jose.JSONWebKey, err error) { + b := make([]byte, 20) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return nil, nil, fmt.Errorf("generate key id: %v", err) + } + keyID := hex.EncodeToString(b) + + return &jose.JSONWebKey{ + Key: key, + KeyID: keyID, + Algorithm: string(algorithm), + Use: "sig", + }, &jose.JSONWebKey{ + Key: key.Public(), + KeyID: keyID, + Algorithm: string(algorithm), + Use: "sig", + }, nil } type keyRotator struct { @@ -54,46 +91,47 @@ type keyRotator struct { logger *slog.Logger } +func (k keyRotator) rotationReason(keys storage.Keys, tNow time.Time) string { + if keys.SigningKey == nil { + return "missing signing key" + } + + if tNow.Before(keys.NextRotation) { + return "" + } + return "expired" +} + func (k keyRotator) rotate() error { keys, err := k.GetKeys(context.Background()) if err != nil && err != storage.ErrNotFound { return fmt.Errorf("get keys: %v", err) } - if k.now().Before(keys.NextRotation) { + + reason := k.rotationReason(keys, k.now()) + if reason == "" { return nil } - k.logger.Info("keys expired, rotating") + k.logger.Info("rotating signing keys", "reason", reason) // Generate the key outside of a storage transaction. key, err := k.strategy.key() if err != nil { return fmt.Errorf("generate key: %v", err) } - b := make([]byte, 20) - if _, err := io.ReadFull(rand.Reader, b); err != nil { - panic(err) - } - keyID := hex.EncodeToString(b) - priv := &jose.JSONWebKey{ - Key: key, - KeyID: keyID, - Algorithm: "RS256", - Use: "sig", - } - pub := &jose.JSONWebKey{ - Key: key.Public(), - KeyID: keyID, - Algorithm: "RS256", - Use: "sig", + priv, pub, err := newJWKPair(key, k.strategy.algorithm) + if err != nil { + return fmt.Errorf("generate JWK pair: %v", err) } var nextRotation time.Time err = k.Storage.UpdateKeys(context.Background(), func(keys storage.Keys) (storage.Keys, error) { tNow := k.now() + reason := k.rotationReason(keys, tNow) // if you are running multiple instances of dex, another instance // could have already rotated the keys. - if tNow.Before(keys.NextRotation) { + if reason == "" { return storage.Keys{}, errAlreadyRotated } @@ -134,6 +172,6 @@ func (k keyRotator) rotate() error { if err != nil { return err } - k.logger.Info("keys rotated", "next_rotation", nextRotation) + k.logger.Info("keys rotated", "reason", reason, "next_rotation", nextRotation) return nil } diff --git a/server/signer/rotation_test.go b/server/signer/rotation_test.go index 1974d996..a4e0f937 100644 --- a/server/signer/rotation_test.go +++ b/server/signer/rotation_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "github.com/go-jose/go-jose/v4" + "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/memory" ) @@ -67,10 +69,14 @@ func TestKeyRotator(t *testing.T) { maxVerificationKeys := 5 l := slog.New(slog.DiscardHandler) + strategy, err := rotationStrategyForAlgorithm(rotationFrequency, validFor, jose.RS256) + if err != nil { + t.Fatal(err) + } r := &keyRotator{ Storage: memory.New(l), - strategy: defaultRotationStrategy(rotationFrequency, validFor), + strategy: strategy, now: func() time.Time { return now }, logger: l, } diff --git a/server/signer/utils.go b/server/signer/utils.go index 6d607a10..92926d5b 100644 --- a/server/signer/utils.go +++ b/server/signer/utils.go @@ -2,6 +2,7 @@ package signer import ( "crypto/ecdsa" + "crypto/ed25519" "crypto/elliptic" "crypto/rsa" "errors" @@ -10,39 +11,54 @@ import ( "github.com/go-jose/go-jose/v4" ) +// signatureAlgorithm returns the JOSE signing algorithm declared by the JWK or +// inferred from its key material when the algorithm field is empty. func signatureAlgorithm(jwk *jose.JSONWebKey) (alg jose.SignatureAlgorithm, err error) { if jwk.Key == nil { return alg, errors.New("no signing key") } - switch key := jwk.Key.(type) { - case *rsa.PrivateKey: + if jwk.Algorithm != "" { + return jose.SignatureAlgorithm(jwk.Algorithm), nil + } + return signatureAlgorithmFromKey(jwk.Key) +} + +func signatureAlgorithmFromKey(key any) (alg jose.SignatureAlgorithm, err error) { + switch key := key.(type) { + case *rsa.PublicKey, *rsa.PrivateKey: // Because OIDC mandates that we support RS256, we always return that // value. In the future, we might want to make this configurable on a // per client basis. For example allowing PS256 or ECDSA variants. // // See https://github.com/dexidp/dex/issues/692 return jose.RS256, nil + case *ecdsa.PublicKey: + return signatureAlgorithmFromECDSACurve(key.Curve) case *ecdsa.PrivateKey: - // We don't actually support ECDSA keys yet, but they're tested for - // in case we want to in the future. - // - // These values are prescribed depending on the ECDSA key type. We - // can't return different values. - switch key.Params() { - case elliptic.P256().Params(): - return jose.ES256, nil - case elliptic.P384().Params(): - return jose.ES384, nil - case elliptic.P521().Params(): - return jose.ES512, nil - default: - return alg, errors.New("unsupported ecdsa curve") - } + return signatureAlgorithmFromECDSACurve(key.Curve) + case ed25519.PublicKey, ed25519.PrivateKey: + return jose.EdDSA, nil default: return alg, fmt.Errorf("unsupported signing key type %T", key) } } +func signatureAlgorithmFromECDSACurve(curve elliptic.Curve) (jose.SignatureAlgorithm, error) { + if curve == nil { + return "", errors.New("unsupported ecdsa curve") + } + switch curve.Params() { + case elliptic.P256().Params(): + return jose.ES256, nil + case elliptic.P384().Params(): + return jose.ES384, nil + case elliptic.P521().Params(): + return jose.ES512, nil + default: + return "", errors.New("unsupported ecdsa curve") + } +} + func signPayload(key *jose.JSONWebKey, alg jose.SignatureAlgorithm, payload []byte) (jws string, err error) { signingKey := jose.SigningKey{Key: key, Algorithm: alg} diff --git a/server/signer/utils_test.go b/server/signer/utils_test.go new file mode 100644 index 00000000..7162447c --- /dev/null +++ b/server/signer/utils_test.go @@ -0,0 +1,50 @@ +package signer + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "testing" + + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSignatureAlgorithmFromKey(t *testing.T) { + t.Run("RSA private key", func(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + alg, err := signatureAlgorithmFromKey(key) + require.NoError(t, err) + assert.Equal(t, jose.RS256, alg) + }) + + t.Run("ECDSA P-256 private key", func(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + alg, err := signatureAlgorithmFromKey(key) + require.NoError(t, err) + assert.Equal(t, jose.ES256, alg) + }) + + t.Run("Ed25519 private key", func(t *testing.T) { + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + alg, err := signatureAlgorithmFromKey(key) + require.NoError(t, err) + assert.Equal(t, jose.EdDSA, alg) + }) + + t.Run("unsupported key type", func(t *testing.T) { + alg, err := signatureAlgorithmFromKey(struct{}{}) + require.Error(t, err) + assert.Empty(t, alg) + assert.EqualError(t, err, "unsupported signing key type struct {}") + }) +} diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index c81c9484..8a06006c 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -2,7 +2,7 @@ package conformance import ( - "context" + "crypto/ecdsa" "reflect" "sort" "testing" @@ -16,6 +16,8 @@ import ( "github.com/dexidp/dex/storage" ) +const keyRoundTripPayload = "storage-keys-round-trip" + // ensure that values being tested on never expire. var neverExpire = time.Now().UTC().Add(time.Hour * 24 * 365 * 100) @@ -86,6 +88,45 @@ func mustBeErrAlreadyExists(t *testing.T, kind string, err error) { } } +func isES256JWK(jwk *jose.JSONWebKey) bool { + if jwk == nil { + return false + } + + switch jwk.Key.(type) { + case *ecdsa.PrivateKey, *ecdsa.PublicKey: + return jose.SignatureAlgorithm(jwk.Algorithm) == jose.ES256 + default: + return false + } +} + +func requireSigningKeyRoundTripUsable(t *testing.T, keys storage.Keys) { + t.Helper() + + if !isES256JWK(keys.SigningKey) { + return + } + require.NotNil(t, keys.SigningKeyPub) + + alg := jose.SignatureAlgorithm(keys.SigningKey.Algorithm) + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: keys.SigningKey}, nil) + require.NoError(t, err) + + signed, err := signer.Sign([]byte(keyRoundTripPayload)) + require.NoError(t, err) + + compact, err := signed.CompactSerialize() + require.NoError(t, err) + + jws, err := jose.ParseSigned(compact, []jose.SignatureAlgorithm{alg}) + require.NoError(t, err) + + payload, err := jws.Verify(keys.SigningKeyPub) + require.NoError(t, err) + require.Equal(t, []byte(keyRoundTripPayload), payload) +} + func testAuthRequestCRUD(t *testing.T, s storage.Storage) { ctx := t.Context() codeChallenge := storage.PKCE{ @@ -730,54 +771,69 @@ func testConnectorCRUD(t *testing.T, s storage.Storage) { } func testKeysCRUD(t *testing.T, s storage.Storage) { - ctx := context.TODO() - - updateAndCompare := func(k storage.Keys) { - err := s.UpdateKeys(ctx, func(oldKeys storage.Keys) (storage.Keys, error) { - return k, nil - }) - if err != nil { - t.Errorf("failed to update keys: %v", err) - return - } - - if got, err := s.GetKeys(ctx); err != nil { - t.Errorf("failed to get keys: %v", err) - } else { - got.NextRotation = got.NextRotation.UTC() - if diff := pretty.Compare(k, got); diff != "" { - t.Errorf("got keys did not equal expected: %s", diff) - } - } - } + ctx := t.Context() // Postgres isn't as accurate with nano seconds as we'd like n := time.Now().UTC().Round(time.Second) - keys1 := storage.Keys{ - SigningKey: jsonWebKeys[0].Private, - SigningKeyPub: jsonWebKeys[0].Public, - NextRotation: n, - } - - keys2 := storage.Keys{ - SigningKey: jsonWebKeys[2].Private, - SigningKeyPub: jsonWebKeys[2].Public, - NextRotation: n.Add(time.Hour), - VerificationKeys: []storage.VerificationKey{ - { - PublicKey: jsonWebKeys[0].Public, - Expiry: n.Add(time.Hour), + tests := []struct { + name string + keys storage.Keys + }{ + { + name: "rsa signing key", + keys: storage.Keys{ + SigningKey: jsonWebKeys[0].Private, + SigningKeyPub: jsonWebKeys[0].Public, + NextRotation: n, }, - { - PublicKey: jsonWebKeys[1].Public, - Expiry: n.Add(time.Hour * 2), + }, + { + name: "es256 signing key", + keys: storage.Keys{ + SigningKey: jsonWebKeys[5].Private, + SigningKeyPub: jsonWebKeys[5].Public, + NextRotation: n.Add(time.Hour), + }, + }, + { + name: "mixed verification key algorithms", + keys: storage.Keys{ + SigningKey: jsonWebKeys[6].Private, + SigningKeyPub: jsonWebKeys[6].Public, + NextRotation: n.Add(2 * time.Hour), + VerificationKeys: []storage.VerificationKey{ + { + PublicKey: jsonWebKeys[1].Public, + Expiry: n.Add(3 * time.Hour), + }, + { + PublicKey: jsonWebKeys[5].Public, + Expiry: n.Add(4 * time.Hour), + }, + }, }, }, } - updateAndCompare(keys1) - updateAndCompare(keys2) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := s.UpdateKeys(ctx, func(oldKeys storage.Keys) (storage.Keys, error) { + return tt.keys, nil + }) + require.NoError(t, err) + + got, err := s.GetKeys(ctx) + require.NoError(t, err) + + got.NextRotation = got.NextRotation.UTC() + if diff := pretty.Compare(tt.keys, got); diff != "" { + t.Fatalf("got keys did not equal expected: %s", diff) + } + + requireSigningKeyRoundTripUsable(t, got) + }) + } } func testGC(t *testing.T, s storage.Storage) { diff --git a/storage/conformance/gen_jwks.go b/storage/conformance/gen_jwks.go index b5affcc8..990f3c54 100644 --- a/storage/conformance/gen_jwks.go +++ b/storage/conformance/gen_jwks.go @@ -7,6 +7,9 @@ package main import ( "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/hex" @@ -44,14 +47,14 @@ type keyPair struct { Private *jose.JSONWebKey } -// keys are generated beforehand so we don't have to generate RSA keys for every test. +// keys are generated beforehand so tests don't spend time on crypto key generation. var jsonWebKeys = []keyPair{ - {{ range $i, $pair := .Keys }} +{{- range $i, $pair := .Keys }} { Public: mustLoadJWK({{ $pair.Public }}), Private: mustLoadJWK({{ $pair.Private }}), }, - {{ end }} +{{- end }} } `[1:])) // Remove the first newline. @@ -60,42 +63,73 @@ type keyPair struct { Private string } +type keySpec struct { + count int + algorithm string + generate func() (crypto.Signer, error) +} + +func newKeyPair(algorithm string, key crypto.Signer) keyPair { + priv := jose.JSONWebKey{ + Key: key, + KeyID: newUUID(), + Algorithm: algorithm, + Use: "sig", + } + pub := jose.JSONWebKey{ + Key: key.Public(), + KeyID: newUUID(), + Algorithm: algorithm, + Use: "sig", + } + + privBytes, err := json.MarshalIndent(priv, "\t\t", "\t") + if err != nil { + log.Fatalf("marshal priv: %v", err) + } + pubBytes, err := json.MarshalIndent(pub, "\t\t", "\t") + if err != nil { + log.Fatalf("marshal pub: %v", err) + } + + return keyPair{ + Private: "`" + string(privBytes) + "`", + Public: "`" + string(pubBytes) + "`", + } +} + func main() { var tmplData struct { Keys []keyPair } - for i := 0; i < 5; i++ { - // TODO(ericchiang): Test with ECDSA keys. - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - log.Fatalf("gen rsa key: %v", err) - } - priv := jose.JSONWebKey{ - Key: key, - KeyID: newUUID(), - Algorithm: "RS256", - Use: "sig", - } - pub := jose.JSONWebKey{ - Key: key.Public(), - KeyID: newUUID(), - Algorithm: "RS256", - Use: "sig", - } - privBytes, err := json.MarshalIndent(priv, "\t\t", "\t") - if err != nil { - log.Fatalf("marshal priv: %v", err) - } - pubBytes, err := json.MarshalIndent(pub, "\t\t", "\t") - if err != nil { - log.Fatalf("marshal pub: %v", err) - } - tmplData.Keys = append(tmplData.Keys, keyPair{ - Private: "`" + string(privBytes) + "`", - Public: "`" + string(pubBytes) + "`", - }) + specs := []keySpec{ + { + count: 5, + algorithm: "RS256", + generate: func() (crypto.Signer, error) { + return rsa.GenerateKey(rand.Reader, 2048) + }, + }, + { + count: 2, + algorithm: "ES256", + generate: func() (crypto.Signer, error) { + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + }, + }, } + + for _, spec := range specs { + for i := 0; i < spec.count; i++ { + key, err := spec.generate() + if err != nil { + log.Fatalf("gen %s key: %v", spec.algorithm, err) + } + tmplData.Keys = append(tmplData.Keys, newKeyPair(spec.algorithm, key)) + } + } + buff := new(bytes.Buffer) if err := tmpl.Execute(buff, tmplData); err != nil { log.Fatalf("execute tmpl: %v", err) diff --git a/storage/conformance/jwks.go b/storage/conformance/jwks.go index 28ce5f72..afccbc0b 100644 --- a/storage/conformance/jwks.go +++ b/storage/conformance/jwks.go @@ -1,35 +1,38 @@ -// This file was generaged by gen_jwks.go +// This file was generated by gen_jwks.go package conformance -import "github.com/go-jose/go-jose/v4" +import jose "github.com/go-jose/go-jose/v4" type keyPair struct { Public *jose.JSONWebKey Private *jose.JSONWebKey } -// keys are generated beforehand so we don't have to generate RSA keys for every test. +// keys are generated beforehand so tests don't spend time on crypto key generation. var jsonWebKeys = []keyPair{ { Public: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "8145b5b9243c41459a8fdaa12acbd371", + "kid": "10cfa7ef3f1d48d4896fd2fcdd4a064d", "alg": "RS256", - "n": "34ls8E4onyEU_JKcxl8BMu2N6hK_D6aG2tOuCHJ_ka4rom8NmdJGdOQPC_fvKhcAxWeDktdAPislTT76Q4iMCC7DbM1aQhgRMaecKHBagc5ue2kSPM3oZPLqe6X-CxdxGTfXAvFIZM9JZTbQeJPcXFdn28iZ086xWPMdQKY5QTRKtoHQSN6EAQuuiuZsXrAC3lBZmE4tda6NoeYLb0UayGqiiFmtoIFJQ4NecI-EECT-mcjkPGWG0Ll5dCIUhGDl8sQSUrmBuaTDpPEzLGo-UtM3ay7AN0gOVN0mLIk2oyroXcVOA626LYNLVU0mz9PDpdkhWBeUfLL6i4HjUS3RaQ", + "n": "nWwoxydjr1ihXg0jnflEoSwOwyR4VRvHJIkEFnFx8ibt6IQJE0oXdSY1RG0yUddTb0Wfgqwp_-sXHEjG_tQbOTLWQoW2f8wQIw_SF3eq5fhk_uwv_tGB980sMKQA3Er13FEr2qttbWn5xYaoglDgjNC6DmxFGjSgjJaRKZGeQ0zXmt38GSHmA3IIWps2GftWPRnSKXj27NPIhw-ZKLkAibCdm2zcqyzy5YCXitymhnGCnlT6LQUCbKaZ0ocbnAY4Ttb-pg5toYMq-jps9tdn58ETT9hBsoagY7x8fy-nZZgdg_CNwqfBnCRbqLeeFJRUR00fbSaxjRIsf1RtMz54Sw", "e": "AQAB" }`), Private: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "f547defc90b34ec08caeb8b294591216", + "kid": "a8481527703a43d8aa8f818b80ae23ba", "alg": "RS256", - "n": "34ls8E4onyEU_JKcxl8BMu2N6hK_D6aG2tOuCHJ_ka4rom8NmdJGdOQPC_fvKhcAxWeDktdAPislTT76Q4iMCC7DbM1aQhgRMaecKHBagc5ue2kSPM3oZPLqe6X-CxdxGTfXAvFIZM9JZTbQeJPcXFdn28iZ086xWPMdQKY5QTRKtoHQSN6EAQuuiuZsXrAC3lBZmE4tda6NoeYLb0UayGqiiFmtoIFJQ4NecI-EECT-mcjkPGWG0Ll5dCIUhGDl8sQSUrmBuaTDpPEzLGo-UtM3ay7AN0gOVN0mLIk2oyroXcVOA626LYNLVU0mz9PDpdkhWBeUfLL6i4HjUS3RaQ", + "n": "nWwoxydjr1ihXg0jnflEoSwOwyR4VRvHJIkEFnFx8ibt6IQJE0oXdSY1RG0yUddTb0Wfgqwp_-sXHEjG_tQbOTLWQoW2f8wQIw_SF3eq5fhk_uwv_tGB980sMKQA3Er13FEr2qttbWn5xYaoglDgjNC6DmxFGjSgjJaRKZGeQ0zXmt38GSHmA3IIWps2GftWPRnSKXj27NPIhw-ZKLkAibCdm2zcqyzy5YCXitymhnGCnlT6LQUCbKaZ0ocbnAY4Ttb-pg5toYMq-jps9tdn58ETT9hBsoagY7x8fy-nZZgdg_CNwqfBnCRbqLeeFJRUR00fbSaxjRIsf1RtMz54Sw", "e": "AQAB", - "d": "3rABHsQ-I4jJZ3SHSfeLMjkFj5JtVCIJZiZK0Y9_Fpn0TjVjz0Fzfy9S7hFo6P1Rf1bH9JkLHuPMnU-H8Y8uMVikxtcse3uOZXEcWAzVnUsRNVBPItPeF_MHNXb_xfzsZrsCL6Q_Am6eJ36b4AMtG7DXflQxKphWhM5s7eKqVxDrkhaDPnALLRFjCvUZ_myQQ3Upn7gMgAbvfIY1fn9rXW_4CfxbxhcPJW5IOcu6bPvpQlfuFkXjF-gGCiNf5kv6Db0lpDOKX5l5T-KFGQ0dIOdasm8vL2GxCKZf55rKRCt0a28fwwH2p94ja-1qtPTc34V8F26LyVRgQgD3e-0aoQ", - "p": "_WoAr3sgL5yfaqBL38yqx4hqSPZGdR6xTS64rhgZaVg14_W6xYmlPI7PmVBRW45Fk4tXhXjv9oMZH9HGrH2v4yqXLEq0gJr4VAPvRaN6p_kb_eCfLHCbNCYBAPNVUdFpOTvOmh7m0zYPrku7DZDnZQEN_A9hYcufjy0em-lV6Tc", - "q": "4dFfwyYQmns1xwVEPABxpazk6nAluS-7yYSAc9A8D25nqm0mNWdPJvmpJS02xSDjIGfe0FtMr1XlPm3XHdUlIu2Z9Ex-J-kcs3lfs2UKmleQqJRXK4MahAEIV3vp0zG47hAJyzE3Oh4sVLFr3ZK9_-SenolCFv5eIikWa3Xg6l8" + "d": "B61Kn8uv9NEhrXB-mCmbyzBW1_VjWOMt5v43kNCfeeukFn654pLuaewfFOMuXQdfLkH68Whr8-sHCX4TFkJJwZRVFpFmwPy2nL8cw8A2OVjPtA7YmFinOepriUO2NwgAzWm39cX-ZCOS5qsWdKR1Duju6d0l0Y1XdwOwuJI9YZK-LE9JusdPHO4ME7JR5NHFmop1VWnt27QPs_VijW6V-_jnSqD-w1p_gnc0tEi3r7cFGjpbOh_JbxWG3WrX8CTmbl7T-j3kCkMkzE9mYAPTxMmHE80ooy4fBXyd4CkoIGeZe8r4JNowUneSCyicI_1t5PlmUAniEv51azq_bM5IIQ", + "p": "yn_xAS2PH7Ko6YLbhNrVowAeLBaujYfJFiJxSqYjMlyDwSiLZ_6rhULNFKT6GRI6UPLDUEwH4B8U8ACcSRg8Vpy0N8wQRuT71NBWYUPL39oc3Jp7ajdT_dv8kcRB_hc5u2XPdP84BFkH-sUfl1CzbaBZ5MAA0jqnjYNFanRImu8", + "q": "xwNrYABQ8OjiB_RkkjycX-i2c8fgQdrLoCDtPpW30IjmHJD5sdeom7Iu7pfp7JQJ87uh4zEq_JYQEMDMmJp4SyqBbV_FQfVOD2YcOJoyozz09ymmLaRO-WPk0GUjdzLWD9LMhBykMN_LmL2S9MZQ4-V8GOtKOpDaFlvYVHvcKGU", + "dp": "eF8XNvkLA--iwrP7o3yl06_lP5X4Cr91hAfTSml5sJ0X4MEmJRpYgO2VlLkAxdh0-9tiyJ95avtu6b-jJzwV2fJqmmReJJZHMFjrkAPJ8_XmhCf8RsY-0j9rYobEJ7NMqR269TQk60i1GpcE0WCyV-8ioHyVwGIHaXJn2ADpi9c", + "dq": "eMk6gimu9ohhpzJNV_QxdIRpOBw3n8CLlcRmOXXk-kwcXXogEjAxxU1_7o-FCnFPGP7oYluVG3h4h7J8eqJGIFjL9PYLYtzfY7k1p6Tu1uRISTqeAVWYGQTn-xNnUxEnmrL2Lbi0bnVlvG5Sov06WxcNGpKzmpgPxUI5Kg2CsYE", + "qi": "BeF87zRr0j7Ff-pEJMDAYINJou2YcCBsZoyA0COKIohVOh-pkTaKfVkJ4ndWShuGOgatjqSbALxn3Z0NDX6VvTgpnMJSBuMNtkzyzeVHahRA8PftfShar8i2l_xb__gSZmr57-YgNe7ditoDu-EngcaebxmuFV6xQn9YkG1Irkc" }`), }, @@ -37,21 +40,24 @@ var jsonWebKeys = []keyPair{ Public: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "3a9365e41b114ec1b9288b214196e158", + "kid": "21fd15ddab654e3cab72d24f94925ea9", "alg": "RS256", - "n": "t3TrxLN5_z-x5X9kebkoPnoYnGAPqAXOVCGBTxcAqev_P8t6SyyeeITDiePhCctYp5dO-WHRkB7_BkUeHZOgoyCBarDkDifQSG7MCtlYDm0yiSij_0vqzJQx-6zlXb5ypwO0P1sAXrO_nO87u69w5yaKf0yEJMpSjU8BDKQ__nskZP2QJJsYwOeAI9aAM2oP8r7Im8KzLy9-mnFSqypxBnL24hFNzKOS_GyHs0tPLjVY7JNDtDOkwPQIQFzsdZSY88n6uYvV-MGu3O-Y3-xLwUqMlJOXFskhmp1AOUnb4JgQ9wEaZ7088PY3Ak0eZkrg2FQ3XRHSWhUCOb2xL5iTvw", + "n": "nv9zOaDskza-NEBerrj_TSgbWx_slrra2UXXNcMFLkZASwU0K81G8iipM5cikbSHAd_4z2Y31NLpIz_-mOHPdyqKRCC9kWcWB9bfrZ06ey47Wvf-_wnCuLKXn9wPYZJwhSSPPrvy0146hVEaZOEpDYqMvRHxY5uU_gl_VLkAGqQKrP2YBNf8ubrulkzMEkuWHySHVrBD1KkJorw4gno22vtszRPjPapsdhpfz1Wm9W4NVOTdxyPAHcyQV5j0Tkmj9xPM6BBWHMpl5iLZpXpMoPAGlGCIX26WNZjsrNRoPDTB478g-SrVzuxR4w78HJ56rbazfseqpFLvNMMlXtWmtw", "e": "AQAB" }`), Private: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "c79418aaf8ee439bb2b0e28672d71584", + "kid": "d7ea68c557754ae19cb40fa114067137", "alg": "RS256", - "n": "t3TrxLN5_z-x5X9kebkoPnoYnGAPqAXOVCGBTxcAqev_P8t6SyyeeITDiePhCctYp5dO-WHRkB7_BkUeHZOgoyCBarDkDifQSG7MCtlYDm0yiSij_0vqzJQx-6zlXb5ypwO0P1sAXrO_nO87u69w5yaKf0yEJMpSjU8BDKQ__nskZP2QJJsYwOeAI9aAM2oP8r7Im8KzLy9-mnFSqypxBnL24hFNzKOS_GyHs0tPLjVY7JNDtDOkwPQIQFzsdZSY88n6uYvV-MGu3O-Y3-xLwUqMlJOXFskhmp1AOUnb4JgQ9wEaZ7088PY3Ak0eZkrg2FQ3XRHSWhUCOb2xL5iTvw", + "n": "nv9zOaDskza-NEBerrj_TSgbWx_slrra2UXXNcMFLkZASwU0K81G8iipM5cikbSHAd_4z2Y31NLpIz_-mOHPdyqKRCC9kWcWB9bfrZ06ey47Wvf-_wnCuLKXn9wPYZJwhSSPPrvy0146hVEaZOEpDYqMvRHxY5uU_gl_VLkAGqQKrP2YBNf8ubrulkzMEkuWHySHVrBD1KkJorw4gno22vtszRPjPapsdhpfz1Wm9W4NVOTdxyPAHcyQV5j0Tkmj9xPM6BBWHMpl5iLZpXpMoPAGlGCIX26WNZjsrNRoPDTB478g-SrVzuxR4w78HJ56rbazfseqpFLvNMMlXtWmtw", "e": "AQAB", - "d": "T7-y0dIXQV8l7RbAza0wkmAvHKMhiy_i7m2WMZRVRIiDb-77HXyq8sb73ZBC_if4RPogaYYdPCJNSCN5oO_Qz7jMqV119bVW9HW9myW6AqNzaW5SRCNzUTVGuRoCpwqn-nRAwZ3EfmZy8DyK4d61HLaDVC0l8HxHAIiMcztfWjbfD2LjwWF2hF5VRG2-haDfT6Kwtz0zEXblvYxyPqVyKOFtuWDlzX8iP8_ryWaChpR-jTmwtm7663wcu4M9teMkdgubCIqkz0LLtd-97ZUM2ti70WO7AEqE6p1evnjfYt4HZpQlsn0psrgGLvX2oCIvmPQMfTjzmtsEC51F5CU-yQ", - "p": "4xi5OdCP9n1ivD3CuMhcaoMrwkC1yVdYnJwaNXjIyuSUT0i_QmuRpViydpZsfiYEoNNczL_PwxlDNdl2ccbelBuoEDbrvAfz0G0-YVYuLJoEKQs_OjenIn_6AZlmn7zSQ0LjoZ1tTjOaKuueB2b8RVtF2pbZ_o1ApyWd3q6QjyU", - "q": "zs5SF-jdzP9xThPTEmAa2yh6SI48KuwVwWXGjOQZThXVEfwo-iZNevPjg3b6gwY9fKi71-J75c1ng0QrgdDuRIackHFpSLaWgcIpN31-uyZl5X-uxpBZON1HeiYT8J2JhgbA9ZJ0_SUq3j4YSrFEGKSpBi741mqwS9CZ6NSN5BM" + "d": "Cevjy0lM6eTFGqqXneyCVdS2XEnSMBep11wB0WOBe8E7etLwzfjpv-ukn0kDibcLo6TPuFVnD3s3vBDeuLiCgyue5CBs49lcaRHR9Fn2z7_zSTpnaY-43GCckIehWBPUvKIq5B3DScg7-5yzem8IakVmgdfQSchoFDAQh39hz8eqhaz0BsaOhSZKKqDXypkbxEwF4KnTIbqej0CILImjHLW8dTKsQRVuW2t4SRruvn-fQJMTicRbG1CnL4FSuDRY1t41HQDMASYdjEDKxz3IVRW-6LHbR_Lwkz5fR_Fttws2wC-fwXDaruIzs_nDNuv5r9YosnHZpN7ajgtt7NYiWQ", + "p": "0jDXyRVaG32RbN3AqeYmLk-Btt-pq7wwLl3ethjfNMT3gMYuExchAsiS86DCoMsS_SV65gyBqsl1jmvVCqR9HZl0vIQ9fuUxtVDVy-EWf5Lx5NxSSzNU4WC4vvbkkRxaN73fKbbIfj-k61BuNU8xL67Dd_eZS3oNNiXGT_6XES8", + "q": "waZnXlYWOmUVLGcy7zhj-2Ua5nT6hnhz1Jw3iCrUhw57SdYP4gGvehaU3lvB451r8whs3xsi-7czA0nJ-Idle83j7Knww0BaVBnVdK2pmDqnBm3r35kyfoPgHUo4ujSMFZAdIQpRztvwYdXcpBRfE3toQoheGSJKhDMjxa2dEPk", + "dp": "qhC-54IRMCD_I1ig5FbFGb9WssJxI_TV_w4QfsHNB4M-xsCS0vtpyDjFPb4a-8KTkKNctvnziF4Mcbmp3DyOxv48x7MvlpaXC59l_NoYPAyHD6d8JkBYMyrxEAHvmcGY9XT5NWUg_5U7OPIQ2a1DnNMIcO6y57DPM7qIpHzXeh0", + "dq": "NGsi1_pdkfkCvj5BH1gYWFiJ65AGsJeyYv7WWVmepeBZpyb2rarfISEzsu0Lkt1t9x90uP_TfGeeu2kh7tBXvCeDZvCpZ3DoApPrn-XSXo6h36-phaEmdfCknckVifHnaX7VI7hzZJdMfm8xhoitI8zn7_qR3iPWH-rn5_6S4aE", + "qi": "XMIySQMcYQBr1u8J_X97CJnknxelXWmux5B7hR9jtwvT7r4d0rzzupqNMCApdC3fCsCQrrdJ94JNaOPHOrM5lL_1UL4_OU2n1PYwpxyqj4KPvEfoewGGyTXj-_2NP8uhzW-nATVgX65ZIg_LyxcJt6ym6qVdv-4cdfMJ30jNNIQ" }`), }, @@ -59,21 +65,24 @@ var jsonWebKeys = []keyPair{ Public: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "4c267fc23c7b44d6973a1722b7201849", + "kid": "67c85e968f3240f59a62084fadc8b5ae", "alg": "RS256", - "n": "yeZexEF1gOXd71iz9jRQR3EhgM2-o3mVO4O1fJYYQTh5APfrrbMhOGLvgK06vytREiY9_1awL7YfEnZzQynq9WTZpkwlAhYujHYf1RbGPeoXJS2cXKThfIhbeITEyhfepqzwU_f-RhvaLS3bydDi7F74oTO9njtLkGV2qNHH3B2uTFBy2G8VmDeHNQrUa868LQ9omrmWFkLnoZOoVPiLZD-5aZXOKJ0In5sg9B1EX1oaF-xejCTBX_8EJvvvKXH-GUZnHc3g3Rf3k4iXCJi8VMyjA8we3fgP8jp2P3Ofv6VOKG3vh8j5lI3ys_rctc2fu6CaNWNNZs9wbjpDVPuc0w", + "n": "tUKibQUIXBTKnBS7MryiW6VPDcAszUwYvKZcVw0eVtQa1wDUITBo1-_VJ6fRXd5KLvKOgDxSg9wJgcN2Qm94HCK_JRvEiNQ-bruVVrQ4rk1V4Y9QkG3wJ3osh-srrO551mAocpKKS4tQ6Dojh9wq7y76TRu__r2bGcylR3Ilotzdp9TckC-6TeW2LT7gkevDZwuJWGojzZEmbmhH-Jt1FTtq2oKP_ZtPbHoyJsJ_HrqluDi8EuiT48j2CwpGnfPOkaKkVtHSpsG39_bcgxTMzwDnEVtALDHTIB2LMAEiKycSvppLaH_lCx6IACJLE3LkJEO4O59JxpNGOfggEKU3Yw", "e": "AQAB" }`), Private: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "eec6ee158cb34d699be4baff419da383", + "kid": "10e9bda24041486e83c06e8b804523b5", "alg": "RS256", - "n": "yeZexEF1gOXd71iz9jRQR3EhgM2-o3mVO4O1fJYYQTh5APfrrbMhOGLvgK06vytREiY9_1awL7YfEnZzQynq9WTZpkwlAhYujHYf1RbGPeoXJS2cXKThfIhbeITEyhfepqzwU_f-RhvaLS3bydDi7F74oTO9njtLkGV2qNHH3B2uTFBy2G8VmDeHNQrUa868LQ9omrmWFkLnoZOoVPiLZD-5aZXOKJ0In5sg9B1EX1oaF-xejCTBX_8EJvvvKXH-GUZnHc3g3Rf3k4iXCJi8VMyjA8we3fgP8jp2P3Ofv6VOKG3vh8j5lI3ys_rctc2fu6CaNWNNZs9wbjpDVPuc0w", + "n": "tUKibQUIXBTKnBS7MryiW6VPDcAszUwYvKZcVw0eVtQa1wDUITBo1-_VJ6fRXd5KLvKOgDxSg9wJgcN2Qm94HCK_JRvEiNQ-bruVVrQ4rk1V4Y9QkG3wJ3osh-srrO551mAocpKKS4tQ6Dojh9wq7y76TRu__r2bGcylR3Ilotzdp9TckC-6TeW2LT7gkevDZwuJWGojzZEmbmhH-Jt1FTtq2oKP_ZtPbHoyJsJ_HrqluDi8EuiT48j2CwpGnfPOkaKkVtHSpsG39_bcgxTMzwDnEVtALDHTIB2LMAEiKycSvppLaH_lCx6IACJLE3LkJEO4O59JxpNGOfggEKU3Yw", "e": "AQAB", - "d": "IOFck5eZfElzMFSA0lrIrCnXa_OV1WeqjwuvFcAX6R86TZcSkbI3echa-ti7VYDHbi4-MIQ8oziErOwPb2O3OQmYjIWgDUvxfryKCJjx5glmhY59BXVwp2hJhUISDlt-ziQh63ratS46BNuQDLjxC8-XrCESA1_iuXxcq7emVclRKN2DpGehf2bZyjcZy-OEwvL1jLsvoY2jmY_2JOT4nFLqoelg5vENj69p8IR9Bpdzp0urngLZJ4-HqFGyfx3tEo4ZUF1M5xnoycBc5LMZjmElK66rjBRWPq9UwZwfqaeQh6wEA9siYw1V9yrNRUkq3Q6BErbXNDKBV36bRIiaIQ", - "p": "_YirCr3Sfs9FkEFFMNsTZ2Wv8e5napONPtg1WUYOxG36k65EkPtlmZLWmiwmBk6592oND_S5WvbW4BbX5lRbEvNiRy9coVPst6lOOnLe69GJoI_GxoRyu_94qIS-VNPSQkyw4gfA1M-lMdfKpaTMv7fvVolvmDs5xN_fmXpl06M", - "q": "y90gdyUcYzDX1u3-fCINzXbDcr80QEO3bjuG8p7feaYY2MP51t6j6MisNsQqcGKY7xFhpc-z8_cEIg1HJ3FSly-yejPj8RGavPX6NVGVHDNGwxxnm_i3kf-4MuDxwRSSHMlgVNAXuoH-3iicz-bNTVYM-5bYucZMvZHC6Ur2JRE" + "d": "FCIoJ4LhGFVI4ghdeLfCMvrMKqNXdqBGuSGjbRnsbkmWB560cVFOu_mMTCDUXSBVThysHmtU4QeeWLcM2jlGdp2XbLhGXspwet1EK8LN7vJxISJJmRlVDRWBf8Fr8wP0LY81vvAvA1AgtwLKMouOi03FCK6V52ZJJZb5HtP7gTjmT5omV6KFsIjJK758In498PfoQcGEp028sl9i3mh3NN6U7Q20UGQ2rQ2adhsECc9RLfKZ5-1axOJG39R41Udqa0jzd-WxySaB3Rds1B4QQBI9KS188dTmXoATiLSd3DtbeQQ7qkWv4g4ToYp9a2EhKMFqsKQREfnG5stsVxjWFQ", + "p": "zXwRx1uxexVs_Y5K5CLZmnE0NPIHiC4pOUCnmcfbYemLZUcUyEUGNxa2_-R87cMca93Tumc67NBzKtpTLAfHC_XtFBZ7iv_h72wP3Q9EJMPobnqbV5dv_pFzMnGLQk7Rnql7Rg7wF_XTJm15vzHo9MfMxINUQgj0WiZeQ5pGkSc", + "q": "4dIIwEihlCFDFXqerLFiFHNOPWUTBxCc-exJMPMSoD_4DS9SFzIVk4Q55yJgODKMPQJDrFGWl_Z6kc-otvdwTzwqFM1RGpcpY1BoIoM9XPBebZoe1pTwPneLfHbIx2g9MsyHWxHg5C33OCjIestYt5KIGRVEKkqBlFTuK4AEVWU", + "dp": "YUKUd7Qi8YtWpz0unYURUlS6zUSx4_dVc8_yhItgf5u3axyV7aUeJ_0MaVqQ90kerr-0c8pgza5slJ_6NiEISdUBfjyWBVjQbC7N42hSbMR9w60s0ezQCcJ2sC2mKKB9_4dg3ew8IFsuskWnFqRdC192wJ0YyJjMb88xVvYy6Ik", + "dq": "f2v4rCdPpTovlEsghemBonB90X7bAb2KFiwSEKDX_byaxzXQaG5GZnrSAW4QRSWgpDxG21EeXJkkCGrYlIWCxfL4-8vu28Y4AGY6nF7ZnTu0zLuOcx6PgXALMSwFlx9miOeH6QdpktetC-9XUJK1eMAiM6UgVYy9GWnjCoCt8VE", + "qi": "vgxFfr9PFlh9zB2FjvLX8ev03Vo-o0G3CXgJC8fTH-RkljzYyvD96PT9MWKjEAZqhG5sMs6oLwh0SNf7arkm50forZGJkVV8cTLrTDPk5ZBqE4YcVsMcmAhC3VdH8ujcFbDbTocO4o5sRiaK3Iyqw-bM8zOOnRxhHQh8cbOjCmI" }`), }, @@ -81,21 +90,24 @@ var jsonWebKeys = []keyPair{ Public: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "e10385c5384046f395fc6d9027db2f35", + "kid": "5942c970c64842d39306c9a359be8ab0", "alg": "RS256", - "n": "299cgJgPiu9CK8hGgQw3j8e-Y_u4-Tm6WXKOFHdjCUPV5EAWMOa34cQNt75KN8pxlIcnujnU6TpH4OPRCw1gA44rrk_uczIEULsTnt6UFuMtUY2r-2UW2BWg5rEHyLcNX_QCA80T9DVSxsWeN8S23YcVk9fVputIRU7ee7auOx3b6K3pkoQJBVUk-_ndaqwlX-JU2CQG52CH91CrDzN0WGUPrhMZOdL7ybv94l5ztBrnjaQupkt0FxTA1_m_tXTvxIgzzegaqXrJ1mJM-z2TxPUJUc_04JaGilPUkxU780jk_03d46Op-pdElgbZ52C9JT9b8nRnA-vHq4e2whY8Yw", + "n": "3D1Z0EEqUux9HyngYQjFjC09sP53KIt1PG2hXUZJqn3IIsR3UPcUVvpOARlfWOsDss9SlZiInvPLXtTsL3K89AalT9Au34yLYVzr1JnkC1W8rU_ej0EYm3QnqT0EH_6nV4bvupIzip4rB4hx6oDu3EDCb2a83FLmntyKgV7HTFH7HUWoGcIQkZ-X3NczRWAfVnMnsgMIUzSVOtIMdcbJZjgvQoCZYUzMljjd0HKPnxZzrzDp76Syr7cLnkjuApHLUwi2sDSaL38oa4jsYseqCOAq-ItltuyLE42LoS1Hwdz4NI30cDkyvK5dFFu7PI8W-bH0rkNqEUO59kJGJtStFw", "e": "AQAB" }`), Private: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "8165cc507cd1492394be64575dfa8261", + "kid": "fe85223c6d054fc4ae0e8cfa38c3eca6", "alg": "RS256", - "n": "299cgJgPiu9CK8hGgQw3j8e-Y_u4-Tm6WXKOFHdjCUPV5EAWMOa34cQNt75KN8pxlIcnujnU6TpH4OPRCw1gA44rrk_uczIEULsTnt6UFuMtUY2r-2UW2BWg5rEHyLcNX_QCA80T9DVSxsWeN8S23YcVk9fVputIRU7ee7auOx3b6K3pkoQJBVUk-_ndaqwlX-JU2CQG52CH91CrDzN0WGUPrhMZOdL7ybv94l5ztBrnjaQupkt0FxTA1_m_tXTvxIgzzegaqXrJ1mJM-z2TxPUJUc_04JaGilPUkxU780jk_03d46Op-pdElgbZ52C9JT9b8nRnA-vHq4e2whY8Yw", + "n": "3D1Z0EEqUux9HyngYQjFjC09sP53KIt1PG2hXUZJqn3IIsR3UPcUVvpOARlfWOsDss9SlZiInvPLXtTsL3K89AalT9Au34yLYVzr1JnkC1W8rU_ej0EYm3QnqT0EH_6nV4bvupIzip4rB4hx6oDu3EDCb2a83FLmntyKgV7HTFH7HUWoGcIQkZ-X3NczRWAfVnMnsgMIUzSVOtIMdcbJZjgvQoCZYUzMljjd0HKPnxZzrzDp76Syr7cLnkjuApHLUwi2sDSaL38oa4jsYseqCOAq-ItltuyLE42LoS1Hwdz4NI30cDkyvK5dFFu7PI8W-bH0rkNqEUO59kJGJtStFw", "e": "AQAB", - "d": "xh587o6WKr2uZV8gUHXettroLpWKtl-TD7hOWBi_j4ClgfdRR50NggwzxCZeH-l18LzcSkyEEefnDriZC5lws6NurrHtjbU6-Dep1VSAIiNwGXVLy8nqDKlog5ZvCigPkC-BhUVMPpexz9QP3faORAzNn5szNCX7yB_qD5WrZy20AUEoWtGPgxGW6xf5Lgu6zg2uQEEB1Z0hKjHV9seIiuQooMrSzpS1D7BLSTHOvM2Y2lXvQQokc3uQXnyT_soHPjHl00bcuJLJaRCmyHRTol7uh9MNe67eMy7pHYmmlwOvTDfW6meKCgoEXd1wKIrS9VRY7WP36ZRpJH6qv8vceQ", - "p": "7sWEsknUaSlAJ-bGhsuFr_j15zupV9O-DLnLobASm4Z7Ylt1HhtPN1NCVzYFTCtltPBE_CXGaAPqw3wiERK3tgYSLV8yk57sU1H28Zsq65A1B-vdlO69-F_6djiGegYKTOO4CXt0VYB4hJ6Trwx_BNJmrAD_Ykjqsp5sR0gOrqU", - "q": "67y_hzbi81IH2DxmHTQOfHgcLYe-TnrEQLGLQtfx8J0J_REf_fLBDL-pt_jy6WIvTAb-LgUIcieiXfhni1nPUw0f_I1SDNv02EYvP0vkfyQdJBR6sLi4jv0mpqyQxvGif9B4eM9Qjngm2Jclj3-el-RkMZOUyf3zGTNGLI3MmGc" + "d": "N03spMAIacRa8x1n69XxDW8645wyveqvKNp6v9prmzV0sU5Wi-1PnTvDpMEDUWfKiPdIzKn9_LRJqGmdWZjdXAXjMzSa4KYBRhS4X625NyNzeq3ovF5jWH4Fi5j31TT0PqVyw439BV0MfxrDSFjSIlrcuG67FDR5FwFYgn4TW9Vu59EaULbno6U53BQHv_a2fuSTIH06zsI1y2Pp3AfCUdxjEviRVbmY-Zdd_qqdJV5b3h8henpABWDhdvMAyXdpOlxJqyTxs9-cud0vUl1MWgR14OUhWOMhGeGbuMK-EMTkaQlYP_-3teaKEzu4sc2YDmlstePBXydYjGSMQzHnEQ", + "p": "-jp9GP9Rqk3eB0KISm7sbXganiIXbpgaSFN7o9rYFgWnjMhAHizluRCX_Ffba-oIDPPXgQK9VKTtrmmrz7mNWC2eYuSVzgPfl24TUA8c1xGoUvISBD_Y6lN0HmljoLapx02u_18BEgKvTcskK09aZLN4YSaMf2FOwA-QvNtASg8", + "q": "4VHJ6QiX39Lm1BQgZpMOtzXUeB-OVutbMK0h92xBd5JNE8uRKfPo6KHLYqUd6roem012wQ3yww0PKjd0AjsjfPchD0ATg6cRgKI2DBX-sHFVTtWJKn22J2p0Ry2zTTuYKQQmikWocccTJHn0yGK1g-cYKSfKd1fikBhsbYBPlHk", + "dp": "qB6j7iects-aXUZWS6w7LVGEkLpXA_ctsWplp7FhfWpPKEdizONddPmxI9njkp2qywkJwaQVkMk-5_AXf3krfhMoV0k9XS09DIDoeOGuftFeRlxdvn0nQEjRu50TUudnKoEHEozCK1eicKILqw2lrgLm3l8IBo1aW7AZjsnAaAE", + "dq": "qA1IevZd0CKM50DbqaWlJCCSB8YBc_K2YOfAnbmwWm5T7p-19YRmApwqBbkBR0Dp8wsjt3mfxrrGxMX3UHKrVVk83xL7ewXwl_BkPz9oZlEhP1skovyAD4Xmk_AR-1indHAUDq1FpRbFwD71wz9cArUp4Ag9iiWSZcbRmBpLQIE", + "qi": "m0Km_G1VJu47Np_OLubdi16Db--C0np_ILetAQ8uZjePtfE96JQ11n2sl3BY8nj2n_A0Al8C8GAzzwHVn47Yw_l2YncS0SFkyuQuOwt83WyseiUXZqSOIq6aY2UvgeBtsNMJznfBFN0Jo8OrjNpQMyzDy04XxXFgucbYcN_elZ0" }`), }, @@ -103,21 +115,68 @@ var jsonWebKeys = []keyPair{ Public: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "941861b40500430da0d09ec213e00832", + "kid": "e8525274b94b466e87236c8288f3888d", "alg": "RS256", - "n": "ub3SiNK-uIvSrUTyIPm1cITzuqPX_CIa6nZTDTP1tJ6PP_KufYz2eGLj9jppWLo_J7XQfKfIAKvET8Mq4HEcLQpNRN90KNyGML17JJtSgYJeLuB38BnalVUxpnycPKeGgoNJMu6t8tKYOtOfxtqTA6x8MnqMeify1cvEc5Tr4QmKjcLLHKcr1yMR7kG48i586bLdchtIBYeB298WXbQaKrgsEjZA0E1exfMnYHyvN12lMBxwhOJtcFu3mngZ7vTh179UKsP3yD8IdO5ITe_RIOmnUKuynW3PdkRUzCK5gS-xuqueGqEzJVIKBv0Hfom3eyDW5DjxpIZxlqkGhGyeNw", + "n": "-OaIVgMSRhDe5KZTiTmElFVqlRaUS_pOt4RB6DAQWubMQBxsRktN5ooBZTWJ8uI4yqnrsMoookuf2uFSFfMKQMNi3YBLCorNkrapV6kfwDaX1yD26MbHz8upetBD3452gRMFHOZznbDAOlZOObtNFp2BCVTpzU3FKhN3fK-ejrQfUdHqsjszjazbiDXLLvjnjByGmlCE7olicvUZRQ2ydr7L3HxUEwZ5_U3FxnWQBAzEkERBiwnIB_7LbIUHzt2GaoqkLRoSnXrcJ26yIj0EneRJ9gCAGzvvzFAKhG7eMvaW5nbF5yJ5PnUEBZVC0N0AnoBqr2yXdYn0yQNvwmVzvw", "e": "AQAB" }`), Private: mustLoadJWK(`{ "use": "sig", "kty": "RSA", - "kid": "c4c09817da9a42ae8d850aaba7b7cd82", + "kid": "62a3f70927f04df6a23ee08a6d826276", "alg": "RS256", - "n": "ub3SiNK-uIvSrUTyIPm1cITzuqPX_CIa6nZTDTP1tJ6PP_KufYz2eGLj9jppWLo_J7XQfKfIAKvET8Mq4HEcLQpNRN90KNyGML17JJtSgYJeLuB38BnalVUxpnycPKeGgoNJMu6t8tKYOtOfxtqTA6x8MnqMeify1cvEc5Tr4QmKjcLLHKcr1yMR7kG48i586bLdchtIBYeB298WXbQaKrgsEjZA0E1exfMnYHyvN12lMBxwhOJtcFu3mngZ7vTh179UKsP3yD8IdO5ITe_RIOmnUKuynW3PdkRUzCK5gS-xuqueGqEzJVIKBv0Hfom3eyDW5DjxpIZxlqkGhGyeNw", + "n": "-OaIVgMSRhDe5KZTiTmElFVqlRaUS_pOt4RB6DAQWubMQBxsRktN5ooBZTWJ8uI4yqnrsMoookuf2uFSFfMKQMNi3YBLCorNkrapV6kfwDaX1yD26MbHz8upetBD3452gRMFHOZznbDAOlZOObtNFp2BCVTpzU3FKhN3fK-ejrQfUdHqsjszjazbiDXLLvjnjByGmlCE7olicvUZRQ2ydr7L3HxUEwZ5_U3FxnWQBAzEkERBiwnIB_7LbIUHzt2GaoqkLRoSnXrcJ26yIj0EneRJ9gCAGzvvzFAKhG7eMvaW5nbF5yJ5PnUEBZVC0N0AnoBqr2yXdYn0yQNvwmVzvw", "e": "AQAB", - "d": "29bQWSEWm1bjBDGWY3EqTwMNdtp1yPaU5O0nX3kgV6dT5VxXKkKtdc-WANkh1uKZ3WZUXTY4gpLKx504Im2965FF4z6XPcXFDes21R0BikfDMbh8PLJdBGLRYTwbr66YheDdwmq9d6nKg9X2RmZtmuuMFDL4EZ02zdVfr22TwcSCghC2gnV6CpHHeEatJBWbK1yE6cHqCeY9UTc_QnXmbZ0TYsQi4qCV1HqTJKZDtkzqZMPvMB5EP_my_SCxcfcIzt6qqujmuXCFiS658Up-Z4W5s0RINLoPmePG8zJVFBmWrQ8xiykCeL8z9XSvXoEo6ZJJC-KSjI6s-KsCfQqZ", - "p": "8LzUJM2YgP7zG618rrFTav3gB2t1yMwFJy9d3J-pOkVFUq-4-74qEZz6H2RTUw7Ae5XEYdVIbRRQInpo0qO2MfLW8vtRexUNFFt1pBiVykq-KdkWcwPETyRD-huEEqswBhg33lFTUrY7BXRukbfNmVY7YfdagIJ5LZU0I-nGMqs", - "q": "xYRoIFTTiXitKBFo0vvHAadqVV8gJq8bCxJZ4lFMpADlU-S8Me7aPmkhPmCDaw-ii940S46bTp9ueh6EJCttmG3cJm8r4YzjK-H1dnqeF_3dpq2pimVFlFILBKWojUHHWC4n0d1IVwdf8-xnDSiUzl9roFZV5IPy4mW1HMTZ4qU" + "d": "E9C6oupcrpJSRG7PcLnRCcQhF5x0EvZrBOY1HwiQjMQaF7KLli8RlRyAju-ru1fyQIQ1nN4DTSRrJhBRKikutEg52zuG_eBeDGOZaL6wpetSvFBGB_MnXi4vIHVrKeWcHpuGiS35n2C-dQaA9MyqDvZcGwOVF4CtBTQGi7kpjN-2-vbaBiqvy9MWMPxBJc6lAjqn6d8U4hqzvRcl2Fgh9gykjRX5RhuuqcbFBn-W5iHT2JeQtCUh2tBiBje8S8d2eN_m3B4JdIvdVTX6ONjLYlcm7yaQ2w5yA4inLVkHq3FkrOwWkwcVheMIUOAWUllBe65wQpl5v_7Dg_I0jHaOnQ", + "p": "_oXrxZUp99Ym-fqYym6130IFeNT5druZyKpdSUcjec8RaDLXiSeGWECRGdL5S9X09o9cz0wvpravIjemIbuiraCe-dSNd3MOKTI6PezDzmeIJzhAa02xJET0ZGHzLDIH_6uKnGHSiUMxKn6XmXg4gCR10gsAMMzBrfSWKU25xnU", + "q": "-lhCcCJOl3gWOz3cGEZkmjE68DUcoVy8VbcVB_nkg8P7qyolOpbdS0xB3r7VFIbEnW63pE6DFnPEpDJnmE4rZ0JCn22HpZzUepPkevCCyvK-5DEzp7MOdZEW34RVnpl5V7L_a64aQdMNkrroNczArH3fUr8UPMiAqjOzlieVUuM", + "dp": "m3J7Ts8NNLpsT5xHmMsI9PPDl8qrGWL2R2IVW13BQvG-dd8nWDL1kAzPEfCSJUjlNXjVfB3RtIY6bDGEm3GXp2dD8N1qERwJ-AsfGxbxd6wvsZRfc6mdmMMsZ-qSs9lSnUnNfDkado69BnEOccLE7y5VbxUc5rEHURDibRN1dMk", + "dq": "XlcOSmWTSl9VuWN5Yqh8VuMAe-736BMjYgXJZiUUqVccqwcJ2odpw2tsUz2E3ORgiZdkmwV_PuHHk7zu7yVeE96TbrL-8DkmlT7QVkf2cfaCW0nzVloSs9lGTlr3TPo3EQaCXJjSikW3krjesw-C-D3C_9LEFJfWbM1o-sfV7NM", + "qi": "otibsKrvyertgnuuxepfzTpPcQ3an31YTdyHwDc32-kaMyvd7WH5SYA_sqZwA2b9IRUNarZ6doEbebcwfwMNJV3I_QRRUNMOs2SDp5XH7cDWUGwtx3SYdziqK56Xt23orNHx95uIfqrkWkYRk1EOo6lRyVREtPwk8HCV_tl5eYY" + }`), + }, + + { + Public: mustLoadJWK(`{ + "use": "sig", + "kty": "EC", + "kid": "bc23e2a3e81f4a17b751ce423c69a3a4", + "crv": "P-256", + "alg": "ES256", + "x": "bIkaBnOXHj-TYlPLZ4cjZernfP6P73KCEZ6JlXQosz4", + "y": "YNyxfwaqsrkAXLIvSRJVsfrJvk9CqvQAJPDIqJ96cNU" + }`), + Private: mustLoadJWK(`{ + "use": "sig", + "kty": "EC", + "kid": "14c1bb757c7940b49ec1401c36a315c7", + "crv": "P-256", + "alg": "ES256", + "x": "bIkaBnOXHj-TYlPLZ4cjZernfP6P73KCEZ6JlXQosz4", + "y": "YNyxfwaqsrkAXLIvSRJVsfrJvk9CqvQAJPDIqJ96cNU", + "d": "VGryC3cIOaJqzgti9FsWMbSi4LsbZ_3BUmOBdsd8-Xo" + }`), + }, + + { + Public: mustLoadJWK(`{ + "use": "sig", + "kty": "EC", + "kid": "e0d6ff6492bd4ab39aa68a9e16f99960", + "crv": "P-256", + "alg": "ES256", + "x": "VCg2WFqMoeULMRr2cMgfqlNvORPI0YyIsjFzEHMGBZI", + "y": "QyWgq1jIfPUDlQPhA8sT_VWk7Z9-uPek0FJJ285G40s" + }`), + Private: mustLoadJWK(`{ + "use": "sig", + "kty": "EC", + "kid": "df1241b64dba40c18a10aff4a806083d", + "crv": "P-256", + "alg": "ES256", + "x": "VCg2WFqMoeULMRr2cMgfqlNvORPI0YyIsjFzEHMGBZI", + "y": "QyWgq1jIfPUDlQPhA8sT_VWk7Z9-uPek0FJJ285G40s", + "d": "Eu1dSBbvVRdZwU8wPSWUq_xhSzxFfx43NqSWFAx4Yas" }`), }, } diff --git a/storage/conformance/transactions.go b/storage/conformance/transactions.go index f018224e..3924e52c 100644 --- a/storage/conformance/transactions.go +++ b/storage/conformance/transactions.go @@ -1,12 +1,12 @@ package conformance import ( - "context" "strconv" "sync" "testing" "time" + "github.com/kylelemons/godebug/pretty" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" @@ -154,34 +154,67 @@ func testPasswordConcurrentUpdate(t *testing.T, s storage.Storage) { } func testKeysConcurrentUpdate(t *testing.T, s storage.Storage) { - // Test twice. Once for a create, once for an update. - for i := 0; i < 2; i++ { - n := time.Now().UTC().Round(time.Second) - keys1 := storage.Keys{ - SigningKey: jsonWebKeys[i].Private, - SigningKeyPub: jsonWebKeys[i].Public, - NextRotation: n, - } - - keys2 := storage.Keys{ - SigningKey: jsonWebKeys[2].Private, - SigningKeyPub: jsonWebKeys[2].Public, - NextRotation: n.Add(time.Hour), - VerificationKeys: []storage.VerificationKey{ - { - PublicKey: jsonWebKeys[0].Public, - Expiry: n.Add(time.Hour), - }, - { - PublicKey: jsonWebKeys[1].Public, - Expiry: n.Add(time.Hour * 2), + tests := []struct { + name string + keys1 storage.Keys + keys2 storage.Keys + }{ + { + name: "create with rsa and es256 payloads", + keys1: storage.Keys{ + SigningKey: jsonWebKeys[0].Private, + SigningKeyPub: jsonWebKeys[0].Public, + }, + keys2: storage.Keys{ + SigningKey: jsonWebKeys[5].Private, + SigningKeyPub: jsonWebKeys[5].Public, + VerificationKeys: []storage.VerificationKey{ + { + PublicKey: jsonWebKeys[1].Public, + }, + { + PublicKey: jsonWebKeys[6].Public, + }, }, }, + }, + { + name: "update with es256 and rsa payloads", + keys1: storage.Keys{ + SigningKey: jsonWebKeys[6].Private, + SigningKeyPub: jsonWebKeys[6].Public, + }, + keys2: storage.Keys{ + SigningKey: jsonWebKeys[2].Private, + SigningKeyPub: jsonWebKeys[2].Public, + VerificationKeys: []storage.VerificationKey{ + { + PublicKey: jsonWebKeys[5].Public, + }, + { + PublicKey: jsonWebKeys[3].Public, + }, + }, + }, + }, + } + + // Run twice against the same storage. The first case exercises row creation, + // the second runs with an existing keys row and therefore exercises updates. + for _, tt := range tests { + n := time.Now().UTC().Round(time.Second) + keys1 := tt.keys1 + keys1.NextRotation = n + keys2 := tt.keys2 + keys2.NextRotation = n.Add(time.Hour) + + for i := range keys2.VerificationKeys { + keys2.VerificationKeys[i].Expiry = n.Add(time.Duration(i+1) * time.Hour) } var err1, err2 error - ctx := context.TODO() + ctx := t.Context() err1 = s.UpdateKeys(ctx, func(old storage.Keys) (storage.Keys, error) { err2 = s.UpdateKeys(ctx, func(old storage.Keys) (storage.Keys, error) { return keys1, nil @@ -190,8 +223,23 @@ func testKeysConcurrentUpdate(t *testing.T, s storage.Storage) { }) if (err1 == nil) == (err2 == nil) { - t.Errorf("update keys: concurrent updates both returned no error") + t.Errorf("%s: concurrent updates both returned no error", tt.name) } + + got, err := s.GetKeys(ctx) + require.NoError(t, err) + + got.NextRotation = got.NextRotation.UTC() + + diff1 := pretty.Compare(keys1, got) + diff2 := pretty.Compare(keys2, got) + match1 := diff1 == "" + match2 := diff2 == "" + if match1 == match2 { + t.Fatalf("%s: final stored keys did not match an expected winner\nkeys1 diff:\n%s\nkeys2 diff:\n%s", tt.name, diff1, diff2) + } + + requireSigningKeyRoundTripUsable(t, got) } } From 896c6952af4c687d30e75a65347965619c54232a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:41:19 +0100 Subject: [PATCH 192/222] build(deps): bump google.golang.org/api from 0.272.0 to 0.273.0 (#4689) --- go.mod | 12 ++++++------ go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index ae1770db..0e661bf0 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 - google.golang.org/api v0.272.0 + google.golang.org/api v0.273.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 ) @@ -80,7 +80,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.18.0 // indirect + github.com/googleapis/gax-go/v2 v2.19.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 @@ -119,9 +119,9 @@ require ( go.etcd.io/etcd/api/v3 v3.6.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect @@ -132,7 +132,7 @@ require ( golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.42.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 0936889f..f8e63469 100644 --- a/go.sum +++ b/go.sum @@ -111,8 +111,8 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= -github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= +github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= +github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= 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= @@ -268,16 +268,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= 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/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -340,12 +340,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= -google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= -google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= -google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= +google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= +google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= From 58e387a5fa29d4a60076af99594b4c944d71a434 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:01:33 +0100 Subject: [PATCH 193/222] build(deps): bump sigstore/cosign-installer from 4.1.0 to 4.1.1 (#4692) Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.0 to 4.1.1. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/ba7bc0a3fef59531c69a25acd34668d6d3fe6f22...cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index ac8d61e8..c8ee272f 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -65,7 +65,7 @@ jobs: uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - name: Install cosign - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Set image name id: image-name From 9f92c71d6286e354bfdd9df9ba360baf09b85dc3 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 27 Mar 2026 09:33:20 +0100 Subject: [PATCH 194/222] feat: cookies encryption support (#4676) Signed-off-by: maksim.nabokikh --- cmd/dex/config.go | 4 +++ cmd/dex/serve.go | 6 ++++ server/server.go | 1 + server/session.go | 75 ++++++++++++++++++++++++++++++++++++++---- server/session_test.go | 65 +++++++++++++++++++++++++----------- 5 files changed, 125 insertions(+), 26 deletions(-) diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 846a924a..e80c1c57 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -683,6 +683,10 @@ type Sessions struct { 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"` } // MFAAuthenticator defines a multi-factor authentication provider. diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 995ae0fc..0c26006d 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -804,6 +804,9 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { if s.RememberMeCheckedByDefault != nil { sc.RememberMeCheckedByDefault = *s.RememberMeCheckedByDefault } + if s.CookieEncryptionKey != "" { + sc.CookieEncryptionKey = []byte(s.CookieEncryptionKey) + } } if sc.AbsoluteLifetime <= 0 { return nil, fmt.Errorf("absoluteLifetime must be positive, got %v", sc.AbsoluteLifetime) @@ -814,6 +817,9 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { 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) + } return sc, nil } diff --git a/server/server.go b/server/server.go index 53ef2d81..4d55d5ee 100644 --- a/server/server.go +++ b/server/server.go @@ -152,6 +152,7 @@ type Config struct { // SessionConfig holds resolved session configuration. type SessionConfig struct { CookieName string + CookieEncryptionKey []byte AbsoluteLifetime time.Duration ValidIfNotUsedFor time.Duration RememberMeCheckedByDefault bool diff --git a/server/session.go b/server/session.go index 52424c82..9640400e 100644 --- a/server/session.go +++ b/server/session.go @@ -2,11 +2,15 @@ package server import ( "context" + "crypto/aes" + "crypto/cipher" "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" + "io" "net/http" "path" "time" @@ -32,8 +36,9 @@ func remoteIP(r *http.Request) string { return r.RemoteAddr } -// sessionCookieValue encodes session identity into a cookie value using protobuf. -func sessionCookieValue(userID, connectorID, nonce string) string { +// sessionCookieValue encodes session identity into a cookie value. +// If encryptionKey is provided, the value is encrypted with AES-GCM. +func sessionCookieValue(userID, connectorID, nonce string, encryptionKey []byte) string { val, err := internal.Marshal(&internal.SessionCookie{ UserId: userID, ConnectorId: connectorID, @@ -43,11 +48,24 @@ func sessionCookieValue(userID, connectorID, nonce string) string { // Should never happen with valid string inputs. panic(fmt.Sprintf("marshal session cookie: %v", err)) } + if len(encryptionKey) > 0 { + val, err = encryptCookieValue(val, encryptionKey) + if err != nil { + panic(fmt.Sprintf("encrypt session cookie: %v", err)) + } + } return val } -// parseSessionCookie decodes a protobuf-encoded session cookie value. -func parseSessionCookie(value string) (userID, connectorID, nonce string, err error) { +// parseSessionCookie decodes a session cookie value. +// If encryptionKey is provided, the value is decrypted first. +func parseSessionCookie(value string, encryptionKey []byte) (userID, connectorID, nonce string, err error) { + if len(encryptionKey) > 0 { + value, err = decryptCookieValue(value, encryptionKey) + if err != nil { + return "", "", "", fmt.Errorf("decrypt session cookie: %w", err) + } + } var cookie internal.SessionCookie if err := internal.Unmarshal(value, &cookie); err != nil { return "", "", "", fmt.Errorf("decode session cookie: %w", err) @@ -55,6 +73,49 @@ func parseSessionCookie(value string) (userID, connectorID, nonce string, err er return cookie.UserId, cookie.ConnectorId, cookie.Nonce, nil } +// encryptCookieValue encrypts plaintext with AES-GCM and returns base64url-encoded ciphertext. +func encryptCookieValue(plaintext string, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.RawURLEncoding.EncodeToString(ciphertext), nil +} + +// decryptCookieValue decodes base64url and decrypts AES-GCM ciphertext. +func decryptCookieValue(encrypted string, key []byte) (string, error) { + data, err := base64.RawURLEncoding.DecodeString(encrypted) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + func (s *Server) sessionCookiePath() string { if s.issuerURL.Path == "" { return "/" @@ -65,7 +126,7 @@ func (s *Server) sessionCookiePath() string { func (s *Server) setSessionCookie(w http.ResponseWriter, userID, connectorID, nonce string, rememberMe bool) { cookie := &http.Cookie{ Name: s.sessionConfig.CookieName, - Value: sessionCookieValue(userID, connectorID, nonce), + Value: sessionCookieValue(userID, connectorID, nonce, s.sessionConfig.CookieEncryptionKey), Path: s.sessionCookiePath(), HttpOnly: true, Secure: s.issuerURL.Scheme == "https", @@ -103,7 +164,7 @@ func (s *Server) getValidAuthSession(ctx context.Context, w http.ResponseWriter, return nil } - userID, connectorID, nonce, err := parseSessionCookie(cookie.Value) + userID, connectorID, nonce, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey) if err != nil { s.logger.DebugContext(ctx, "invalid session cookie format", "err", err) s.clearSessionCookie(w) @@ -336,7 +397,7 @@ func (s *Server) updateSessionTokenIssuedAt(r *http.Request, clientID string) { return } - userID, connectorID, _, err := parseSessionCookie(cookie.Value) + userID, connectorID, _, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey) if err != nil { return } diff --git a/server/session_test.go b/server/session_test.go index f00eed59..eca04b95 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -47,7 +47,7 @@ func TestSetSessionCookie(t *testing.T) { c := cookies[0] assert.Equal(t, "dex_session", c.Name) - assert.Equal(t, sessionCookieValue("user1", "conn1", "nonce123"), c.Value) + assert.Equal(t, sessionCookieValue("user1", "conn1", "nonce123", nil), c.Value) assert.Equal(t, "/dex", c.Path) assert.True(t, c.HttpOnly) assert.True(t, c.Secure) @@ -93,8 +93,8 @@ func TestSessionCookieValueRoundtrip(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - value := sessionCookieValue(tt.userID, tt.connectorID, tt.nonce) - gotUser, gotConn, gotNonce, err := parseSessionCookie(value) + value := sessionCookieValue(tt.userID, tt.connectorID, tt.nonce, nil) + gotUser, gotConn, gotNonce, err := parseSessionCookie(value, nil) require.NoError(t, err) assert.Equal(t, tt.userID, gotUser) assert.Equal(t, tt.connectorID, gotConn) @@ -103,12 +103,39 @@ func TestSessionCookieValueRoundtrip(t *testing.T) { } } +func TestSessionCookieValueEncryptedRoundtrip(t *testing.T) { + key := []byte("0123456789abcdef") // 16 bytes = AES-128 + + value := sessionCookieValue("user1", "ldap", "nonce1", key) + // Encrypted value must differ from unencrypted. + unencrypted := sessionCookieValue("user1", "ldap", "nonce1", nil) + assert.NotEqual(t, unencrypted, value) + + // Must decrypt correctly. + gotUser, gotConn, gotNonce, err := parseSessionCookie(value, key) + require.NoError(t, err) + assert.Equal(t, "user1", gotUser) + assert.Equal(t, "ldap", gotConn) + assert.Equal(t, "nonce1", gotNonce) + + // Wrong key must fail. + wrongKey := []byte("abcdef0123456789") + //nolint:dogsled // only for tests + _, _, _, err = parseSessionCookie(value, wrongKey) + assert.Error(t, err) + + // No key must fail (encrypted value isn't valid protobuf). + //nolint:dogsled // only for tests + _, _, _, err = parseSessionCookie(value, nil) + assert.Error(t, err) +} + func TestParseSessionCookie_Invalid(t *testing.T) { //nolint:dogsled // only for tests - _, _, _, err := parseSessionCookie("invalid") + _, _, _, err := parseSessionCookie("invalid", nil) assert.Error(t, err) //nolint:dogsled // only for tests - _, _, _, err = parseSessionCookie("a.b") + _, _, _, err = parseSessionCookie("a.b", nil) assert.Error(t, err) } @@ -142,7 +169,7 @@ func TestGetValidAuthSession(t *testing.T) { 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: sessionCookieValue("nouser", "noconn", "nonce")}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("nouser", "noconn", "nonce", nil)}) w := httptest.NewRecorder() assert.Nil(t, s.getValidAuthSession(ctx, w, r, authReq)) // Cookie should be cleared. @@ -169,7 +196,7 @@ func TestGetValidAuthSession(t *testing.T) { require.NoError(t, s.storage.CreateAuthSession(ctx, session)) r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "conn1", nonce)}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "conn1", nonce, nil)}) result := s.getValidAuthSession(ctx, httptest.NewRecorder(), r, authReq) require.NotNil(t, result) @@ -197,7 +224,7 @@ func TestGetValidAuthSession(t *testing.T) { require.NoError(t, s.storage.CreateAuthSession(ctx, session)) r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "ldap", nonce)}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "ldap", nonce, nil)}) githubReq := &storage.AuthRequest{ConnectorID: "github"} assert.Nil(t, s.getValidAuthSession(ctx, httptest.NewRecorder(), r, githubReq)) @@ -222,7 +249,7 @@ func TestGetValidAuthSession(t *testing.T) { require.NoError(t, s.storage.CreateAuthSession(ctx, session)) r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user2", "conn2", "wrong-nonce")}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user2", "conn2", "wrong-nonce", nil)}) conn2Req := &storage.AuthRequest{ConnectorID: "conn2"} w := httptest.NewRecorder() @@ -250,7 +277,7 @@ func TestGetValidAuthSession(t *testing.T) { require.NoError(t, s.storage.CreateAuthSession(ctx, session)) r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user3", "conn3", nonce)}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user3", "conn3", nonce, nil)}) conn3Req := &storage.AuthRequest{ConnectorID: "conn3"} w := httptest.NewRecorder() @@ -282,7 +309,7 @@ func TestGetValidAuthSession(t *testing.T) { require.NoError(t, s.storage.CreateAuthSession(ctx, session)) r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user4", "conn4", nonce)}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user4", "conn4", nonce, nil)}) conn4Req := &storage.AuthRequest{ConnectorID: "conn4"} w := httptest.NewRecorder() @@ -317,7 +344,7 @@ func TestCreateOrUpdateAuthSession(t *testing.T) { cookies := w.Result().Cookies() require.Len(t, cookies, 1) - userID, connectorID, nonce, err := parseSessionCookie(cookies[0].Value) + userID, connectorID, nonce, err := parseSessionCookie(cookies[0].Value, nil) require.NoError(t, err) assert.Equal(t, "user-1", userID) assert.Equal(t, "mock", connectorID) @@ -373,7 +400,7 @@ func TestCreateOrUpdateAuthSession(t *testing.T) { // Cookie should be set with existing nonce. cookies := w.Result().Cookies() require.Len(t, cookies, 1) - _, _, gotNonce, err := parseSessionCookie(cookies[0].Value) + _, _, gotNonce, err := parseSessionCookie(cookies[0].Value, nil) require.NoError(t, err) assert.Equal(t, nonce, gotNonce) @@ -451,7 +478,7 @@ func setupSessionLoginFixture(t *testing.T, s *Server) storage.AuthRequest { func sessionCookieRequest(userID, connectorID, nonce string) *http.Request { r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue(userID, connectorID, nonce)}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue(userID, connectorID, nonce, nil)}) return r } @@ -664,7 +691,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) { authReq.MaxAge = -1 // not specified r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)}) w := httptest.NewRecorder() _, ok := s.trySessionLogin(ctx, r, w, &authReq) @@ -680,7 +707,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) { authReq.MaxAge = 3600 r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)}) w := httptest.NewRecorder() _, ok := s.trySessionLogin(ctx, r, w, &authReq) @@ -696,7 +723,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) { authReq.MaxAge = 3600 r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)}) w := httptest.NewRecorder() _, ok := s.trySessionLogin(ctx, r, w, &authReq) @@ -712,7 +739,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) { authReq.MaxAge = 0 r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)}) w := httptest.NewRecorder() _, ok := s.trySessionLogin(ctx, r, w, &authReq) @@ -734,7 +761,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) { })) r := httptest.NewRequest(http.MethodGet, "/", nil) - r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")}) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)}) w := httptest.NewRecorder() redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq) From 9caf0f1c9dacbac323c23f48d65e9a8f4e37d411 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 27 Mar 2026 09:55:08 +0100 Subject: [PATCH 195/222] feat: prompt select_login (#4678) Signed-off-by: maksim.nabokikh Signed-off-by: Maksim Nabokikh --- server/handlers.go | 44 +++++++++++++ server/handlers_test.go | 137 ++++++++++++++++++++++++++++++++++++++++ server/prompt.go | 17 +++-- server/prompt_test.go | 8 ++- server/session.go | 16 ++++- 5 files changed, 212 insertions(+), 10 deletions(-) diff --git a/server/handlers.go b/server/handlers.go index 8db9b05f..7bc04c48 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -224,6 +224,50 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { if len(connectors) == 1 && !s.alwaysShowLogin { connURL.Path = s.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 s.sessionConfig != nil { + authReq, _, err := s.parseAuthorizationRequest(r) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to parse authorization request", "err", 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") + } + } + prompt, err := ParsePrompt(authReq.Prompt) + if err != nil { + // Server error because authReq was validated before saving it to database. + s.redirectWithError(w, r, authReq, errServerError, "Invalid authentication request") + return + } + + // Invalid prompts will be validated and properly redirected later + if !s.alwaysShowLogin && !prompt.SelectAccount() { + session := s.getValidSession(ctx, w, r) + if session != nil { + for _, c := range connectors { + if c.ID != session.ConnectorID { + continue + } + connURL.Path = s.absPath("/auth", url.PathEscape(session.ConnectorID)) + http.Redirect(w, r, connURL.String(), http.StatusFound) + return + } + } + } + if prompt.None() { + // Cannot authenticate silently with prompt=none. + s.redirectWithError(w, r, authReq, errLoginRequired, "id_token_hint does not match authenticated user") + return + } } connectorInfos := make([]connectorInfo, 0, len(connectors)) diff --git a/server/handlers_test.go b/server/handlers_test.go index dad1ab2a..c25aeaf5 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -1913,6 +1913,143 @@ func TestHandleAuthorizationWithNoMatchingConnectors(t *testing.T) { require.Equal(t, http.StatusBadRequest, rr.Code) } +func TestHandleAuthorizationSessionSkipsConnectorSelection(t *testing.T) { + ctx := t.Context() + + sessionConfig := &SessionConfig{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: 1 * time.Hour, + } + + client := storage.Client{ + ID: "test-client", + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + Name: "Test Client", + } + + authURL := fmt.Sprintf("/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=openid", + client.ID, url.QueryEscape("https://example.com/callback")) + + createSession := func(t *testing.T, s *Server, connectorID string) *http.Cookie { + t.Helper() + now := time.Now() + nonce := "test-nonce" + session := storage.AuthSession{ + UserID: "user1", + ConnectorID: connectorID, + Nonce: 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)) + return &http.Cookie{ + Name: "dex_session", + Value: sessionCookieValue("user1", connectorID, nonce), + } + } + + t.Run("valid session redirects to session connector", func(t *testing.T) { + httpServer, s := newTestServerMultipleConnectors(t, func(c *Config) { + c.SessionConfig = sessionConfig + }) + defer httpServer.Close() + require.NoError(t, s.storage.CreateClient(ctx, client)) + + cookie := createSession(t, s, "mock") + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", authURL, nil) + req.AddCookie(cookie) + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusFound, rr.Code) + require.Contains(t, rr.Header().Get("Location"), "/auth/mock") + }) + + t.Run("prompt=select_account shows connector selection despite session", func(t *testing.T) { + httpServer, s := newTestServerMultipleConnectors(t, func(c *Config) { + c.SessionConfig = sessionConfig + }) + defer httpServer.Close() + require.NoError(t, s.storage.CreateClient(ctx, client)) + + cookie := createSession(t, s, "mock") + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", authURL+"&prompt=select_account", nil) + req.AddCookie(cookie) + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("no session shows connector selection", func(t *testing.T) { + httpServer, s := newTestServerMultipleConnectors(t, func(c *Config) { + c.SessionConfig = sessionConfig + }) + defer httpServer.Close() + require.NoError(t, s.storage.CreateClient(ctx, client)) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", authURL, nil) + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("alwaysShowLogin shows connector selection despite session", func(t *testing.T) { + httpServer, s := newTestServerMultipleConnectors(t, func(c *Config) { + c.SessionConfig = sessionConfig + c.AlwaysShowLoginScreen = true + }) + defer httpServer.Close() + require.NoError(t, s.storage.CreateClient(ctx, client)) + + cookie := createSession(t, s, "mock") + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", authURL, nil) + req.AddCookie(cookie) + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("session connector not in filtered list shows connector selection", func(t *testing.T) { + httpServer, s := newTestServerMultipleConnectors(t, func(c *Config) { + c.SessionConfig = sessionConfig + }) + defer httpServer.Close() + + filteredClient := storage.Client{ + ID: "filtered-client", + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + Name: "Filtered Client", + AllowedConnectors: []string{"mock", "mock2"}, + } + require.NoError(t, s.storage.CreateClient(ctx, filteredClient)) + + // Session is for "other-connector" which is not in the allowed list. + cookie := createSession(t, s, "other-connector") + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", fmt.Sprintf("/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=openid", + filteredClient.ID, url.QueryEscape("https://example.com/callback")), nil) + req.AddCookie(cookie) + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + }) +} + func TestHandleAuthorizationWithoutAllowedConnectors(t *testing.T) { ctx := t.Context() diff --git a/server/prompt.go b/server/prompt.go index f4941745..c225a270 100644 --- a/server/prompt.go +++ b/server/prompt.go @@ -9,9 +9,10 @@ import ( // The parameter is space-separated and may contain: "none", "login", "consent", "select_account". // "none" must not be combined with any other value. type Prompt struct { - none bool - login bool - consent bool + none bool + login bool + consent bool + selectAccount bool } // ParsePrompt parses and validates the raw prompt query parameter. @@ -39,13 +40,13 @@ func ParsePrompt(raw string) (Prompt, error) { case "consent": p.consent = true case "select_account": - // Dex does not support account selection; ignore per spec recommendation. + p.selectAccount = true default: return Prompt{}, fmt.Errorf("invalid prompt value %q", v) } } - if p.none && (p.login || p.consent) { + if p.none && (p.login || p.consent || p.selectAccount) { return Prompt{}, fmt.Errorf("prompt=none must not be combined with other values") } @@ -61,6 +62,9 @@ func (p Prompt) Login() bool { return p.login } // Consent returns true if the caller requested forced consent screen. func (p Prompt) Consent() bool { return p.consent } +// SelectAccount returns true if the caller requested account/connector selection. +func (p Prompt) SelectAccount() bool { return p.selectAccount } + // String returns the canonical space-separated representation stored in the database. func (p Prompt) String() string { var parts []string @@ -73,5 +77,8 @@ func (p Prompt) String() string { if p.consent { parts = append(parts, "consent") } + if p.selectAccount { + parts = append(parts, "select_account") + } return strings.Join(parts, " ") } diff --git a/server/prompt_test.go b/server/prompt_test.go index 00820ce8..e02b78b3 100644 --- a/server/prompt_test.go +++ b/server/prompt_test.go @@ -20,14 +20,16 @@ func TestParsePrompt(t *testing.T) { {name: "consent", raw: "consent", want: Prompt{consent: true}}, {name: "login consent", raw: "login consent", want: Prompt{login: true, consent: true}}, {name: "consent login", raw: "consent login", want: Prompt{login: true, consent: true}}, - {name: "select_account ignored", raw: "select_account", want: Prompt{}}, - {name: "login select_account", raw: "login select_account", want: Prompt{login: true}}, + {name: "select_account", raw: "select_account", want: Prompt{selectAccount: true}}, + {name: "login select_account", raw: "login select_account", want: Prompt{login: true, selectAccount: true}}, + {name: "consent select_account", raw: "consent select_account", want: Prompt{consent: true, selectAccount: true}}, {name: "duplicate values", raw: "login login", want: Prompt{login: true}}, {name: "whitespace padding", raw: " login ", want: Prompt{login: true}}, // Errors. {name: "none with login", raw: "none login", wantErr: true}, {name: "none with consent", raw: "none consent", wantErr: true}, + {name: "none with select_account", raw: "none select_account", wantErr: true}, {name: "unknown value", raw: "bogus", wantErr: true}, } @@ -54,6 +56,8 @@ func TestPromptString(t *testing.T) { {Prompt{login: true}, "login"}, {Prompt{consent: true}, "consent"}, {Prompt{login: true, consent: true}, "login consent"}, + {Prompt{selectAccount: true}, "select_account"}, + {Prompt{login: true, selectAccount: true}, "login select_account"}, } for _, tc := range tests { diff --git a/server/session.go b/server/session.go index 9640400e..3d0e0ec2 100644 --- a/server/session.go +++ b/server/session.go @@ -150,11 +150,11 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) { }) } -// getValidAuthSession returns a valid, non-expired session or nil. +// getValidSession returns a valid, non-expired session or nil. // It parses the session cookie to extract (userID, connectorID, nonce), // looks up the session by composite key, and verifies the nonce. // Invalid or expired session cookies are cleared automatically. -func (s *Server) getValidAuthSession(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq *storage.AuthRequest) *storage.AuthSession { +func (s *Server) getValidSession(ctx context.Context, w http.ResponseWriter, r *http.Request) *storage.AuthSession { if s.sessionConfig == nil { return nil } @@ -211,12 +211,22 @@ func (s *Server) getValidAuthSession(ctx context.Context, w http.ResponseWriter, return nil } + return &session +} + +// getValidAuthSession returns a valid session matching the auth request's connector, or nil. +func (s *Server) getValidAuthSession(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq *storage.AuthRequest) *storage.AuthSession { + session := s.getValidSession(ctx, w, r) + if session == nil { + return nil + } + // Only reuse sessions from the same connector. if session.ConnectorID != authReq.ConnectorID { return nil } - return &session + return session } // createOrUpdateAuthSession creates a new session or updates an existing one From 1558aacc8ca6a835865a900ecf851b5fa3f7a723 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 27 Mar 2026 12:30:38 +0100 Subject: [PATCH 196/222] fix: fix handler tests after merging cookie enc (#4693) Signed-off-by: Maksim Nabokikh --- server/handlers_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/handlers_test.go b/server/handlers_test.go index c25aeaf5..64f40356 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -1951,7 +1951,7 @@ func TestHandleAuthorizationSessionSkipsConnectorSelection(t *testing.T) { require.NoError(t, s.storage.CreateAuthSession(ctx, session)) return &http.Cookie{ Name: "dex_session", - Value: sessionCookieValue("user1", connectorID, nonce), + Value: sessionCookieValue("user1", connectorID, nonce, nil), } } From 31cf652930ce0ca819fe5baba369990c248f3fea Mon Sep 17 00:00:00 2001 From: Roger Meier Date: Sun, 29 Mar 2026 00:07:11 +0100 Subject: [PATCH 197/222] feat: add a jti per default (#4695) Signed-off-by: Roger Meier --- server/oauth2.go | 3 ++ server/oauth2_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/server/oauth2.go b/server/oauth2.go index 7e102302..40f8063b 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -21,6 +21,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/go-jose/go-jose/v4" + "github.com/google/uuid" "github.com/dexidp/dex/connector" "github.com/dexidp/dex/server/internal" @@ -271,6 +272,7 @@ type idTokenClaims struct { Audience audience `json:"aud"` Expiry int64 `json:"exp"` IssuedAt int64 `json:"iat"` + JWTID string `json:"jti,omitempty"` AuthorizingParty string `json:"azp,omitempty"` Nonce string `json:"nonce,omitempty"` AuthTime int64 `json:"auth_time,omitempty"` @@ -357,6 +359,7 @@ func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage Nonce: nonce, Expiry: expiry.Unix(), IssuedAt: issuedAt.Unix(), + JWTID: uuid.New().String(), } // Include auth_time when sessions are enabled and the value is available. diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 6df028e0..2cdc938a 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -1099,6 +1099,84 @@ func TestNewIDTokenUsesStoredAlgorithmUntilNextRotation(t *testing.T) { require.Equal(t, wantCodeHash, claims.CodeHash) } +func TestNewIDTokenContainsJTI(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := slog.New(slog.DiscardHandler) + store := memory.New(logger) + + now := time.Now().UTC() + err := store.UpdateKeys(ctx, func(keys storage.Keys) (storage.Keys, error) { + keys.SigningKey = &jose.JSONWebKey{ + Key: testKey, + KeyID: "test-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.SigningKeyPub = &jose.JSONWebKey{ + Key: testKey.Public(), + KeyID: "test-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.NextRotation = now.Add(time.Hour) + return keys, nil + }) + require.NoError(t, err) + + localConfig := signer.LocalConfig{ + KeysRotationPeriod: time.Hour.String(), + Algorithm: jose.RS256, + } + sig, err := localConfig.Open(ctx, store, time.Hour, func() time.Time { return now }, logger) + require.NoError(t, err) + + sig.Start(ctx) + + issuerURL, err := url.Parse("https://issuer.example.com") + require.NoError(t, err) + + s := &Server{ + signer: sig, + issuerURL: *issuerURL, + logger: logger, + now: func() time.Time { return now }, + idTokensValidFor: time.Hour, + } + + keys, err := sig.ValidationKeys(ctx) + require.NoError(t, err) + require.NotEmpty(t, keys) + + extractJTI := func(t *testing.T, idToken string) string { + t.Helper() + jws, err := jose.ParseSigned(idToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + payload, err := jws.Verify(keys[0]) + require.NoError(t, err) + var claims struct { + JTI string `json:"jti"` + } + err = json.Unmarshal(payload, &claims) + require.NoError(t, err) + return claims.JTI + } + + token1, _, err := s.newIDToken(ctx, "client", storage.Claims{UserID: "1", Username: "alice"}, []string{"openid"}, "n1", "", "", "mock", time.Time{}) + require.NoError(t, err) + + token2, _, err := s.newIDToken(ctx, "client", storage.Claims{UserID: "1", Username: "alice"}, []string{"openid"}, "n2", "", "", "mock", time.Time{}) + require.NoError(t, err) + + jti1 := extractJTI(t, token1) + jti2 := extractJTI(t, token2) + + assert.NotEmpty(t, jti1, "jti claim must be present and non-empty") + assert.NotEmpty(t, jti2, "jti claim must be present and non-empty") + assert.NotEqual(t, jti1, jti2, "each token must have a unique jti") +} + func TestSessionMatchesHint(t *testing.T) { // genSubject("foo", "bar") == "CgNmb28SA2Jhcg" (from TestGetSubject) assert.True(t, sessionMatchesHint(&storage.AuthSession{UserID: "foo", ConnectorID: "bar"}, "CgNmb28SA2Jhcg")) From 89f4321b626f29a42018bc3b6a5d505fce3cde2b Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Sun, 29 Mar 2026 13:20:42 +0200 Subject: [PATCH 198/222] Updating the maintainers list (#4696) Signed-off-by: Maksim Nabokikh --- MAINTAINERS | 6 ------ MAINTAINERS.md | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) delete mode 100644 MAINTAINERS create mode 100644 MAINTAINERS.md diff --git a/MAINTAINERS b/MAINTAINERS deleted file mode 100644 index b95c2499..00000000 --- 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 00000000..889cbc05 --- /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 From bf323d313903daf0e5db55b0efa87a551829173e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:15:24 +0200 Subject: [PATCH 199/222] build(deps): bump github.com/mattn/go-sqlite3 from 1.14.37 to 1.14.38 (#4699) Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.37 to 1.14.38. - [Release notes](https://github.com/mattn/go-sqlite3/releases) - [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.37...v1.14.38) --- updated-dependencies: - dependency-name: github.com/mattn/go-sqlite3 dependency-version: 1.14.38 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0e661bf0..e55f3e93 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/kylelemons/godebug v1.1.0 github.com/lib/pq v1.12.0 github.com/mattermost/xml-roundtrip-validator v0.1.0 - github.com/mattn/go-sqlite3 v1.14.37 + github.com/mattn/go-sqlite3 v1.14.38 github.com/oklog/run v1.2.0 github.com/openbao/openbao/api/v2 v2.5.1 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index f8e63469..507ec03f 100644 --- a/go.sum +++ b/go.sum @@ -185,8 +185,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= -github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= +github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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= From 6e695a6fb97d2b25e84e19deaccc73ee76b6047e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:15:47 +0200 Subject: [PATCH 200/222] build(deps): bump actions/setup-go from 6.3.0 to 6.4.0 (#4700) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.3.0 to 6.4.0. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/4b73464bb391d4059bd26b0524d20df3927bd417...4a3601121dd01d1626a1e23e37211e3254c1c06c) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5224e1b3..ba456075 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -105,7 +105,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "1.25" @@ -193,7 +193,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "1.25" From 2fa7d8023f4e56b4152e9da5e1626a206ada345f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:18:17 +0200 Subject: [PATCH 201/222] build(deps): bump github/codeql-action from 4.34.1 to 4.35.1 (#4698) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.34.1 to 4.35.1. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/38697555549f1db7851b81482ff19f1fa5c4fedc...c10b8064de6f491fea524254123dbe5e09572f13) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/analysis-scorecard.yaml | 2 +- .github/workflows/artifacts.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml index 4c4ebeb4..d21c1093 100644 --- a/.github/workflows/analysis-scorecard.yaml +++ b/.github/workflows/analysis-scorecard.yaml @@ -42,6 +42,6 @@ jobs: retention-days: 5 - name: Upload results to GitHub Security tab - uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v3.29.5 + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index c8ee272f..b4aea86b 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -264,6 +264,6 @@ jobs: overwrite: true - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v3.29.5 + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5 with: sarif_file: trivy-results.sarif From f90a36c390348cbfee5b025cbd6ca205c49299f7 Mon Sep 17 00:00:00 2001 From: Ilia Andreev Date: Mon, 30 Mar 2026 09:20:18 +0200 Subject: [PATCH 202/222] docs: mention LDAP recursionGroupAttr in config.yaml.dist (#4697) The recursionGroupAttr option for nested LDAP group resolution is documented on dexidp.io but absent from config.yaml.dist, making it hard to discover. Add a brief note pointing to the LDAP connector docs. Ref: https://github.com/dexidp/dex/issues/2955 Signed-off-by: Ilia Andreev Co-authored-by: Ilia Andreev --- config.yaml.dist | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config.yaml.dist b/config.yaml.dist index 669066c9..807448de 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -163,6 +163,9 @@ web: # 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. From 8031f5b1cab0705f48d1f926ebef11416eabcff9 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Mon, 30 Mar 2026 15:33:30 +0200 Subject: [PATCH 203/222] feat: add home page with user session info (#4677) Signed-off-by: maksim.nabokikh --- server/home.go | 81 ++++++++++++++++++ server/home_test.go | 180 +++++++++++++++++++++++++++++++++++++++ server/server.go | 15 +--- server/templates.go | 30 +++++++ server/templates_test.go | 21 +++++ web/static/main.css | 79 +++++++++++++++++ web/templates/home.html | 98 +++++++++++++++++++++ 7 files changed, 490 insertions(+), 14 deletions(-) create mode 100644 server/home.go create mode 100644 server/home_test.go create mode 100644 web/templates/home.html diff --git a/server/home.go b/server/home.go new file mode 100644 index 00000000..0e15ab32 --- /dev/null +++ b/server/home.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/dexidp/dex/storage" +) + +func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { + if s.sessionConfig == nil || s.templates.homeTmpl == nil { + s.handleHomeInline(w, r) + return + } + + ctx := r.Context() + data := homeData{ + DiscoveryURL: s.issuerURL.JoinPath(".well-known", "openid-configuration").String(), + LogoutURL: s.absURL("/logout"), + } + + if cookie, err := r.Cookie(s.sessionConfig.CookieName); err == nil && cookie.Value != "" { + if userID, connectorID, nonce, err := parseSessionCookie(cookie.Value); err == nil { + session, err := s.storage.GetAuthSession(ctx, userID, connectorID) + if err == nil && session.Nonce == nonce { + data.LoggedIn = true + data.IPAddress = session.IPAddress + data.UserAgent = session.UserAgent + s.populateHomeData(ctx, &data, userID, connectorID) + } else if err != nil && !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "home: failed to get auth session", "err", err) + } + } + } + + if err := s.templates.home(r, w, data); err != nil { + s.logger.ErrorContext(ctx, "failed to render home template", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + } +} + +func (s *Server) handleHomeInline(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprintf(w, ` + Dex +

Dex IdP

+

A Federated OpenID Connect Provider

+

Discovery

`, + s.issuerURL.JoinPath(".well-known", "openid-configuration").String()) + if err != nil { + s.logger.Error("failed to write response", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Handling the / path error.") + } +} + +func (s *Server) populateHomeData(ctx context.Context, data *homeData, userID, connectorID string) { + ui, err := s.storage.GetUserIdentity(ctx, userID, connectorID) + if err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "home: failed to get user identity", "err", err) + } + return + } + + data.Username = ui.Claims.PreferredUsername + if data.Username == "" { + data.Username = ui.Claims.Username + } + data.Email = ui.Claims.Email + data.EmailVerified = ui.Claims.EmailVerified + data.Groups = ui.Claims.Groups + if !ui.LastLogin.IsZero() { + data.LastLoginEpoch = ui.LastLogin.Unix() + } + + conn, err := s.storage.GetConnector(ctx, connectorID) + if err == nil { + data.ConnectorName = conn.Name + } +} diff --git a/server/home_test.go b/server/home_test.go new file mode 100644 index 00000000..3425408a --- /dev/null +++ b/server/home_test.go @@ -0,0 +1,180 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + gosundheit "github.com/AppsFlyer/go-sundheit" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" +) + +func newTestServerWithSessions(t *testing.T, 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) + + config := Config{ + Issuer: s.URL, + Storage: memory.New(logger), + Web: WebConfig{ + Dir: "../web", + }, + Logger: logger, + PrometheusRegistry: prometheus.NewRegistry(), + HealthChecker: gosundheit.New(), + SkipApprovalScreen: true, + AllowedGrantTypes: []string{ + grantTypeAuthorizationCode, + grantTypeClientCredentials, + grantTypeRefreshToken, + grantTypeTokenExchange, + grantTypeDeviceCode, + }, + Signer: sig, + SessionConfig: &SessionConfig{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: 1 * time.Hour, + }, + } + if updateConfig != nil { + updateConfig(&config) + } + s.URL = config.Issuer + + connector := storage.Connector{ + ID: "mock", + Type: "mockCallback", + Name: "Mock", + ResourceVersion: "1", + } + require.NoError(t, config.Storage.CreateConnector(ctx, connector)) + + server, err = newServer(ctx, config) + require.NoError(t, err) + + if server.refreshTokenPolicy == nil { + server.refreshTokenPolicy, err = NewRefreshTokenPolicy(logger, false, "", "", "") + require.NoError(t, err) + server.refreshTokenPolicy.now = config.Now + } + + return s, server +} + +func TestHomeNoSessions(t *testing.T) { + httpServer, server := newTestServer(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + body := rr.Body.String() + require.Contains(t, body, "Dex IdP") + require.Contains(t, body, "Discovery") + require.NotContains(t, body, "Logout") +} + +func TestHomeNotLoggedIn(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + body := rr.Body.String() + require.Contains(t, body, "Discovery") + require.Contains(t, body, "Not logged in") + require.NotContains(t, body, "Logout") +} + +func TestHomeLoggedIn(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + userID := "test-user" + connectorID := "mock" + nonce := "testnonce" + now := time.Now() + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, + ConnectorID: connectorID, + Nonce: nonce, + CreatedAt: now, + LastActivity: now, + })) + + require.NoError(t, server.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: userID, + ConnectorID: connectorID, + Claims: storage.Claims{ + UserID: userID, + Username: "Test User", + PreferredUsername: "testuser", + Email: "test@example.com", + EmailVerified: true, + Groups: []string{"admins", "devs"}, + }, + LastLogin: now, + })) + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{ + Name: "dex_session", + Value: sessionCookieValue(userID, connectorID, nonce), + }) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + body := rr.Body.String() + require.Contains(t, body, "Logout") + require.Contains(t, body, "/logout") + require.Contains(t, body, "testuser") + require.Contains(t, body, "test@example.com") + require.Contains(t, body, "Mock") + require.Contains(t, body, "admins") + require.Contains(t, body, "Discovery") + require.NotContains(t, body, "Not logged in") +} + +func TestHomeInvalidCookie(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{ + Name: "dex_session", + Value: "invalid-cookie-value", + }) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + body := rr.Body.String() + require.NotContains(t, body, "Logout") + require.Contains(t, body, "Not logged in") + require.Contains(t, body, "Discovery") +} diff --git a/server/server.go b/server/server.go index 4d55d5ee..2f023623 100644 --- a/server/server.go +++ b/server/server.go @@ -521,20 +521,7 @@ func newServer(ctx context.Context, c Config) (*Server, error) { return nil, err } handleWithCORS("/.well-known/openid-configuration", discoveryHandler) - // Handle the root path for the better user experience. - handleWithCORS("/", func(w http.ResponseWriter, r *http.Request) { - _, err := fmt.Fprintf(w, ` - Dex -

Dex IdP

-

A Federated OpenID Connect Provider

-

Discovery

`, - s.issuerURL.JoinPath(".well-known", "openid-configuration").String()) - if err != nil { - s.logger.Error("failed to write response", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Handling the / path error.") - return - } - }) + handleWithCORS("/", s.handleHome) // TODO(ericchiang): rate limit certain paths based on IP. handleWithCORS("/token", s.handleToken) diff --git a/server/templates.go b/server/templates.go index 693f0f1c..a2818b28 100644 --- a/server/templates.go +++ b/server/templates.go @@ -23,6 +23,7 @@ const ( tmplDevice = "device.html" tmplDeviceSuccess = "device_success.html" tmplTOTPVerify = "totp_verify.html" + tmplHome = "home.html" ) var requiredTmpls = []string{ @@ -44,6 +45,7 @@ type templates struct { deviceTmpl *template.Template deviceSuccessTmpl *template.Template totpVerifyTmpl *template.Template + homeTmpl *template.Template } type webConfig struct { @@ -172,6 +174,7 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { deviceTmpl: tmpls.Lookup(tmplDevice), deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess), totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify), + homeTmpl: tmpls.Lookup(tmplHome), }, nil } @@ -228,6 +231,13 @@ func relativeURL(serverPath, reqPath, assetPath string) string { // Remove common prefix of request path with server path _, req = stripCommonParts(server, req) + // When the request is at the server root (e.g., reqPath == "/dex"), + // the browser treats the last path segment as a file, not a directory. + // Prepend the server path so relative URLs resolve correctly. + if len(req) == 0 && len(server) > 0 { + asset = append(server, asset...) + } + // Remove common prefix of request path with asset path asset, req = stripCommonParts(asset, req) @@ -356,6 +366,26 @@ func (t *templates) totpVerify(r *http.Request, w http.ResponseWriter, postURL, return renderTemplate(w, t.totpVerifyTmpl, data) } +type homeData struct { + LoggedIn bool + Username string + Email string + EmailVerified bool + Groups []string + ConnectorName string + LastLoginEpoch int64 + IPAddress string + UserAgent string + LogoutURL string + DiscoveryURL string + ReqPath string +} + +func (t *templates) home(r *http.Request, w http.ResponseWriter, data homeData) error { + data.ReqPath = r.URL.Path + return renderTemplate(w, t.homeTmpl, data) +} + func (t *templates) oob(r *http.Request, w http.ResponseWriter, code string) error { data := struct { Code string diff --git a/server/templates_test.go b/server/templates_test.go index defb2e5e..726aa843 100644 --- a/server/templates_test.go +++ b/server/templates_test.go @@ -31,6 +31,27 @@ func TestRelativeURL(t *testing.T) { assetPath: "assets/css/main.css", expected: "../assets/css/main.css", }, + { + name: "server-root-page", + serverPath: "/dex", + reqPath: "/dex", + assetPath: "static/main.css", + expected: "dex/static/main.css", + }, + { + name: "server-root-page-nested", + serverPath: "/dex/idp", + reqPath: "/dex/idp", + assetPath: "theme/styles.css", + expected: "dex/idp/theme/styles.css", + }, + { + name: "server-root-no-subpath", + serverPath: "/", + reqPath: "/", + assetPath: "static/main.css", + expected: "static/main.css", + }, { name: "external-url", serverPath: "/dex", diff --git a/web/static/main.css b/web/static/main.css index 47e5c9a6..da1d836e 100644 --- a/web/static/main.css +++ b/web/static/main.css @@ -146,6 +146,85 @@ body { text-align: left; } +.dex-info-table { + margin: 12px auto; + text-align: left; + max-width: 300px; +} + +.dex-info-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #eee; + font-size: 13px; +} + +.dex-info-row:last-child { + border-bottom: none; +} + +.dex-info-label { + color: #888; + font-weight: 500; +} + +.dex-info-value { + color: #333; + text-align: right; + word-break: break-word; + min-width: 0; + max-width: 200px; +} + +.dex-info-details { + color: #333; + font-size: 13px; + min-width: 0; + max-width: 200px; + text-align: right; +} + +.dex-info-details summary { + cursor: pointer; + list-style: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #4A90D9; +} + +.dex-info-details summary:hover { + text-decoration: underline; +} + +.dex-info-details summary::-webkit-details-marker { + display: none; +} + +.dex-info-details summary::after { + content: " \25B8"; + color: #aaa; +} + +.dex-info-details[open] summary::after { + content: " \25BE"; +} + +.dex-info-details[open] > :not(summary) { + text-align: left; +} + +.dex-info-details[open] summary { + margin-bottom: 4px; +} + +.dex-info-details .dex-info-details__list { + margin: 0; + padding-left: 16px; + line-height: 1.8; +} + .dex-error-box { background-color: #e5383b; border-radius: 6px; diff --git a/web/templates/home.html b/web/templates/home.html new file mode 100644 index 00000000..1dca8433 --- /dev/null +++ b/web/templates/home.html @@ -0,0 +1,98 @@ +{{ define "info-value" }} + {{ if gt (len .) 24 }} +
+ {{ . }} +
{{ . }}
+
+ {{ else }} + {{ . }} + {{ end }} +{{ end }} + +{{ template "header.html" . }} + +
+
+ {{ issuer }} +
+ + {{ if .LoggedIn }} +
+
+ {{ if .Username }} +
+ Username + {{ template "info-value" .Username }} +
+ {{ end }} + {{ if .Email }} +
+ Email + {{ .Email }}{{ if .EmailVerified }} ✓{{ end }} +
+ {{ end }} + {{ if .Groups }} +
+ Groups +
+ {{ len .Groups }} group{{ if gt (len .Groups) 1 }}s{{ end }} +
    + {{ range .Groups }}
  • {{ . }}
  • {{ end }} +
+
+
+ {{ end }} + {{ if .ConnectorName }} +
+ Connector + {{ template "info-value" .ConnectorName }} +
+ {{ end }} + {{ if .IPAddress }} +
+ IP address + {{ template "info-value" .IPAddress }} +
+ {{ end }} + {{ if .UserAgent }} +
+ Browser + {{ template "info-value" .UserAgent }} +
+ {{ end }} + {{ if .LastLoginEpoch }} +
+ Last login + +
+ {{ end }} +
+
+ +
+ Logout +
+ {{ else }} +
+
Not logged in
+
+ {{ end }} + +
+ Discovery +
+
+ +{{ if .LastLoginEpoch }} + +{{ end }} + +{{ template "footer.html" . }} From bc8f045ad8b7967fc0758808bda75b9c357a451c Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Mon, 30 Mar 2026 16:12:04 +0200 Subject: [PATCH 204/222] feat: include prompt=select_account in back link for multiple connectors (#4694) Signed-off-by: maksim.nabokikh Signed-off-by: Maksim Nabokikh --- server/handlers.go | 10 ++++++++- server/handlers_test.go | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/server/handlers.go b/server/handlers.go index 7bc04c48..200d1415 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "html/template" + "maps" "net/http" "net/url" "path" @@ -464,11 +465,18 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { scopes := 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 len(s.connectors) > 1 { + backLinkParams := make(url.Values) + maps.Copy(backLinkParams, r.Form) + if s.sessionConfig != nil { + backLinkParams.Set("prompt", "select_account") + } backLinkURL := url.URL{ Path: s.absPath("/auth"), - RawQuery: r.Form.Encode(), + RawQuery: backLinkParams.Encode(), } backLink = backLinkURL.String() } diff --git a/server/handlers_test.go b/server/handlers_test.go index 64f40356..907993e4 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -2074,3 +2074,52 @@ func TestHandleAuthorizationWithoutAllowedConnectors(t *testing.T) { // With multiple connectors and no filter, the login page should be rendered (200 OK) require.Equal(t, http.StatusOK, rr.Code) } + +func TestBackLinkIncludesPromptSelectAccount(t *testing.T) { + ctx := t.Context() + + httpServer, s := newTestServerMultipleConnectors(t, nil) + defer httpServer.Close() + + // select_account prompt only works with the sessions feature flag enabled. + s.sessionConfig = &SessionConfig{} + + // Add a password connector so handleConnectorLogin passes the backlink via redirect. + pwConn := storage.Connector{ + ID: "mockPw", + Type: "mockPassword", + Name: "MockPassword", + ResourceVersion: "1", + Config: []byte(`{"username": "foo", "password": "bar"}`), + } + require.NoError(t, s.storage.CreateConnector(ctx, pwConn)) + _, err := s.OpenConnector(pwConn) + require.NoError(t, err) + + client := storage.Client{ + ID: "test-client", + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + Name: "Test Client", + } + require.NoError(t, s.storage.CreateClient(ctx, client)) + + rr := httptest.NewRecorder() + authURL := fmt.Sprintf("/auth/mockPw?client_id=%s&redirect_uri=%s&response_type=code&scope=openid", + client.ID, url.QueryEscape("https://example.com/callback")) + req := httptest.NewRequest("GET", authURL, nil) + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusFound, rr.Code) + + loc, err := url.Parse(rr.Header().Get("Location")) + require.NoError(t, err) + + backLink := loc.Query().Get("back") + require.NotEmpty(t, backLink, "back link should be set when multiple connectors exist") + + backURL, err := url.Parse(backLink) + require.NoError(t, err) + require.Equal(t, "select_account", backURL.Query().Get("prompt"), + "back link should include prompt=select_account") +} From d4807b6ae6cf2de613b875bc7c68a667b982f858 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Mon, 30 Mar 2026 22:58:49 +0200 Subject: [PATCH 205/222] fix: update parseSession callas after merging home page (#4701) Signed-off-by: maksim.nabokikh --- server/home.go | 2 +- server/home_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/home.go b/server/home.go index 0e15ab32..ee0c496a 100644 --- a/server/home.go +++ b/server/home.go @@ -22,7 +22,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { } if cookie, err := r.Cookie(s.sessionConfig.CookieName); err == nil && cookie.Value != "" { - if userID, connectorID, nonce, err := parseSessionCookie(cookie.Value); err == nil { + if userID, connectorID, nonce, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey); err == nil { session, err := s.storage.GetAuthSession(ctx, userID, connectorID) if err == nil && session.Nonce == nonce { data.LoggedIn = true diff --git a/server/home_test.go b/server/home_test.go index 3425408a..c032f6b7 100644 --- a/server/home_test.go +++ b/server/home_test.go @@ -141,7 +141,7 @@ func TestHomeLoggedIn(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) req.AddCookie(&http.Cookie{ Name: "dex_session", - Value: sessionCookieValue(userID, connectorID, nonce), + Value: sessionCookieValue(userID, connectorID, nonce, nil), }) rr := httptest.NewRecorder() From 830fca93e0ef11fbd2e75e23dfec73aa37b82bfa Mon Sep 17 00:00:00 2001 From: Nick Nikolakakis Date: Tue, 31 Mar 2026 10:31:59 +0300 Subject: [PATCH 206/222] fix: migrate Bitbucket Cloud connector to current workspace API (#4687) Signed-off-by: Nick Nikolakakis --- connector/bitbucketcloud/bitbucketcloud.go | 121 +++++++++--------- .../bitbucketcloud/bitbucketcloud_test.go | 87 +++++++++---- 2 files changed, 124 insertions(+), 84 deletions(-) diff --git a/connector/bitbucketcloud/bitbucketcloud.go b/connector/bitbucketcloud/bitbucketcloud.go index d7fb64ca..dcf104a3 100644 --- a/connector/bitbucketcloud/bitbucketcloud.go +++ b/connector/bitbucketcloud/bitbucketcloud.go @@ -21,37 +21,44 @@ import ( 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(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.With(slog.Group("connector", "type", "bitbucketcloud", "id", id)), + 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 *slog.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 != "" { @@ -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 67a74dab..1ae1286f 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"}, From 06c52332dc03d57f6b42d3425876cf8daf942881 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:42:59 +0200 Subject: [PATCH 207/222] build(deps): bump github.com/lib/pq from 1.12.0 to 1.12.1 (#4702) Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.12.0 to 1.12.1. - [Release notes](https://github.com/lib/pq/releases) - [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md) - [Commits](https://github.com/lib/pq/compare/v1.12.0...v1.12.1) --- updated-dependencies: - dependency-name: github.com/lib/pq dependency-version: 1.12.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e55f3e93..c40f591f 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 github.com/kylelemons/godebug v1.1.0 - github.com/lib/pq v1.12.0 + github.com/lib/pq v1.12.1 github.com/mattermost/xml-roundtrip-validator v0.1.0 github.com/mattn/go-sqlite3 v1.14.38 github.com/oklog/run v1.2.0 diff --git a/go.sum b/go.sum index 507ec03f..1c5bfd45 100644 --- a/go.sum +++ b/go.sum @@ -175,8 +175,8 @@ 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 v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= -github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.1 h1:x1nbl/338GLqeDJ/FAiILallhAsqubLzEZu/pXtHUow= +github.com/lib/pq v1.12.1/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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= From 142d776c641694e72438c8ad85b1ab57ddf2bc8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:07:26 +0200 Subject: [PATCH 208/222] build(deps): bump google.golang.org/api from 0.273.0 to 0.273.1 (#4707) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c40f591f..25ea27dc 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 - google.golang.org/api v0.273.0 + google.golang.org/api v0.273.1 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 1c5bfd45..e4f98579 100644 --- a/go.sum +++ b/go.sum @@ -340,8 +340,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= -google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/api v0.273.1 h1:L7G/TmpAMz0nKx/ciAVssVmWQiOF6+pOuXeKrWVsquY= +google.golang.org/api v0.273.1/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= From 486320de0740122d6d51966850c7b1b8c7c40cf4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:07:50 +0200 Subject: [PATCH 209/222] build(deps): bump github.com/go-jose/go-jose/v4 from 4.1.3 to 4.1.4 (#4706) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 25ea27dc..2c68ebaf 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/dexidp/dex/api/v2 v2.4.0 github.com/fsnotify/fsnotify v1.9.0 github.com/ghodss/yaml v1.0.0 - github.com/go-jose/go-jose/v4 v4.1.3 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-sql-driver/mysql v1.9.3 github.com/google/cel-go v0.27.0 diff --git a/go.sum b/go.sum index e4f98579..a8aa547a 100644 --- a/go.sum +++ b/go.sum @@ -79,8 +79,8 @@ 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.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +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.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= From 58f148dd287fff9eccb04a39b2fd546c586f33d6 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Wed, 1 Apr 2026 08:58:44 +0200 Subject: [PATCH 210/222] feat: implement OIDC RP-Initiated logout (#4674) Signed-off-by: maksim.nabokikh Signed-off-by: Maksim Nabokikh --- connector/connector.go | 20 ++ connector/oidc/oidc.go | 62 +++- connector/oidc/oidc_test.go | 106 ++++++ server/handlers.go | 6 + server/logout.go | 356 ++++++++++++++++++++ server/logout_test.go | 326 ++++++++++++++++++ server/oauth2.go | 14 +- server/oauth2_test.go | 8 +- server/server.go | 4 + server/templates.go | 12 + storage/ent/client/client.go | 2 + storage/ent/client/types.go | 19 +- storage/ent/db/migrate/schema.go | 1 + storage/ent/db/mutation.go | 130 +++++-- storage/ent/db/oauth2client.go | 19 +- storage/ent/db/oauth2client/oauth2client.go | 3 + storage/ent/db/oauth2client/where.go | 10 + storage/ent/db/oauth2client_create.go | 10 + storage/ent/db/oauth2client_update.go | 58 ++++ storage/ent/schema/client.go | 2 + storage/kubernetes/types.go | 43 ++- storage/sql/crud.go | 71 ++-- storage/sql/migrate.go | 2 + storage/storage.go | 19 ++ web/templates/logout.html | 24 ++ 25 files changed, 1231 insertions(+), 96 deletions(-) create mode 100644 server/logout.go create mode 100644 server/logout_test.go create mode 100644 web/templates/logout.html diff --git a/connector/connector.go b/connector/connector.go index 1e554789..9ab19cb9 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -116,3 +116,23 @@ type RefreshConnector interface { 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. + // connectorData is the data stored during the user's authentication session. + // 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, connectorData []byte, 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/oidc/oidc.go b/connector/oidc/oidc.go index 8e1fe724..07e371dd 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -136,10 +136,13 @@ type ProviderDiscoveryOverrides struct { // JWKSURL provides a way to user overwrite the JWKS URL // from the .well-known/openid-configuration jwks_uri JWKSURL string `json:"jwksURL"` + // 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 == "" + return o.TokenURL == "" && o.AuthURL == "" && o.JWKSURL == "" && o.EndSessionURL == "" } func getProvider(ctx context.Context, issuer string, overrides ProviderDiscoveryOverrides) (*oidc.Provider, error) { @@ -319,9 +322,10 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, } } - // Obtain CodeChallengeMethodsSupported from the provider + // 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") @@ -340,6 +344,22 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.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 return &oidcConnector{ provider: provider, @@ -375,13 +395,15 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, 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.TokenIdentityConnector = (*oidcConnector)(nil) + _ connector.CallbackConnector = (*oidcConnector)(nil) + _ connector.RefreshConnector = (*oidcConnector)(nil) + _ connector.TokenIdentityConnector = (*oidcConnector)(nil) + _ connector.LogoutCallbackConnector = (*oidcConnector)(nil) ) type oidcConnector struct { @@ -409,6 +431,7 @@ type oidcConnector struct { groupsPrefix string groupsSuffix string pkceChallenge string + endSessionURL string } func (c *oidcConnector) Close() error { @@ -739,3 +762,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, _ []byte, 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 71a30b6e..77473b0d 100644 --- a/connector/oidc/oidc_test.go +++ b/connector/oidc/oidc_test.go @@ -20,6 +20,7 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "github.com/dexidp/dex/connector" ) @@ -976,3 +977,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(), nil, 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/server/handlers.go b/server/handlers.go index 200d1415..825cf35e 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -82,6 +82,7 @@ type discovery struct { UserInfo string `json:"userinfo_endpoint"` DeviceEndpoint string `json:"device_authorization_endpoint"` Introspect string `json:"introspection_endpoint"` + EndSession string `json:"end_session_endpoint,omitempty"` GrantTypes []string `json:"grant_types_supported"` ResponseTypes []string `json:"response_types_supported"` Subjects []string `json:"subject_types_supported"` @@ -141,6 +142,11 @@ func (s *Server) constructDiscovery(ctx context.Context) discovery { sort.Strings(d.ResponseTypes) d.GrantTypes = s.supportedGrantTypes + + if s.sessionConfig != nil { + d.EndSession = s.absURL("/logout") + } + return d } diff --git a/server/logout.go b/server/logout.go new file mode 100644 index 00000000..c3a0699f --- /dev/null +++ b/server/logout.go @@ -0,0 +1,356 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/url" + "slices" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/storage" +) + +// handleLogout implements OIDC RP-Initiated Logout (https://openid.net/specs/openid-connect-rpinitiated-1_0.html). +// +// GET/POST /logout?id_token_hint=...&post_logout_redirect_uri=...&state=... +// +// Flow: +// 1. Validate id_token_hint (signature + issuer; expiry skipped per spec) +// 2. Extract user identity (subject) and client (audience/azp) from the token +// 3. Validate post_logout_redirect_uri against the client's registered URIs +// 4. Revoke refresh tokens for the user/connector pair +// 5. If the auth session exists and upstream connector implements LogoutCallbackConnector: +// a. Store LogoutState + HMAC key in the session (not deleted yet) +// b. Redirect to upstream logout with signed state +// c. On callback: verify HMAC, read LogoutState from session, delete session, render page +// 6. If no session or no upstream logout support: delete session, clear cookie, render page +// +// Upstream redirect requires a live AuthSession because the session stores the +// HMAC key and logout parameters. Without a session (e.g. already expired, or +// id_token_hint without a cookie) upstream logout is skipped — this is acceptable +// because RP-Initiated Logout treats upstream SLO as best-effort. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if r.Method != http.MethodGet && r.Method != http.MethodPost { + s.renderError(r, w, http.StatusMethodNotAllowed, "Method not allowed.") + return + } + + idTokenHint := r.FormValue("id_token_hint") + postLogoutRedirectURI := r.FormValue("post_logout_redirect_uri") + state := r.FormValue("state") + + var userID, connectorID, clientID string + + if idTokenHint != "" { + idToken, err := s.validateIDTokenHint(ctx, idTokenHint) + if err != nil { + s.logger.ErrorContext(ctx, "logout: invalid id_token_hint", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Invalid id_token_hint.") + return + } + + sub := new(internal.IDTokenSubject) + if err := internal.Unmarshal(idToken.Subject, sub); err != nil { + s.logger.ErrorContext(ctx, "logout: failed to unmarshal subject", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Invalid id_token_hint subject.") + return + } + + userID = sub.UserId + connectorID = sub.ConnId + + s.logger.DebugContext(ctx, "logout: parsed id_token_hint", + "user_id", userID, "connector_id", connectorID) + + // When cross-client (trusted peers) scopes are used, the token may have + // multiple audiences. In that case the requesting client is in the "azp" + // claim, not necessarily Audience[0]. Use the same logic as token introspection. + var claims struct { + AuthorizingParty string `json:"azp"` + } + if err := idToken.Claims(&claims); err != nil { + s.logger.ErrorContext(ctx, "logout: failed to decode id_token_hint claims", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Invalid id_token_hint.") + return + } + + switch len(idToken.Audience) { + case 0: + // No audience — cannot determine client. + case 1: + clientID = idToken.Audience[0] + default: + clientID = claims.AuthorizingParty + } + } + + // If no id_token_hint, try to identify the user from the session cookie. + // This allows logout without a hint when the user has an active session. + if userID == "" && connectorID == "" { + if cookie, err := r.Cookie(s.sessionConfig.CookieName); err == nil && cookie.Value != "" { + if uid, cid, nonce, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey); err == nil { + // Verify the session exists and nonce matches before trusting the cookie. + if session, err := s.storage.GetAuthSession(ctx, uid, cid); err == nil && session.Nonce == nonce { + userID = uid + connectorID = cid + s.logger.DebugContext(ctx, "logout: identified user from session cookie", + "user_id", userID, "connector_id", connectorID) + } + } + } + } + + // Validate post_logout_redirect_uri against registered client URIs. + if postLogoutRedirectURI != "" { + if clientID == "" { + s.renderError(r, w, http.StatusBadRequest, "post_logout_redirect_uri requires id_token_hint.") + return + } + + client, err := s.storage.GetClient(ctx, clientID) + if err != nil { + s.logger.ErrorContext(ctx, "logout: failed to get client", "client_id", clientID, "err", err) + s.renderError(r, w, http.StatusBadRequest, "Invalid client.") + return + } + + if !slices.Contains(client.PostLogoutRedirectURIs, postLogoutRedirectURI) { + s.logger.WarnContext(ctx, "logout: unregistered post_logout_redirect_uri", + "uri", postLogoutRedirectURI, "client_id", clientID) + s.renderError(r, w, http.StatusBadRequest, "Unregistered post_logout_redirect_uri.") + return + } + } + + // Revoke refresh tokens (does not touch the auth session or user identity). + var connectorData []byte + if userID != "" && connectorID != "" { + connectorData = s.revokeRefreshTokens(ctx, userID, connectorID) + } + + // Try upstream logout. This requires a live auth session to store the HMAC key + // and logout parameters. If the session doesn't exist (expired, no cookie, etc.) + // upstream logout is skipped — RP-Initiated Logout treats upstream SLO as best-effort. + if redirectURL, ok := s.tryUpstreamLogout(ctx, userID, connectorID, connectorData, postLogoutRedirectURI, state, clientID); ok { + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + return + } + + // No upstream logout — delete session now, clear cookie, show page. + s.logger.DebugContext(ctx, "logout: completing", + "user_id", userID, "connector_id", connectorID, "client_id", clientID) + loggedOut := s.deleteAuthSession(ctx, userID, connectorID) + s.clearSessionCookie(w) + s.finishLogout(w, r, postLogoutRedirectURI, state, loggedOut) +} + +// handleLogoutCallback receives the redirect back from the upstream provider +// after it has completed its logout. +// +// Identity is resolved from the session cookie (HttpOnly, Secure, SameSite=Lax). +// The session must still exist in storage with a non-nil LogoutState (set before +// the upstream redirect). After validation, the session is deleted. +func (s *Server) handleLogoutCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Resolve identity from the session cookie. + cookie, err := r.Cookie(s.sessionConfig.CookieName) + if err != nil || cookie.Value == "" { + s.renderError(r, w, http.StatusBadRequest, "Missing session cookie.") + return + } + + userID, connectorID, nonce, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey) + if err != nil { + s.renderError(r, w, http.StatusBadRequest, "Invalid session cookie.") + return + } + + // Load the session and verify nonce. + session, err := s.storage.GetAuthSession(ctx, userID, connectorID) + if err != nil { + s.logger.ErrorContext(ctx, "logout callback: session not found", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Session not found.") + return + } + + if session.Nonce != nonce { + s.renderError(r, w, http.StatusBadRequest, "Invalid session.") + return + } + + if session.LogoutState == nil { + s.renderError(r, w, http.StatusBadRequest, "No logout in progress.") + return + } + + ls := session.LogoutState + + // Let the connector validate the upstream logout response if it supports it. + if ls.ConnectorID != "" { + conn, err := s.getConnector(ctx, ls.ConnectorID) + if err == nil { + if logoutConn, ok := conn.Connector.(connector.LogoutCallbackConnector); ok { + if err := logoutConn.HandleLogoutCallback(ctx, r); err != nil { + s.logger.ErrorContext(ctx, "logout: upstream logout response validation failed", + "connector_id", ls.ConnectorID, "err", err) + } + } + } + } + + // Session kept alive until now — delete it and clear the cookie. + s.deleteAuthSession(ctx, userID, connectorID) + s.clearSessionCookie(w) + s.finishLogout(w, r, ls.PostLogoutRedirectURI, ls.State, true) +} + +// finishLogout renders the logout page with a "Back to Application" link. +// loggedOut indicates whether an active session was actually terminated. +func (s *Server) finishLogout(w http.ResponseWriter, r *http.Request, postLogoutRedirectURI, state string, loggedOut bool) { + var backURL string + if postLogoutRedirectURI != "" { + u, err := url.Parse(postLogoutRedirectURI) + if err == nil { + if state != "" { + q := u.Query() + q.Set("state", state) + u.RawQuery = q.Encode() + } + backURL = u.String() + } + } + + if err := s.templates.logout(r, w, backURL, loggedOut); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } +} + +// tryUpstreamLogout attempts to redirect to the upstream provider's logout endpoint. +// It stores LogoutState in the auth session before redirecting so the callback can +// read it back. Returns the redirect URL and true on success, or ("", false) if +// upstream logout is not possible (no session, connector doesn't support it, etc.). +func (s *Server) tryUpstreamLogout(ctx context.Context, userID, connectorID string, connectorData []byte, postLogoutRedirectURI, state, clientID string) (string, bool) { + if connectorID == "" { + return "", false + } + + conn, err := s.getConnector(ctx, connectorID) + if err != nil { + return "", false + } + + logoutConn, ok := conn.Connector.(connector.LogoutCallbackConnector) + if !ok { + return "", false + } + + // Check that the session exists — we need it to store logout state. + _, err = s.storage.GetAuthSession(ctx, userID, connectorID) + if err != nil { + s.logger.DebugContext(ctx, "logout: no auth session for upstream logout, skipping", + "user_id", userID, "connector_id", connectorID) + return "", false + } + + // Store logout parameters in the session. + if err := s.storage.UpdateAuthSession(ctx, userID, connectorID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.LogoutState = &storage.LogoutState{ + PostLogoutRedirectURI: postLogoutRedirectURI, + State: state, + ClientID: clientID, + ConnectorID: connectorID, + } + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "logout: failed to save logout state", "err", err) + return "", false + } + + callbackURI := s.absURL("/logout/callback") + upstreamURL, err := logoutConn.LogoutURL(ctx, connectorData, callbackURI) + if err != nil { + s.logger.ErrorContext(ctx, "logout: upstream connector error", "err", err) + return "", false + } + if upstreamURL == "" { + return "", false + } + + u, err := url.Parse(upstreamURL) + if err != nil { + s.logger.ErrorContext(ctx, "logout: failed to parse upstream URL", "err", err) + return "", false + } + + return u.String(), true +} + +// deleteAuthSession deletes the session and returns true if it existed. +func (s *Server) deleteAuthSession(ctx context.Context, userID, connectorID string) bool { + if userID == "" || connectorID == "" { + return false + } + if err := s.storage.DeleteAuthSession(ctx, userID, connectorID); err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "logout: failed to delete auth session", "err", err) + } + return false + } + s.logger.InfoContext(ctx, "logout successful", "user_id", userID, "connector_id", connectorID) + return true +} + +// revokeRefreshTokens deletes all refresh tokens for the given user/connector +// and clears the references in the offline session (but keeps the session object). +// Returns the connector data from the offline session (for upstream logout). +// +// To avoid a race condition where a new token issued between deletion and the +// OfflineSessions update would have its reference wiped, we: +// 1. Snapshot the token IDs to revoke +// 2. Remove only those specific references from OfflineSessions (the updater +// sees the latest state, so concurrently added refs are preserved) +// 3. Delete the actual refresh tokens +func (s *Server) revokeRefreshTokens(ctx context.Context, userID, connectorID string) []byte { + offlineSessions, err := s.storage.GetOfflineSessions(ctx, userID, connectorID) + if err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "logout: failed to get offline sessions", "err", err) + } + return nil + } + + // Snapshot token IDs to revoke. + tokenIDs := make(map[string]struct{}, len(offlineSessions.Refresh)) + for _, ref := range offlineSessions.Refresh { + tokenIDs[ref.ID] = struct{}{} + } + + // Remove only the snapshotted references — any token added concurrently + // will not be in tokenIDs and will be left untouched. + if err := s.storage.UpdateOfflineSessions(ctx, userID, connectorID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { + for clientID, ref := range old.Refresh { + if _, ok := tokenIDs[ref.ID]; ok { + delete(old.Refresh, clientID) + } + } + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "logout: failed to update offline sessions", "err", err) + } + + // Delete the actual refresh tokens. + for id := range tokenIDs { + if err := s.storage.DeleteRefresh(ctx, id); err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "logout: failed to delete refresh token", "token_id", id, "err", err) + } + } + } + + return offlineSessions.ConnectorData +} diff --git a/server/logout_test.go b/server/logout_test.go new file mode 100644 index 00000000..4c55b236 --- /dev/null +++ b/server/logout_test.go @@ -0,0 +1,326 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/storage" +) + +func TestHandleLogoutNoSessions(t *testing.T) { + httpServer, server := newTestServer(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/logout", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestHandleLogoutMethodNotAllowed(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("PUT", "/logout", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusMethodNotAllowed, rr.Code) +} + +func TestHandleLogoutPOST(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/logout", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + require.Contains(t, body, "No active session") +} + +func TestHandleLogoutNoHint(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/logout", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + require.Contains(t, body, "No active session") + require.NotContains(t, body, "successfully logged out") +} + +func TestHandleLogoutInvalidHint(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/logout?id_token_hint=invalid-token", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestHandleLogoutWithValidHint(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + + clientID := "test-client" + postLogoutURI := "https://example.com/done" + userID := "test-user" + connectorID := "mock" + + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: clientID, + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + PostLogoutRedirectURIs: []string{postLogoutURI}, + })) + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, + ConnectorID: connectorID, + Nonce: "testnonce", + CreatedAt: time.Now(), + LastActivity: time.Now(), + })) + + idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{ + UserID: userID, Username: "testuser", Email: "test@example.com", + }, []string{"openid"}, "", "", "", connectorID, time.Now()) + require.NoError(t, err) + + logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s&state=mystate", + url.QueryEscape(idToken), url.QueryEscape(postLogoutURI)) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + require.Contains(t, body, "successfully logged out") + require.Contains(t, body, "Back to Application") + require.Contains(t, body, postLogoutURI) + require.Contains(t, body, "state=mystate") + + // Session deleted. + _, err = server.storage.GetAuthSession(ctx, userID, connectorID) + require.ErrorIs(t, err, storage.ErrNotFound) + + // Cookie cleared. + for _, c := range rr.Result().Cookies() { + if c.Name == "dex_session" { + require.Equal(t, -1, c.MaxAge) + } + } +} + +func TestHandleLogoutUnregisteredRedirectURI(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + clientID := "test-client" + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: clientID, + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + PostLogoutRedirectURIs: []string{"https://example.com/done"}, + })) + + idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{ + UserID: "user1", Username: "testuser", Email: "test@example.com", + }, []string{"openid"}, "", "", "", "mock", time.Now()) + require.NoError(t, err) + + logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s", + url.QueryEscape(idToken), url.QueryEscape("https://evil.com/steal")) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestHandleLogoutRedirectURIWithoutHint(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/logout?post_logout_redirect_uri=https://example.com/done", nil)) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestHandleLogoutRevokesRefreshTokens(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + clientID := "test-client" + postLogoutURI := "https://example.com/done" + userID := "test-user" + connectorID := "mock" + + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: clientID, + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + PostLogoutRedirectURIs: []string{postLogoutURI}, + })) + + refreshID := storage.NewID() + require.NoError(t, server.storage.CreateRefresh(ctx, storage.RefreshToken{ + ID: refreshID, Token: "token-value", ClientID: clientID, ConnectorID: connectorID, + Claims: storage.Claims{UserID: userID, Username: "testuser", Email: "test@example.com"}, + Scopes: []string{"openid", "offline_access"}, CreatedAt: time.Now(), LastUsed: time.Now(), + })) + + require.NoError(t, server.storage.CreateOfflineSessions(ctx, storage.OfflineSessions{ + UserID: userID, ConnID: connectorID, + Refresh: map[string]*storage.RefreshTokenRef{ + clientID: {ID: refreshID, ClientID: clientID, CreatedAt: time.Now(), LastUsed: time.Now()}, + }, + })) + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, ConnectorID: connectorID, Nonce: "testnonce", + CreatedAt: time.Now(), LastActivity: time.Now(), + })) + + idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{ + UserID: userID, Username: "testuser", Email: "test@example.com", + }, []string{"openid"}, "", "", "", connectorID, time.Now()) + require.NoError(t, err) + + logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s", + url.QueryEscape(idToken), url.QueryEscape(postLogoutURI)) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + require.Equal(t, http.StatusOK, rr.Code) + + _, err = server.storage.GetRefresh(ctx, refreshID) + require.ErrorIs(t, err, storage.ErrNotFound) + + os, err := server.storage.GetOfflineSessions(ctx, userID, connectorID) + require.NoError(t, err) + require.Empty(t, os.Refresh) +} + +func TestHandleLogoutRepeat(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + clientID := "test-client" + postLogoutURI := "https://example.com/done" + userID := "test-user" + connectorID := "mock" + + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: clientID, Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + PostLogoutRedirectURIs: []string{postLogoutURI}, + })) + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, ConnectorID: connectorID, Nonce: "testnonce", + CreatedAt: time.Now(), LastActivity: time.Now(), + })) + + idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{ + UserID: userID, Username: "testuser", Email: "test@example.com", + }, []string{"openid"}, "", "", "", connectorID, time.Now()) + require.NoError(t, err) + + logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s", + url.QueryEscape(idToken), url.QueryEscape(postLogoutURI)) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), "successfully logged out") + + // Second logout — session already deleted, shows "no active session". + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), "No active session") +} + +func TestLogoutCallbackNoState(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/logout/callback", nil)) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestDiscoveryWithSessions(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + var d discovery + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&d)) + require.Equal(t, fmt.Sprintf("%s/logout", httpServer.URL), d.EndSession) +} + +func TestDiscoveryWithoutSessions(t *testing.T) { + httpServer, server := newTestServer(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + var d discovery + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&d)) + require.Empty(t, d.EndSession) +} + +func TestRevokeRefreshTokensReturnsConnectorData(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := context.Background() + userID := "user1" + connectorID := "mock" + expectedConnData := []byte(`{"RefreshToken":"abc"}`) + + refreshID := storage.NewID() + require.NoError(t, server.storage.CreateRefresh(ctx, storage.RefreshToken{ + ID: refreshID, Token: "tok", ClientID: "client1", ConnectorID: connectorID, + Claims: storage.Claims{UserID: userID}, CreatedAt: time.Now(), LastUsed: time.Now(), + })) + + require.NoError(t, server.storage.CreateOfflineSessions(ctx, storage.OfflineSessions{ + UserID: userID, ConnID: connectorID, ConnectorData: expectedConnData, + Refresh: map[string]*storage.RefreshTokenRef{"client1": {ID: refreshID, ClientID: "client1"}}, + })) + + connData := server.revokeRefreshTokens(ctx, userID, connectorID) + require.Equal(t, expectedConnData, connData) + + _, err := server.storage.GetRefresh(ctx, refreshID) + require.ErrorIs(t, err, storage.ErrNotFound) + + os, err := server.storage.GetOfflineSessions(ctx, userID, connectorID) + require.NoError(t, err) + require.Empty(t, os.Refresh) + require.Equal(t, expectedConnData, os.ConnectorData) +} diff --git a/server/oauth2.go b/server/oauth2.go index 40f8063b..6e91facf 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -444,8 +444,8 @@ func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage // validateIDTokenHint verifies the signature and issuer of an id_token_hint. // Expired tokens are accepted per OIDC Core 1.0 §3.1.2.1. -// Returns the raw subject claim from the token. -func (s *Server) validateIDTokenHint(ctx context.Context, hint string) (string, error) { +// Returns the verified token so callers can extract Subject, Audience, etc. +func (s *Server) validateIDTokenHint(ctx context.Context, hint string) (*oidc.IDToken, error) { verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{ SkipExpiryCheck: true, // SkipClientIDCheck is set because the hint may originate from any client that @@ -455,11 +455,7 @@ func (s *Server) validateIDTokenHint(ctx context.Context, hint string) (string, // Dex does the client id check later in the scope of the session validation. SkipClientIDCheck: true, }) - idToken, err := verifier.Verify(ctx, hint) - if err != nil { - return "", err - } - return idToken.Subject, nil + return verifier.Verify(ctx, hint) } // sessionMatchesHint checks whether the session's user identity matches the @@ -662,11 +658,11 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques // 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 != "" { - sub, err := s.validateIDTokenHint(ctx, hint) + idToken, err := s.validateIDTokenHint(ctx, hint) if err != nil { return nil, "", newRedirectedErr(errInvalidRequest, "Invalid id_token_hint.") } - idTokenHintSubject = sub + idTokenHintSubject = idToken.Subject } return &storage.AuthRequest{ diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 2cdc938a..403b497b 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -952,9 +952,9 @@ func TestValidateIDTokenHint(t *testing.T) { Subject: "CgNmb28SA2Jhcg", Expiry: now.Add(1 * time.Hour).Unix(), }) - sub, err := s.validateIDTokenHint(t.Context(), token) + idToken, err := s.validateIDTokenHint(t.Context(), token) require.NoError(t, err) - assert.Equal(t, "CgNmb28SA2Jhcg", sub) + assert.Equal(t, "CgNmb28SA2Jhcg", idToken.Subject) }) t.Run("valid hint (expired)", func(t *testing.T) { @@ -963,9 +963,9 @@ func TestValidateIDTokenHint(t *testing.T) { Subject: "CgNmb28SA2Jhcg", Expiry: now.Add(-1 * time.Hour).Unix(), }) - sub, err := s.validateIDTokenHint(t.Context(), token) + idToken, err := s.validateIDTokenHint(t.Context(), token) require.NoError(t, err) - assert.Equal(t, "CgNmb28SA2Jhcg", sub) + assert.Equal(t, "CgNmb28SA2Jhcg", idToken.Subject) }) t.Run("invalid signature", func(t *testing.T) { diff --git a/server/server.go b/server/server.go index 2f023623..711ef7f0 100644 --- a/server/server.go +++ b/server/server.go @@ -551,6 +551,10 @@ func newServer(ctx context.Context, c Config) (*Server, error) { // "authproxy" connector. handleFunc("/callback/{connector}", s.handleConnectorCallback) handleFunc("/approval", s.handleApproval) + if c.SessionConfig != nil { + handleFunc("/logout", s.handleLogout) + handleFunc("/logout/callback", s.handleLogoutCallback) + } handleFunc("/mfa/verify", s.handleMFAVerify) handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !c.HealthChecker.IsHealthy() { diff --git a/server/templates.go b/server/templates.go index a2818b28..dd94025d 100644 --- a/server/templates.go +++ b/server/templates.go @@ -24,6 +24,7 @@ const ( tmplDeviceSuccess = "device_success.html" tmplTOTPVerify = "totp_verify.html" tmplHome = "home.html" + tmplLogout = "logout.html" ) var requiredTmpls = []string{ @@ -46,6 +47,7 @@ type templates struct { deviceSuccessTmpl *template.Template totpVerifyTmpl *template.Template homeTmpl *template.Template + logoutTmpl *template.Template } type webConfig struct { @@ -175,6 +177,7 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess), totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify), homeTmpl: tmpls.Lookup(tmplHome), + logoutTmpl: tmpls.Lookup(tmplLogout), }, nil } @@ -386,6 +389,15 @@ func (t *templates) home(r *http.Request, w http.ResponseWriter, data homeData) return renderTemplate(w, t.homeTmpl, data) } +func (t *templates) logout(r *http.Request, w http.ResponseWriter, backURL string, loggedOut bool) error { + data := struct { + BackURL string + LoggedOut bool + ReqPath string + }{backURL, loggedOut, r.URL.Path} + return renderTemplate(w, t.logoutTmpl, data) +} + func (t *templates) oob(r *http.Request, w http.ResponseWriter, code string) error { data := struct { Code string diff --git a/storage/ent/client/client.go b/storage/ent/client/client.go index 4c18939d..20b0f9f9 100644 --- a/storage/ent/client/client.go +++ b/storage/ent/client/client.go @@ -18,6 +18,7 @@ func (d *Database) CreateClient(ctx context.Context, client storage.Client) erro SetTrustedPeers(client.TrustedPeers). SetAllowedConnectors(client.AllowedConnectors). SetMfaChain(client.MFAChain). + SetPostLogoutRedirectUris(client.PostLogoutRedirectURIs). Save(ctx) if err != nil { return convertDBError("create oauth2 client: %w", err) @@ -83,6 +84,7 @@ func (d *Database) UpdateClient(ctx context.Context, id string, updater func(old SetTrustedPeers(newClient.TrustedPeers). SetAllowedConnectors(newClient.AllowedConnectors). SetMfaChain(newClient.MFAChain). + SetPostLogoutRedirectUris(newClient.PostLogoutRedirectURIs). Save(ctx) if err != nil { return rollback(tx, "update client uploading: %w", err) diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 91689619..14f248fa 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -81,15 +81,16 @@ func toStorageAuthCode(a *db.AuthCode) storage.AuthCode { func toStorageClient(c *db.OAuth2Client) storage.Client { return storage.Client{ - ID: c.ID, - Secret: c.Secret, - RedirectURIs: c.RedirectUris, - TrustedPeers: c.TrustedPeers, - Public: c.Public, - Name: c.Name, - LogoURL: c.LogoURL, - AllowedConnectors: c.AllowedConnectors, - MFAChain: c.MfaChain, + ID: c.ID, + Secret: c.Secret, + RedirectURIs: c.RedirectUris, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + Name: c.Name, + LogoURL: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, + MFAChain: c.MfaChain, + PostLogoutRedirectURIs: c.PostLogoutRedirectUris, } } diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index e8958ec9..bf1d086d 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -162,6 +162,7 @@ var ( {Name: "logo_url", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "allowed_connectors", Type: field.TypeJSON, Nullable: true}, {Name: "mfa_chain", Type: field.TypeJSON, Nullable: true}, + {Name: "post_logout_redirect_uris", Type: field.TypeJSON, Nullable: true}, } // Oauth2clientsTable holds the schema information for the "oauth2clients" table. Oauth2clientsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 4c7858f0..ddd3c5b3 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -6380,25 +6380,27 @@ func (m *KeysMutation) ResetEdge(name string) error { // OAuth2ClientMutation represents an operation that mutates the OAuth2Client nodes in the graph. type OAuth2ClientMutation struct { config - op Op - typ string - id *string - secret *string - redirect_uris *[]string - appendredirect_uris []string - trusted_peers *[]string - appendtrusted_peers []string - public *bool - name *string - logo_url *string - allowed_connectors *[]string - appendallowed_connectors []string - mfa_chain *[]string - appendmfa_chain []string - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*OAuth2Client, error) - predicates []predicate.OAuth2Client + op Op + typ string + id *string + secret *string + redirect_uris *[]string + appendredirect_uris []string + trusted_peers *[]string + appendtrusted_peers []string + public *bool + name *string + logo_url *string + allowed_connectors *[]string + appendallowed_connectors []string + mfa_chain *[]string + appendmfa_chain []string + post_logout_redirect_uris *[]string + appendpost_logout_redirect_uris []string + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*OAuth2Client, error) + predicates []predicate.OAuth2Client } var _ ent.Mutation = (*OAuth2ClientMutation)(nil) @@ -6909,6 +6911,71 @@ func (m *OAuth2ClientMutation) ResetMfaChain() { delete(m.clearedFields, oauth2client.FieldMfaChain) } +// SetPostLogoutRedirectUris sets the "post_logout_redirect_uris" field. +func (m *OAuth2ClientMutation) SetPostLogoutRedirectUris(s []string) { + m.post_logout_redirect_uris = &s + m.appendpost_logout_redirect_uris = nil +} + +// PostLogoutRedirectUris returns the value of the "post_logout_redirect_uris" field in the mutation. +func (m *OAuth2ClientMutation) PostLogoutRedirectUris() (r []string, exists bool) { + v := m.post_logout_redirect_uris + if v == nil { + return + } + return *v, true +} + +// OldPostLogoutRedirectUris returns the old "post_logout_redirect_uris" field's value of the OAuth2Client entity. +// If the OAuth2Client object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *OAuth2ClientMutation) OldPostLogoutRedirectUris(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPostLogoutRedirectUris is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPostLogoutRedirectUris requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPostLogoutRedirectUris: %w", err) + } + return oldValue.PostLogoutRedirectUris, nil +} + +// AppendPostLogoutRedirectUris adds s to the "post_logout_redirect_uris" field. +func (m *OAuth2ClientMutation) AppendPostLogoutRedirectUris(s []string) { + m.appendpost_logout_redirect_uris = append(m.appendpost_logout_redirect_uris, s...) +} + +// AppendedPostLogoutRedirectUris returns the list of values that were appended to the "post_logout_redirect_uris" field in this mutation. +func (m *OAuth2ClientMutation) AppendedPostLogoutRedirectUris() ([]string, bool) { + if len(m.appendpost_logout_redirect_uris) == 0 { + return nil, false + } + return m.appendpost_logout_redirect_uris, true +} + +// ClearPostLogoutRedirectUris clears the value of the "post_logout_redirect_uris" field. +func (m *OAuth2ClientMutation) ClearPostLogoutRedirectUris() { + m.post_logout_redirect_uris = nil + m.appendpost_logout_redirect_uris = nil + m.clearedFields[oauth2client.FieldPostLogoutRedirectUris] = struct{}{} +} + +// PostLogoutRedirectUrisCleared returns if the "post_logout_redirect_uris" field was cleared in this mutation. +func (m *OAuth2ClientMutation) PostLogoutRedirectUrisCleared() bool { + _, ok := m.clearedFields[oauth2client.FieldPostLogoutRedirectUris] + return ok +} + +// ResetPostLogoutRedirectUris resets all changes to the "post_logout_redirect_uris" field. +func (m *OAuth2ClientMutation) ResetPostLogoutRedirectUris() { + m.post_logout_redirect_uris = nil + m.appendpost_logout_redirect_uris = nil + delete(m.clearedFields, oauth2client.FieldPostLogoutRedirectUris) +} + // Where appends a list predicates to the OAuth2ClientMutation builder. func (m *OAuth2ClientMutation) Where(ps ...predicate.OAuth2Client) { m.predicates = append(m.predicates, ps...) @@ -6943,7 +7010,7 @@ func (m *OAuth2ClientMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *OAuth2ClientMutation) Fields() []string { - fields := make([]string, 0, 8) + fields := make([]string, 0, 9) if m.secret != nil { fields = append(fields, oauth2client.FieldSecret) } @@ -6968,6 +7035,9 @@ func (m *OAuth2ClientMutation) Fields() []string { if m.mfa_chain != nil { fields = append(fields, oauth2client.FieldMfaChain) } + if m.post_logout_redirect_uris != nil { + fields = append(fields, oauth2client.FieldPostLogoutRedirectUris) + } return fields } @@ -6992,6 +7062,8 @@ func (m *OAuth2ClientMutation) Field(name string) (ent.Value, bool) { return m.AllowedConnectors() case oauth2client.FieldMfaChain: return m.MfaChain() + case oauth2client.FieldPostLogoutRedirectUris: + return m.PostLogoutRedirectUris() } return nil, false } @@ -7017,6 +7089,8 @@ func (m *OAuth2ClientMutation) OldField(ctx context.Context, name string) (ent.V return m.OldAllowedConnectors(ctx) case oauth2client.FieldMfaChain: return m.OldMfaChain(ctx) + case oauth2client.FieldPostLogoutRedirectUris: + return m.OldPostLogoutRedirectUris(ctx) } return nil, fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -7082,6 +7156,13 @@ func (m *OAuth2ClientMutation) SetField(name string, value ent.Value) error { } m.SetMfaChain(v) return nil + case oauth2client.FieldPostLogoutRedirectUris: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPostLogoutRedirectUris(v) + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -7124,6 +7205,9 @@ func (m *OAuth2ClientMutation) ClearedFields() []string { if m.FieldCleared(oauth2client.FieldMfaChain) { fields = append(fields, oauth2client.FieldMfaChain) } + if m.FieldCleared(oauth2client.FieldPostLogoutRedirectUris) { + fields = append(fields, oauth2client.FieldPostLogoutRedirectUris) + } return fields } @@ -7150,6 +7234,9 @@ func (m *OAuth2ClientMutation) ClearField(name string) error { case oauth2client.FieldMfaChain: m.ClearMfaChain() return nil + case oauth2client.FieldPostLogoutRedirectUris: + m.ClearPostLogoutRedirectUris() + return nil } return fmt.Errorf("unknown OAuth2Client nullable field %s", name) } @@ -7182,6 +7269,9 @@ func (m *OAuth2ClientMutation) ResetField(name string) error { case oauth2client.FieldMfaChain: m.ResetMfaChain() return nil + case oauth2client.FieldPostLogoutRedirectUris: + m.ResetPostLogoutRedirectUris() + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } diff --git a/storage/ent/db/oauth2client.go b/storage/ent/db/oauth2client.go index 81a87afc..990edc97 100644 --- a/storage/ent/db/oauth2client.go +++ b/storage/ent/db/oauth2client.go @@ -32,8 +32,10 @@ type OAuth2Client struct { // AllowedConnectors holds the value of the "allowed_connectors" field. AllowedConnectors []string `json:"allowed_connectors,omitempty"` // MfaChain holds the value of the "mfa_chain" field. - MfaChain []string `json:"mfa_chain,omitempty"` - selectValues sql.SelectValues + MfaChain []string `json:"mfa_chain,omitempty"` + // PostLogoutRedirectUris holds the value of the "post_logout_redirect_uris" field. + PostLogoutRedirectUris []string `json:"post_logout_redirect_uris,omitempty"` + selectValues sql.SelectValues } // scanValues returns the types for scanning values from sql.Rows. @@ -41,7 +43,7 @@ func (*OAuth2Client) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors, oauth2client.FieldMfaChain: + case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors, oauth2client.FieldMfaChain, oauth2client.FieldPostLogoutRedirectUris: values[i] = new([]byte) case oauth2client.FieldPublic: values[i] = new(sql.NullBool) @@ -124,6 +126,14 @@ func (_m *OAuth2Client) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field mfa_chain: %w", err) } } + case oauth2client.FieldPostLogoutRedirectUris: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field post_logout_redirect_uris", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.PostLogoutRedirectUris); err != nil { + return fmt.Errorf("unmarshal field post_logout_redirect_uris: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -183,6 +193,9 @@ func (_m *OAuth2Client) String() string { builder.WriteString(", ") builder.WriteString("mfa_chain=") builder.WriteString(fmt.Sprintf("%v", _m.MfaChain)) + builder.WriteString(", ") + builder.WriteString("post_logout_redirect_uris=") + builder.WriteString(fmt.Sprintf("%v", _m.PostLogoutRedirectUris)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/oauth2client/oauth2client.go b/storage/ent/db/oauth2client/oauth2client.go index 0143eb6e..9474f0bd 100644 --- a/storage/ent/db/oauth2client/oauth2client.go +++ b/storage/ent/db/oauth2client/oauth2client.go @@ -27,6 +27,8 @@ const ( FieldAllowedConnectors = "allowed_connectors" // FieldMfaChain holds the string denoting the mfa_chain field in the database. FieldMfaChain = "mfa_chain" + // FieldPostLogoutRedirectUris holds the string denoting the post_logout_redirect_uris field in the database. + FieldPostLogoutRedirectUris = "post_logout_redirect_uris" // Table holds the table name of the oauth2client in the database. Table = "oauth2clients" ) @@ -42,6 +44,7 @@ var Columns = []string{ FieldLogoURL, FieldAllowedConnectors, FieldMfaChain, + FieldPostLogoutRedirectUris, } // ValidColumn reports if the column name is valid (part of the table columns). diff --git a/storage/ent/db/oauth2client/where.go b/storage/ent/db/oauth2client/where.go index d8ce00d6..f23967a7 100644 --- a/storage/ent/db/oauth2client/where.go +++ b/storage/ent/db/oauth2client/where.go @@ -327,6 +327,16 @@ func MfaChainNotNil() predicate.OAuth2Client { return predicate.OAuth2Client(sql.FieldNotNull(FieldMfaChain)) } +// PostLogoutRedirectUrisIsNil applies the IsNil predicate on the "post_logout_redirect_uris" field. +func PostLogoutRedirectUrisIsNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldIsNull(FieldPostLogoutRedirectUris)) +} + +// PostLogoutRedirectUrisNotNil applies the NotNil predicate on the "post_logout_redirect_uris" field. +func PostLogoutRedirectUrisNotNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldNotNull(FieldPostLogoutRedirectUris)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.OAuth2Client) predicate.OAuth2Client { return predicate.OAuth2Client(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/oauth2client_create.go b/storage/ent/db/oauth2client_create.go index 1e8d2a54..da576440 100644 --- a/storage/ent/db/oauth2client_create.go +++ b/storage/ent/db/oauth2client_create.go @@ -67,6 +67,12 @@ func (_c *OAuth2ClientCreate) SetMfaChain(v []string) *OAuth2ClientCreate { return _c } +// SetPostLogoutRedirectUris sets the "post_logout_redirect_uris" field. +func (_c *OAuth2ClientCreate) SetPostLogoutRedirectUris(v []string) *OAuth2ClientCreate { + _c.mutation.SetPostLogoutRedirectUris(v) + return _c +} + // SetID sets the "id" field. func (_c *OAuth2ClientCreate) SetID(v string) *OAuth2ClientCreate { _c.mutation.SetID(v) @@ -206,6 +212,10 @@ func (_c *OAuth2ClientCreate) createSpec() (*OAuth2Client, *sqlgraph.CreateSpec) _spec.SetField(oauth2client.FieldMfaChain, field.TypeJSON, value) _node.MfaChain = value } + if value, ok := _c.mutation.PostLogoutRedirectUris(); ok { + _spec.SetField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON, value) + _node.PostLogoutRedirectUris = value + } return _node, _spec } diff --git a/storage/ent/db/oauth2client_update.go b/storage/ent/db/oauth2client_update.go index 7e9f753c..efa92c4c 100644 --- a/storage/ent/db/oauth2client_update.go +++ b/storage/ent/db/oauth2client_update.go @@ -156,6 +156,24 @@ func (_u *OAuth2ClientUpdate) ClearMfaChain() *OAuth2ClientUpdate { return _u } +// SetPostLogoutRedirectUris sets the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdate) SetPostLogoutRedirectUris(v []string) *OAuth2ClientUpdate { + _u.mutation.SetPostLogoutRedirectUris(v) + return _u +} + +// AppendPostLogoutRedirectUris appends value to the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdate) AppendPostLogoutRedirectUris(v []string) *OAuth2ClientUpdate { + _u.mutation.AppendPostLogoutRedirectUris(v) + return _u +} + +// ClearPostLogoutRedirectUris clears the value of the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdate) ClearPostLogoutRedirectUris() *OAuth2ClientUpdate { + _u.mutation.ClearPostLogoutRedirectUris() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdate) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -276,6 +294,17 @@ func (_u *OAuth2ClientUpdate) sqlSave(ctx context.Context) (_node int, err error if _u.mutation.MfaChainCleared() { _spec.ClearField(oauth2client.FieldMfaChain, field.TypeJSON) } + if value, ok := _u.mutation.PostLogoutRedirectUris(); ok { + _spec.SetField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedPostLogoutRedirectUris(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldPostLogoutRedirectUris, value) + }) + } + if _u.mutation.PostLogoutRedirectUrisCleared() { + _spec.ClearField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{oauth2client.Label} @@ -424,6 +453,24 @@ func (_u *OAuth2ClientUpdateOne) ClearMfaChain() *OAuth2ClientUpdateOne { return _u } +// SetPostLogoutRedirectUris sets the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdateOne) SetPostLogoutRedirectUris(v []string) *OAuth2ClientUpdateOne { + _u.mutation.SetPostLogoutRedirectUris(v) + return _u +} + +// AppendPostLogoutRedirectUris appends value to the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdateOne) AppendPostLogoutRedirectUris(v []string) *OAuth2ClientUpdateOne { + _u.mutation.AppendPostLogoutRedirectUris(v) + return _u +} + +// ClearPostLogoutRedirectUris clears the value of the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdateOne) ClearPostLogoutRedirectUris() *OAuth2ClientUpdateOne { + _u.mutation.ClearPostLogoutRedirectUris() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdateOne) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -574,6 +621,17 @@ func (_u *OAuth2ClientUpdateOne) sqlSave(ctx context.Context) (_node *OAuth2Clie if _u.mutation.MfaChainCleared() { _spec.ClearField(oauth2client.FieldMfaChain, field.TypeJSON) } + if value, ok := _u.mutation.PostLogoutRedirectUris(); ok { + _spec.SetField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedPostLogoutRedirectUris(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldPostLogoutRedirectUris, value) + }) + } + if _u.mutation.PostLogoutRedirectUrisCleared() { + _spec.ClearField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON) + } _node = &OAuth2Client{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/schema/client.go b/storage/ent/schema/client.go index c7737d70..882842ce 100644 --- a/storage/ent/schema/client.go +++ b/storage/ent/schema/client.go @@ -49,6 +49,8 @@ func (OAuth2Client) Fields() []ent.Field { Optional(), field.JSON("mfa_chain", []string{}). Optional(), + field.JSON("post_logout_redirect_uris", []string{}). + Optional(), } } diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index c945874a..5d40acb2 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -289,6 +289,8 @@ type Client struct { AllowedConnectors []string `json:"allowedConnectors,omitempty"` MFAChain []string `json:"mfaChain,omitempty"` + + PostLogoutRedirectURIs []string `json:"postLogoutRedirectURIs,omitempty"` } // ClientList is a list of Clients. @@ -308,29 +310,31 @@ func (cli *client) fromStorageClient(c storage.Client) Client { Name: cli.idToName(c.ID), Namespace: cli.namespace, }, - ID: c.ID, - Secret: c.Secret, - RedirectURIs: c.RedirectURIs, - TrustedPeers: c.TrustedPeers, - Public: c.Public, - Name: c.Name, - LogoURL: c.LogoURL, - AllowedConnectors: c.AllowedConnectors, - MFAChain: c.MFAChain, + ID: c.ID, + Secret: c.Secret, + RedirectURIs: c.RedirectURIs, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + Name: c.Name, + LogoURL: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, + MFAChain: c.MFAChain, + PostLogoutRedirectURIs: c.PostLogoutRedirectURIs, } } func toStorageClient(c Client) storage.Client { return storage.Client{ - ID: c.ID, - Secret: c.Secret, - RedirectURIs: c.RedirectURIs, - TrustedPeers: c.TrustedPeers, - Public: c.Public, - Name: c.Name, - LogoURL: c.LogoURL, - AllowedConnectors: c.AllowedConnectors, - MFAChain: c.MFAChain, + ID: c.ID, + Secret: c.Secret, + RedirectURIs: c.RedirectURIs, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + Name: c.Name, + LogoURL: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, + MFAChain: c.MFAChain, + PostLogoutRedirectURIs: c.PostLogoutRedirectURIs, } } @@ -1006,6 +1010,7 @@ type AuthSession struct { UserAgent string `json:"userAgent,omitempty"` AbsoluteExpiry time.Time `json:"absoluteExpiry,omitempty"` IdleExpiry time.Time `json:"idleExpiry,omitempty"` + LogoutState *storage.LogoutState `json:"logoutState,omitempty"` } // AuthSessionList is a list of AuthSessions. @@ -1035,6 +1040,7 @@ func (cli *client) fromStorageAuthSession(s storage.AuthSession) AuthSession { UserAgent: s.UserAgent, AbsoluteExpiry: s.AbsoluteExpiry, IdleExpiry: s.IdleExpiry, + LogoutState: s.LogoutState, } } @@ -1050,6 +1056,7 @@ func toStorageAuthSession(s AuthSession) storage.AuthSession { UserAgent: s.UserAgent, AbsoluteExpiry: s.AbsoluteExpiry, IdleExpiry: s.IdleExpiry, + LogoutState: s.LogoutState, } if result.ClientStates == nil { result.ClientStates = make(map[string]*storage.ClientAuthState) diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 83308672..2864ebb3 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -539,9 +539,10 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto name = $5, logo_url = $6, allowed_connectors = $7, - mfa_chain = $8 - where id = $9; - `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, encoder(nc.AllowedConnectors), encoder(nc.MFAChain), id, + mfa_chain = $8, + post_logout_redirect_uris = $9 + where id = $10; + `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, encoder(nc.AllowedConnectors), encoder(nc.MFAChain), encoder(nc.PostLogoutRedirectURIs), id, ) if err != nil { return fmt.Errorf("update client: %v", err) @@ -553,12 +554,12 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { _, err := c.Exec(` insert into client ( - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris ) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9); + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); `, cli.ID, cli.Secret, encoder(cli.RedirectURIs), encoder(cli.TrustedPeers), - cli.Public, cli.Name, cli.LogoURL, encoder(cli.AllowedConnectors), encoder(cli.MFAChain), + cli.Public, cli.Name, cli.LogoURL, encoder(cli.AllowedConnectors), encoder(cli.MFAChain), encoder(cli.PostLogoutRedirectURIs), ) if err != nil { if c.alreadyExistsCheck(err) { @@ -572,7 +573,7 @@ func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { func getClient(ctx context.Context, q querier, id string) (storage.Client, error) { return scanClient(q.QueryRow(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris from client where id = $1; `, id)) } @@ -584,7 +585,7 @@ func (c *conn) GetClient(ctx context.Context, id string) (storage.Client, error) func (c *conn) ListClients(ctx context.Context) ([]storage.Client, error) { rows, err := c.Query(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris from client; `) if err != nil { @@ -609,9 +610,10 @@ func (c *conn) ListClients(ctx context.Context) ([]storage.Client, error) { func scanClient(s scanner) (cli storage.Client, err error) { var allowedConnectors []byte var mfaChain []byte + var postLogoutRedirectURIs []byte err = s.Scan( &cli.ID, &cli.Secret, decoder(&cli.RedirectURIs), decoder(&cli.TrustedPeers), - &cli.Public, &cli.Name, &cli.LogoURL, &allowedConnectors, &mfaChain, + &cli.Public, &cli.Name, &cli.LogoURL, &allowedConnectors, &mfaChain, &postLogoutRedirectURIs, ) if err != nil { if err == sql.ErrNoRows { @@ -629,6 +631,11 @@ func scanClient(s scanner) (cli storage.Client, err error) { return cli, fmt.Errorf("unmarshal client mfa chain: %v", err) } } + if len(postLogoutRedirectURIs) > 0 { + if err := json.Unmarshal(postLogoutRedirectURIs, &cli.PostLogoutRedirectURIs); err != nil { + return cli, fmt.Errorf("unmarshal client post logout redirect uris: %v", err) + } + } return cli, nil } @@ -971,15 +978,17 @@ func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) err client_states, created_at, last_activity, ip_address, user_agent, - absolute_expiry, idle_expiry + absolute_expiry, idle_expiry, + logout_state ) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); `, s.UserID, s.ConnectorID, s.Nonce, encoder(s.ClientStates), s.CreatedAt, s.LastActivity, s.IPAddress, s.UserAgent, s.AbsoluteExpiry, s.IdleExpiry, + encoder(s.LogoutState), ) if err != nil { if c.alreadyExistsCheck(err) { @@ -1007,12 +1016,14 @@ func (c *conn) UpdateAuthSession(ctx context.Context, userID, connectorID string client_states = $1, last_activity = $2, ip_address = $3, - user_agent = $4 - where user_id = $5 AND connector_id = $6; + user_agent = $4, + logout_state = $5 + where user_id = $6 AND connector_id = $7; `, encoder(newSession.ClientStates), newSession.LastActivity, newSession.IPAddress, newSession.UserAgent, + encoder(newSession.LogoutState), userID, connectorID, ) if err != nil { @@ -1026,26 +1037,32 @@ func (c *conn) GetAuthSession(ctx context.Context, userID, connectorID string) ( return getAuthSession(ctx, c, userID, connectorID) } +const authSessionColumns = ` + user_id, connector_id, nonce, + client_states, + created_at, last_activity, + ip_address, user_agent, + absolute_expiry, idle_expiry, + logout_state +` + func getAuthSession(ctx context.Context, q querier, userID, connectorID string) (storage.AuthSession, error) { return scanAuthSession(q.QueryRow(` - select - user_id, connector_id, nonce, - client_states, - created_at, last_activity, - ip_address, user_agent, - absolute_expiry, idle_expiry + select `+authSessionColumns+` from auth_session where user_id = $1 AND connector_id = $2; `, userID, connectorID)) } func scanAuthSession(s scanner) (session storage.AuthSession, err error) { + var logoutState []byte err = s.Scan( &session.UserID, &session.ConnectorID, &session.Nonce, decoder(&session.ClientStates), &session.CreatedAt, &session.LastActivity, &session.IPAddress, &session.UserAgent, &session.AbsoluteExpiry, &session.IdleExpiry, + &logoutState, ) if err != nil { if err == sql.ErrNoRows { @@ -1056,19 +1073,17 @@ func scanAuthSession(s scanner) (session storage.AuthSession, err error) { if session.ClientStates == nil { session.ClientStates = make(map[string]*storage.ClientAuthState) } + if len(logoutState) > 0 && string(logoutState) != "null" { + session.LogoutState = new(storage.LogoutState) + if err := json.Unmarshal(logoutState, session.LogoutState); err != nil { + return session, fmt.Errorf("unmarshal auth session logout state: %v", err) + } + } return session, nil } func (c *conn) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, error) { - rows, err := c.Query(` - select - user_id, connector_id, nonce, - client_states, - created_at, last_activity, - ip_address, user_agent, - absolute_expiry, idle_expiry - from auth_session; - `) + rows, err := c.Query(`select ` + authSessionColumns + ` from auth_session;`) if err != nil { return nil, fmt.Errorf("query: %v", err) } diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 80b81b4f..942b119a 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -450,6 +450,8 @@ var migrations = []migration{ ` alter table auth_session add column idle_expiry timestamptz not null default '1970-01-01 00:00:00';`, + `alter table client add column post_logout_redirect_uris bytea;`, + `alter table auth_session add column logout_state bytea;`, }, }, } diff --git a/storage/storage.go b/storage/storage.go index 0889e6b6..21d1e971 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -170,6 +170,11 @@ type Client struct { // requested to redirect to MUST match one of these values, unless the client is "public". RedirectURIs []string `json:"redirectURIs"` + // PostLogoutRedirectURIs is a registered set of URIs that the client can redirect to + // after logout. Per OIDC RP-Initiated Logout Section 2, the post_logout_redirect_uri + // parameter MUST match one of these values. + PostLogoutRedirectURIs []string `json:"postLogoutRedirectURIs"` + // TrustedPeers are a list of peers which can issue tokens on this client's behalf using // the dynamic "oauth2:server:client_id:(client_id)" scope. If a peer makes such a request, // this client's ID will appear as the ID Token's audience. @@ -388,6 +393,15 @@ type ClientAuthState struct { LastTokenIssuedAt time.Time } +// LogoutState holds RP parameters saved in the auth session during logout. +// These are written before the upstream logout redirect and read back in the callback. +type LogoutState struct { + PostLogoutRedirectURI string + State string // RP's opaque state parameter + ClientID string + ConnectorID string +} + // AuthSession represents a user's authentication session from a specific connector. // Keyed by composite (UserID, ConnectorID), similar to OfflineSessions. // The Nonce field is a random value included in the session cookie to prevent forgery. @@ -409,6 +423,11 @@ type AuthSession struct { AbsoluteExpiry time.Time // IdleExpiry is LastActivity + ValidIfNotUsedFor, updated on every activity. IdleExpiry time.Time + + // LogoutState is set during RP-Initiated Logout before redirecting to the + // upstream provider. The callback handler reads it back to complete the flow. + // Nil when no logout is in progress. + LogoutState *LogoutState } // OfflineSessions objects are sessions pertaining to users with refresh tokens. diff --git a/web/templates/logout.html b/web/templates/logout.html new file mode 100644 index 00000000..eb2a2b74 --- /dev/null +++ b/web/templates/logout.html @@ -0,0 +1,24 @@ +{{ template "header.html" . }} + +
+ {{ if .LoggedOut }} +

Logged Out

+
+
You have been successfully logged out.
+
+ {{ else }} +

Session Not Found

+
+
No active session found.
+
+ {{ end }} + + {{ if .BackURL }} + + {{ end }} + +
+ +{{ template "footer.html" . }} From 546e66cb5d1005e5eb3b87455513b2e5c730a0aa Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Thu, 2 Apr 2026 11:48:46 +0200 Subject: [PATCH 211/222] feat: add WebAuthn support (#4704) Signed-off-by: maksim.nabokikh Signed-off-by: Maksim Nabokikh Co-authored-by: Alwx --- cmd/dex/config.go | 35 +- cmd/dex/serve.go | 18 +- examples/config-dev.yaml | 41 ++- go.mod | 8 +- go.sum | 20 +- server/handlers.go | 64 ++-- server/handlers_approval_test.go | 7 +- server/handlers_test.go | 7 +- server/hmac.go | 40 ++ server/internal/types.pb.go | 62 +++- server/internal/types.proto | 7 + server/mfa.go | 236 ++++++------ server/mfa_webauthn.go | 383 ++++++++++++++++++++ server/mfa_webauthn_test.go | 270 ++++++++++++++ server/server.go | 19 +- server/session.go | 11 +- server/templates.go | 78 ++-- storage/ent/client/authrequest.go | 2 + storage/ent/client/types.go | 21 +- storage/ent/client/useridentity.go | 12 + storage/ent/db/authrequest.go | 15 +- storage/ent/db/authrequest/authrequest.go | 3 + storage/ent/db/authrequest/where.go | 55 +++ storage/ent/db/authrequest_create.go | 10 + storage/ent/db/authrequest_update.go | 36 ++ storage/ent/db/migrate/schema.go | 2 + storage/ent/db/mutation.go | 150 +++++++- storage/ent/db/runtime.go | 4 +- storage/ent/db/useridentity.go | 15 +- storage/ent/db/useridentity/useridentity.go | 3 + storage/ent/db/useridentity/where.go | 55 +++ storage/ent/db/useridentity_create.go | 10 + storage/ent/db/useridentity_update.go | 36 ++ storage/ent/schema/authrequest.go | 3 + storage/ent/schema/useridentity.go | 3 + storage/etcd/types.go | 65 ++-- storage/kubernetes/types.go | 65 ++-- storage/sql/crud.go | 42 ++- storage/sql/migrate.go | 6 + storage/storage.go | 36 +- web/templates/webauthn_verify.html | 173 +++++++++ 41 files changed, 1811 insertions(+), 317 deletions(-) create mode 100644 server/hmac.go create mode 100644 server/mfa_webauthn.go create mode 100644 server/mfa_webauthn_test.go create mode 100644 web/templates/webauthn_verify.html diff --git a/cmd/dex/config.go b/cmd/dex/config.go index e80c1c57..fdb086f0 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -143,7 +143,7 @@ func (c Config) validateMFA() error { return fmt.Errorf("mfa requires sessions to be enabled (DEX_SESSIONS_ENABLED=true)") } - knownTypes := map[string]bool{"TOTP": true} + knownTypes := map[string]bool{"TOTP": true, "WebAuthn": true} ids := make(map[string]bool, len(mfa.Authenticators)) for _, auth := range mfa.Authenticators { @@ -705,3 +705,36 @@ 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/serve.go b/cmd/dex/serve.go index 0c26006d..22eed432 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -385,7 +385,7 @@ func runServe(options serveOptions) error { ContinueOnConnectorFailure: featureflags.ContinueOnConnectorFailure.Enabled(), Signer: signerInstance, IDTokensValidFor: idTokensValidFor, - MFAProviders: buildMFAProviders(c.MFA.Authenticators, logger), + MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger), DefaultMFAChain: c.MFA.DefaultMFAChain, } @@ -823,7 +823,7 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { return sc, nil } -func buildMFAProviders(authenticators []MFAAuthenticator, logger *slog.Logger) map[string]server.MFAProvider { +func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider { if len(authenticators) == 0 { return nil } @@ -839,6 +839,20 @@ func buildMFAProviders(authenticators []MFAAuthenticator, logger *slog.Logger) m } providers[auth.ID] = server.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 := server.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) } diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 1b9da1f1..5179cb68 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -141,21 +141,32 @@ telemetry: # 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 -# # Default MFA chain applied to clients that don't specify their own mfaChain. -# # If omitted or empty, no MFA is required by default. -# defaultMFAChain: -# - totp-1 +# 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. # diff --git a/go.mod b/go.mod index 2c68ebaf..1e1440bb 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-sql-driver/mysql v1.9.3 + github.com/go-webauthn/webauthn v0.16.1 github.com/google/cel-go v0.27.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 @@ -70,14 +71,18 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // 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.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.2 // indirect github.com/gogo/protobuf v1.3.2 // 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.14 // indirect github.com/googleapis/gax-go/v2 v2.19.0 // indirect @@ -114,6 +119,7 @@ require ( 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/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.9 // indirect diff --git a/go.sum b/go.sum index a8aa547a..202a9f23 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/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.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= @@ -94,17 +96,27 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +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.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU= +github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ= +github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk= +github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= 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= @@ -250,6 +262,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= @@ -280,6 +294,8 @@ go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4Len go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= 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= diff --git a/server/handlers.go b/server/handlers.go index 825cf35e..4f615d01 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -2,7 +2,6 @@ package server import ( "context" - "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" @@ -13,7 +12,6 @@ import ( "maps" "net/http" "net/url" - "path" "sort" "strconv" "strings" @@ -887,30 +885,13 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, userIdentity = &ui } - // an HMAC is used here to ensure that the request ID is unpredictable, ensuring that an attacker who intercepted the original - // flow would be unable to poll for the result at the /approval endpoint - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID)) - mac := h.Sum(nil) - hmacParam := base64.RawURLEncoding.EncodeToString(mac) - // Check if the client requires MFA. mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) if err != nil { return "", false, fmt.Errorf("failed to get MFA chain for client: %v", err) } if len(mfaChain) > 0 { - // Redirect to MFA verification starting with the first authenticator. - // Each authenticator redirects to the next one in the chain upon success. - // HMAC includes authenticatorID to prevent skipping steps by URL manipulation. - h.Reset() - h.Write([]byte(authReq.ID + "|" + mfaChain[0])) - v := url.Values{} - v.Set("req", authReq.ID) - v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil))) - v.Set("authenticator", mfaChain[0]) - returnURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode() - return returnURL, false, nil + return s.buildMFARedirectURL(authReq, mfaChain[0]), false, nil } // No MFA required — mark as validated. @@ -933,8 +914,7 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, } } - returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + hmacParam - return returnURL, false, nil + return s.buildApprovalURL(authReq), false, nil } func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { @@ -944,12 +924,6 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") return } - mac, err := base64.RawURLEncoding.DecodeString(macEncoded) - if err != nil { - s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") - return - } - authReq, err := s.storage.GetAuthRequest(ctx, r.FormValue("req")) if err != nil { if err == storage.ErrNotFound { @@ -966,7 +940,6 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { return } - h := hmac.New(sha256.New, authReq.HMACKey) if !authReq.MFAValidated { // Check if MFA is actually required — if so, redirect to TOTP instead of blocking. // This handles the case where MFA was enabled after the auth flow started. @@ -977,30 +950,37 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { return } if len(mfaChain) > 0 { - h.Write([]byte(authReq.ID + "|" + mfaChain[0])) - v := url.Values{} - v.Set("req", authReq.ID) - v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil))) - v.Set("authenticator", mfaChain[0]) - h.Reset() - totpURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode() - http.Redirect(w, r, totpURL, http.StatusSeeOther) + http.Redirect(w, r, s.buildMFARedirectURL(authReq, mfaChain[0]), http.StatusSeeOther) return } // No MFA required but flag not set — allow through (backward compat). } - // build expected hmac with secret key - h.Write([]byte(authReq.ID)) - expectedMAC := h.Sum(nil) - // constant time comparison - if !hmac.Equal(mac, expectedMAC) { + if !verifyHMAC(authReq.HMACKey, macEncoded, authReq.ID, "") { s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") return } switch r.Method { case http.MethodGet: + // Skip the approval page and issue the code directly if: + // 1. The client didn't force the approval prompt, AND + // 2. Either the server is configured to skip approval globally, + // or the user has already consented to all requested scopes for this client. + // This handles the MFA redirect case: after MFA completion the user lands on + // /approval via GET, and we don't want to show the consent screen again. + if !authReq.ForceApprovalPrompt { + if s.skipApproval { + s.sendCodeResponse(w, r, authReq) + return + } + ui, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) + if err == nil && scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes) { + s.sendCodeResponse(w, r, authReq) + return + } + } + client, err := s.storage.GetClient(ctx, authReq.ClientID) if err != nil { s.logger.ErrorContext(r.Context(), "Failed to get client", "client_id", authReq.ClientID, "err", err) diff --git a/server/handlers_approval_test.go b/server/handlers_approval_test.go index 24a72b83..4f10b654 100644 --- a/server/handlers_approval_test.go +++ b/server/handlers_approval_test.go @@ -2,9 +2,6 @@ package server import ( "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" "errors" "net/http" "net/http/httptest" @@ -89,9 +86,7 @@ func TestHandleApprovalDoubleSubmitPOST(t *testing.T) { } require.NoError(t, server.storage.CreateAuthRequest(ctx, authReq)) - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID)) - mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + mac := computeHMAC(authReq.HMACKey, authReq.ID, "") form := url.Values{ "approval": {"approve"}, diff --git a/server/handlers_test.go b/server/handlers_test.go index 907993e4..2f152b8d 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -3,9 +3,6 @@ package server import ( "bytes" "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -832,9 +829,7 @@ func TestConsentPersistedOnApproval(t *testing.T) { } require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID)) - mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + mac := computeHMAC(authReq.HMACKey, authReq.ID, "") form := url.Values{ "approval": {"approve"}, diff --git a/server/hmac.go b/server/hmac.go new file mode 100644 index 00000000..39795176 --- /dev/null +++ b/server/hmac.go @@ -0,0 +1,40 @@ +package server + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + + "google.golang.org/protobuf/proto" + + "github.com/dexidp/dex/server/internal" +) + +// computeHMAC computes a SHA-256 HMAC over a protobuf-encoded payload +// and returns the result as a base64 raw-URL-encoded string. +func computeHMAC(key []byte, values ...string) string { + msg := marshalHMACPayload(values) + h := hmac.New(sha256.New, key) + h.Write(msg) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +// verifyHMAC checks that encodedMAC (base64 raw-URL) matches the +// HMAC-SHA256 of the protobuf-encoded payload under key. +func verifyHMAC(key []byte, encodedMAC string, values ...string) bool { + mac, err := base64.RawURLEncoding.DecodeString(encodedMAC) + if err != nil { + return false + } + msg := marshalHMACPayload(values) + h := hmac.New(sha256.New, key) + h.Write(msg) + return hmac.Equal(mac, h.Sum(nil)) +} + +func marshalHMACPayload(values []string) []byte { + payload := &internal.HMACPayload{Values: values} + // proto.Marshal is deterministic for the same input in the Go implementation. + data, _ := proto.Marshal(payload) + return data +} diff --git a/server/internal/types.pb.go b/server/internal/types.pb.go index 8f9cbeb8..d00e0842 100644 --- a/server/internal/types.pb.go +++ b/server/internal/types.pb.go @@ -191,6 +191,53 @@ func (x *SessionCookie) GetNonce() string { return "" } +// HMACPayload is the structured message used as HMAC input. +// Using protobuf encoding instead of string concatenation avoids +// delimiter-based ambiguities in the HMAC message. +type HMACPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HMACPayload) Reset() { + *x = HMACPayload{} + mi := &file_server_internal_types_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HMACPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HMACPayload) ProtoMessage() {} + +func (x *HMACPayload) ProtoReflect() protoreflect.Message { + mi := &file_server_internal_types_proto_msgTypes[3] + 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 HMACPayload.ProtoReflect.Descriptor instead. +func (*HMACPayload) Descriptor() ([]byte, []int) { + return file_server_internal_types_proto_rawDescGZIP(), []int{3} +} + +func (x *HMACPayload) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + var File_server_internal_types_proto protoreflect.FileDescriptor var file_server_internal_types_proto_rawDesc = string([]byte{ @@ -211,10 +258,12 @@ var file_server_internal_types_proto_rawDesc = string([]byte{ 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, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x6f, - 0x6e, 0x63, 0x65, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x33, + 0x6e, 0x63, 0x65, 0x22, 0x25, 0x0a, 0x0b, 0x48, 0x4d, 0x41, 0x43, 0x50, 0x61, 0x79, 0x6c, 0x6f, + 0x61, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, + 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -229,11 +278,12 @@ func file_server_internal_types_proto_rawDescGZIP() []byte { return file_server_internal_types_proto_rawDescData } -var file_server_internal_types_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_server_internal_types_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_server_internal_types_proto_goTypes = []any{ (*RefreshToken)(nil), // 0: internal.RefreshToken (*IDTokenSubject)(nil), // 1: internal.IDTokenSubject (*SessionCookie)(nil), // 2: internal.SessionCookie + (*HMACPayload)(nil), // 3: internal.HMACPayload } var file_server_internal_types_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type @@ -254,7 +304,7 @@ func file_server_internal_types_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_server_internal_types_proto_rawDesc), len(file_server_internal_types_proto_rawDesc)), NumEnums: 0, - NumMessages: 3, + NumMessages: 4, NumExtensions: 0, NumServices: 0, }, diff --git a/server/internal/types.proto b/server/internal/types.proto index 324e8854..d6c6c2e8 100644 --- a/server/internal/types.proto +++ b/server/internal/types.proto @@ -24,3 +24,10 @@ message SessionCookie { string connector_id = 2; string nonce = 3; } + +// HMACPayload is the structured message used as HMAC input. +// Using protobuf encoding instead of string concatenation avoids +// delimiter-based ambiguities in the HMAC message. +message HMACPayload { + repeated string values = 1; +} diff --git a/server/mfa.go b/server/mfa.go index 7d2417f6..efd24642 100644 --- a/server/mfa.go +++ b/server/mfa.go @@ -3,14 +3,11 @@ package server import ( "bytes" "context" - "crypto/hmac" - "crypto/sha256" "encoding/base64" "fmt" "image/png" "net/http" "net/url" - "path" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" @@ -59,16 +56,21 @@ func (p *TOTPProvider) generate(connID, email string) (*otp.Key, error) { }) } -func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) { +// mfaRequestContext holds validated MFA request data shared across handlers. +type mfaRequestContext struct { + authReq storage.AuthRequest + identity storage.UserIdentity + authenticatorID string + approvalURL string +} + +// validateMFARequest performs common MFA request validation: HMAC check, auth request +// lookup, user identity lookup, and approval URL generation. +func (s *Server) validateMFARequest(w http.ResponseWriter, r *http.Request) (*mfaRequestContext, bool) { macEncoded := r.FormValue("hmac") if macEncoded == "" { s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request.") - return - } - mac, err := base64.RawURLEncoding.DecodeString(macEncoded) - if err != nil { - s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request.") - return + return nil, false } ctx := r.Context() @@ -77,54 +79,92 @@ func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) { if err != nil { s.logger.ErrorContext(ctx, "failed to get auth request", "err", err) s.renderError(r, w, http.StatusInternalServerError, "Database error.") - return + return nil, false } if !authReq.LoggedIn { s.logger.ErrorContext(ctx, "auth request does not have an identity for MFA verification") s.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.") - return + return nil, false } authenticatorID := r.FormValue("authenticator") - // Verify HMAC — includes authenticatorID to prevent skipping steps in the MFA chain. - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID + "|" + authenticatorID)) - if !hmac.Equal(mac, h.Sum(nil)) { + if !verifyHMAC(authReq.HMACKey, macEncoded, authReq.ID, authenticatorID) { s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request.") - return - } - provider, ok := s.mfaProviders[authenticatorID] - if !ok { - s.renderError(r, w, http.StatusBadRequest, "Unknown authenticator.") - return - } - - totpProvider, ok := provider.(*TOTPProvider) - if !ok { - s.renderError(r, w, http.StatusInternalServerError, "Unsupported authenticator type.") - return + return nil, false } identity, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) if err != nil { s.logger.ErrorContext(ctx, "failed to get user identity", "err", err) s.renderError(r, w, http.StatusInternalServerError, "Database error.") - return + return nil, false } - // Build approval URL with an HMAC that covers only the request ID - // (MFA HMAC includes authenticatorID and is not valid for approval). - approvalH := hmac.New(sha256.New, authReq.HMACKey) - approvalH.Write([]byte(authReq.ID)) - returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + - "&hmac=" + base64.RawURLEncoding.EncodeToString(approvalH.Sum(nil)) + approvalURL := s.buildApprovalURL(authReq) if authReq.MFAValidated { - http.Redirect(w, r, returnURL, http.StatusSeeOther) + http.Redirect(w, r, approvalURL, http.StatusSeeOther) + return nil, false + } + + return &mfaRequestContext{ + authReq: authReq, + identity: identity, + authenticatorID: authenticatorID, + approvalURL: approvalURL, + }, true +} + +func (s *Server) handleTOTP(w http.ResponseWriter, r *http.Request) { + mfa, ok := s.validateMFARequest(w, r) + if !ok { return } + provider, ok := s.mfaProviders[mfa.authenticatorID] + if !ok { + s.renderError(r, w, http.StatusBadRequest, "Unknown authenticator.") + return + } + totpProvider, ok := provider.(*TOTPProvider) + if !ok { + s.renderError(r, w, http.StatusBadRequest, "Not a TOTP authenticator.") + return + } + + s.handleTOTPVerify(w, r, r.Context(), mfa.authReq, mfa.identity, mfa.authenticatorID, totpProvider, mfa.approvalURL) +} + +func (s *Server) handleWebAuthn(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + s.renderError(r, w, http.StatusMethodNotAllowed, "Unsupported request method.") + return + } + + mfa, ok := s.validateMFARequest(w, r) + if !ok { + return + } + + w.Header().Set("Cache-Control", "no-store") + + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + mode := "login" + if len(user.credentials) == 0 { + mode = "register" + } + + if err := s.templates.webauthnVerify(r, w, mode, mfa.authenticatorID); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } +} + +// handleTOTPVerify handles TOTP enrollment and verification. +func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request, ctx context.Context, + authReq storage.AuthRequest, identity storage.UserIdentity, + authenticatorID string, totpProvider *TOTPProvider, returnURL string, +) { secret := identity.MFASecrets[authenticatorID] switch r.Method { @@ -201,47 +241,16 @@ func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) { } } - // Check if there are more authenticators in the MFA chain. - mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) + redirectURL, err := s.completeMFAStep(ctx, authReq, authenticatorID) if err != nil { - s.logger.ErrorContext(ctx, "failed to get MFA chain", "err", err) + s.logger.ErrorContext(ctx, "failed to complete MFA step", "err", err) s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") return } - // Find the next authenticator in the chain after the current one. - var nextAuthenticator string - for i, id := range mfaChain { - if id == authenticatorID && i+1 < len(mfaChain) { - nextAuthenticator = mfaChain[i+1] - break - } - } - - if nextAuthenticator != "" { - // Redirect to the next authenticator in the chain. - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID + "|" + nextAuthenticator)) - v := url.Values{} - v.Set("req", authReq.ID) - v.Set("hmac", base64.RawURLEncoding.EncodeToString(h.Sum(nil))) - v.Set("authenticator", nextAuthenticator) - nextURL := path.Join(s.issuerURL.Path, "/mfa/verify") + "?" + v.Encode() - http.Redirect(w, r, nextURL, http.StatusSeeOther) - return - } - - // All authenticators in the chain completed — mark as validated. - if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, func(old storage.AuthRequest) (storage.AuthRequest, error) { - old.MFAValidated = true - return old, nil - }); err != nil { - s.logger.ErrorContext(ctx, "failed to update auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") - return - } - - s.sendCodeOrRedirectToApproval(w, r, authReq, returnURL) + // completeMFAStep returns either the next MFA step URL or the approval URL. + // Redirect in both cases — the approval handler handles skipApproval logic. + http.Redirect(w, r, redirectURL, http.StatusSeeOther) default: s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") @@ -266,39 +275,6 @@ func (s *Server) renderTOTPPage(secret *storage.MFASecret, lastFail bool, issuer } } -// sendCodeOrRedirectToApproval checks skipApproval and stored consent, -// sending a code response directly if possible, or redirecting to the approval page. -func (s *Server) sendCodeOrRedirectToApproval(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest, approvalURL string) { - ctx := r.Context() - - if !authReq.ForceApprovalPrompt { - if s.skipApproval { - authReq, err := s.storage.GetAuthRequest(ctx, authReq.ID) - if err != nil { - s.logger.ErrorContext(ctx, "failed to get auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } - s.sendCodeResponse(w, r, authReq) - return - } - - ui, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) - if err == nil && scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes) { - authReq, err := s.storage.GetAuthRequest(ctx, authReq.ID) - if err != nil { - s.logger.ErrorContext(ctx, "failed to get auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } - s.sendCodeResponse(w, r, authReq) - return - } - } - - http.Redirect(w, r, approvalURL, http.StatusSeeOther) -} - func generateTOTPQRCode(keyURL string) (string, error) { generated, err := otp.NewKeyFromURL(keyURL) if err != nil { @@ -362,3 +338,53 @@ func (s *Server) getConnectorType(ctx context.Context, connectorID string) (stri } return conn.Type, nil } + +// mfaPagePath returns the page URL path for the given MFA provider type. +func (s *Server) mfaPagePath(authenticatorID string) string { + provider, ok := s.mfaProviders[authenticatorID] + if ok && provider.Type() == "WebAuthn" { + return "/mfa/webauthn" + } + return "/mfa/totp" +} + +// completeMFAStep checks for the next authenticator in the MFA chain and either +// returns the URL for the next step or marks MFA as validated and returns the approval URL. +func (s *Server) completeMFAStep(ctx context.Context, authReq storage.AuthRequest, authenticatorID string) (string, error) { + mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) + if err != nil { + return "", fmt.Errorf("get MFA chain: %w", err) + } + + // Find the next authenticator in the chain after the current one. + var nextAuthenticator string + for i, id := range mfaChain { + if id == authenticatorID && i+1 < len(mfaChain) { + nextAuthenticator = mfaChain[i+1] + break + } + } + + if nextAuthenticator != "" { + return s.buildMFARedirectURL(authReq, nextAuthenticator), nil + } + + // All authenticators completed — mark as validated. + if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, func(old storage.AuthRequest) (storage.AuthRequest, error) { + old.MFAValidated = true + return old, nil + }); err != nil { + return "", fmt.Errorf("update auth request: %w", err) + } + + return s.buildApprovalURL(authReq), nil +} + +// buildMFARedirectURL builds an HMAC-protected redirect URL for the given authenticator. +func (s *Server) buildMFARedirectURL(authReq storage.AuthRequest, authenticatorID string) string { + v := url.Values{} + v.Set("req", authReq.ID) + v.Set("hmac", computeHMAC(authReq.HMACKey, authReq.ID, authenticatorID)) + v.Set("authenticator", authenticatorID) + return s.absPath(s.mfaPagePath(authenticatorID)) + "?" + v.Encode() +} diff --git a/server/mfa_webauthn.go b/server/mfa_webauthn.go new file mode 100644 index 00000000..9ab97904 --- /dev/null +++ b/server/mfa_webauthn.go @@ -0,0 +1,383 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + gowebauthn "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/dexidp/dex/storage" +) + +// WebAuthnProvider implements WebAuthn-based multi-factor authentication. +type WebAuthnProvider struct { + wan *webauthn.WebAuthn + connectorTypes map[string]struct{} +} + +// NewWebAuthnProvider creates a new WebAuthn MFA provider. +// If rpID or rpOrigins are empty, they are derived from the issuerURL. +func NewWebAuthnProvider(rpDisplayName, rpID string, rpOrigins []string, + attestationPreference, timeout, issuerURL string, + connectorTypes []string, +) (*WebAuthnProvider, error) { + parsed, err := url.Parse(issuerURL) + if err != nil { + return nil, fmt.Errorf("parse issuer URL: %w", err) + } + + if rpID == "" { + rpID = parsed.Hostname() + } + if len(rpOrigins) == 0 { + rpOrigins = []string{parsed.Scheme + "://" + parsed.Host} + } + if rpDisplayName == "" { + rpDisplayName = rpID + } + + cfg := &webauthn.Config{ + RPID: rpID, + RPDisplayName: rpDisplayName, + RPOrigins: rpOrigins, + } + + switch attestationPreference { + case "none": + cfg.AttestationPreference = gowebauthn.PreferNoAttestation + case "direct": + cfg.AttestationPreference = gowebauthn.PreferDirectAttestation + case "", "indirect": + cfg.AttestationPreference = gowebauthn.PreferIndirectAttestation + } + + if timeout != "" { + d, err := time.ParseDuration(timeout) + if err != nil { + return nil, fmt.Errorf("parse timeout: %w", err) + } + timeoutCfg := webauthn.TimeoutConfig{Enforce: true, Timeout: d} + cfg.Timeouts = webauthn.TimeoutsConfig{ + Login: timeoutCfg, + Registration: timeoutCfg, + } + } + + wan, err := webauthn.New(cfg) + if err != nil { + return nil, fmt.Errorf("create webauthn: %w", err) + } + + m := make(map[string]struct{}, len(connectorTypes)) + for _, t := range connectorTypes { + m[t] = struct{}{} + } + + return &WebAuthnProvider{wan: wan, connectorTypes: m}, nil +} + +func (p *WebAuthnProvider) Type() string { return "WebAuthn" } + +func (p *WebAuthnProvider) EnabledForConnectorType(connectorType string) bool { + if len(p.connectorTypes) == 0 { + return true + } + _, ok := p.connectorTypes[connectorType] + return ok +} + +// webauthnUser implements the webauthn.User interface. +type webauthnUser struct { + id []byte + name string + displayName string + credentials []webauthn.Credential +} + +func (u *webauthnUser) WebAuthnID() []byte { return u.id } +func (u *webauthnUser) WebAuthnName() string { return u.name } +func (u *webauthnUser) WebAuthnDisplayName() string { return u.displayName } +func (u *webauthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } + +// buildWebAuthnUser creates a webauthn.User from a UserIdentity, using credentials for the given authenticatorID. +func buildWebAuthnUser(identity storage.UserIdentity, authenticatorID string) *webauthnUser { + stored := identity.WebAuthnCredentials[authenticatorID] + creds := make([]webauthn.Credential, 0, len(stored)) + for _, c := range stored { + transports := make([]gowebauthn.AuthenticatorTransport, len(c.Transport)) + for i, t := range c.Transport { + transports[i] = gowebauthn.AuthenticatorTransport(t) + } + creds = append(creds, webauthn.Credential{ + ID: c.CredentialID, + PublicKey: c.PublicKey, + AttestationType: c.AttestationType, + Transport: transports, + Flags: webauthn.CredentialFlags{ + BackupEligible: c.BackupEligible, + BackupState: c.BackupState, + }, + Authenticator: webauthn.Authenticator{ + AAGUID: c.AAGUID, + SignCount: c.SignCount, + CloneWarning: c.CloneWarning, + }, + }) + } + + return &webauthnUser{ + id: []byte(identity.UserID + "|" + identity.ConnectorID), + name: identity.Claims.Email, + displayName: identity.Claims.PreferredUsername, + credentials: creds, + } +} + +// handleWebAuthnRegisterBegin starts the WebAuthn registration ceremony. +func (s *Server) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) { + mfa, provider, ok := s.validateWebAuthnAPIRequest(w, r) + if !ok { + return + } + + ctx := r.Context() + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + user.credentials = nil // don't exclude existing credentials during registration + + creation, session, err := provider.wan.BeginRegistration(user) + if err != nil { + s.logger.ErrorContext(ctx, "failed to begin webauthn registration", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + if err := s.storeWebAuthnSession(ctx, mfa.authReq.ID, session); err != nil { + s.logger.ErrorContext(ctx, "failed to store session data", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + writeJSON(w, creation) +} + +// handleWebAuthnRegisterFinish completes the WebAuthn registration ceremony. +func (s *Server) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) { + mfa, provider, ok := s.validateWebAuthnAPIRequest(w, r) + if !ok { + return + } + + ctx := r.Context() + session, err := s.loadWebAuthnSession(mfa.authReq) + if err != nil { + s.logger.ErrorContext(ctx, "failed to load session data", "err", err) + writeJSONError(w, http.StatusBadRequest, "Invalid session.") + return + } + + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + user.credentials = nil + + credential, err := provider.wan.FinishRegistration(user, *session, r) + if err != nil { + s.logger.ErrorContext(ctx, "webauthn registration failed", "err", err) + writeJSONError(w, http.StatusBadRequest, "Registration failed: "+err.Error()) + return + } + + newCred := convertCredential(credential, s.now()) + if err := s.storage.UpdateUserIdentity(ctx, mfa.authReq.Claims.UserID, mfa.authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + if old.WebAuthnCredentials == nil { + old.WebAuthnCredentials = make(map[string][]storage.WebAuthnCredential) + } + old.WebAuthnCredentials[mfa.authenticatorID] = append(old.WebAuthnCredentials[mfa.authenticatorID], newCred) + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to store credential", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + s.writeCompleteMFAStepResponse(w, r, mfa) +} + +// handleWebAuthnLoginBegin starts the WebAuthn login ceremony. +func (s *Server) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) { + mfa, provider, ok := s.validateWebAuthnAPIRequest(w, r) + if !ok { + return + } + + ctx := r.Context() + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + if len(user.credentials) == 0 { + writeJSONError(w, http.StatusBadRequest, "No WebAuthn credentials registered.") + return + } + + assertion, session, err := provider.wan.BeginLogin(user) + if err != nil { + s.logger.ErrorContext(ctx, "failed to begin webauthn login", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + if err := s.storeWebAuthnSession(ctx, mfa.authReq.ID, session); err != nil { + s.logger.ErrorContext(ctx, "failed to store session data", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + writeJSON(w, assertion) +} + +// handleWebAuthnLoginFinish completes the WebAuthn login ceremony. +// +// TODO(nabokihms): this endpoint should be protected with a rate limit (like the auth endpoint). +// Although WebAuthn is more resistant to brute-force than TOTP (challenges are random and +// cryptographically signed), repeated attempts could still be used for denial-of-service. +// +// For now the best way is to use external rate limiting solutions. +func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) { + mfa, provider, ok := s.validateWebAuthnAPIRequest(w, r) + if !ok { + return + } + + ctx := r.Context() + session, err := s.loadWebAuthnSession(mfa.authReq) + if err != nil { + s.logger.ErrorContext(ctx, "failed to load session data", "err", err) + writeJSONError(w, http.StatusBadRequest, "Invalid session.") + return + } + + user := buildWebAuthnUser(mfa.identity, mfa.authenticatorID) + credential, err := provider.wan.FinishLogin(user, *session, r) + if err != nil { + s.logger.ErrorContext(ctx, "webauthn login failed", "err", err) + writeJSONError(w, http.StatusUnauthorized, "Authentication failed.") + return + } + + // Update sign count and clone warning for the matched credential. + if err := s.storage.UpdateUserIdentity(ctx, mfa.authReq.Claims.UserID, mfa.authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + creds := old.WebAuthnCredentials[mfa.authenticatorID] + for i := range creds { + if bytes.Equal(creds[i].CredentialID, credential.ID) { + creds[i].SignCount = credential.Authenticator.SignCount + creds[i].CloneWarning = credential.Authenticator.CloneWarning + break + } + } + old.WebAuthnCredentials[mfa.authenticatorID] = creds + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to update credential", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + + s.writeCompleteMFAStepResponse(w, r, mfa) +} + +// validateWebAuthnAPIRequest validates a WebAuthn JSON API request. +// It reuses validateMFARequest for HMAC/auth checks, then asserts the provider type +// and loads the user identity. +func (s *Server) validateWebAuthnAPIRequest(w http.ResponseWriter, r *http.Request) (*mfaRequestContext, *WebAuthnProvider, bool) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed.") + return nil, nil, false + } + + mfa, ok := s.validateMFARequest(w, r) + if !ok { + return nil, nil, false + } + + provider, ok := s.mfaProviders[mfa.authenticatorID] + if !ok { + writeJSONError(w, http.StatusBadRequest, "Unknown authenticator.") + return nil, nil, false + } + webauthnProvider, ok := provider.(*WebAuthnProvider) + if !ok { + writeJSONError(w, http.StatusBadRequest, "Not a WebAuthn authenticator.") + return nil, nil, false + } + + return mfa, webauthnProvider, true +} + +// storeWebAuthnSession marshals and stores WebAuthn session data in the auth request. +func (s *Server) storeWebAuthnSession(ctx context.Context, authReqID string, session *webauthn.SessionData) error { + data, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + return s.storage.UpdateAuthRequest(ctx, authReqID, func(old storage.AuthRequest) (storage.AuthRequest, error) { + old.WebAuthnSessionData = data + return old, nil + }) +} + +// loadWebAuthnSession unmarshals WebAuthn session data from an auth request. +func (s *Server) loadWebAuthnSession(authReq storage.AuthRequest) (*webauthn.SessionData, error) { + var session webauthn.SessionData + if err := json.Unmarshal(authReq.WebAuthnSessionData, &session); err != nil { + return nil, fmt.Errorf("unmarshal session: %w", err) + } + return &session, nil +} + +// writeCompleteMFAStepResponse completes the MFA step and writes a JSON redirect response. +func (s *Server) writeCompleteMFAStepResponse(w http.ResponseWriter, r *http.Request, mfa *mfaRequestContext) { + ctx := r.Context() + redirectURL, err := s.completeMFAStep(ctx, mfa.authReq, mfa.authenticatorID) + if err != nil { + s.logger.ErrorContext(ctx, "failed to complete MFA step", "err", err) + writeJSONError(w, http.StatusInternalServerError, "Internal server error.") + return + } + writeJSON(w, map[string]string{"status": "ok", "redirect": redirectURL}) +} + +// convertCredential converts a webauthn.Credential to a storage.WebAuthnCredential. +func convertCredential(cred *webauthn.Credential, now time.Time) storage.WebAuthnCredential { + transports := make([]string, len(cred.Transport)) + for i, t := range cred.Transport { + transports[i] = string(t) + } + return storage.WebAuthnCredential{ + CredentialID: cred.ID, + PublicKey: cred.PublicKey, + AttestationType: cred.AttestationType, + AAGUID: cred.Authenticator.AAGUID, + SignCount: cred.Authenticator.SignCount, + CloneWarning: cred.Authenticator.CloneWarning, + Transport: transports, + BackupEligible: cred.Flags.BackupEligible, + BackupState: cred.Flags.BackupState, + DisplayName: "Security Key", + CreatedAt: now, + } +} + +// Dex return JSON errors because of the dynamic nature of WebAuthn, they are received +// by a JavaScript code and printed for users. +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func writeJSONError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/server/mfa_webauthn_test.go b/server/mfa_webauthn_test.go new file mode 100644 index 00000000..d74f6753 --- /dev/null +++ b/server/mfa_webauthn_test.go @@ -0,0 +1,270 @@ +package server + +import ( + "crypto/rand" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/storage" +) + +func TestNewWebAuthnProvider(t *testing.T) { + tests := []struct { + name string + rpDisplay string + rpID string + rpOrigins []string + timeout string + issuerURL string + wantErr bool + errContains string + }{ + { + name: "derives rpID and origin from issuer", + rpDisplay: "Test App", + issuerURL: "https://auth.example.com/dex", + }, + { + name: "explicit rpID and origins", + rpDisplay: "Test App", + rpID: "example.com", + rpOrigins: []string{"https://auth.example.com"}, + issuerURL: "https://auth.example.com/dex", + }, + { + name: "defaults rpDisplayName from hostname", + issuerURL: "https://auth.example.com", + }, + { + name: "invalid timeout", + rpDisplay: "Test", + timeout: "not-a-duration", + issuerURL: "https://auth.example.com", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + provider, err := NewWebAuthnProvider( + tc.rpDisplay, tc.rpID, tc.rpOrigins, + "", tc.timeout, tc.issuerURL, + nil, + ) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, "WebAuthn", provider.Type()) + }) + } +} + +func TestWebAuthnProviderConnectorTypeFiltering(t *testing.T) { + provider, err := NewWebAuthnProvider("Test", "", nil, "", "", + "https://example.com", []string{"ldap", "oidc"}) + require.NoError(t, err) + + require.True(t, provider.EnabledForConnectorType("ldap")) + require.True(t, provider.EnabledForConnectorType("oidc")) + require.False(t, provider.EnabledForConnectorType("saml")) + + // No filter — all types allowed. + providerAll, err := NewWebAuthnProvider("Test", "", nil, "", "", + "https://example.com", nil) + require.NoError(t, err) + require.True(t, providerAll.EnabledForConnectorType("anything")) +} + +func TestBuildWebAuthnUser(t *testing.T) { + identity := storage.UserIdentity{ + UserID: "user-123", + ConnectorID: "conn-1", + Claims: storage.Claims{ + Email: "user@example.com", + PreferredUsername: "User Name", + }, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "webauthn-1": { + { + CredentialID: []byte("cred-1"), + PublicKey: []byte("pk-1"), + AttestationType: "none", + AAGUID: []byte("aaguid-1"), + SignCount: 5, + Transport: []string{"usb"}, + }, + }, + "webauthn-2": { + { + CredentialID: []byte("cred-2"), + PublicKey: []byte("pk-2"), + }, + }, + }, + } + + user := buildWebAuthnUser(identity, "webauthn-1") + require.Equal(t, []byte("user-123|conn-1"), user.WebAuthnID()) + require.Equal(t, "user@example.com", user.WebAuthnName()) + require.Equal(t, "User Name", user.WebAuthnDisplayName()) + require.Len(t, user.WebAuthnCredentials(), 1) + require.Equal(t, []byte("cred-1"), user.WebAuthnCredentials()[0].ID) + require.Equal(t, uint32(5), user.WebAuthnCredentials()[0].Authenticator.SignCount) + + // Different authenticator — should get the other credential. + user2 := buildWebAuthnUser(identity, "webauthn-2") + require.Len(t, user2.WebAuthnCredentials(), 1) + require.Equal(t, []byte("cred-2"), user2.WebAuthnCredentials()[0].ID) + + // Unknown authenticator — no credentials. + user3 := buildWebAuthnUser(identity, "unknown") + require.Empty(t, user3.WebAuthnCredentials()) +} + +func TestCompleteMFAStep(t *testing.T) { + httpServer, server := newTestServer(t, func(c *Config) { + c.SessionConfig = &SessionConfig{AbsoluteLifetime: time.Hour, ValidIfNotUsedFor: time.Hour} + + provider, err := NewWebAuthnProvider("Test", "", nil, "", "", + "http://127.0.0.1", nil) + require.NoError(t, err) + + c.MFAProviders = map[string]MFAProvider{ + "webauthn-1": provider, + "webauthn-2": provider, + } + c.DefaultMFAChain = []string{"webauthn-1", "webauthn-2"} + }) + defer httpServer.Close() + + ctx := t.Context() + + hmacKey := make([]byte, 32) + _, err := rand.Read(hmacKey) + require.NoError(t, err) + + authReq := storage.AuthRequest{ + ID: "test-req-chain", + ClientID: "example-app", + Expiry: time.Now().Add(time.Hour), + HMACKey: hmacKey, + LoggedIn: true, + Claims: storage.Claims{ + UserID: "user-1", + Email: "user@example.com", + }, + ConnectorID: "mock", + } + require.NoError(t, server.storage.CreateAuthRequest(ctx, authReq)) + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: "example-app", + Secret: "secret", + })) + + // Completing first step should redirect to second. + redirectURL, err := server.completeMFAStep(ctx, authReq, "webauthn-1") + require.NoError(t, err) + require.Contains(t, redirectURL, "/mfa/webauthn") + require.Contains(t, redirectURL, "authenticator=webauthn-2") + + // Completing second (last) step should redirect to approval. + redirectURL, err = server.completeMFAStep(ctx, authReq, "webauthn-2") + require.NoError(t, err) + require.Contains(t, redirectURL, "/approval") + + // Verify MFAValidated was set. + updated, err := server.storage.GetAuthRequest(ctx, authReq.ID) + require.NoError(t, err) + require.True(t, updated.MFAValidated) +} + +func TestWebAuthnHandlersMissingHMAC(t *testing.T) { + httpServer, server := newTestServer(t, func(c *Config) { + c.SessionConfig = &SessionConfig{AbsoluteLifetime: time.Hour, ValidIfNotUsedFor: time.Hour} + }) + defer httpServer.Close() + + endpoints := []string{ + "/mfa/webauthn/register/begin", + "/mfa/webauthn/register/finish", + "/mfa/webauthn/login/begin", + "/mfa/webauthn/login/finish", + } + + for _, ep := range endpoints { + t.Run(ep, func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, ep, nil) + server.ServeHTTP(rr, req) + // Should fail with unauthorized (no hmac). + require.Equal(t, http.StatusUnauthorized, rr.Code) + }) + } +} + +func TestWebAuthnVerifyPageRender(t *testing.T) { + httpServer, server := newTestServer(t, func(c *Config) { + c.SessionConfig = &SessionConfig{AbsoluteLifetime: time.Hour, ValidIfNotUsedFor: time.Hour} + + provider, err := NewWebAuthnProvider("Test", "", nil, "", "", + "http://127.0.0.1", nil) + require.NoError(t, err) + + c.MFAProviders = map[string]MFAProvider{ + "webauthn-1": provider, + } + c.DefaultMFAChain = []string{"webauthn-1"} + }) + defer httpServer.Close() + + ctx := t.Context() + + hmacKey := make([]byte, 32) + _, err := rand.Read(hmacKey) + require.NoError(t, err) + + authReq := storage.AuthRequest{ + ID: "test-webauthn-verify", + ClientID: "example-app", + Expiry: time.Now().Add(time.Hour), + HMACKey: hmacKey, + LoggedIn: true, + Claims: storage.Claims{ + UserID: "user-1", + Email: "user@example.com", + }, + ConnectorID: "mock", + } + require.NoError(t, server.storage.CreateAuthRequest(ctx, authReq)) + + // Create user identity without WebAuthn credentials (enrollment mode). + require.NoError(t, server.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: authReq.Claims, + Consents: map[string][]string{}, + MFASecrets: map[string]*storage.MFASecret{}, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{}, + CreatedAt: time.Now(), + LastLogin: time.Now(), + })) + + // Generate HMAC for the request. + hmacVal := computeHMAC(hmacKey, authReq.ID, "webauthn-1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/mfa/webauthn?req="+authReq.ID+"&hmac="+hmacVal+"&authenticator=webauthn-1", nil) + server.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + require.Contains(t, body, "Register security key") + require.Contains(t, body, "startWebAuthn") +} diff --git a/server/server.go b/server/server.go index 711ef7f0..49626d1e 100644 --- a/server/server.go +++ b/server/server.go @@ -551,11 +551,20 @@ func newServer(ctx context.Context, c Config) (*Server, error) { // "authproxy" connector. handleFunc("/callback/{connector}", s.handleConnectorCallback) handleFunc("/approval", s.handleApproval) + // OIDC RP-Initiated logout endpoints, DEX_SESSIONS_ENABLED=true feature flag is required. if c.SessionConfig != nil { handleFunc("/logout", s.handleLogout) handleFunc("/logout/callback", s.handleLogoutCallback) } - handleFunc("/mfa/verify", s.handleMFAVerify) + // MFA verification endpoints, DEX_SESSIONS_ENABLED=true feature flag is required. + if c.SessionConfig != nil { + handleFunc("/mfa/totp", s.handleTOTP) + handleFunc("/mfa/webauthn", s.handleWebAuthn) + handleFunc("/mfa/webauthn/register/begin", s.handleWebAuthnRegisterBegin) + handleFunc("/mfa/webauthn/register/finish", s.handleWebAuthnRegisterFinish) + handleFunc("/mfa/webauthn/login/begin", s.handleWebAuthnLoginBegin) + handleFunc("/mfa/webauthn/login/finish", s.handleWebAuthnLoginFinish) + } handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !c.HealthChecker.IsHealthy() { s.renderError(r, w, http.StatusInternalServerError, "Health check failed.") @@ -843,6 +852,14 @@ func (s *Server) getConnector(ctx context.Context, id string) (Connector, error) return conn, nil } +// buildApprovalURL builds an HMAC-protected approval URL. +func (s *Server) buildApprovalURL(authReq storage.AuthRequest) string { + v := url.Values{} + v.Set("req", authReq.ID) + v.Set("hmac", computeHMAC(authReq.HMACKey, authReq.ID, "")) + return s.absPath("/approval") + "?" + v.Encode() +} + type logRequestKey string const ( diff --git a/server/session.go b/server/session.go index 3d0e0ec2..27de56d1 100644 --- a/server/session.go +++ b/server/session.go @@ -4,15 +4,12 @@ import ( "context" "crypto/aes" "crypto/cipher" - "crypto/hmac" "crypto/rand" - "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "net/http" - "path" "time" "github.com/dexidp/dex/server/internal" @@ -375,11 +372,6 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request return old, nil }) - // Build HMAC for approval URL. - h := hmac.New(sha256.New, authReq.HMACKey) - h.Write([]byte(authReq.ID)) - mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - // Skip approval if globally configured or user already consented to the requested scopes. if !authReq.ForceApprovalPrompt && (s.skipApproval || scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes)) { // Re-read to get the updated AuthRequest (LoggedIn, Claims, ConnectorID set above). @@ -392,8 +384,7 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request return "", true } - returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + mac - return returnURL, true + return s.buildApprovalURL(*authReq), true } // updateSessionTokenIssuedAt updates the session's LastTokenIssuedAt for the given client. diff --git a/server/templates.go b/server/templates.go index dd94025d..81da3d4d 100644 --- a/server/templates.go +++ b/server/templates.go @@ -15,16 +15,17 @@ import ( ) const ( - tmplApproval = "approval.html" - tmplLogin = "login.html" - tmplPassword = "password.html" - tmplOOB = "oob.html" - tmplError = "error.html" - tmplDevice = "device.html" - tmplDeviceSuccess = "device_success.html" - tmplTOTPVerify = "totp_verify.html" - tmplHome = "home.html" - tmplLogout = "logout.html" + tmplApproval = "approval.html" + tmplLogin = "login.html" + tmplPassword = "password.html" + tmplOOB = "oob.html" + tmplError = "error.html" + tmplDevice = "device.html" + tmplDeviceSuccess = "device_success.html" + tmplTOTPVerify = "totp_verify.html" + tmplWebAuthnVerify = "webauthn_verify.html" + tmplHome = "home.html" + tmplLogout = "logout.html" ) var requiredTmpls = []string{ @@ -35,19 +36,24 @@ var requiredTmpls = []string{ tmplError, tmplDevice, tmplDeviceSuccess, + tmplTOTPVerify, + tmplWebAuthnVerify, + tmplHome, + tmplLogout, } type templates struct { - loginTmpl *template.Template - approvalTmpl *template.Template - passwordTmpl *template.Template - oobTmpl *template.Template - errorTmpl *template.Template - deviceTmpl *template.Template - deviceSuccessTmpl *template.Template - totpVerifyTmpl *template.Template - homeTmpl *template.Template - logoutTmpl *template.Template + loginTmpl *template.Template + approvalTmpl *template.Template + passwordTmpl *template.Template + oobTmpl *template.Template + errorTmpl *template.Template + deviceTmpl *template.Template + deviceSuccessTmpl *template.Template + totpVerifyTmpl *template.Template + webauthnVerifyTmpl *template.Template + homeTmpl *template.Template + logoutTmpl *template.Template } type webConfig struct { @@ -168,16 +174,17 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { return nil, fmt.Errorf("missing template(s): %s", missingTmpls) } return &templates{ - loginTmpl: tmpls.Lookup(tmplLogin), - approvalTmpl: tmpls.Lookup(tmplApproval), - passwordTmpl: tmpls.Lookup(tmplPassword), - oobTmpl: tmpls.Lookup(tmplOOB), - errorTmpl: tmpls.Lookup(tmplError), - deviceTmpl: tmpls.Lookup(tmplDevice), - deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess), - totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify), - homeTmpl: tmpls.Lookup(tmplHome), - logoutTmpl: tmpls.Lookup(tmplLogout), + loginTmpl: tmpls.Lookup(tmplLogin), + approvalTmpl: tmpls.Lookup(tmplApproval), + passwordTmpl: tmpls.Lookup(tmplPassword), + oobTmpl: tmpls.Lookup(tmplOOB), + errorTmpl: tmpls.Lookup(tmplError), + deviceTmpl: tmpls.Lookup(tmplDevice), + deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess), + totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify), + webauthnVerifyTmpl: tmpls.Lookup(tmplWebAuthnVerify), + homeTmpl: tmpls.Lookup(tmplHome), + logoutTmpl: tmpls.Lookup(tmplLogout), }, nil } @@ -398,6 +405,17 @@ func (t *templates) logout(r *http.Request, w http.ResponseWriter, backURL strin return renderTemplate(w, t.logoutTmpl, data) } +func (t *templates) webauthnVerify(r *http.Request, w http.ResponseWriter, mode, authenticatorID string) error { + data := struct { + // Mode must be server-controlled ("register" or "login") and never derived + // from user input to prevent XSS in the template's script context. + Mode string + AuthenticatorID string + ReqPath string + }{mode, authenticatorID, r.URL.Path} + return renderTemplate(w, t.webauthnVerifyTmpl, data) +} + func (t *templates) oob(r *http.Request, w http.ResponseWriter, code string) error { data := struct { Code string diff --git a/storage/ent/client/authrequest.go b/storage/ent/client/authrequest.go index c1a3dfdb..74b51a1c 100644 --- a/storage/ent/client/authrequest.go +++ b/storage/ent/client/authrequest.go @@ -33,6 +33,7 @@ func (d *Database) CreateAuthRequest(ctx context.Context, authRequest storage.Au SetConnectorData(authRequest.ConnectorData). SetHmacKey(authRequest.HMACKey). SetMfaValidated(authRequest.MFAValidated). + SetWebauthnSessionData(authRequest.WebAuthnSessionData). SetPrompt(authRequest.Prompt). SetMaxAge(authRequest.MaxAge). SetAuthTime(authRequest.AuthTime). @@ -101,6 +102,7 @@ func (d *Database) UpdateAuthRequest(ctx context.Context, id string, updater fun SetConnectorData(newAuthRequest.ConnectorData). SetHmacKey(newAuthRequest.HMACKey). SetMfaValidated(newAuthRequest.MFAValidated). + SetWebauthnSessionData(newAuthRequest.WebAuthnSessionData). SetPrompt(newAuthRequest.Prompt). SetMaxAge(newAuthRequest.MaxAge). SetAuthTime(newAuthRequest.AuthTime). diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 14f248fa..ddb36eb9 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -47,9 +47,15 @@ func toStorageAuthRequest(a *db.AuthRequest) storage.AuthRequest { }, HMACKey: a.HmacKey, MFAValidated: a.MfaValidated, - Prompt: a.Prompt, - MaxAge: a.MaxAge, - AuthTime: a.AuthTime, + WebAuthnSessionData: func() []byte { + if a.WebauthnSessionData != nil { + return *a.WebauthnSessionData + } + return nil + }(), + Prompt: a.Prompt, + MaxAge: a.MaxAge, + AuthTime: a.AuthTime, } } @@ -212,6 +218,15 @@ func toStorageUserIdentity(u *db.UserIdentity) storage.UserIdentity { } else { s.MFASecrets = make(map[string]*storage.MFASecret) } + + if wc := u.WebauthnCredentials; wc != nil { + if err := json.Unmarshal(*wc, &s.WebAuthnCredentials); err != nil { + panic(err) + } + } + if s.WebAuthnCredentials == nil { + s.WebAuthnCredentials = make(map[string][]storage.WebAuthnCredential) + } return s } diff --git a/storage/ent/client/useridentity.go b/storage/ent/client/useridentity.go index 31e9d049..f66f9017 100644 --- a/storage/ent/client/useridentity.go +++ b/storage/ent/client/useridentity.go @@ -26,6 +26,11 @@ func (d *Database) CreateUserIdentity(ctx context.Context, identity storage.User return fmt.Errorf("encode mfa secrets user identity: %w", err) } + encodedWebAuthnCreds, err := json.Marshal(identity.WebAuthnCredentials) + if err != nil { + return fmt.Errorf("encode webauthn credentials user identity: %w", err) + } + id := compositeKeyID(identity.UserID, identity.ConnectorID, d.hasher) _, err = d.client.UserIdentity.Create(). SetID(id). @@ -39,6 +44,7 @@ func (d *Database) CreateUserIdentity(ctx context.Context, identity storage.User SetClaimsGroups(identity.Claims.Groups). SetConsents(encodedConsents). SetMfaSecrets(encodedMFASecrets). + SetWebauthnCredentials(encodedWebAuthnCreds). SetCreatedAt(identity.CreatedAt). SetLastLogin(identity.LastLogin). SetBlockedUntil(identity.BlockedUntil). @@ -108,6 +114,11 @@ func (d *Database) UpdateUserIdentity(ctx context.Context, userID string, connec return rollback(tx, "encode mfa secrets user identity: %w", err) } + encodedWebAuthnCreds, err := json.Marshal(newUserIdentity.WebAuthnCredentials) + if err != nil { + return rollback(tx, "encode webauthn credentials user identity: %w", err) + } + _, err = tx.UserIdentity.UpdateOneID(id). SetUserID(newUserIdentity.UserID). SetConnectorID(newUserIdentity.ConnectorID). @@ -119,6 +130,7 @@ func (d *Database) UpdateUserIdentity(ctx context.Context, userID string, connec SetClaimsGroups(newUserIdentity.Claims.Groups). SetConsents(encodedConsents). SetMfaSecrets(encodedMFASecrets). + SetWebauthnCredentials(encodedWebAuthnCreds). SetCreatedAt(newUserIdentity.CreatedAt). SetLastLogin(newUserIdentity.LastLogin). SetBlockedUntil(newUserIdentity.BlockedUntil). diff --git a/storage/ent/db/authrequest.go b/storage/ent/db/authrequest.go index 82619eeb..938b9e78 100644 --- a/storage/ent/db/authrequest.go +++ b/storage/ent/db/authrequest.go @@ -60,6 +60,8 @@ type AuthRequest struct { HmacKey []byte `json:"hmac_key,omitempty"` // MfaValidated holds the value of the "mfa_validated" field. MfaValidated bool `json:"mfa_validated,omitempty"` + // WebauthnSessionData holds the value of the "webauthn_session_data" field. + WebauthnSessionData *[]byte `json:"webauthn_session_data,omitempty"` // Prompt holds the value of the "prompt" field. Prompt string `json:"prompt,omitempty"` // MaxAge holds the value of the "max_age" field. @@ -74,7 +76,7 @@ func (*AuthRequest) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case authrequest.FieldScopes, authrequest.FieldResponseTypes, authrequest.FieldClaimsGroups, authrequest.FieldConnectorData, authrequest.FieldHmacKey: + case authrequest.FieldScopes, authrequest.FieldResponseTypes, authrequest.FieldClaimsGroups, authrequest.FieldConnectorData, authrequest.FieldHmacKey, authrequest.FieldWebauthnSessionData: values[i] = new([]byte) case authrequest.FieldForceApprovalPrompt, authrequest.FieldLoggedIn, authrequest.FieldClaimsEmailVerified, authrequest.FieldMfaValidated: values[i] = new(sql.NullBool) @@ -237,6 +239,12 @@ func (_m *AuthRequest) assignValues(columns []string, values []any) error { } else if value.Valid { _m.MfaValidated = value.Bool } + case authrequest.FieldWebauthnSessionData: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field webauthn_session_data", values[i]) + } else if value != nil { + _m.WebauthnSessionData = value + } case authrequest.FieldPrompt: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field prompt", values[i]) @@ -356,6 +364,11 @@ func (_m *AuthRequest) String() string { builder.WriteString("mfa_validated=") builder.WriteString(fmt.Sprintf("%v", _m.MfaValidated)) builder.WriteString(", ") + if v := _m.WebauthnSessionData; v != nil { + builder.WriteString("webauthn_session_data=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("prompt=") builder.WriteString(_m.Prompt) builder.WriteString(", ") diff --git a/storage/ent/db/authrequest/authrequest.go b/storage/ent/db/authrequest/authrequest.go index 33c52c8f..5ac1e4b2 100644 --- a/storage/ent/db/authrequest/authrequest.go +++ b/storage/ent/db/authrequest/authrequest.go @@ -53,6 +53,8 @@ const ( FieldHmacKey = "hmac_key" // FieldMfaValidated holds the string denoting the mfa_validated field in the database. FieldMfaValidated = "mfa_validated" + // FieldWebauthnSessionData holds the string denoting the webauthn_session_data field in the database. + FieldWebauthnSessionData = "webauthn_session_data" // FieldPrompt holds the string denoting the prompt field in the database. FieldPrompt = "prompt" // FieldMaxAge holds the string denoting the max_age field in the database. @@ -87,6 +89,7 @@ var Columns = []string{ FieldCodeChallengeMethod, FieldHmacKey, FieldMfaValidated, + FieldWebauthnSessionData, FieldPrompt, FieldMaxAge, FieldAuthTime, diff --git a/storage/ent/db/authrequest/where.go b/storage/ent/db/authrequest/where.go index f87780b1..27fd9131 100644 --- a/storage/ent/db/authrequest/where.go +++ b/storage/ent/db/authrequest/where.go @@ -154,6 +154,11 @@ func MfaValidated(v bool) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldMfaValidated, v)) } +// WebauthnSessionData applies equality check predicate on the "webauthn_session_data" field. It's identical to WebauthnSessionDataEQ. +func WebauthnSessionData(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldWebauthnSessionData, v)) +} + // Prompt applies equality check predicate on the "prompt" field. It's identical to PromptEQ. func Prompt(v string) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldPrompt, v)) @@ -1084,6 +1089,56 @@ func MfaValidatedNEQ(v bool) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldNEQ(FieldMfaValidated, v)) } +// WebauthnSessionDataEQ applies the EQ predicate on the "webauthn_session_data" field. +func WebauthnSessionDataEQ(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldEQ(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataNEQ applies the NEQ predicate on the "webauthn_session_data" field. +func WebauthnSessionDataNEQ(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNEQ(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataIn applies the In predicate on the "webauthn_session_data" field. +func WebauthnSessionDataIn(vs ...[]byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldIn(FieldWebauthnSessionData, vs...)) +} + +// WebauthnSessionDataNotIn applies the NotIn predicate on the "webauthn_session_data" field. +func WebauthnSessionDataNotIn(vs ...[]byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNotIn(FieldWebauthnSessionData, vs...)) +} + +// WebauthnSessionDataGT applies the GT predicate on the "webauthn_session_data" field. +func WebauthnSessionDataGT(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGT(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataGTE applies the GTE predicate on the "webauthn_session_data" field. +func WebauthnSessionDataGTE(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldGTE(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataLT applies the LT predicate on the "webauthn_session_data" field. +func WebauthnSessionDataLT(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLT(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataLTE applies the LTE predicate on the "webauthn_session_data" field. +func WebauthnSessionDataLTE(v []byte) predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldLTE(FieldWebauthnSessionData, v)) +} + +// WebauthnSessionDataIsNil applies the IsNil predicate on the "webauthn_session_data" field. +func WebauthnSessionDataIsNil() predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldIsNull(FieldWebauthnSessionData)) +} + +// WebauthnSessionDataNotNil applies the NotNil predicate on the "webauthn_session_data" field. +func WebauthnSessionDataNotNil() predicate.AuthRequest { + return predicate.AuthRequest(sql.FieldNotNull(FieldWebauthnSessionData)) +} + // PromptEQ applies the EQ predicate on the "prompt" field. func PromptEQ(v string) predicate.AuthRequest { return predicate.AuthRequest(sql.FieldEQ(FieldPrompt, v)) diff --git a/storage/ent/db/authrequest_create.go b/storage/ent/db/authrequest_create.go index 324c99d9..1a341534 100644 --- a/storage/ent/db/authrequest_create.go +++ b/storage/ent/db/authrequest_create.go @@ -178,6 +178,12 @@ func (_c *AuthRequestCreate) SetNillableMfaValidated(v *bool) *AuthRequestCreate return _c } +// SetWebauthnSessionData sets the "webauthn_session_data" field. +func (_c *AuthRequestCreate) SetWebauthnSessionData(v []byte) *AuthRequestCreate { + _c.mutation.SetWebauthnSessionData(v) + return _c +} + // SetPrompt sets the "prompt" field. func (_c *AuthRequestCreate) SetPrompt(v string) *AuthRequestCreate { _c.mutation.SetPrompt(v) @@ -470,6 +476,10 @@ func (_c *AuthRequestCreate) createSpec() (*AuthRequest, *sqlgraph.CreateSpec) { _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) _node.MfaValidated = value } + if value, ok := _c.mutation.WebauthnSessionData(); ok { + _spec.SetField(authrequest.FieldWebauthnSessionData, field.TypeBytes, value) + _node.WebauthnSessionData = &value + } if value, ok := _c.mutation.Prompt(); ok { _spec.SetField(authrequest.FieldPrompt, field.TypeString, value) _node.Prompt = value diff --git a/storage/ent/db/authrequest_update.go b/storage/ent/db/authrequest_update.go index 7edece02..96595ed5 100644 --- a/storage/ent/db/authrequest_update.go +++ b/storage/ent/db/authrequest_update.go @@ -325,6 +325,18 @@ func (_u *AuthRequestUpdate) SetNillableMfaValidated(v *bool) *AuthRequestUpdate return _u } +// SetWebauthnSessionData sets the "webauthn_session_data" field. +func (_u *AuthRequestUpdate) SetWebauthnSessionData(v []byte) *AuthRequestUpdate { + _u.mutation.SetWebauthnSessionData(v) + return _u +} + +// ClearWebauthnSessionData clears the value of the "webauthn_session_data" field. +func (_u *AuthRequestUpdate) ClearWebauthnSessionData() *AuthRequestUpdate { + _u.mutation.ClearWebauthnSessionData() + return _u +} + // SetPrompt sets the "prompt" field. func (_u *AuthRequestUpdate) SetPrompt(v string) *AuthRequestUpdate { _u.mutation.SetPrompt(v) @@ -511,6 +523,12 @@ func (_u *AuthRequestUpdate) sqlSave(ctx context.Context) (_node int, err error) if value, ok := _u.mutation.MfaValidated(); ok { _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) } + if value, ok := _u.mutation.WebauthnSessionData(); ok { + _spec.SetField(authrequest.FieldWebauthnSessionData, field.TypeBytes, value) + } + if _u.mutation.WebauthnSessionDataCleared() { + _spec.ClearField(authrequest.FieldWebauthnSessionData, field.TypeBytes) + } if value, ok := _u.mutation.Prompt(); ok { _spec.SetField(authrequest.FieldPrompt, field.TypeString, value) } @@ -842,6 +860,18 @@ func (_u *AuthRequestUpdateOne) SetNillableMfaValidated(v *bool) *AuthRequestUpd return _u } +// SetWebauthnSessionData sets the "webauthn_session_data" field. +func (_u *AuthRequestUpdateOne) SetWebauthnSessionData(v []byte) *AuthRequestUpdateOne { + _u.mutation.SetWebauthnSessionData(v) + return _u +} + +// ClearWebauthnSessionData clears the value of the "webauthn_session_data" field. +func (_u *AuthRequestUpdateOne) ClearWebauthnSessionData() *AuthRequestUpdateOne { + _u.mutation.ClearWebauthnSessionData() + return _u +} + // SetPrompt sets the "prompt" field. func (_u *AuthRequestUpdateOne) SetPrompt(v string) *AuthRequestUpdateOne { _u.mutation.SetPrompt(v) @@ -1058,6 +1088,12 @@ func (_u *AuthRequestUpdateOne) sqlSave(ctx context.Context) (_node *AuthRequest if value, ok := _u.mutation.MfaValidated(); ok { _spec.SetField(authrequest.FieldMfaValidated, field.TypeBool, value) } + if value, ok := _u.mutation.WebauthnSessionData(); ok { + _spec.SetField(authrequest.FieldWebauthnSessionData, field.TypeBytes, value) + } + if _u.mutation.WebauthnSessionDataCleared() { + _spec.ClearField(authrequest.FieldWebauthnSessionData, field.TypeBytes) + } if value, ok := _u.mutation.Prompt(); ok { _spec.SetField(authrequest.FieldPrompt, field.TypeString, value) } diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index bf1d086d..ff066308 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -58,6 +58,7 @@ var ( {Name: "code_challenge_method", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "hmac_key", Type: field.TypeBytes}, {Name: "mfa_validated", Type: field.TypeBool, Default: false}, + {Name: "webauthn_session_data", Type: field.TypeBytes, Nullable: true}, {Name: "prompt", Type: field.TypeString, Size: 2147483647, Default: "", SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "max_age", Type: field.TypeInt, Default: -1}, {Name: "auth_time", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, @@ -240,6 +241,7 @@ var ( {Name: "claims_groups", Type: field.TypeJSON, Nullable: true}, {Name: "consents", Type: field.TypeBytes}, {Name: "mfa_secrets", Type: field.TypeBytes, Nullable: true}, + {Name: "webauthn_credentials", Type: field.TypeBytes, Nullable: true}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "last_login", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "blocked_until", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index ddd3c5b3..fad85c3d 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -1336,6 +1336,7 @@ type AuthRequestMutation struct { code_challenge_method *string hmac_key *[]byte mfa_validated *bool + webauthn_session_data *[]byte prompt *string max_age *int addmax_age *int @@ -2306,6 +2307,55 @@ func (m *AuthRequestMutation) ResetMfaValidated() { m.mfa_validated = nil } +// SetWebauthnSessionData sets the "webauthn_session_data" field. +func (m *AuthRequestMutation) SetWebauthnSessionData(b []byte) { + m.webauthn_session_data = &b +} + +// WebauthnSessionData returns the value of the "webauthn_session_data" field in the mutation. +func (m *AuthRequestMutation) WebauthnSessionData() (r []byte, exists bool) { + v := m.webauthn_session_data + if v == nil { + return + } + return *v, true +} + +// OldWebauthnSessionData returns the old "webauthn_session_data" field's value of the AuthRequest entity. +// If the AuthRequest object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthRequestMutation) OldWebauthnSessionData(ctx context.Context) (v *[]byte, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldWebauthnSessionData is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldWebauthnSessionData requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldWebauthnSessionData: %w", err) + } + return oldValue.WebauthnSessionData, nil +} + +// ClearWebauthnSessionData clears the value of the "webauthn_session_data" field. +func (m *AuthRequestMutation) ClearWebauthnSessionData() { + m.webauthn_session_data = nil + m.clearedFields[authrequest.FieldWebauthnSessionData] = struct{}{} +} + +// WebauthnSessionDataCleared returns if the "webauthn_session_data" field was cleared in this mutation. +func (m *AuthRequestMutation) WebauthnSessionDataCleared() bool { + _, ok := m.clearedFields[authrequest.FieldWebauthnSessionData] + return ok +} + +// ResetWebauthnSessionData resets all changes to the "webauthn_session_data" field. +func (m *AuthRequestMutation) ResetWebauthnSessionData() { + m.webauthn_session_data = nil + delete(m.clearedFields, authrequest.FieldWebauthnSessionData) +} + // SetPrompt sets the "prompt" field. func (m *AuthRequestMutation) SetPrompt(s string) { m.prompt = &s @@ -2481,7 +2531,7 @@ func (m *AuthRequestMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AuthRequestMutation) Fields() []string { - fields := make([]string, 0, 24) + fields := make([]string, 0, 25) if m.client_id != nil { fields = append(fields, authrequest.FieldClientID) } @@ -2545,6 +2595,9 @@ func (m *AuthRequestMutation) Fields() []string { if m.mfa_validated != nil { fields = append(fields, authrequest.FieldMfaValidated) } + if m.webauthn_session_data != nil { + fields = append(fields, authrequest.FieldWebauthnSessionData) + } if m.prompt != nil { fields = append(fields, authrequest.FieldPrompt) } @@ -2604,6 +2657,8 @@ func (m *AuthRequestMutation) Field(name string) (ent.Value, bool) { return m.HmacKey() case authrequest.FieldMfaValidated: return m.MfaValidated() + case authrequest.FieldWebauthnSessionData: + return m.WebauthnSessionData() case authrequest.FieldPrompt: return m.Prompt() case authrequest.FieldMaxAge: @@ -2661,6 +2716,8 @@ func (m *AuthRequestMutation) OldField(ctx context.Context, name string) (ent.Va return m.OldHmacKey(ctx) case authrequest.FieldMfaValidated: return m.OldMfaValidated(ctx) + case authrequest.FieldWebauthnSessionData: + return m.OldWebauthnSessionData(ctx) case authrequest.FieldPrompt: return m.OldPrompt(ctx) case authrequest.FieldMaxAge: @@ -2823,6 +2880,13 @@ func (m *AuthRequestMutation) SetField(name string, value ent.Value) error { } m.SetMfaValidated(v) return nil + case authrequest.FieldWebauthnSessionData: + v, ok := value.([]byte) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetWebauthnSessionData(v) + return nil case authrequest.FieldPrompt: v, ok := value.(string) if !ok { @@ -2901,6 +2965,9 @@ func (m *AuthRequestMutation) ClearedFields() []string { if m.FieldCleared(authrequest.FieldConnectorData) { fields = append(fields, authrequest.FieldConnectorData) } + if m.FieldCleared(authrequest.FieldWebauthnSessionData) { + fields = append(fields, authrequest.FieldWebauthnSessionData) + } if m.FieldCleared(authrequest.FieldAuthTime) { fields = append(fields, authrequest.FieldAuthTime) } @@ -2930,6 +2997,9 @@ func (m *AuthRequestMutation) ClearField(name string) error { case authrequest.FieldConnectorData: m.ClearConnectorData() return nil + case authrequest.FieldWebauthnSessionData: + m.ClearWebauthnSessionData() + return nil case authrequest.FieldAuthTime: m.ClearAuthTime() return nil @@ -3004,6 +3074,9 @@ func (m *AuthRequestMutation) ResetField(name string) error { case authrequest.FieldMfaValidated: m.ResetMfaValidated() return nil + case authrequest.FieldWebauthnSessionData: + m.ResetWebauthnSessionData() + return nil case authrequest.FieldPrompt: m.ResetPrompt() return nil @@ -9801,6 +9874,7 @@ type UserIdentityMutation struct { appendclaims_groups []string consents *[]byte mfa_secrets *[]byte + webauthn_credentials *[]byte created_at *time.Time last_login *time.Time blocked_until *time.Time @@ -10316,6 +10390,55 @@ func (m *UserIdentityMutation) ResetMfaSecrets() { delete(m.clearedFields, useridentity.FieldMfaSecrets) } +// SetWebauthnCredentials sets the "webauthn_credentials" field. +func (m *UserIdentityMutation) SetWebauthnCredentials(b []byte) { + m.webauthn_credentials = &b +} + +// WebauthnCredentials returns the value of the "webauthn_credentials" field in the mutation. +func (m *UserIdentityMutation) WebauthnCredentials() (r []byte, exists bool) { + v := m.webauthn_credentials + if v == nil { + return + } + return *v, true +} + +// OldWebauthnCredentials returns the old "webauthn_credentials" field's value of the UserIdentity entity. +// If the UserIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserIdentityMutation) OldWebauthnCredentials(ctx context.Context) (v *[]byte, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldWebauthnCredentials is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldWebauthnCredentials requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldWebauthnCredentials: %w", err) + } + return oldValue.WebauthnCredentials, nil +} + +// ClearWebauthnCredentials clears the value of the "webauthn_credentials" field. +func (m *UserIdentityMutation) ClearWebauthnCredentials() { + m.webauthn_credentials = nil + m.clearedFields[useridentity.FieldWebauthnCredentials] = struct{}{} +} + +// WebauthnCredentialsCleared returns if the "webauthn_credentials" field was cleared in this mutation. +func (m *UserIdentityMutation) WebauthnCredentialsCleared() bool { + _, ok := m.clearedFields[useridentity.FieldWebauthnCredentials] + return ok +} + +// ResetWebauthnCredentials resets all changes to the "webauthn_credentials" field. +func (m *UserIdentityMutation) ResetWebauthnCredentials() { + m.webauthn_credentials = nil + delete(m.clearedFields, useridentity.FieldWebauthnCredentials) +} + // SetCreatedAt sets the "created_at" field. func (m *UserIdentityMutation) SetCreatedAt(t time.Time) { m.created_at = &t @@ -10458,7 +10581,7 @@ func (m *UserIdentityMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserIdentityMutation) Fields() []string { - fields := make([]string, 0, 13) + fields := make([]string, 0, 14) if m.user_id != nil { fields = append(fields, useridentity.FieldUserID) } @@ -10489,6 +10612,9 @@ func (m *UserIdentityMutation) Fields() []string { if m.mfa_secrets != nil { fields = append(fields, useridentity.FieldMfaSecrets) } + if m.webauthn_credentials != nil { + fields = append(fields, useridentity.FieldWebauthnCredentials) + } if m.created_at != nil { fields = append(fields, useridentity.FieldCreatedAt) } @@ -10526,6 +10652,8 @@ func (m *UserIdentityMutation) Field(name string) (ent.Value, bool) { return m.Consents() case useridentity.FieldMfaSecrets: return m.MfaSecrets() + case useridentity.FieldWebauthnCredentials: + return m.WebauthnCredentials() case useridentity.FieldCreatedAt: return m.CreatedAt() case useridentity.FieldLastLogin: @@ -10561,6 +10689,8 @@ func (m *UserIdentityMutation) OldField(ctx context.Context, name string) (ent.V return m.OldConsents(ctx) case useridentity.FieldMfaSecrets: return m.OldMfaSecrets(ctx) + case useridentity.FieldWebauthnCredentials: + return m.OldWebauthnCredentials(ctx) case useridentity.FieldCreatedAt: return m.OldCreatedAt(ctx) case useridentity.FieldLastLogin: @@ -10646,6 +10776,13 @@ func (m *UserIdentityMutation) SetField(name string, value ent.Value) error { } m.SetMfaSecrets(v) return nil + case useridentity.FieldWebauthnCredentials: + v, ok := value.([]byte) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetWebauthnCredentials(v) + return nil case useridentity.FieldCreatedAt: v, ok := value.(time.Time) if !ok { @@ -10703,6 +10840,9 @@ func (m *UserIdentityMutation) ClearedFields() []string { if m.FieldCleared(useridentity.FieldMfaSecrets) { fields = append(fields, useridentity.FieldMfaSecrets) } + if m.FieldCleared(useridentity.FieldWebauthnCredentials) { + fields = append(fields, useridentity.FieldWebauthnCredentials) + } return fields } @@ -10723,6 +10863,9 @@ func (m *UserIdentityMutation) ClearField(name string) error { case useridentity.FieldMfaSecrets: m.ClearMfaSecrets() return nil + case useridentity.FieldWebauthnCredentials: + m.ClearWebauthnCredentials() + return nil } return fmt.Errorf("unknown UserIdentity nullable field %s", name) } @@ -10761,6 +10904,9 @@ func (m *UserIdentityMutation) ResetField(name string) error { case useridentity.FieldMfaSecrets: m.ResetMfaSecrets() return nil + case useridentity.FieldWebauthnCredentials: + m.ResetWebauthnCredentials() + return nil case useridentity.FieldCreatedAt: m.ResetCreatedAt() return nil diff --git a/storage/ent/db/runtime.go b/storage/ent/db/runtime.go index 2c1c5404..df7d7d49 100644 --- a/storage/ent/db/runtime.go +++ b/storage/ent/db/runtime.go @@ -89,11 +89,11 @@ func init() { // authrequest.DefaultMfaValidated holds the default value on creation for the mfa_validated field. authrequest.DefaultMfaValidated = authrequestDescMfaValidated.Default.(bool) // authrequestDescPrompt is the schema descriptor for prompt field. - authrequestDescPrompt := authrequestFields[22].Descriptor() + authrequestDescPrompt := authrequestFields[23].Descriptor() // authrequest.DefaultPrompt holds the default value on creation for the prompt field. authrequest.DefaultPrompt = authrequestDescPrompt.Default.(string) // authrequestDescMaxAge is the schema descriptor for max_age field. - authrequestDescMaxAge := authrequestFields[23].Descriptor() + authrequestDescMaxAge := authrequestFields[24].Descriptor() // authrequest.DefaultMaxAge holds the default value on creation for the max_age field. authrequest.DefaultMaxAge = authrequestDescMaxAge.Default.(int) // authrequestDescID is the schema descriptor for id field. diff --git a/storage/ent/db/useridentity.go b/storage/ent/db/useridentity.go index 91f291de..eaa0edbf 100644 --- a/storage/ent/db/useridentity.go +++ b/storage/ent/db/useridentity.go @@ -38,6 +38,8 @@ type UserIdentity struct { Consents []byte `json:"consents,omitempty"` // MfaSecrets holds the value of the "mfa_secrets" field. MfaSecrets *[]byte `json:"mfa_secrets,omitempty"` + // WebauthnCredentials holds the value of the "webauthn_credentials" field. + WebauthnCredentials *[]byte `json:"webauthn_credentials,omitempty"` // CreatedAt holds the value of the "created_at" field. CreatedAt time.Time `json:"created_at,omitempty"` // LastLogin holds the value of the "last_login" field. @@ -52,7 +54,7 @@ func (*UserIdentity) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case useridentity.FieldClaimsGroups, useridentity.FieldConsents, useridentity.FieldMfaSecrets: + case useridentity.FieldClaimsGroups, useridentity.FieldConsents, useridentity.FieldMfaSecrets, useridentity.FieldWebauthnCredentials: values[i] = new([]byte) case useridentity.FieldClaimsEmailVerified: values[i] = new(sql.NullBool) @@ -143,6 +145,12 @@ func (_m *UserIdentity) assignValues(columns []string, values []any) error { } else if value != nil { _m.MfaSecrets = value } + case useridentity.FieldWebauthnCredentials: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field webauthn_credentials", values[i]) + } else if value != nil { + _m.WebauthnCredentials = value + } case useridentity.FieldCreatedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created_at", values[i]) @@ -229,6 +237,11 @@ func (_m *UserIdentity) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + if v := _m.WebauthnCredentials; v != nil { + builder.WriteString("webauthn_credentials=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("created_at=") builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) builder.WriteString(", ") diff --git a/storage/ent/db/useridentity/useridentity.go b/storage/ent/db/useridentity/useridentity.go index 9fae1444..63781c78 100644 --- a/storage/ent/db/useridentity/useridentity.go +++ b/storage/ent/db/useridentity/useridentity.go @@ -31,6 +31,8 @@ const ( FieldConsents = "consents" // FieldMfaSecrets holds the string denoting the mfa_secrets field in the database. FieldMfaSecrets = "mfa_secrets" + // FieldWebauthnCredentials holds the string denoting the webauthn_credentials field in the database. + FieldWebauthnCredentials = "webauthn_credentials" // FieldCreatedAt holds the string denoting the created_at field in the database. FieldCreatedAt = "created_at" // FieldLastLogin holds the string denoting the last_login field in the database. @@ -54,6 +56,7 @@ var Columns = []string{ FieldClaimsGroups, FieldConsents, FieldMfaSecrets, + FieldWebauthnCredentials, FieldCreatedAt, FieldLastLogin, FieldBlockedUntil, diff --git a/storage/ent/db/useridentity/where.go b/storage/ent/db/useridentity/where.go index c3e3d911..e902d359 100644 --- a/storage/ent/db/useridentity/where.go +++ b/storage/ent/db/useridentity/where.go @@ -109,6 +109,11 @@ func MfaSecrets(v []byte) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldEQ(FieldMfaSecrets, v)) } +// WebauthnCredentials applies equality check predicate on the "webauthn_credentials" field. It's identical to WebauthnCredentialsEQ. +func WebauthnCredentials(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldWebauthnCredentials, v)) +} + // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. func CreatedAt(v time.Time) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldEQ(FieldCreatedAt, v)) @@ -624,6 +629,56 @@ func MfaSecretsNotNil() predicate.UserIdentity { return predicate.UserIdentity(sql.FieldNotNull(FieldMfaSecrets)) } +// WebauthnCredentialsEQ applies the EQ predicate on the "webauthn_credentials" field. +func WebauthnCredentialsEQ(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldEQ(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsNEQ applies the NEQ predicate on the "webauthn_credentials" field. +func WebauthnCredentialsNEQ(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNEQ(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsIn applies the In predicate on the "webauthn_credentials" field. +func WebauthnCredentialsIn(vs ...[]byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIn(FieldWebauthnCredentials, vs...)) +} + +// WebauthnCredentialsNotIn applies the NotIn predicate on the "webauthn_credentials" field. +func WebauthnCredentialsNotIn(vs ...[]byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotIn(FieldWebauthnCredentials, vs...)) +} + +// WebauthnCredentialsGT applies the GT predicate on the "webauthn_credentials" field. +func WebauthnCredentialsGT(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGT(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsGTE applies the GTE predicate on the "webauthn_credentials" field. +func WebauthnCredentialsGTE(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldGTE(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsLT applies the LT predicate on the "webauthn_credentials" field. +func WebauthnCredentialsLT(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLT(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsLTE applies the LTE predicate on the "webauthn_credentials" field. +func WebauthnCredentialsLTE(v []byte) predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldLTE(FieldWebauthnCredentials, v)) +} + +// WebauthnCredentialsIsNil applies the IsNil predicate on the "webauthn_credentials" field. +func WebauthnCredentialsIsNil() predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldIsNull(FieldWebauthnCredentials)) +} + +// WebauthnCredentialsNotNil applies the NotNil predicate on the "webauthn_credentials" field. +func WebauthnCredentialsNotNil() predicate.UserIdentity { + return predicate.UserIdentity(sql.FieldNotNull(FieldWebauthnCredentials)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.UserIdentity { return predicate.UserIdentity(sql.FieldEQ(FieldCreatedAt, v)) diff --git a/storage/ent/db/useridentity_create.go b/storage/ent/db/useridentity_create.go index 0f4b355a..adb17b25 100644 --- a/storage/ent/db/useridentity_create.go +++ b/storage/ent/db/useridentity_create.go @@ -120,6 +120,12 @@ func (_c *UserIdentityCreate) SetMfaSecrets(v []byte) *UserIdentityCreate { return _c } +// SetWebauthnCredentials sets the "webauthn_credentials" field. +func (_c *UserIdentityCreate) SetWebauthnCredentials(v []byte) *UserIdentityCreate { + _c.mutation.SetWebauthnCredentials(v) + return _c +} + // SetCreatedAt sets the "created_at" field. func (_c *UserIdentityCreate) SetCreatedAt(v time.Time) *UserIdentityCreate { _c.mutation.SetCreatedAt(v) @@ -326,6 +332,10 @@ func (_c *UserIdentityCreate) createSpec() (*UserIdentity, *sqlgraph.CreateSpec) _spec.SetField(useridentity.FieldMfaSecrets, field.TypeBytes, value) _node.MfaSecrets = &value } + if value, ok := _c.mutation.WebauthnCredentials(); ok { + _spec.SetField(useridentity.FieldWebauthnCredentials, field.TypeBytes, value) + _node.WebauthnCredentials = &value + } if value, ok := _c.mutation.CreatedAt(); ok { _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) _node.CreatedAt = value diff --git a/storage/ent/db/useridentity_update.go b/storage/ent/db/useridentity_update.go index 9de8e51f..ada98ead 100644 --- a/storage/ent/db/useridentity_update.go +++ b/storage/ent/db/useridentity_update.go @@ -163,6 +163,18 @@ func (_u *UserIdentityUpdate) ClearMfaSecrets() *UserIdentityUpdate { return _u } +// SetWebauthnCredentials sets the "webauthn_credentials" field. +func (_u *UserIdentityUpdate) SetWebauthnCredentials(v []byte) *UserIdentityUpdate { + _u.mutation.SetWebauthnCredentials(v) + return _u +} + +// ClearWebauthnCredentials clears the value of the "webauthn_credentials" field. +func (_u *UserIdentityUpdate) ClearWebauthnCredentials() *UserIdentityUpdate { + _u.mutation.ClearWebauthnCredentials() + return _u +} + // SetCreatedAt sets the "created_at" field. func (_u *UserIdentityUpdate) SetCreatedAt(v time.Time) *UserIdentityUpdate { _u.mutation.SetCreatedAt(v) @@ -305,6 +317,12 @@ func (_u *UserIdentityUpdate) sqlSave(ctx context.Context) (_node int, err error if _u.mutation.MfaSecretsCleared() { _spec.ClearField(useridentity.FieldMfaSecrets, field.TypeBytes) } + if value, ok := _u.mutation.WebauthnCredentials(); ok { + _spec.SetField(useridentity.FieldWebauthnCredentials, field.TypeBytes, value) + } + if _u.mutation.WebauthnCredentialsCleared() { + _spec.ClearField(useridentity.FieldWebauthnCredentials, field.TypeBytes) + } if value, ok := _u.mutation.CreatedAt(); ok { _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) } @@ -468,6 +486,18 @@ func (_u *UserIdentityUpdateOne) ClearMfaSecrets() *UserIdentityUpdateOne { return _u } +// SetWebauthnCredentials sets the "webauthn_credentials" field. +func (_u *UserIdentityUpdateOne) SetWebauthnCredentials(v []byte) *UserIdentityUpdateOne { + _u.mutation.SetWebauthnCredentials(v) + return _u +} + +// ClearWebauthnCredentials clears the value of the "webauthn_credentials" field. +func (_u *UserIdentityUpdateOne) ClearWebauthnCredentials() *UserIdentityUpdateOne { + _u.mutation.ClearWebauthnCredentials() + return _u +} + // SetCreatedAt sets the "created_at" field. func (_u *UserIdentityUpdateOne) SetCreatedAt(v time.Time) *UserIdentityUpdateOne { _u.mutation.SetCreatedAt(v) @@ -640,6 +670,12 @@ func (_u *UserIdentityUpdateOne) sqlSave(ctx context.Context) (_node *UserIdenti if _u.mutation.MfaSecretsCleared() { _spec.ClearField(useridentity.FieldMfaSecrets, field.TypeBytes) } + if value, ok := _u.mutation.WebauthnCredentials(); ok { + _spec.SetField(useridentity.FieldWebauthnCredentials, field.TypeBytes, value) + } + if _u.mutation.WebauthnCredentialsCleared() { + _spec.ClearField(useridentity.FieldWebauthnCredentials, field.TypeBytes) + } if value, ok := _u.mutation.CreatedAt(); ok { _spec.SetField(useridentity.FieldCreatedAt, field.TypeTime, value) } diff --git a/storage/ent/schema/authrequest.go b/storage/ent/schema/authrequest.go index be24c29f..2d6cfebf 100644 --- a/storage/ent/schema/authrequest.go +++ b/storage/ent/schema/authrequest.go @@ -90,6 +90,9 @@ func (AuthRequest) Fields() []ent.Field { field.Bytes("hmac_key"), field.Bool("mfa_validated"). Default(false), + field.Bytes("webauthn_session_data"). + Nillable(). + Optional(), field.Text("prompt").SchemaType(textSchema).Default(""), field.Int("max_age").Default(-1), field.Time("auth_time").SchemaType(timeSchema).Optional(), diff --git a/storage/ent/schema/useridentity.go b/storage/ent/schema/useridentity.go index f8a4f2b8..b4fe75d0 100644 --- a/storage/ent/schema/useridentity.go +++ b/storage/ent/schema/useridentity.go @@ -44,6 +44,9 @@ func (UserIdentity) Fields() []ent.Field { field.Bytes("mfa_secrets"). Nillable(). Optional(), + field.Bytes("webauthn_credentials"). + Nillable(). + Optional(), field.Time("created_at"). SchemaType(timeSchema), field.Time("last_login"). diff --git a/storage/etcd/types.go b/storage/etcd/types.go index 7be8bcf8..aabb16f6 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -93,6 +93,8 @@ type AuthRequest struct { MFAValidated bool `json:"mfa_validated"` + WebAuthnSessionData []byte `json:"webauthn_session_data,omitempty"` + Prompt string `json:"prompt,omitempty"` MaxAge int `json:"max_age"` AuthTime time.Time `json:"auth_time"` @@ -117,6 +119,7 @@ func fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { CodeChallengeMethod: a.PKCE.CodeChallengeMethod, HMACKey: a.HMACKey, MFAValidated: a.MFAValidated, + WebAuthnSessionData: a.WebAuthnSessionData, Prompt: a.Prompt, MaxAge: a.MaxAge, AuthTime: a.AuthTime, @@ -142,11 +145,12 @@ func toStorageAuthRequest(a AuthRequest) storage.AuthRequest { CodeChallenge: a.CodeChallenge, CodeChallengeMethod: a.CodeChallengeMethod, }, - HMACKey: a.HMACKey, - MFAValidated: a.MFAValidated, - Prompt: a.Prompt, - MaxAge: a.MaxAge, - AuthTime: a.AuthTime, + HMACKey: a.HMACKey, + MFAValidated: a.MFAValidated, + WebAuthnSessionData: a.WebAuthnSessionData, + Prompt: a.Prompt, + MaxAge: a.MaxAge, + AuthTime: a.AuthTime, } } @@ -276,39 +280,42 @@ func toStorageOfflineSessions(o OfflineSessions) storage.OfflineSessions { // UserIdentity is a mirrored struct from storage with JSON struct tags type UserIdentity struct { - UserID string `json:"user_id,omitempty"` - ConnectorID string `json:"connector_id,omitempty"` - Claims Claims `json:"claims,omitempty"` - Consents map[string][]string `json:"consents,omitempty"` - MFASecrets map[string]*storage.MFASecret `json:"mfa_secrets,omitempty"` - CreatedAt time.Time `json:"created_at"` - LastLogin time.Time `json:"last_login"` - BlockedUntil time.Time `json:"blocked_until"` + UserID string `json:"user_id,omitempty"` + ConnectorID string `json:"connector_id,omitempty"` + Claims Claims `json:"claims,omitempty"` + Consents map[string][]string `json:"consents,omitempty"` + MFASecrets map[string]*storage.MFASecret `json:"mfa_secrets,omitempty"` + WebAuthnCredentials map[string][]storage.WebAuthnCredential `json:"webauthn_credentials,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastLogin time.Time `json:"last_login"` + BlockedUntil time.Time `json:"blocked_until"` } func fromStorageUserIdentity(u storage.UserIdentity) UserIdentity { return UserIdentity{ - UserID: u.UserID, - ConnectorID: u.ConnectorID, - Claims: fromStorageClaims(u.Claims), - Consents: u.Consents, - MFASecrets: u.MFASecrets, - CreatedAt: u.CreatedAt, - LastLogin: u.LastLogin, - BlockedUntil: u.BlockedUntil, + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: fromStorageClaims(u.Claims), + Consents: u.Consents, + MFASecrets: u.MFASecrets, + WebAuthnCredentials: u.WebAuthnCredentials, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, } } func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { s := storage.UserIdentity{ - UserID: u.UserID, - ConnectorID: u.ConnectorID, - Claims: toStorageClaims(u.Claims), - Consents: u.Consents, - MFASecrets: u.MFASecrets, - CreatedAt: u.CreatedAt, - LastLogin: u.LastLogin, - BlockedUntil: u.BlockedUntil, + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: toStorageClaims(u.Claims), + Consents: u.Consents, + MFASecrets: u.MFASecrets, + WebAuthnCredentials: u.WebAuthnCredentials, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, } if s.Consents == nil { // Server code assumes this will be non-nil. diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 5d40acb2..cec39f3f 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -407,6 +407,8 @@ type AuthRequest struct { MFAValidated bool `json:"mfa_validated"` + WebAuthnSessionData []byte `json:"webauthn_session_data,omitempty"` + Prompt string `json:"prompt,omitempty"` MaxAge int `json:"maxAge"` AuthTime time.Time `json:"authTime,omitempty"` @@ -438,11 +440,12 @@ func toStorageAuthRequest(req AuthRequest) storage.AuthRequest { CodeChallenge: req.CodeChallenge, CodeChallengeMethod: req.CodeChallengeMethod, }, - HMACKey: req.HMACKey, - MFAValidated: req.MFAValidated, - Prompt: req.Prompt, - MaxAge: req.MaxAge, - AuthTime: req.AuthTime, + HMACKey: req.HMACKey, + MFAValidated: req.MFAValidated, + WebAuthnSessionData: req.WebAuthnSessionData, + Prompt: req.Prompt, + MaxAge: req.MaxAge, + AuthTime: req.AuthTime, } return a } @@ -473,6 +476,7 @@ func (cli *client) fromStorageAuthRequest(a storage.AuthRequest) AuthRequest { CodeChallengeMethod: a.PKCE.CodeChallengeMethod, HMACKey: a.HMACKey, MFAValidated: a.MFAValidated, + WebAuthnSessionData: a.WebAuthnSessionData, Prompt: a.Prompt, MaxAge: a.MaxAge, AuthTime: a.AuthTime, @@ -939,14 +943,15 @@ type UserIdentity struct { k8sapi.TypeMeta `json:",inline"` k8sapi.ObjectMeta `json:"metadata,omitempty"` - UserID string `json:"userID,omitempty"` - ConnectorID string `json:"connectorID,omitempty"` - Claims Claims `json:"claims,omitempty"` - Consents map[string][]string `json:"consents,omitempty"` - MFASecrets map[string]*storage.MFASecret `json:"mfaSecrets,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - LastLogin time.Time `json:"lastLogin,omitempty"` - BlockedUntil time.Time `json:"blockedUntil,omitempty"` + UserID string `json:"userID,omitempty"` + ConnectorID string `json:"connectorID,omitempty"` + Claims Claims `json:"claims,omitempty"` + Consents map[string][]string `json:"consents,omitempty"` + MFASecrets map[string]*storage.MFASecret `json:"mfaSecrets,omitempty"` + WebAuthnCredentials map[string][]storage.WebAuthnCredential `json:"webauthnCredentials,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + LastLogin time.Time `json:"lastLogin,omitempty"` + BlockedUntil time.Time `json:"blockedUntil,omitempty"` } // UserIdentityList is a list of UserIdentities. @@ -966,27 +971,29 @@ func (cli *client) fromStorageUserIdentity(u storage.UserIdentity) UserIdentity Name: cli.offlineTokenName(u.UserID, u.ConnectorID), Namespace: cli.namespace, }, - UserID: u.UserID, - ConnectorID: u.ConnectorID, - Claims: fromStorageClaims(u.Claims), - Consents: u.Consents, - MFASecrets: u.MFASecrets, - CreatedAt: u.CreatedAt, - LastLogin: u.LastLogin, - BlockedUntil: u.BlockedUntil, + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: fromStorageClaims(u.Claims), + Consents: u.Consents, + MFASecrets: u.MFASecrets, + WebAuthnCredentials: u.WebAuthnCredentials, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, } } func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { s := storage.UserIdentity{ - UserID: u.UserID, - ConnectorID: u.ConnectorID, - Claims: toStorageClaims(u.Claims), - Consents: u.Consents, - MFASecrets: u.MFASecrets, - CreatedAt: u.CreatedAt, - LastLogin: u.LastLogin, - BlockedUntil: u.BlockedUntil, + UserID: u.UserID, + ConnectorID: u.ConnectorID, + Claims: toStorageClaims(u.Claims), + Consents: u.Consents, + MFASecrets: u.MFASecrets, + WebAuthnCredentials: u.WebAuthnCredentials, + CreatedAt: u.CreatedAt, + LastLogin: u.LastLogin, + BlockedUntil: u.BlockedUntil, } if s.Consents == nil { // Server code assumes this will be non-nil. diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 2864ebb3..e9c2faf6 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -144,10 +144,11 @@ func (c *conn) CreateAuthRequest(ctx context.Context, a storage.AuthRequest) err code_challenge, code_challenge_method, hmac_key, mfa_validated, + webauthn_session_data, prompt, max_age, auth_time ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26 ); `, a.ID, a.ClientID, encoder(a.ResponseTypes), encoder(a.Scopes), a.RedirectURI, a.Nonce, a.State, @@ -159,6 +160,7 @@ func (c *conn) CreateAuthRequest(ctx context.Context, a storage.AuthRequest) err a.PKCE.CodeChallenge, a.PKCE.CodeChallengeMethod, a.HMACKey, a.MFAValidated, + a.WebAuthnSessionData, a.Prompt, a.MaxAge, a.AuthTime, ) if err != nil { @@ -194,8 +196,9 @@ func (c *conn) UpdateAuthRequest(ctx context.Context, id string, updater func(a code_challenge = $18, code_challenge_method = $19, hmac_key = $20, mfa_validated = $21, - prompt = $22, max_age = $23, auth_time = $24 - where id = $25; + webauthn_session_data = $22, + prompt = $23, max_age = $24, auth_time = $25 + where id = $26; `, a.ClientID, encoder(a.ResponseTypes), encoder(a.Scopes), a.RedirectURI, a.Nonce, a.State, a.ForceApprovalPrompt, a.LoggedIn, @@ -206,6 +209,7 @@ func (c *conn) UpdateAuthRequest(ctx context.Context, id string, updater func(a a.Expiry, a.PKCE.CodeChallenge, a.PKCE.CodeChallengeMethod, a.HMACKey, a.MFAValidated, + a.WebAuthnSessionData, a.Prompt, a.MaxAge, a.AuthTime, r.ID, ) @@ -230,6 +234,7 @@ func getAuthRequest(ctx context.Context, q querier, id string) (a storage.AuthRe connector_id, connector_data, expiry, code_challenge, code_challenge_method, hmac_key, mfa_validated, + webauthn_session_data, prompt, max_age, auth_time from auth_request where id = $1; `, id).Scan( @@ -241,6 +246,7 @@ func getAuthRequest(ctx context.Context, q querier, id string) (a storage.AuthRe &a.ConnectorID, &a.ConnectorData, &a.Expiry, &a.PKCE.CodeChallenge, &a.PKCE.CodeChallengeMethod, &a.HMACKey, &a.MFAValidated, + &a.WebAuthnSessionData, &a.Prompt, &a.MaxAge, &a.AuthTime, ) if err != nil { @@ -819,17 +825,17 @@ func (c *conn) CreateUserIdentity(ctx context.Context, u storage.UserIdentity) e user_id, connector_id, claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, - consents, mfa_secrets, + consents, mfa_secrets, webauthn_credentials, created_at, last_login, blocked_until ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ); `, u.UserID, u.ConnectorID, u.Claims.UserID, u.Claims.Username, u.Claims.PreferredUsername, u.Claims.Email, u.Claims.EmailVerified, encoder(u.Claims.Groups), - encoder(u.Consents), encoder(u.MFASecrets), + encoder(u.Consents), encoder(u.MFASecrets), encoder(u.WebAuthnCredentials), u.CreatedAt, u.LastLogin, u.BlockedUntil, ) if err != nil { @@ -863,14 +869,15 @@ func (c *conn) UpdateUserIdentity(ctx context.Context, userID, connectorID strin claims_groups = $6, consents = $7, mfa_secrets = $8, - created_at = $9, - last_login = $10, - blocked_until = $11 - where user_id = $12 AND connector_id = $13; + webauthn_credentials = $9, + created_at = $10, + last_login = $11, + blocked_until = $12 + where user_id = $13 AND connector_id = $14; `, newIdentity.Claims.UserID, newIdentity.Claims.Username, newIdentity.Claims.PreferredUsername, newIdentity.Claims.Email, newIdentity.Claims.EmailVerified, encoder(newIdentity.Claims.Groups), - encoder(newIdentity.Consents), encoder(newIdentity.MFASecrets), + encoder(newIdentity.Consents), encoder(newIdentity.MFASecrets), encoder(newIdentity.WebAuthnCredentials), newIdentity.CreatedAt, newIdentity.LastLogin, newIdentity.BlockedUntil, u.UserID, u.ConnectorID, ) @@ -891,7 +898,7 @@ func getUserIdentity(ctx context.Context, q querier, userID, connectorID string) user_id, connector_id, claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, - consents, mfa_secrets, + consents, mfa_secrets, webauthn_credentials, created_at, last_login, blocked_until from user_identity where user_id = $1 AND connector_id = $2; @@ -904,7 +911,7 @@ func (c *conn) ListUserIdentities(ctx context.Context) ([]storage.UserIdentity, user_id, connector_id, claims_user_id, claims_username, claims_preferred_username, claims_email, claims_email_verified, claims_groups, - consents, mfa_secrets, + consents, mfa_secrets, webauthn_credentials, created_at, last_login, blocked_until from user_identity; `) @@ -928,12 +935,12 @@ func (c *conn) ListUserIdentities(ctx context.Context) ([]storage.UserIdentity, } func scanUserIdentity(s scanner) (u storage.UserIdentity, err error) { - var mfaSecrets []byte + var mfaSecrets, webauthnCreds []byte err = s.Scan( &u.UserID, &u.ConnectorID, &u.Claims.UserID, &u.Claims.Username, &u.Claims.PreferredUsername, &u.Claims.Email, &u.Claims.EmailVerified, decoder(&u.Claims.Groups), - decoder(&u.Consents), &mfaSecrets, + decoder(&u.Consents), &mfaSecrets, &webauthnCreds, &u.CreatedAt, &u.LastLogin, &u.BlockedUntil, ) if err != nil { @@ -950,6 +957,11 @@ func scanUserIdentity(s scanner) (u storage.UserIdentity, err error) { return u, fmt.Errorf("unmarshal user identity mfa secrets: %v", err) } } + if len(webauthnCreds) > 0 { + if err := json.Unmarshal(webauthnCreds, &u.WebAuthnCredentials); err != nil { + return u, fmt.Errorf("unmarshal user identity webauthn credentials: %v", err) + } + } return u, nil } diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 942b119a..36a42135 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -454,4 +454,10 @@ var migrations = []migration{ `alter table auth_session add column logout_state bytea;`, }, }, + { + stmts: []string{ + `alter table auth_request add column webauthn_session_data bytea;`, + `alter table user_identity add column webauthn_credentials bytea;`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index 21d1e971..7484474b 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -275,6 +275,10 @@ type AuthRequest struct { // MFAValidated is set to true if the user has completed multi-factor authentication. MFAValidated bool + + // WebAuthnSessionData stores temporary WebAuthn ceremony data (challenge, etc.) + // between Begin and Finish calls. JSON-encoded webauthn.SessionData. + WebAuthnSessionData []byte } // AuthCode represents a code which can be exchanged for an OAuth2 token response. @@ -373,16 +377,32 @@ type MFASecret struct { CreatedAt time.Time `json:"createdAt"` } +// WebAuthnCredential stores a registered WebAuthn credential for a user. +type WebAuthnCredential struct { + CredentialID []byte `json:"credentialID"` + PublicKey []byte `json:"publicKey"` + AttestationType string `json:"attestationType"` + AAGUID []byte `json:"aaguid"` + SignCount uint32 `json:"signCount"` + CloneWarning bool `json:"cloneWarning"` + Transport []string `json:"transport"` + BackupEligible bool `json:"backupEligible"` + BackupState bool `json:"backupState"` + DisplayName string `json:"displayName"` + CreatedAt time.Time `json:"createdAt"` +} + // UserIdentity represents persistent per-user identity data. type UserIdentity struct { - UserID string - ConnectorID string - Claims Claims - Consents map[string][]string // clientID -> approved scopes - MFASecrets map[string]*MFASecret // authenticatorID -> secret - CreatedAt time.Time - LastLogin time.Time - BlockedUntil time.Time + UserID string + ConnectorID string + Claims Claims + Consents map[string][]string // clientID -> approved scopes + MFASecrets map[string]*MFASecret // authenticatorID -> secret + WebAuthnCredentials map[string][]WebAuthnCredential // authenticatorID -> credentials + CreatedAt time.Time + LastLogin time.Time + BlockedUntil time.Time } // ClientAuthState represents authentication state for a specific client within an auth session. diff --git a/web/templates/webauthn_verify.html b/web/templates/webauthn_verify.html new file mode 100644 index 00000000..7f5d90f3 --- /dev/null +++ b/web/templates/webauthn_verify.html @@ -0,0 +1,173 @@ +{{ template "header.html" . }} + +
+ {{ if eq .Mode "register" }} +

Register security key

+

Register a security key for two-factor authentication.

+ {{ else }} +

Two-factor authentication

+

Use your security key to verify your identity.

+ {{ end }} + + + + + +
+ + + +{{ template "footer.html" . }} From 3bf25fd6e0cb118415c4e633283b2c7d5c41ea92 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Thu, 2 Apr 2026 14:18:53 +0200 Subject: [PATCH 212/222] feat: add SSO sharing policy (#4705) Signed-off-by: maksim.nabokikh --- api/v2/api.pb.go | 559 +++++++++++--------- api/v2/api.proto | 3 + cmd/dex/config.go | 3 + cmd/dex/serve.go | 9 + config.yaml.dist | 27 + examples/config-dev.yaml | 8 + server/api.go | 6 + server/handlers.go | 1 + server/handlers_test.go | 24 + server/server.go | 3 + server/session.go | 119 ++++- server/session_test.go | 486 +++++++++++++++++ storage/conformance/conformance.go | 35 ++ storage/ent/client/client.go | 2 + storage/ent/client/types.go | 1 + storage/ent/db/migrate/schema.go | 1 + storage/ent/db/mutation.go | 92 +++- storage/ent/db/oauth2client.go | 17 +- storage/ent/db/oauth2client/oauth2client.go | 3 + storage/ent/db/oauth2client/where.go | 10 + storage/ent/db/oauth2client_create.go | 10 + storage/ent/db/oauth2client_update.go | 58 ++ storage/ent/schema/client.go | 2 + storage/kubernetes/types.go | 4 + storage/sql/crud.go | 25 +- storage/sql/migrate.go | 7 + storage/storage.go | 7 + 27 files changed, 1235 insertions(+), 287 deletions(-) diff --git a/api/v2/api.pb.go b/api/v2/api.pb.go index 84955190..d7f7f16c 100644 --- a/api/v2/api.pb.go +++ b/api/v2/api.pb.go @@ -32,6 +32,7 @@ type Client struct { 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"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -122,6 +123,13 @@ func (x *Client) GetAllowedConnectors() []string { return nil } +func (x *Client) GetSsoSharedWith() []string { + if x != nil { + return x.SsoSharedWith + } + return nil +} + // ClientInfo represents an OAuth2 client without sensitive information. type ClientInfo struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -132,6 +140,7 @@ type ClientInfo struct { 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"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -215,6 +224,13 @@ func (x *ClientInfo) GetAllowedConnectors() []string { return nil } +func (x *ClientInfo) GetSsoSharedWith() []string { + if x != nil { + return x.SsoSharedWith + } + return nil +} + // GetClientReq is a request to retrieve client details. type GetClientReq struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -504,6 +520,7 @@ type UpdateClientReq struct { 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"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -580,6 +597,13 @@ func (x *UpdateClientReq) GetAllowedConnectors() []string { return nil } +func (x *UpdateClientReq) GetSsoSharedWith() []string { + if x != nil { + return x.SsoSharedWith + } + return nil +} + // UpdateClientResp returns the response from updating a client. type UpdateClientResp struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2313,7 +2337,7 @@ var File_api_v2_api_proto protoreflect.FileDescriptor 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, 0xf0, 0x01, 0x0a, 0x06, 0x43, 0x6c, 0x69, 0x65, + 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x22, 0x98, 0x02, 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, @@ -2328,21 +2352,26 @@ var file_api_v2_api_proto_rawDesc = string([]byte{ 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, 0x22, 0xdc, 0x01, 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, 0x22, 0x1e, 0x0a, 0x0c, 0x47, 0x65, 0x74, + 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, 0x22, 0x84, 0x02, 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, 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, @@ -2362,7 +2391,7 @@ var file_api_v2_api_proto_rawDesc = string([]byte{ 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, + 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0xf1, 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, @@ -2375,261 +2404,263 @@ var file_api_v2_api_proto_rawDesc = string([]byte{ 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 0x32, 0x8b, 0x09, 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, + 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, 0x32, 0x8b, 0x09, 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, - 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, + 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, 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, + 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, 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, + 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, - 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, + 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, 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, + 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, 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, + 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, 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, + 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, }) var ( diff --git a/api/v2/api.proto b/api/v2/api.proto index 4d5b850e..dffe3212 100644 --- a/api/v2/api.proto +++ b/api/v2/api.proto @@ -15,6 +15,7 @@ message Client { string name = 6; string logo_url = 7; repeated string allowed_connectors = 8; + repeated string sso_shared_with = 9; } // ClientInfo represents an OAuth2 client without sensitive information. @@ -26,6 +27,7 @@ message ClientInfo { string name = 5; string logo_url = 6; repeated string allowed_connectors = 7; + repeated string sso_shared_with = 8; } // GetClientReq is a request to retrieve client details. @@ -69,6 +71,7 @@ message UpdateClientReq { string name = 4; string logo_url = 5; repeated string allowed_connectors = 6; + repeated string sso_shared_with = 7; } // UpdateClientResp returns the response from updating a client. diff --git a/cmd/dex/config.go b/cmd/dex/config.go index fdb086f0..12d3557c 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -687,6 +687,9 @@ type Sessions struct { // 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. diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 22eed432..2e134007 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -807,6 +807,9 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { 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) @@ -820,6 +823,12 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { 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 } diff --git a/config.yaml.dist b/config.yaml.dist index 807448de..d2d31c80 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -101,6 +101,20 @@ web: # 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: # # use ["code", "token", "id_token"] to enable implicit flow for web-only clients @@ -159,6 +173,19 @@ web: # 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 against upstream identity providers. # diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 5179cb68..2714dee3 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -102,6 +102,9 @@ telemetry: # 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: @@ -187,6 +190,11 @@ staticClients: # 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 diff --git a/server/api.go b/server/api.go index 1d2f73ba..c5ee7874 100644 --- a/server/api.go +++ b/server/api.go @@ -66,6 +66,7 @@ func (d dexAPI) GetClient(ctx context.Context, req *api.GetClientReq) (*api.GetC Public: c.Public, LogoUrl: c.LogoURL, AllowedConnectors: c.AllowedConnectors, + SsoSharedWith: c.SSOSharedWith, }, }, nil } @@ -91,6 +92,7 @@ func (d dexAPI) CreateClient(ctx context.Context, req *api.CreateClientReq) (*ap Name: req.Client.Name, LogoURL: req.Client.LogoUrl, AllowedConnectors: req.Client.AllowedConnectors, + SSOSharedWith: req.Client.SsoSharedWith, } if err := d.s.CreateClient(ctx, c); err != nil { if err == storage.ErrAlreadyExists { @@ -126,6 +128,9 @@ func (d dexAPI) UpdateClient(ctx context.Context, req *api.UpdateClientReq) (*ap if req.AllowedConnectors != nil { old.AllowedConnectors = req.AllowedConnectors } + if req.SsoSharedWith != nil { + old.SSOSharedWith = req.SsoSharedWith + } return old, nil }) if err != nil { @@ -167,6 +172,7 @@ func (d dexAPI) ListClients(ctx context.Context, req *api.ListClientReq) (*api.L Public: client.Public, LogoUrl: client.LogoURL, AllowedConnectors: client.AllowedConnectors, + SsoSharedWith: client.SSOSharedWith, } clients = append(clients, &c) } diff --git a/server/handlers.go b/server/handlers.go index 4f615d01..32b2b5b1 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -246,6 +246,7 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { default: panic("unsupported error type") } + return } prompt, err := ParsePrompt(authReq.Prompt) if err != nil { diff --git a/server/handlers_test.go b/server/handlers_test.go index 2f152b8d..76c3c2e0 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -1604,6 +1604,30 @@ func TestHandleAuthorizationConnectorGrantTypeFiltering(t *testing.T) { } } +func TestHandleAuthorizationInvalidRequestWithSessions(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServerMultipleConnectors(t, func(c *Config) { + c.SessionConfig = &SessionConfig{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: 1 * time.Hour, + } + c.Storage.CreateClient(ctx, storage.Client{ + ID: "test", + RedirectURIs: []string{"http://example.com/callback"}, + }) + }) + defer httpServer.Close() + + // Send a request with an unregistered redirect_uri — should not panic. + rr := httptest.NewRecorder() + reqURL := fmt.Sprintf("%s/auth?response_type=code&client_id=test&redirect_uri=http://evil.com/callback&scope=openid", httpServer.URL) + req := httptest.NewRequest(http.MethodGet, reqURL, nil) + s.handleAuthorization(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) +} + func TestHandleConnectorLoginGrantTypeRejection(t *testing.T) { ctx := t.Context() httpServer, s := newTestServer(t, func(c *Config) { diff --git a/server/server.go b/server/server.go index 49626d1e..a3ebd638 100644 --- a/server/server.go +++ b/server/server.go @@ -156,6 +156,9 @@ type SessionConfig struct { AbsoluteLifetime time.Duration ValidIfNotUsedFor time.Duration RememberMeCheckedByDefault bool + // SSOSharedWithDefault is the default SSO sharing policy for clients without explicit SSOSharedWith. + // "all" = share with all clients, "none" or "" = share with no one (default). + SSOSharedWithDefault string } // WebConfig holds the server's frontend templates and asset configuration. diff --git a/server/session.go b/server/session.go index 27de56d1..437f74a6 100644 --- a/server/session.go +++ b/server/session.go @@ -306,6 +306,65 @@ func (s *Server) trySessionLogin(ctx context.Context, r *http.Request, w http.Re return s.trySessionLoginWithSession(ctx, r, w, authReq, session) } +// clientSharesSessionWith checks if sourceClient shares its session with targetClientID. +// SSO sharing is unidirectional: source sharing with target does NOT mean target shares with source. +func (s *Server) clientSharesSessionWith(sourceClient storage.Client, targetClientID string) bool { + ssoSharedWith := sourceClient.SSOSharedWith + + // If client has no explicit ssoSharedWith, use default from session config. + if ssoSharedWith == nil { + switch s.sessionConfig.SSOSharedWithDefault { + case "all": + return true + default: // "none" or "" + return false + } + } + + // Explicit empty slice means share with no one. + if len(ssoSharedWith) == 0 { + return false + } + + for _, peer := range ssoSharedWith { + if peer == "*" || peer == targetClientID { + return true + } + } + return false +} + +// findSSOSession checks whether any active client in the session shares its +// authentication with targetClientID via the ssoSharedWith policy. +// +// Note: the caller already has the target client loaded (for AllowedConnectors +// validation), but here we need the *source* client configs - those are the +// clients the user previously authenticated for, and their ssoSharedWith +// policies determine whether SSO is allowed. These are different clients, +// so the GetClient calls below are not redundant. +func (s *Server) findSSOSession(ctx context.Context, session *storage.AuthSession, targetClientID string) *storage.ClientAuthState { + now := s.now() + + for sourceClientID, state := range session.ClientStates { + if !state.Active || now.After(state.ExpiresAt) { + continue + } + + sourceClient, err := s.storage.GetClient(ctx, sourceClientID) + if err != nil { + s.logger.DebugContext(ctx, "session: SSO lookup failed to get source client", + "source_client_id", sourceClientID, "err", err) + continue + } + + if s.clientSharesSessionWith(sourceClient, targetClientID) { + return state + } + } + + return nil +} + // trySessionLoginWithSession is like trySessionLogin but accepts a pre-retrieved session. // This allows callers to inspect the session (e.g., for id_token_hint comparison) before // attempting session-based login. @@ -314,17 +373,47 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request return "", false } - clientState, ok := session.ClientStates[authReq.ClientID] - if !ok || !clientState.Active { - return "", false - } - now := s.now() - if now.After(clientState.ExpiresAt) { - return "", false + + clientState, ok := session.ClientStates[authReq.ClientID] + fallbackToSSO := !ok || !clientState.Active || now.After(clientState.ExpiresAt) + + if fallbackToSSO { + // No direct session for this client — try SSO from a sharing client. + sourceState := s.findSSOSession(ctx, session, authReq.ClientID) + if sourceState == nil { + return "", false + } + + // Cap the derived state expiry at min(configured lifetime, source state expiry). + expiresAt := now.Add(s.sessionConfig.AbsoluteLifetime) + if sourceState.ExpiresAt.Before(expiresAt) { + expiresAt = sourceState.ExpiresAt + } + + // Create a new client state for the target client via SSO. + if err := s.storage.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) { + if old.ClientStates == nil { + old.ClientStates = make(map[string]*storage.ClientAuthState) + } + old.ClientStates[authReq.ClientID] = &storage.ClientAuthState{ + Active: true, + ExpiresAt: expiresAt, + LastActivity: now, + } + old.LastActivity = now + old.IdleExpiry = now.Add(s.sessionConfig.ValidIfNotUsedFor) + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "session: failed to create SSO client state", "err", err) + return "", false + } + + s.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. + // Load identity from storage (same path for direct and SSO login). ui, err := s.storage.GetUserIdentity(ctx, session.UserID, session.ConnectorID) if err != nil { s.logger.ErrorContext(ctx, "session: failed to get user identity", "err", err) @@ -338,6 +427,17 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request } } + if !fallbackToSSO { + s.logger.DebugContext(ctx, "session: re-authenticated from session", + "user_id", session.UserID, "connector_id", session.ConnectorID) + } + + return s.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 (s *Server) finishSessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest, session *storage.AuthSession, ui *storage.UserIdentity, now time.Time) (string, bool) { claims := storage.Claims{ UserID: ui.Claims.UserID, Username: ui.Claims.Username, @@ -359,9 +459,6 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request return "", false } - s.logger.DebugContext(ctx, "session: re-authenticated from session", - "user_id", session.UserID, "connector_id", session.ConnectorID) - // Update session activity. _ = s.storage.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) { old.LastActivity = now diff --git a/server/session_test.go b/server/session_test.go index eca04b95..06e91948 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -869,3 +869,489 @@ func TestParseAuthRequest_PromptAndMaxAge(t *testing.T) { 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) + s.sessionConfig.SSOSharedWithDefault = tt.defaultPolicy + + client := storage.Client{ + ID: "source-client", + SSOSharedWith: tt.ssoSharedWith, + } + got := s.clientSharesSessionWith(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": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + LastActivity: now.Add(-5 * time.Minute), + }, + }, + } + + assert.NotNil(t, s.findSSOSession(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": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + LastActivity: now.Add(-5 * time.Minute), + }, + }, + } + + assert.Nil(t, s.findSSOSession(ctx, session, "client-b")) + }) + + t.Run("skips inactive client states", 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{"*"}, + })) + + session := &storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + Active: false, // Inactive + ExpiresAt: now.Add(24 * time.Hour), + LastActivity: now.Add(-5 * time.Minute), + }, + }, + } + + assert.Nil(t, s.findSSOSession(ctx, session, "client-b")) + }) + + t.Run("skips expired client states", 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{"*"}, + })) + + session := &storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + Active: true, + ExpiresAt: now.Add(-1 * time.Hour), // Expired + LastActivity: now.Add(-5 * time.Minute), + }, + }, + } + + assert.Nil(t, s.findSSOSession(ctx, session, "client-b")) + }) + + t.Run("wildcard SSO with default all", func(t *testing.T) { + s := newTestSessionServer(t) + s.sessionConfig.SSOSharedWithDefault = "all" + 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": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + LastActivity: now.Add(-5 * time.Minute), + }, + }, + } + + assert.NotNil(t, s.findSSOSession(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", + Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + 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("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(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, "user-1", "mock") + require.NoError(t, err) + assert.Contains(t, updated.ClientStates, "client-b") + assert.True(t, updated.ClientStates["client-b"].Active) + }) + + t.Run("SSO derived state capped by source expiry", 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"}, + })) + + // Source state expires in 1 hour — less than AbsoluteLifetime (24h). + sourceExpiry := now.Add(1 * time.Hour) + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + Active: true, + ExpiresAt: sourceExpiry, + 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("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(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, "user-1", "mock") + require.NoError(t, err) + require.Contains(t, updated.ClientStates, "client-b") + assert.Equal(t, sourceExpiry, updated.ClientStates["client-b"].ExpiresAt, + "derived state expiry should be capped at source state expiry") + }) + + t.Run("SSO derived state uses configured lifetime when source expires later", 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"}, + })) + + // Source state expires in 48 hours — more than AbsoluteLifetime (24h). + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + Active: true, + ExpiresAt: now.Add(48 * time.Hour), + 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("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(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, "user-1", "mock") + require.NoError(t, err) + require.Contains(t, updated.ClientStates, "client-b") + assert.Equal(t, now.Add(s.sessionConfig.AbsoluteLifetime), updated.ClientStates["client-b"].ExpiresAt, + "derived state expiry should use configured AbsoluteLifetime when source expires later") + }) + + 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", + Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-a": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + 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("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + session := s.getValidAuthSession(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") + }) +} diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index 8a06006c..33ec950c 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -369,6 +369,41 @@ func testClientCRUD(t *testing.T, s storage.Storage) { c1.Secret = newSecret getAndCompare(id1, c1) + // Verify SSOSharedWith nil vs empty slice roundtrip. + err = s.UpdateClient(ctx, id1, func(old storage.Client) (storage.Client, error) { + old.SSOSharedWith = []string{} + return old, nil + }) + if err != nil { + t.Fatalf("update client ssoSharedWith to empty: %v", err) + } + gc, err := s.GetClient(ctx, id1) + if err != nil { + t.Fatalf("get client: %v", err) + } + if gc.SSOSharedWith == nil { + t.Error("expected empty slice for SSOSharedWith, got nil") + } + if len(gc.SSOSharedWith) != 0 { + t.Errorf("expected empty SSOSharedWith, got %v", gc.SSOSharedWith) + } + + // Verify nil SSOSharedWith stays nil after roundtrip. + err = s.UpdateClient(ctx, id1, func(old storage.Client) (storage.Client, error) { + old.SSOSharedWith = nil + return old, nil + }) + if err != nil { + t.Fatalf("update client ssoSharedWith to nil: %v", err) + } + gc, err = s.GetClient(ctx, id1) + if err != nil { + t.Fatalf("get client: %v", err) + } + if gc.SSOSharedWith != nil { + t.Errorf("expected nil SSOSharedWith, got %v", gc.SSOSharedWith) + } + if err := s.DeleteClient(ctx, id1); err != nil { t.Fatalf("delete client: %v", err) } diff --git a/storage/ent/client/client.go b/storage/ent/client/client.go index 20b0f9f9..8ee0a39f 100644 --- a/storage/ent/client/client.go +++ b/storage/ent/client/client.go @@ -19,6 +19,7 @@ func (d *Database) CreateClient(ctx context.Context, client storage.Client) erro SetAllowedConnectors(client.AllowedConnectors). SetMfaChain(client.MFAChain). SetPostLogoutRedirectUris(client.PostLogoutRedirectURIs). + SetSSOSharedWith(client.SSOSharedWith). Save(ctx) if err != nil { return convertDBError("create oauth2 client: %w", err) @@ -85,6 +86,7 @@ func (d *Database) UpdateClient(ctx context.Context, id string, updater func(old SetAllowedConnectors(newClient.AllowedConnectors). SetMfaChain(newClient.MFAChain). SetPostLogoutRedirectUris(newClient.PostLogoutRedirectURIs). + SetSSOSharedWith(newClient.SSOSharedWith). Save(ctx) if err != nil { return rollback(tx, "update client uploading: %w", err) diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index ddb36eb9..4a6e2bc7 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -97,6 +97,7 @@ func toStorageClient(c *db.OAuth2Client) storage.Client { AllowedConnectors: c.AllowedConnectors, MFAChain: c.MfaChain, PostLogoutRedirectURIs: c.PostLogoutRedirectUris, + SSOSharedWith: c.SSOSharedWith, } } diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index ff066308..a6050cb3 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -164,6 +164,7 @@ var ( {Name: "allowed_connectors", Type: field.TypeJSON, Nullable: true}, {Name: "mfa_chain", Type: field.TypeJSON, Nullable: true}, {Name: "post_logout_redirect_uris", Type: field.TypeJSON, Nullable: true}, + {Name: "sso_shared_with", Type: field.TypeJSON, Nullable: true}, } // Oauth2clientsTable holds the schema information for the "oauth2clients" table. Oauth2clientsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index fad85c3d..a21c6576 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -6470,6 +6470,8 @@ type OAuth2ClientMutation struct { appendmfa_chain []string post_logout_redirect_uris *[]string appendpost_logout_redirect_uris []string + sso_shared_with *[]string + appendsso_shared_with []string clearedFields map[string]struct{} done bool oldValue func(context.Context) (*OAuth2Client, error) @@ -7049,6 +7051,71 @@ func (m *OAuth2ClientMutation) ResetPostLogoutRedirectUris() { delete(m.clearedFields, oauth2client.FieldPostLogoutRedirectUris) } +// SetSSOSharedWith sets the "sso_shared_with" field. +func (m *OAuth2ClientMutation) SetSSOSharedWith(s []string) { + m.sso_shared_with = &s + m.appendsso_shared_with = nil +} + +// SSOSharedWith returns the value of the "sso_shared_with" field in the mutation. +func (m *OAuth2ClientMutation) SSOSharedWith() (r []string, exists bool) { + v := m.sso_shared_with + if v == nil { + return + } + return *v, true +} + +// OldSSOSharedWith returns the old "sso_shared_with" field's value of the OAuth2Client entity. +// If the OAuth2Client object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *OAuth2ClientMutation) OldSSOSharedWith(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSSOSharedWith is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSSOSharedWith requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSSOSharedWith: %w", err) + } + return oldValue.SSOSharedWith, nil +} + +// AppendSSOSharedWith adds s to the "sso_shared_with" field. +func (m *OAuth2ClientMutation) AppendSSOSharedWith(s []string) { + m.appendsso_shared_with = append(m.appendsso_shared_with, s...) +} + +// AppendedSSOSharedWith returns the list of values that were appended to the "sso_shared_with" field in this mutation. +func (m *OAuth2ClientMutation) AppendedSSOSharedWith() ([]string, bool) { + if len(m.appendsso_shared_with) == 0 { + return nil, false + } + return m.appendsso_shared_with, true +} + +// ClearSSOSharedWith clears the value of the "sso_shared_with" field. +func (m *OAuth2ClientMutation) ClearSSOSharedWith() { + m.sso_shared_with = nil + m.appendsso_shared_with = nil + m.clearedFields[oauth2client.FieldSSOSharedWith] = struct{}{} +} + +// SSOSharedWithCleared returns if the "sso_shared_with" field was cleared in this mutation. +func (m *OAuth2ClientMutation) SSOSharedWithCleared() bool { + _, ok := m.clearedFields[oauth2client.FieldSSOSharedWith] + return ok +} + +// ResetSSOSharedWith resets all changes to the "sso_shared_with" field. +func (m *OAuth2ClientMutation) ResetSSOSharedWith() { + m.sso_shared_with = nil + m.appendsso_shared_with = nil + delete(m.clearedFields, oauth2client.FieldSSOSharedWith) +} + // Where appends a list predicates to the OAuth2ClientMutation builder. func (m *OAuth2ClientMutation) Where(ps ...predicate.OAuth2Client) { m.predicates = append(m.predicates, ps...) @@ -7083,7 +7150,7 @@ func (m *OAuth2ClientMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *OAuth2ClientMutation) Fields() []string { - fields := make([]string, 0, 9) + fields := make([]string, 0, 10) if m.secret != nil { fields = append(fields, oauth2client.FieldSecret) } @@ -7111,6 +7178,9 @@ func (m *OAuth2ClientMutation) Fields() []string { if m.post_logout_redirect_uris != nil { fields = append(fields, oauth2client.FieldPostLogoutRedirectUris) } + if m.sso_shared_with != nil { + fields = append(fields, oauth2client.FieldSSOSharedWith) + } return fields } @@ -7137,6 +7207,8 @@ func (m *OAuth2ClientMutation) Field(name string) (ent.Value, bool) { return m.MfaChain() case oauth2client.FieldPostLogoutRedirectUris: return m.PostLogoutRedirectUris() + case oauth2client.FieldSSOSharedWith: + return m.SSOSharedWith() } return nil, false } @@ -7164,6 +7236,8 @@ func (m *OAuth2ClientMutation) OldField(ctx context.Context, name string) (ent.V return m.OldMfaChain(ctx) case oauth2client.FieldPostLogoutRedirectUris: return m.OldPostLogoutRedirectUris(ctx) + case oauth2client.FieldSSOSharedWith: + return m.OldSSOSharedWith(ctx) } return nil, fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -7236,6 +7310,13 @@ func (m *OAuth2ClientMutation) SetField(name string, value ent.Value) error { } m.SetPostLogoutRedirectUris(v) return nil + case oauth2client.FieldSSOSharedWith: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSSOSharedWith(v) + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -7281,6 +7362,9 @@ func (m *OAuth2ClientMutation) ClearedFields() []string { if m.FieldCleared(oauth2client.FieldPostLogoutRedirectUris) { fields = append(fields, oauth2client.FieldPostLogoutRedirectUris) } + if m.FieldCleared(oauth2client.FieldSSOSharedWith) { + fields = append(fields, oauth2client.FieldSSOSharedWith) + } return fields } @@ -7310,6 +7394,9 @@ func (m *OAuth2ClientMutation) ClearField(name string) error { case oauth2client.FieldPostLogoutRedirectUris: m.ClearPostLogoutRedirectUris() return nil + case oauth2client.FieldSSOSharedWith: + m.ClearSSOSharedWith() + return nil } return fmt.Errorf("unknown OAuth2Client nullable field %s", name) } @@ -7345,6 +7432,9 @@ func (m *OAuth2ClientMutation) ResetField(name string) error { case oauth2client.FieldPostLogoutRedirectUris: m.ResetPostLogoutRedirectUris() return nil + case oauth2client.FieldSSOSharedWith: + m.ResetSSOSharedWith() + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } diff --git a/storage/ent/db/oauth2client.go b/storage/ent/db/oauth2client.go index 990edc97..c8c4f857 100644 --- a/storage/ent/db/oauth2client.go +++ b/storage/ent/db/oauth2client.go @@ -35,7 +35,9 @@ type OAuth2Client struct { MfaChain []string `json:"mfa_chain,omitempty"` // PostLogoutRedirectUris holds the value of the "post_logout_redirect_uris" field. PostLogoutRedirectUris []string `json:"post_logout_redirect_uris,omitempty"` - selectValues sql.SelectValues + // SSOSharedWith holds the value of the "sso_shared_with" field. + SSOSharedWith []string `json:"sso_shared_with,omitempty"` + selectValues sql.SelectValues } // scanValues returns the types for scanning values from sql.Rows. @@ -43,7 +45,7 @@ func (*OAuth2Client) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors, oauth2client.FieldMfaChain, oauth2client.FieldPostLogoutRedirectUris: + case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors, oauth2client.FieldMfaChain, oauth2client.FieldPostLogoutRedirectUris, oauth2client.FieldSSOSharedWith: values[i] = new([]byte) case oauth2client.FieldPublic: values[i] = new(sql.NullBool) @@ -134,6 +136,14 @@ func (_m *OAuth2Client) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field post_logout_redirect_uris: %w", err) } } + case oauth2client.FieldSSOSharedWith: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field sso_shared_with", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.SSOSharedWith); err != nil { + return fmt.Errorf("unmarshal field sso_shared_with: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -196,6 +206,9 @@ func (_m *OAuth2Client) String() string { builder.WriteString(", ") builder.WriteString("post_logout_redirect_uris=") builder.WriteString(fmt.Sprintf("%v", _m.PostLogoutRedirectUris)) + builder.WriteString(", ") + builder.WriteString("sso_shared_with=") + builder.WriteString(fmt.Sprintf("%v", _m.SSOSharedWith)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/oauth2client/oauth2client.go b/storage/ent/db/oauth2client/oauth2client.go index 9474f0bd..9bb6c376 100644 --- a/storage/ent/db/oauth2client/oauth2client.go +++ b/storage/ent/db/oauth2client/oauth2client.go @@ -29,6 +29,8 @@ const ( FieldMfaChain = "mfa_chain" // FieldPostLogoutRedirectUris holds the string denoting the post_logout_redirect_uris field in the database. FieldPostLogoutRedirectUris = "post_logout_redirect_uris" + // FieldSSOSharedWith holds the string denoting the sso_shared_with field in the database. + FieldSSOSharedWith = "sso_shared_with" // Table holds the table name of the oauth2client in the database. Table = "oauth2clients" ) @@ -45,6 +47,7 @@ var Columns = []string{ FieldAllowedConnectors, FieldMfaChain, FieldPostLogoutRedirectUris, + FieldSSOSharedWith, } // ValidColumn reports if the column name is valid (part of the table columns). diff --git a/storage/ent/db/oauth2client/where.go b/storage/ent/db/oauth2client/where.go index f23967a7..f84c6613 100644 --- a/storage/ent/db/oauth2client/where.go +++ b/storage/ent/db/oauth2client/where.go @@ -337,6 +337,16 @@ func PostLogoutRedirectUrisNotNil() predicate.OAuth2Client { return predicate.OAuth2Client(sql.FieldNotNull(FieldPostLogoutRedirectUris)) } +// SSOSharedWithIsNil applies the IsNil predicate on the "sso_shared_with" field. +func SSOSharedWithIsNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldIsNull(FieldSSOSharedWith)) +} + +// SSOSharedWithNotNil applies the NotNil predicate on the "sso_shared_with" field. +func SSOSharedWithNotNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldNotNull(FieldSSOSharedWith)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.OAuth2Client) predicate.OAuth2Client { return predicate.OAuth2Client(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/oauth2client_create.go b/storage/ent/db/oauth2client_create.go index da576440..0bd226cd 100644 --- a/storage/ent/db/oauth2client_create.go +++ b/storage/ent/db/oauth2client_create.go @@ -73,6 +73,12 @@ func (_c *OAuth2ClientCreate) SetPostLogoutRedirectUris(v []string) *OAuth2Clien return _c } +// SetSSOSharedWith sets the "sso_shared_with" field. +func (_c *OAuth2ClientCreate) SetSSOSharedWith(v []string) *OAuth2ClientCreate { + _c.mutation.SetSSOSharedWith(v) + return _c +} + // SetID sets the "id" field. func (_c *OAuth2ClientCreate) SetID(v string) *OAuth2ClientCreate { _c.mutation.SetID(v) @@ -216,6 +222,10 @@ func (_c *OAuth2ClientCreate) createSpec() (*OAuth2Client, *sqlgraph.CreateSpec) _spec.SetField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON, value) _node.PostLogoutRedirectUris = value } + if value, ok := _c.mutation.SSOSharedWith(); ok { + _spec.SetField(oauth2client.FieldSSOSharedWith, field.TypeJSON, value) + _node.SSOSharedWith = value + } return _node, _spec } diff --git a/storage/ent/db/oauth2client_update.go b/storage/ent/db/oauth2client_update.go index efa92c4c..8647eeb3 100644 --- a/storage/ent/db/oauth2client_update.go +++ b/storage/ent/db/oauth2client_update.go @@ -174,6 +174,24 @@ func (_u *OAuth2ClientUpdate) ClearPostLogoutRedirectUris() *OAuth2ClientUpdate return _u } +// SetSSOSharedWith sets the "sso_shared_with" field. +func (_u *OAuth2ClientUpdate) SetSSOSharedWith(v []string) *OAuth2ClientUpdate { + _u.mutation.SetSSOSharedWith(v) + return _u +} + +// AppendSSOSharedWith appends value to the "sso_shared_with" field. +func (_u *OAuth2ClientUpdate) AppendSSOSharedWith(v []string) *OAuth2ClientUpdate { + _u.mutation.AppendSSOSharedWith(v) + return _u +} + +// ClearSSOSharedWith clears the value of the "sso_shared_with" field. +func (_u *OAuth2ClientUpdate) ClearSSOSharedWith() *OAuth2ClientUpdate { + _u.mutation.ClearSSOSharedWith() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdate) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -305,6 +323,17 @@ func (_u *OAuth2ClientUpdate) sqlSave(ctx context.Context) (_node int, err error if _u.mutation.PostLogoutRedirectUrisCleared() { _spec.ClearField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON) } + if value, ok := _u.mutation.SSOSharedWith(); ok { + _spec.SetField(oauth2client.FieldSSOSharedWith, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedSSOSharedWith(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldSSOSharedWith, value) + }) + } + if _u.mutation.SSOSharedWithCleared() { + _spec.ClearField(oauth2client.FieldSSOSharedWith, field.TypeJSON) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{oauth2client.Label} @@ -471,6 +500,24 @@ func (_u *OAuth2ClientUpdateOne) ClearPostLogoutRedirectUris() *OAuth2ClientUpda return _u } +// SetSSOSharedWith sets the "sso_shared_with" field. +func (_u *OAuth2ClientUpdateOne) SetSSOSharedWith(v []string) *OAuth2ClientUpdateOne { + _u.mutation.SetSSOSharedWith(v) + return _u +} + +// AppendSSOSharedWith appends value to the "sso_shared_with" field. +func (_u *OAuth2ClientUpdateOne) AppendSSOSharedWith(v []string) *OAuth2ClientUpdateOne { + _u.mutation.AppendSSOSharedWith(v) + return _u +} + +// ClearSSOSharedWith clears the value of the "sso_shared_with" field. +func (_u *OAuth2ClientUpdateOne) ClearSSOSharedWith() *OAuth2ClientUpdateOne { + _u.mutation.ClearSSOSharedWith() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdateOne) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -632,6 +679,17 @@ func (_u *OAuth2ClientUpdateOne) sqlSave(ctx context.Context) (_node *OAuth2Clie if _u.mutation.PostLogoutRedirectUrisCleared() { _spec.ClearField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON) } + if value, ok := _u.mutation.SSOSharedWith(); ok { + _spec.SetField(oauth2client.FieldSSOSharedWith, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedSSOSharedWith(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldSSOSharedWith, value) + }) + } + if _u.mutation.SSOSharedWithCleared() { + _spec.ClearField(oauth2client.FieldSSOSharedWith, field.TypeJSON) + } _node = &OAuth2Client{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/schema/client.go b/storage/ent/schema/client.go index 882842ce..be4e52d8 100644 --- a/storage/ent/schema/client.go +++ b/storage/ent/schema/client.go @@ -51,6 +51,8 @@ func (OAuth2Client) Fields() []ent.Field { Optional(), field.JSON("post_logout_redirect_uris", []string{}). Optional(), + field.JSON("sso_shared_with", []string{}). + Optional(), } } diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index cec39f3f..d936865a 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -291,6 +291,8 @@ type Client struct { MFAChain []string `json:"mfaChain,omitempty"` PostLogoutRedirectURIs []string `json:"postLogoutRedirectURIs,omitempty"` + + SSOSharedWith []string `json:"ssoSharedWith"` } // ClientList is a list of Clients. @@ -320,6 +322,7 @@ func (cli *client) fromStorageClient(c storage.Client) Client { AllowedConnectors: c.AllowedConnectors, MFAChain: c.MFAChain, PostLogoutRedirectURIs: c.PostLogoutRedirectURIs, + SSOSharedWith: c.SSOSharedWith, } } @@ -335,6 +338,7 @@ func toStorageClient(c Client) storage.Client { AllowedConnectors: c.AllowedConnectors, MFAChain: c.MFAChain, PostLogoutRedirectURIs: c.PostLogoutRedirectURIs, + SSOSharedWith: c.SSOSharedWith, } } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index e9c2faf6..a8eaf2fd 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -546,9 +546,10 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto logo_url = $6, allowed_connectors = $7, mfa_chain = $8, - post_logout_redirect_uris = $9 - where id = $10; - `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, encoder(nc.AllowedConnectors), encoder(nc.MFAChain), encoder(nc.PostLogoutRedirectURIs), id, + post_logout_redirect_uris = $9, + sso_shared_with = $10 + where id = $11; + `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, encoder(nc.AllowedConnectors), encoder(nc.MFAChain), encoder(nc.PostLogoutRedirectURIs), encoder(nc.SSOSharedWith), id, ) if err != nil { return fmt.Errorf("update client: %v", err) @@ -560,12 +561,12 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { _, err := c.Exec(` insert into client ( - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris, sso_shared_with ) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); `, cli.ID, cli.Secret, encoder(cli.RedirectURIs), encoder(cli.TrustedPeers), - cli.Public, cli.Name, cli.LogoURL, encoder(cli.AllowedConnectors), encoder(cli.MFAChain), encoder(cli.PostLogoutRedirectURIs), + cli.Public, cli.Name, cli.LogoURL, encoder(cli.AllowedConnectors), encoder(cli.MFAChain), encoder(cli.PostLogoutRedirectURIs), encoder(cli.SSOSharedWith), ) if err != nil { if c.alreadyExistsCheck(err) { @@ -579,7 +580,7 @@ func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { func getClient(ctx context.Context, q querier, id string) (storage.Client, error) { return scanClient(q.QueryRow(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris, sso_shared_with from client where id = $1; `, id)) } @@ -591,7 +592,7 @@ func (c *conn) GetClient(ctx context.Context, id string) (storage.Client, error) func (c *conn) ListClients(ctx context.Context) ([]storage.Client, error) { rows, err := c.Query(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris, sso_shared_with from client; `) if err != nil { @@ -617,9 +618,10 @@ func scanClient(s scanner) (cli storage.Client, err error) { var allowedConnectors []byte var mfaChain []byte var postLogoutRedirectURIs []byte + var ssoSharedWith []byte err = s.Scan( &cli.ID, &cli.Secret, decoder(&cli.RedirectURIs), decoder(&cli.TrustedPeers), - &cli.Public, &cli.Name, &cli.LogoURL, &allowedConnectors, &mfaChain, &postLogoutRedirectURIs, + &cli.Public, &cli.Name, &cli.LogoURL, &allowedConnectors, &mfaChain, &postLogoutRedirectURIs, &ssoSharedWith, ) if err != nil { if err == sql.ErrNoRows { @@ -642,6 +644,11 @@ func scanClient(s scanner) (cli storage.Client, err error) { return cli, fmt.Errorf("unmarshal client post logout redirect uris: %v", err) } } + if len(ssoSharedWith) > 0 { + if err := json.Unmarshal(ssoSharedWith, &cli.SSOSharedWith); err != nil { + return cli, fmt.Errorf("unmarshal client sso shared with: %v", err) + } + } return cli, nil } diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 36a42135..d9b3dbed 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -460,4 +460,11 @@ var migrations = []migration{ `alter table user_identity add column webauthn_credentials bytea;`, }, }, + { + stmts: []string{ + ` + alter table client + add column sso_shared_with bytea;`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index 7484474b..6d5f40b4 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -196,6 +196,13 @@ type Client struct { // MFAChain is an ordered list of MFA authenticator IDs that a user must complete // during login. Empty means no MFA required. MFAChain []string `json:"mfaChain"` + + // 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. + // 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" yaml:"ssoSharedWith"` } // Claims represents the ID Token claims supported by the server. From 6f2e233c7ab215275512deb7e4c0620a933c0434 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Thu, 2 Apr 2026 14:19:10 +0200 Subject: [PATCH 213/222] feat: example app session refactoring (#4712) Signed-off-by: maksim.nabokikh --- examples/config-dev.yaml | 2 + examples/example-app/handlers.go | 141 --------- examples/example-app/handlers_device.go | 273 ------------------ examples/example-app/main.go | 171 ++--------- examples/example-app/server/authcode.go | 194 +++++++++++++ examples/example-app/server/devicecode.go | 220 ++++++++++++++ examples/example-app/server/logout.go | 36 +++ examples/example-app/server/render.go | 119 ++++++++ examples/example-app/server/server.go | 204 +++++++++++++ .../example-app/{ => server}/static/app.js | 0 .../example-app/{ => server}/static/device.js | 0 .../{ => server}/static/dex-glyph-color.svg | 40 +-- .../example-app/{ => server}/static/style.css | 94 +++++- .../example-app/{ => server}/static/token.js | 0 .../{ => server}/templates/device.html | 0 .../{ => server}/templates/index.html | 28 +- .../{ => server}/templates/token.html | 5 +- examples/example-app/server/token.go | 163 +++++++++++ examples/example-app/server/transport.go | 104 +++++++ .../userinfo.go} | 28 +- examples/example-app/session/auth.go | 68 +++++ examples/example-app/session/device.go | 104 +++++++ examples/example-app/templates.go | 190 ------------ examples/example-app/utils.go | 154 ---------- 24 files changed, 1373 insertions(+), 965 deletions(-) delete mode 100644 examples/example-app/handlers.go delete mode 100644 examples/example-app/handlers_device.go create mode 100644 examples/example-app/server/authcode.go create mode 100644 examples/example-app/server/devicecode.go create mode 100644 examples/example-app/server/logout.go create mode 100644 examples/example-app/server/render.go create mode 100644 examples/example-app/server/server.go rename examples/example-app/{ => server}/static/app.js (100%) rename examples/example-app/{ => server}/static/device.js (100%) rename examples/example-app/{ => server}/static/dex-glyph-color.svg (98%) rename examples/example-app/{ => server}/static/style.css (86%) rename examples/example-app/{ => server}/static/token.js (100%) rename examples/example-app/{ => server}/templates/device.html (100%) rename examples/example-app/{ => server}/templates/index.html (72%) rename examples/example-app/{ => server}/templates/token.html (96%) create mode 100644 examples/example-app/server/token.go create mode 100644 examples/example-app/server/transport.go rename examples/example-app/{handlers_userinfo.go => server/userinfo.go} (59%) create mode 100644 examples/example-app/session/auth.go create mode 100644 examples/example-app/session/device.go delete mode 100644 examples/example-app/templates.go delete mode 100644 examples/example-app/utils.go diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 2714dee3..2966eee3 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -179,6 +179,8 @@ staticClients: redirectURIs: - 'http://127.0.0.1:5555/callback' - '/dex/device/callback' + postLogoutRedirectURIs: + - 'http://127.0.0.1:5555/' name: 'Example App' secret: ZXhhbXBsZS1hcHAtc2VjcmV0 # Optional: restrict which connectors this client can use for authentication. diff --git a/examples/example-app/handlers.go b/examples/example-app/handlers.go deleted file mode 100644 index fce65d77..00000000 --- a/examples/example-app/handlers.go +++ /dev/null @@ -1,141 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "net/url" - "time" - - "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" -) - -func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) { - renderIndex(w, indexPageData{ - ScopesSupported: a.scopesSupported, - LogoURI: dexLogoDataURI, - }) -} - -func (a *app) 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 - } - - // Only use scopes that are checked in the form - scopes := r.Form["extra_scopes"] - crossClients := r.Form["cross_client"] - - // Build complete scope list with audience scopes - scopes = buildScopes(scopes, crossClients) - - connectorID := "" - if id := r.FormValue("connector_id"); id != "" { - connectorID = id - } - - authCodeURL := "" - - var authCodeOptions []oauth2.AuthCodeOption - - if a.pkce { - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_challenge", codeChallenge)) - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_challenge_method", "S256")) - } - - // Check if offline_access scope is present to determine offline access mode - hasOfflineAccess := false - for _, scope := range scopes { - if scope == "offline_access" { - hasOfflineAccess = true - break - } - } - - if hasOfflineAccess && !a.offlineAsScope { - // Provider uses access_type=offline instead of offline_access scope - authCodeOptions = append(authCodeOptions, oauth2.AccessTypeOffline) - // Remove offline_access from scopes as it's not supported - filteredScopes := make([]string, 0, len(scopes)) - for _, scope := range scopes { - if scope != "offline_access" { - filteredScopes = append(filteredScopes, scope) - } - } - scopes = filteredScopes - } - - authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState, authCodeOptions...) - - // Parse the auth code URL and safely add connector_id parameter if provided - u, err := url.Parse(authCodeURL) - if err != nil { - http.Error(w, "Failed to parse auth URL", http.StatusInternalServerError) - return - } - - if connectorID != "" { - query := u.Query() - query.Set("connector_id", connectorID) - u.RawQuery = query.Encode() - } - - http.Redirect(w, r, u.String(), 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 - } - - var authCodeOptions []oauth2.AuthCodeOption - if a.pkce { - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) - } - - token, err = oauth2Config.Exchange(ctx, code, authCodeOptions...) - 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 - } - - parseAndRenderToken(w, r, a, token) -} diff --git a/examples/example-app/handlers_device.go b/examples/example-app/handlers_device.go deleted file mode 100644 index 40209ca2..00000000 --- a/examples/example-app/handlers_device.go +++ /dev/null @@ -1,273 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - - "golang.org/x/oauth2" -) - -func (a *app) handleDeviceLogin(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Parse request body to get options - 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 - } - - // Build complete scope list with audience scopes (same as handleLogin) - scopes := buildScopes(reqBody.Scopes, reqBody.CrossClients) - - // Build scope string - scopeStr := strings.Join(scopes, " ") - - // Get device authorization endpoint - // Properly construct the device code endpoint URL - authURL := a.provider.Endpoint().AuthURL - deviceAuthURL := strings.TrimSuffix(authURL, "/auth") + "/device/code" - - // Request device code - data := url.Values{} - data.Set("client_id", a.clientID) - data.Set("client_secret", a.clientSecret) - data.Set("scope", scopeStr) - - // Add connector_id if specified - if reqBody.ConnectorID != "" { - data.Set("connector_id", reqBody.ConnectorID) - } - - resp, err := a.client.PostForm(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"` - VerificationURIComplete string `json:"verification_uri_complete"` - 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 - } - - // Store device flow data with new session - sessionID := generateSessionID() - - a.deviceFlowMutex.Lock() - a.deviceFlowData.sessionID = sessionID - a.deviceFlowData.deviceCode = deviceResp.DeviceCode - a.deviceFlowData.userCode = deviceResp.UserCode - a.deviceFlowData.verificationURI = deviceResp.VerificationURI - a.deviceFlowData.pollInterval = deviceResp.Interval - if a.deviceFlowData.pollInterval == 0 { - a.deviceFlowData.pollInterval = 5 - } - a.deviceFlowData.token = nil - a.deviceFlowMutex.Unlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "ok", - "session_id": sessionID, - }) -} - -func (a *app) handleDevicePage(w http.ResponseWriter, r *http.Request) { - a.deviceFlowMutex.Lock() - data := devicePageData{ - SessionID: a.deviceFlowData.sessionID, - DeviceCode: a.deviceFlowData.deviceCode, - UserCode: a.deviceFlowData.userCode, - VerificationURI: a.deviceFlowData.verificationURI, - PollInterval: a.deviceFlowData.pollInterval, - LogoURI: dexLogoDataURI, - } - a.deviceFlowMutex.Unlock() - - if data.DeviceCode == "" { - http.Error(w, "No device flow in progress", http.StatusBadRequest) - return - } - - renderDevice(w, data) -} - -func (a *app) handleDevicePoll(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - DeviceCode string `json:"device_code"` - SessionID string `json:"session_id"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - a.deviceFlowMutex.Lock() - storedSessionID := a.deviceFlowData.sessionID - storedDeviceCode := a.deviceFlowData.deviceCode - existingToken := a.deviceFlowData.token - a.deviceFlowMutex.Unlock() - - // Check if this session has been superseded by a new one - if req.SessionID != storedSessionID { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusGone) - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "session_expired", - "error_description": "This device flow session has been superseded by a new one", - }) - return - } - - if req.DeviceCode != storedDeviceCode { - http.Error(w, "Invalid device code", http.StatusBadRequest) - return - } - - // If we already have a token, return success - if existingToken != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "complete", - }) - return - } - - // Poll the token endpoint - tokenURL := a.provider.Endpoint().TokenURL - - data := url.Values{} - data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") - data.Set("device_code", req.DeviceCode) - data.Set("client_id", a.clientID) - data.Set("client_secret", a.clientSecret) - - tokenResp, err := a.client.PostForm(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 { - // Success! We got the token - // Parse the full response including id_token - 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 - } - - // Create oauth2.Token with all fields - token := &oauth2.Token{ - AccessToken: tokenData.AccessToken, - TokenType: tokenData.TokenType, - RefreshToken: tokenData.RefreshToken, - } - - // Add id_token to Extra - token = token.WithExtra(map[string]interface{}{ - "id_token": tokenData.IDToken, - }) - - // Store the token - a.deviceFlowMutex.Lock() - a.deviceFlowData.token = token - a.deviceFlowMutex.Unlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "complete", - }) - return - } - - // Check for errors - 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 - } - - // Other errors - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(tokenResp.StatusCode) - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": errorResp.Error, - "error_description": errorResp.ErrorDescription, - }) - return - } - - // Unknown response - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "pending", - }) -} - -func (a *app) handleDeviceResult(w http.ResponseWriter, r *http.Request) { - a.deviceFlowMutex.Lock() - token := a.deviceFlowData.token - a.deviceFlowMutex.Unlock() - - if token == nil { - http.Error(w, "No token available", http.StatusBadRequest) - return - } - - parseAndRenderToken(w, r, a, token) -} diff --git a/examples/example-app/main.go b/examples/example-app/main.go index 389d2ff0..fc265139 100644 --- a/examples/example-app/main.go +++ b/examples/example-app/main.go @@ -1,182 +1,51 @@ package main import ( - "context" "errors" "fmt" - "log" - "net/http" - "net/url" "os" - "sync" - "github.com/coreos/go-oidc/v3/oidc" "github.com/spf13/cobra" - "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/server" ) -const exampleAppState = "I wish to wash my irish wristwatch" - -var ( - codeVerifier string - codeChallenge string -) - -func init() { - codeVerifier = oauth2.GenerateVerifier() - codeChallenge = oauth2.S256ChallengeFromVerifier(codeVerifier) -} - -type app struct { - clientID string - clientSecret string - pkce bool - redirectURI string - - verifier *oidc.IDTokenVerifier - provider *oidc.Provider - scopesSupported []string - - // 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 - - // Device flow state - // Only one session is possible at a time - // Since it is an example, we don't bother locking', this is a simplicity tradeoff - deviceFlowMutex sync.Mutex - deviceFlowData struct { - sessionID string // Unique ID for current flow session - deviceCode string - userCode string - verificationURI string - pollInterval int - token *oauth2.Token - } -} - 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: "", RunE: func(cmd *cobra.Command, args []string) error { if len(args) != 0 { return errors.New("surplus arguments provided") } - u, err := url.Parse(a.redirectURI) + s, err := server.New(opts) if err != nil { - return fmt.Errorf("parse redirect-uri: %v", err) - } - listenURL, err := url.Parse(listen) - 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} - } - } - - if a.client == nil { - a.client = http.DefaultClient - } - - // 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) - } - - 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 - }() - } - - a.provider = provider - a.verifier = provider.Verifier(&oidc.Config{ClientID: a.clientID}) - a.scopesSupported = s.ScopesSupported - - http.Handle("/static/", http.StripPrefix("/static/", staticHandler)) - http.HandleFunc("/", a.handleIndex) - http.HandleFunc("/login", a.handleLogin) - http.HandleFunc("/device/login", a.handleDeviceLogin) - http.HandleFunc("/device", a.handleDevicePage) - http.HandleFunc("/device/poll", a.handleDevicePoll) - http.HandleFunc("/device/result", a.handleDeviceResult) - http.HandleFunc("/userinfo", a.handleUserInfo) - http.HandleFunc(u.Path, a.handleCallback) - - 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) + return err } + return s.Run(listen, tlsCert, tlsKey) }, } - 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().BoolVar(&a.pkce, "pkce", true, "Use PKCE flow for the code exchange.") - 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(&opts.ClientID, "client-id", "example-app", "OAuth2 client ID of this application.") + c.Flags().StringVar(&opts.ClientSecret, "client-secret", "ZXhhbXBsZS1hcHAtc2VjcmV0", "OAuth2 client secret of this application.") + c.Flags().BoolVar(&opts.PKCE, "pkce", true, "Use PKCE flow for the code exchange.") + c.Flags().StringVar(&opts.RedirectURI, "redirect-uri", "http://127.0.0.1:5555/callback", "Callback URL for OAuth2 responses.") + c.Flags().StringVar(&opts.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.") + c.Flags().StringVar(&opts.RootCAs, "issuer-root-ca", "", "Root certificate authorities for the issuer. Defaults to host certs.") + c.Flags().BoolVar(&opts.Debug, "debug", false, "Print all request and responses from the OpenID Connect issuer.") + c.Flags().BoolVar(&opts.SessionAware, "session-aware", false, "Check Dex session on index page via prompt=none and show logout button.") + return &c } diff --git a/examples/example-app/server/authcode.go b/examples/example-app/server/authcode.go new file mode 100644 index 00000000..aedf8ef5 --- /dev/null +++ b/examples/example-app/server/authcode.go @@ -0,0 +1,194 @@ +package server + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +// handleLoginPage renders the login page with available scopes. +// When session-aware mode is enabled, it checks for an existing Dex session +// via prompt=none and displays the authenticated user if found. +func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { + data := LoginPageData{ + ScopesSupported: s.scopesSupported, + LogoURI: dexLogoDataURI, + } + + if s.sessionAware { + authState := s.auth.Get() + + if authState.Claims != nil { + data.User = authState.Claims + data.LogoutURL = "/app-logout" + } else if !authState.Checked { + // First visit: redirect to Dex with prompt=none to check session. + scopes := []string{"openid", "profile", "email"} + + var opts []oauth2.AuthCodeOption + opts = append(opts, oauth2.SetAuthURLParam("prompt", "none")) + if s.pkce { + opts = append(opts, oauth2.SetAuthURLParam("code_challenge", s.codeChallenge)) + opts = append(opts, oauth2.SetAuthURLParam("code_challenge_method", "S256")) + } + + authCodeURL := s.oauth2Config(scopes).AuthCodeURL(silentAuthState, opts...) + http.Redirect(w, r, authCodeURL, http.StatusFound) + return + } else { + data.NotLoggedIn = true + } + } + + s.renderer.RenderLoginPage(w, data) +} + +// handleLogin initiates the Authorization Code Flow by redirecting to the IdP. +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 + } + + scopes := buildScopes(r.Form["extra_scopes"], r.Form["cross_client"]) + connectorID := r.FormValue("connector_id") + + var authCodeOptions []oauth2.AuthCodeOption + + if s.pkce { + authCodeOptions = append(authCodeOptions, + oauth2.SetAuthURLParam("code_challenge", s.codeChallenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + } + + // If provider doesn't support "offline_access" scope natively, + // use "access_type=offline" parameter instead (e.g. Google). + hasOfflineAccess := false + for _, scope := range scopes { + if scope == oidc.ScopeOfflineAccess { + hasOfflineAccess = true + break + } + } + + if hasOfflineAccess && !s.offlineAsScope { + authCodeOptions = append(authCodeOptions, oauth2.AccessTypeOffline) + filtered := make([]string, 0, len(scopes)) + for _, scope := range scopes { + if scope != oidc.ScopeOfflineAccess { + filtered = append(filtered, scope) + } + } + scopes = filtered + } + + authCodeURL := s.oauth2Config(scopes).AuthCodeURL(exampleAppState, authCodeOptions...) + + u, err := url.Parse(authCodeURL) + if err != nil { + http.Error(w, "Failed to parse auth URL", http.StatusInternalServerError) + return + } + + if connectorID != "" { + query := u.Query() + query.Set("connector_id", connectorID) + u.RawQuery = query.Encode() + } + + http.Redirect(w, r, u.String(), http.StatusSeeOther) +} + +// handleAuthCallback handles the OAuth2 authorization redirect callback. +// It validates the state parameter and exchanges the authorization code for tokens. +// It also handles silent auth callbacks (prompt=none) for session detection. +func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { + state := r.FormValue("state") + + // Silent auth callback (prompt=none). + if state == silentAuthState { + ctx := oidc.ClientContext(r.Context(), s.client) + claims, rawIDToken := s.exchangeSilentAuth(ctx, r, s.oauth2Config(nil)) + s.auth.Set(claims, rawIDToken) + http.Redirect(w, r, "/", http.StatusFound) + return + } + + // Normal authorization code callback. + 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 != exampleAppState { + http.Error(w, fmt.Sprintf("expected state %q got %q", exampleAppState, state), http.StatusBadRequest) + return + } + + ctx := oidc.ClientContext(r.Context(), s.client) + + var exchangeOpts []oauth2.AuthCodeOption + if s.pkce { + exchangeOpts = append(exchangeOpts, oauth2.SetAuthURLParam("code_verifier", s.codeVerifier)) + } + + token, err := s.oauth2Config(nil).Exchange(ctx, code, exchangeOpts...) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) + return + } + + s.renderTokenResult(w, r, token) +} + +// exchangeSilentAuth attempts a token exchange for a silent auth callback. +// Returns the parsed claims and raw ID token on success, or (nil, "") on any failure. +func (s *Server) exchangeSilentAuth(ctx context.Context, r *http.Request, oauth2Config *oauth2.Config) (*session.UserClaims, string) { + if r.FormValue("error") != "" { + return nil, "" + } + + code := r.FormValue("code") + if code == "" { + return nil, "" + } + + var opts []oauth2.AuthCodeOption + if s.pkce { + opts = append(opts, oauth2.SetAuthURLParam("code_verifier", s.codeVerifier)) + } + + token, err := oauth2Config.Exchange(ctx, code, opts...) + if err != nil { + return nil, "" + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + log.Printf("silent auth: no id_token in response") + return nil, "" + } + + idToken, err := s.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + return nil, "" + } + + var claims session.UserClaims + _ = idToken.Claims(&claims) + return &claims, rawIDToken +} diff --git a/examples/example-app/server/devicecode.go b/examples/example-app/server/devicecode.go new file mode 100644 index 00000000..b121c30d --- /dev/null +++ b/examples/example-app/server/devicecode.go @@ -0,0 +1,220 @@ +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 + } + + sessionID := s.devices.Save(session.DeviceState{ + 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", + "session_id": sessionID, + }) +} + +// handleDeviceStatus renders the device flow pending page with verification URL and user code. +func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) { + // JS redirects here without session_id, so always show the latest session. + sessionID, state, ok := s.devices.GetLatest() + if !ok { + http.Error(w, "No device flow in progress", http.StatusBadRequest) + return + } + + s.renderer.RenderDevicePage(w, DevicePageData{ + SessionID: sessionID, + DeviceCode: state.DeviceCode, + UserCode: state.UserCode, + VerificationURI: state.VerificationURI, + PollInterval: state.PollInterval, + LogoURI: dexLogoDataURI, + }) +} + +// handleDevicePoll polls the token endpoint on behalf of the device. +func (s *Server) handleDevicePoll(w http.ResponseWriter, r *http.Request) { + var req struct { + DeviceCode string `json:"device_code"` + SessionID string `json:"session_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + state, ok := s.devices.Get(req.SessionID) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusGone) + json.NewEncoder(w).Encode(map[string]any{ + "error": "session_expired", + "error_description": "This device flow session has been superseded by a new one", + }) + return + } + + if req.DeviceCode != state.DeviceCode { + http.Error(w, "Invalid device code", http.StatusBadRequest) + return + } + + // If we already have a token, return success. + if state.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", req.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.devices.SetToken(req.SessionID, 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. +func (s *Server) handleDeviceComplete(w http.ResponseWriter, r *http.Request) { + // JS redirects here without session_id, so always show the latest session. + _, state, ok := s.devices.GetLatest() + if !ok || state.Token == nil { + http.Error(w, "No token available", http.StatusBadRequest) + return + } + + s.renderTokenResult(w, r, state.Token) +} diff --git a/examples/example-app/server/logout.go b/examples/example-app/server/logout.go new file mode 100644 index 00000000..7aa9c730 --- /dev/null +++ b/examples/example-app/server/logout.go @@ -0,0 +1,36 @@ +package server + +import ( + "net/http" + "net/url" +) + +// handleAppLogout clears the local session and redirects to the provider's +// end_session_endpoint for RP-Initiated Logout (if available). +func (s *Server) handleAppLogout(w http.ResponseWriter, r *http.Request) { + idToken := s.auth.Clear() + + 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) + } + // Derive app base URL from redirect URI for post-logout redirect. + 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) +} diff --git a/examples/example-app/server/render.go b/examples/example-app/server/render.go new file mode 100644 index 00000000..93f12562 --- /dev/null +++ b/examples/example-app/server/render.go @@ -0,0 +1,119 @@ +package server + +import ( + "embed" + "html/template" + "io/fs" + "log" + "net/http" + + "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)) +} + +// LoginPageData holds data for the login page template. +type LoginPageData struct { + ScopesSupported []string + LogoURI string + User *session.UserClaims + NotLoggedIn bool + LogoutURL string +} + +// TokenPageData holds data for the token display template. +type TokenPageData struct { + IDToken string + IDTokenJWTLink string + AccessToken string + AccessTokenJWTLink string + RefreshToken string + RedirectURL string + Claims string + PublicKeyPEM string +} + +// DevicePageData holds data for the device flow template. +type DevicePageData struct { + SessionID string + DeviceCode string + UserCode string + VerificationURI string + PollInterval int + LogoURI string +} + +// Renderer renders HTML pages for the application. +type Renderer interface { + RenderLoginPage(w http.ResponseWriter, data LoginPageData) + RenderTokenPage(w http.ResponseWriter, data TokenPageData) + RenderDevicePage(w http.ResponseWriter, data DevicePageData) +} + +// templateRenderer implements Renderer using Go html/template. +type templateRenderer struct { + index *template.Template + token *template.Template + device *template.Template +} + +// newTemplateRenderer parses embedded templates and returns a Renderer. +func newTemplateRenderer() Renderer { + parse := func(name string) *template.Template { + t, err := template.ParseFS(templatesFS, name) + if err != nil { + log.Fatalf("failed to parse template %s: %v", name, err) + } + return t + } + + return &templateRenderer{ + index: parse("templates/index.html"), + token: parse("templates/token.html"), + device: parse("templates/device.html"), + } +} + +func (r *templateRenderer) RenderLoginPage(w http.ResponseWriter, data LoginPageData) { + 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 renderTemplate(w http.ResponseWriter, tmpl *template.Template, data any) { + err := tmpl.Execute(w, 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 00000000..3e46aadc --- /dev/null +++ b/examples/example-app/server/server.go @@ -0,0 +1,204 @@ +package server + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "slices" + "syscall" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +const ( + // exampleAppState is a static CSRF state parameter. + // In production, this must be a cryptographically random per-request value. + exampleAppState = "I wish to wash my irish wristwatch" + + // silentAuthState is the state value used for prompt=none session checks. + silentAuthState = "silent-auth-check" +) + +// Options configures the Server. +type Options struct { + ClientID string + ClientSecret string + RedirectURI string + IssuerURL string + PKCE bool + SessionAware bool + RootCAs string + Debug bool +} + +// Server is the HTTP server for the example OIDC client application. +type Server struct { + clientID string + clientSecret string + redirectURI string + pkce bool + sessionAware bool + + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + scopesSupported []string + offlineAsScope bool + codeVerifier string + codeChallenge string + + // Discovered endpoint URLs + deviceAuthURL string + userInfoURL string + jwksURL string + endSessionEndpoint string + + client *http.Client + renderer Renderer + devices session.DeviceStore + auth session.AuthStore +} + +// 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"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + JWKSURI string `json:"jwks_uri"` + 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) + } + + s := &Server{ + clientID: opts.ClientID, + clientSecret: opts.ClientSecret, + redirectURI: opts.RedirectURI, + pkce: opts.PKCE, + sessionAware: opts.SessionAware, + + provider: provider, + verifier: provider.Verifier(&oidc.Config{ClientID: opts.ClientID}), + scopesSupported: discovery.ScopesSupported, + offlineAsScope: offlineAsScope, + + deviceAuthURL: discovery.DeviceAuthorizationEndpoint, + userInfoURL: discovery.UserInfoEndpoint, + jwksURL: discovery.JWKSURI, + endSessionEndpoint: discovery.EndSessionEndpoint, + + client: client, + renderer: newTemplateRenderer(), + devices: session.NewMemoryDeviceStore(), + auth: session.NewMemoryAuthStore(), + } + + if s.pkce { + s.codeVerifier = oauth2.GenerateVerifier() + s.codeChallenge = oauth2.S256ChallengeFromVerifier(s.codeVerifier) + } + + return s, nil +} + +// 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, + } +} + +// 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.handleLoginPage) + mux.HandleFunc("POST /login", s.handleLogin) + + // Parse redirect URI to register callback on the correct path. + callbackPath := "/callback" + if u, err := url.Parse(s.redirectURI); err == nil { + callbackPath = u.Path + } + mux.HandleFunc("GET "+callbackPath, s.handleAuthCallback) + mux.HandleFunc("POST "+callbackPath, s.handleTokenRefresh) + + 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) + + mux.HandleFunc("POST /userinfo", s.handleUserInfo) + mux.HandleFunc("GET /app-logout", s.handleAppLogout) + + 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) + } + + srv := &http.Server{ + Addr: u.Host, + Handler: s.routes(), + } + + 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...") + return srv.Shutdown(context.Background()) + } +} diff --git a/examples/example-app/static/app.js b/examples/example-app/server/static/app.js similarity index 100% rename from examples/example-app/static/app.js rename to examples/example-app/server/static/app.js diff --git a/examples/example-app/static/device.js b/examples/example-app/server/static/device.js similarity index 100% rename from examples/example-app/static/device.js rename to examples/example-app/server/static/device.js diff --git a/examples/example-app/static/dex-glyph-color.svg b/examples/example-app/server/static/dex-glyph-color.svg similarity index 98% rename from examples/example-app/static/dex-glyph-color.svg rename to examples/example-app/server/static/dex-glyph-color.svg index 2668039f..5852a5f3 100644 --- a/examples/example-app/static/dex-glyph-color.svg +++ b/examples/example-app/server/static/dex-glyph-color.svg @@ -1,20 +1,20 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/examples/example-app/static/style.css b/examples/example-app/server/static/style.css similarity index 86% rename from examples/example-app/static/style.css rename to examples/example-app/server/static/style.css index fafca567..8abc600f 100644 --- a/examples/example-app/static/style.css +++ b/examples/example-app/server/static/style.css @@ -262,28 +262,100 @@ button { background-color: #357FAA; } -/* Token page styles */ -.back-button { +/* Token page header */ +.token-header { + position: sticky; + top: 0; + background-color: #f2f2f2; + padding: 12px 0; + margin-bottom: 15px; + z-index: 10; +} + +.header-back-link { + color: #3F9FD8; + text-decoration: none; + font-size: 14px; + font-weight: 500; +} + +.header-back-link:hover { + text-decoration: underline; +} + +/* User card on index page */ +.user-card { + background-color: #fff; + padding: 20px 25px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + width: 100%; + margin-bottom: 20px; + border-left: 4px solid #3F9FD8; +} + +.user-card-title { + font-weight: 700; + font-size: 15px; + color: #333; + margin-bottom: 12px; +} + +.user-info-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 12px; + font-size: 14px; +} + +.user-info-label { + color: #666; + font-weight: 500; +} + +.user-info-value { + color: #333; + word-break: break-all; +} + +.user-info-sub { + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + color: #888; +} + +.logout-button { display: inline-block; - padding: 8px 16px; + margin-top: 15px; + padding: 8px 20px; background-color: #EF4B5C; color: white; border: none; border-radius: 4px; - cursor: pointer; - font-size: 12px; + font-size: 13px; + font-weight: 500; text-decoration: none; - transition: background-color 0.3s ease, transform 0.2s ease; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - position: fixed; - right: 20px; - bottom: 20px; + cursor: pointer; + transition: background-color 0.2s; } -.back-button:hover { +.logout-button:hover { background-color: #C43B4B; } +.guest-message { + background-color: #fff; + padding: 15px 25px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + width: 100%; + margin-bottom: 20px; + text-align: center; + color: #666; + font-size: 14px; + border-left: 4px solid #ccc; +} + .token-block { background-color: #fff; padding: 10px 15px; diff --git a/examples/example-app/static/token.js b/examples/example-app/server/static/token.js similarity index 100% rename from examples/example-app/static/token.js rename to examples/example-app/server/static/token.js diff --git a/examples/example-app/templates/device.html b/examples/example-app/server/templates/device.html similarity index 100% rename from examples/example-app/templates/device.html rename to examples/example-app/server/templates/device.html diff --git a/examples/example-app/templates/index.html b/examples/example-app/server/templates/index.html similarity index 72% rename from examples/example-app/templates/index.html rename to examples/example-app/server/templates/index.html index 063b154f..c072c718 100644 --- a/examples/example-app/templates/index.html +++ b/examples/example-app/server/templates/index.html @@ -3,12 +3,38 @@ - Example App - Login + Example App
+ {{if .User}} +
+
Authenticated User
+ + {{if .LogoutURL}} + Logout + {{end}} +
+ {{else if .NotLoggedIn}} +
No active session. Log in to get started.
+ {{end}}
This is an example application for Dex OpenID Connect provider.
diff --git a/examples/example-app/templates/token.html b/examples/example-app/server/templates/token.html similarity index 96% rename from examples/example-app/templates/token.html rename to examples/example-app/server/templates/token.html index b003deeb..c538a612 100644 --- a/examples/example-app/templates/token.html +++ b/examples/example-app/server/templates/token.html @@ -7,6 +7,9 @@ + {{ if .IDToken }}
@@ -76,8 +79,6 @@
{{ end }} - Back to Home - diff --git a/examples/example-app/server/token.go b/examples/example-app/server/token.go new file mode 100644 index 00000000..a708a099 --- /dev/null +++ b/examples/example-app/server/token.go @@ -0,0 +1,163 @@ +package server + +import ( + "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "net/url" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/examples/example-app/session" +) + +// handleTokenRefresh redeems a refresh token for a new token set. +func (s *Server) handleTokenRefresh(w http.ResponseWriter, r *http.Request) { + refresh := r.FormValue("refresh_token") + if refresh == "" { + http.Error(w, fmt.Sprintf("no refresh_token in request: %q", r.Form), http.StatusBadRequest) + return + } + + ctx := oidc.ClientContext(r.Context(), s.client) + + t := &oauth2.Token{ + RefreshToken: refresh, + Expiry: time.Now().Add(-time.Hour), + } + token, err := s.oauth2Config(nil).TokenSource(ctx, t).Token() + if err != nil { + http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) + return + } + + s.renderTokenResult(w, r, token) +} + +// renderTokenResult verifies an ID token, extracts claims, and renders the token page. +func (s *Server) renderTokenResult(w http.ResponseWriter, r *http.Request, token *oauth2.Token) { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + http.Error(w, "no id_token in token response", http.StatusInternalServerError) + return + } + + idToken, err := s.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 { + accessToken = token.AccessToken + if accessToken == "" { + http.Error(w, "no access_token in token response", http.StatusInternalServerError) + return + } + } + + // Persist claims for session-aware index page and logout. + var uc session.UserClaims + _ = idToken.Claims(&uc) + s.auth.Set(&uc, rawIDToken) + + claims, err := encodeIDTokenClaims(idToken) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.renderer.RenderTokenPage(w, TokenPageData{ + IDToken: rawIDToken, + IDTokenJWTLink: jwtIOLink(rawIDToken), + AccessToken: accessToken, + AccessTokenJWTLink: jwtIOLink(accessToken), + RefreshToken: token.RefreshToken, + RedirectURL: s.redirectURI, + Claims: claims, + PublicKeyPEM: s.fetchPublicKeyPEM(), + }) +} + +// encodeIDTokenClaims extracts and pretty-prints the claims from an ID token. +func encodeIDTokenClaims(idToken *oidc.IDToken) (string, error) { + var claims json.RawMessage + if err := idToken.Claims(&claims); err != nil { + return "", fmt.Errorf("error decoding ID token claims: %v", err) + } + + buf := new(bytes.Buffer) + if err := json.Indent(buf, claims, "", " "); err != nil { + return "", fmt.Errorf("error indenting ID token claims: %v", err) + } + return buf.String(), nil +} + +// 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, + })) +} diff --git a/examples/example-app/server/transport.go b/examples/example-app/server/transport.go new file mode 100644 index 00000000..47fa9fa9 --- /dev/null +++ b/examples/example-app/server/transport.go @@ -0,0 +1,104 @@ +package server + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "os" + "slices" + "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 +} + +// buildScopes constructs a scope list from base scopes and cross-client IDs. +func buildScopes(baseScopes, crossClients []string) []string { + scopes := make([]string, len(baseScopes)) + copy(scopes, baseScopes) + + for _, client := range crossClients { + if client != "" { + scopes = append(scopes, "audience:server:client_id:"+client) + } + } + + return uniqueStrings(scopes) +} + +// uniqueStrings deduplicates and sorts a string slice in place. +func uniqueStrings(values []string) []string { + slices.Sort(values) + return slices.Compact(values) +} diff --git a/examples/example-app/handlers_userinfo.go b/examples/example-app/server/userinfo.go similarity index 59% rename from examples/example-app/handlers_userinfo.go rename to examples/example-app/server/userinfo.go index 36bab851..c8e331fd 100644 --- a/examples/example-app/handlers_userinfo.go +++ b/examples/example-app/server/userinfo.go @@ -1,4 +1,4 @@ -package main +package server import ( "encoding/json" @@ -7,13 +7,8 @@ import ( "net/http" ) -func (a *app) handleUserInfo(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Parse form to get access token +// handleUserInfo fetches user information from the provider's UserInfo endpoint. +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 @@ -25,25 +20,15 @@ func (a *app) handleUserInfo(w http.ResponseWriter, r *http.Request) { return } - // Get UserInfo endpoint from provider - userInfoEndpoint := a.provider.Endpoint().AuthURL - if len(userInfoEndpoint) > 5 { - // Replace /auth with /userinfo - userInfoEndpoint = userInfoEndpoint[:len(userInfoEndpoint)-5] + "/userinfo" - } - - // Create request to UserInfo endpoint - req, err := http.NewRequestWithContext(r.Context(), "GET", userInfoEndpoint, nil) + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.userInfoURL, nil) if err != nil { http.Error(w, fmt.Sprintf("Failed to create request: %v", err), http.StatusInternalServerError) return } - // Add Authorization header with access token req.Header.Set("Authorization", "Bearer "+accessToken) - // Make the request - resp, err := a.client.Do(req) + resp, err := s.client.Do(req) if err != nil { http.Error(w, fmt.Sprintf("Failed to fetch userinfo: %v", err), http.StatusInternalServerError) return @@ -56,8 +41,7 @@ func (a *app) handleUserInfo(w http.ResponseWriter, r *http.Request) { return } - // Parse and return the userinfo - var userInfo map[string]interface{} + var userInfo map[string]any if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { http.Error(w, fmt.Sprintf("Failed to decode userinfo: %v", err), http.StatusInternalServerError) return diff --git a/examples/example-app/session/auth.go b/examples/example-app/session/auth.go new file mode 100644 index 00000000..46fdc025 --- /dev/null +++ b/examples/example-app/session/auth.go @@ -0,0 +1,68 @@ +package session + +import "sync" + +// 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"` +} + +// AuthState holds the authenticated user's state. +type AuthState struct { + Claims *UserClaims + IDToken string + Checked bool +} + +// AuthStore manages the application's authentication session state. +type AuthStore interface { + // Set stores authenticated user claims and the raw ID token, + // and marks the session as checked. + Set(claims *UserClaims, rawIDToken string) + + // Get returns the current authentication session state. + Get() AuthState + + // Clear resets the session and returns the last raw ID token (for logout). + Clear() string +} + +// memoryAuthStore is an in-memory AuthStore. +type memoryAuthStore struct { + mu sync.RWMutex + state AuthState +} + +// NewMemoryAuthStore creates an in-memory AuthStore. +func NewMemoryAuthStore() AuthStore { + return &memoryAuthStore{} +} + +func (s *memoryAuthStore) Set(claims *UserClaims, rawIDToken string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.state.Claims = claims + if rawIDToken != "" { + s.state.IDToken = rawIDToken + } + s.state.Checked = true +} + +func (s *memoryAuthStore) Get() AuthState { + s.mu.RLock() + defer s.mu.RUnlock() + return s.state +} + +func (s *memoryAuthStore) Clear() string { + s.mu.Lock() + defer s.mu.Unlock() + + idToken := s.state.IDToken + s.state = AuthState{} + return idToken +} diff --git a/examples/example-app/session/device.go b/examples/example-app/session/device.go new file mode 100644 index 00000000..4f0a1bcd --- /dev/null +++ b/examples/example-app/session/device.go @@ -0,0 +1,104 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "time" + + "golang.org/x/oauth2" +) + +// DeviceState holds the state of an active Device Code Flow. +type DeviceState struct { + DeviceCode string + UserCode string + VerificationURI string + PollInterval int + Token *oauth2.Token +} + +// DeviceStore manages Device Code Flow sessions. +type DeviceStore interface { + // Save stores a new session and returns its ID. + // Previous sessions are considered invalidated. + Save(state DeviceState) string + + // Get returns a session by ID. Returns false if the session + // is not found or has been invalidated by a newer one. + Get(sessionID string) (DeviceState, bool) + + // GetLatest returns the most recent session and its ID. + // Returns false if no session exists. + GetLatest() (string, DeviceState, bool) + + // SetToken attaches a token to the session. + // Returns false if the session is not found or not current. + SetToken(sessionID string, token *oauth2.Token) bool +} + +// memoryDeviceStore is an in-memory DeviceStore that supports +// a single active session at a time. +type memoryDeviceStore struct { + mu sync.Mutex + sessionID string + state DeviceState +} + +// NewMemoryDeviceStore creates an in-memory DeviceStore. +func NewMemoryDeviceStore() DeviceStore { + return &memoryDeviceStore{} +} + +func (s *memoryDeviceStore) Save(state DeviceState) string { + id := generateSessionID() + + s.mu.Lock() + defer s.mu.Unlock() + + s.sessionID = id + s.state = state + + return id +} + +func (s *memoryDeviceStore) Get(sessionID string) (DeviceState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if sessionID != s.sessionID { + return DeviceState{}, false + } + return s.state, true +} + +func (s *memoryDeviceStore) GetLatest() (string, DeviceState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.sessionID == "" { + return "", DeviceState{}, false + } + return s.sessionID, s.state, true +} + +func (s *memoryDeviceStore) SetToken(sessionID string, token *oauth2.Token) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if sessionID != s.sessionID { + return false + } + s.state.Token = token + return true +} + +// generateSessionID creates a random session identifier. +func generateSessionID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} diff --git a/examples/example-app/templates.go b/examples/example-app/templates.go deleted file mode 100644 index b3ae7396..00000000 --- a/examples/example-app/templates.go +++ /dev/null @@ -1,190 +0,0 @@ -package main - -import ( - "context" - "crypto/rsa" - "crypto/x509" - "embed" - "encoding/base64" - "encoding/json" - "encoding/pem" - "html/template" - "io/fs" - "log" - "math/big" - "net/http" - "net/url" - - "github.com/coreos/go-oidc/v3/oidc" -) - -//go:embed templates/*.html -var templatesFS embed.FS - -//go:embed static/* -var staticFS embed.FS - -const dexLogoDataURI = "/static/dex-glyph-color.svg" - -var ( - indexTmpl *template.Template - tokenTmpl *template.Template - deviceTmpl *template.Template - staticHandler http.Handler -) - -func init() { - var err error - indexTmpl, err = template.ParseFS(templatesFS, "templates/index.html") - if err != nil { - log.Fatalf("failed to parse index template: %v", err) - } - - tokenTmpl, err = template.ParseFS(templatesFS, "templates/token.html") - if err != nil { - log.Fatalf("failed to parse token template: %v", err) - } - - deviceTmpl, err = template.ParseFS(templatesFS, "templates/device.html") - if err != nil { - log.Fatalf("failed to parse device template: %v", err) - } - - // Create handler for static files - 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)) -} - -func renderIndex(w http.ResponseWriter, data indexPageData) { - renderTemplate(w, indexTmpl, data) -} - -func renderDevice(w http.ResponseWriter, data devicePageData) { - renderTemplate(w, deviceTmpl, data) -} - -type indexPageData struct { - ScopesSupported []string - LogoURI string -} - -type devicePageData struct { - SessionID string - DeviceCode string - UserCode string - VerificationURI string - PollInterval int - LogoURI string -} - -type tokenTmplData struct { - IDToken string - IDTokenJWTLink string - AccessToken string - AccessTokenJWTLink string - RefreshToken string - RedirectURL string - Claims string - PublicKeyPEM string -} - -func generateJWTIOLink(token string, provider *oidc.Provider, ctx context.Context) string { - // JWT.io doesn't support automatic public key via URL parameter - // The public key is displayed separately on the page for manual copy-paste - return "https://jwt.io/#debugger-io?token=" + url.QueryEscape(token) -} - -func getPublicKeyPEM(provider *oidc.Provider) string { - if provider == nil { - return "" - } - - jwksURL := provider.Endpoint().AuthURL - if len(jwksURL) > 5 { - jwksURL = jwksURL[:len(jwksURL)-5] + "/keys" - } else { - return "" - } - - resp, err := http.Get(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 "" - } - - pubKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: pubKeyBytes, - }) - - return string(pubKeyPEM) -} - -func renderToken(w http.ResponseWriter, ctx context.Context, provider *oidc.Provider, redirectURL, idToken, accessToken, refreshToken, claims string) { - data := tokenTmplData{ - IDToken: idToken, - IDTokenJWTLink: generateJWTIOLink(idToken, provider, ctx), - AccessToken: accessToken, - AccessTokenJWTLink: generateJWTIOLink(accessToken, provider, ctx), - RefreshToken: refreshToken, - RedirectURL: redirectURL, - Claims: claims, - PublicKeyPEM: getPublicKeyPEM(provider), - } - renderTemplate(w, tokenTmpl, data) -} - -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: - log.Printf("Error rendering template %s: %s", tmpl.Name(), err) - 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/example-app/utils.go b/examples/example-app/utils.go deleted file mode 100644 index 099c1062..00000000 --- a/examples/example-app/utils.go +++ /dev/null @@ -1,154 +0,0 @@ -package main - -import ( - "bytes" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "net" - "net/http" - "net/http/httputil" - "os" - "slices" - "time" - - "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" -) - -// generateSessionID creates a random session identifier -func generateSessionID() string { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - // Fallback to timestamp if random fails - return fmt.Sprintf("%d", time.Now().UnixNano()) - } - return hex.EncodeToString(b) -} - -// buildScopes constructs a scope list from base scopes and cross-client IDs -func buildScopes(baseScopes []string, crossClients []string) []string { - scopes := make([]string, len(baseScopes)) - copy(scopes, baseScopes) - - // Add audience scopes for cross-client authorization - for _, client := range crossClients { - if client != "" { - scopes = append(scopes, "audience:server:client_id:"+client) - } - } - - return uniqueStrings(scopes) -} - -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 uniqueStrings(values []string) []string { - slices.Sort(values) - values = slices.Compact(values) - return values -} - -// 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) - - 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 -} - -func encodeToken(idToken *oidc.IDToken) (string, error) { - var claims json.RawMessage - if err := idToken.Claims(&claims); err != nil { - return "", fmt.Errorf("error decoding ID token claims: %v", err) - } - - buff := new(bytes.Buffer) - if err := json.Indent(buff, claims, "", " "); err != nil { - return "", fmt.Errorf("error indenting ID token claims: %v", err) - } - return buff.String(), nil -} - -func parseAndRenderToken(w http.ResponseWriter, r *http.Request, a *app, token *oauth2.Token) { - 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 { - accessToken = token.AccessToken - if accessToken == "" { - http.Error(w, "no access_token in token response", http.StatusInternalServerError) - return - } - } - - buf, err := encodeToken(idToken) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - renderToken(w, r.Context(), a.provider, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buf) -} From ed886522c2f7030b687629f18b71315909e00699 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:19:31 +0200 Subject: [PATCH 214/222] build(deps): bump google.golang.org/grpc in /examples (#4710) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.3 to 1.80.0. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.80.0) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.80.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/go.mod | 12 ++++++------ examples/go.sum | 28 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 3ca1c1a1..4e2e12a0 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -7,16 +7,16 @@ require ( github.com/dexidp/dex/api/v2 v2.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/oauth2 v0.36.0 - google.golang.org/grpc v1.79.3 + google.golang.org/grpc v1.80.0 ) require ( github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/protobuf v1.36.10 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/examples/go.sum b/examples/go.sum index a92e0dbc..de668978 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -37,20 +37,20 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5w go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +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-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +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= From 61635a6660b672604012474823f08cfb93d42a4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:19:57 +0200 Subject: [PATCH 215/222] build(deps): bump google.golang.org/grpc from 1.79.3 to 1.80.0 (#4709) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.3 to 1.80.0. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.80.0) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.80.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 1e1440bb..b566afe6 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.273.1 - google.golang.org/grpc v1.79.3 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 202a9f23..8ea3d1fe 100644 --- a/go.sum +++ b/go.sum @@ -354,8 +354,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T 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= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +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.273.1 h1:L7G/TmpAMz0nKx/ciAVssVmWQiOF6+pOuXeKrWVsquY= google.golang.org/api v0.273.1/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= @@ -364,8 +364,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= 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= From 6511fb938a0f2a65f1953f2a986fbe24011a735f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:20:16 +0200 Subject: [PATCH 216/222] build(deps): bump the etcd group with 2 updates (#4708) Bumps the etcd group with 2 updates: [go.etcd.io/etcd/client/pkg/v3](https://github.com/etcd-io/etcd) and [go.etcd.io/etcd/client/v3](https://github.com/etcd-io/etcd). Updates `go.etcd.io/etcd/client/pkg/v3` from 3.6.9 to 3.6.10 - [Release notes](https://github.com/etcd-io/etcd/releases) - [Commits](https://github.com/etcd-io/etcd/compare/v3.6.9...v3.6.10) Updates `go.etcd.io/etcd/client/v3` from 3.6.9 to 3.6.10 - [Release notes](https://github.com/etcd-io/etcd/releases) - [Commits](https://github.com/etcd-io/etcd/compare/v3.6.9...v3.6.10) --- updated-dependencies: - dependency-name: go.etcd.io/etcd/client/pkg/v3 dependency-version: 3.6.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: etcd - dependency-name: go.etcd.io/etcd/client/v3 dependency-version: 3.6.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: etcd ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index b566afe6..b847f3c6 100644 --- a/go.mod +++ b/go.mod @@ -34,8 +34,8 @@ require ( 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.9 - go.etcd.io/etcd/client/v3 v3.6.9 + go.etcd.io/etcd/client/pkg/v3 v3.6.10 + go.etcd.io/etcd/client/v3 v3.6.10 golang.org/x/crypto v0.49.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.52.0 @@ -122,7 +122,7 @@ require ( 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.9 // indirect + go.etcd.io/etcd/api/v3 v3.6.10 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.42.0 // indirect diff --git a/go.sum b/go.sum index 8ea3d1fe..9c93605f 100644 --- a/go.sum +++ b/go.sum @@ -270,12 +270,12 @@ 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.9 h1:UA7iKfEW1AzgihcBSGXci2kDGQiokSq41F9HMCI/RTI= -go.etcd.io/etcd/api/v3 v3.6.9/go.mod h1:csEk/qTfxKL36NqJdU15Tgtl65A8dyEY2BYo7PRsIwk= -go.etcd.io/etcd/client/pkg/v3 v3.6.9 h1:T8nuk8Lz64C+Hzb0coBFLMSlVSQZBpAtFk46swdM1DA= -go.etcd.io/etcd/client/pkg/v3 v3.6.9/go.mod h1:WEy3PpwbbEBVRdh1NVJYsuUe/8eyI21PNJRazeD8z/Y= -go.etcd.io/etcd/client/v3 v3.6.9 h1:3X555hQXmhRr27O37wls53g68CpUiPOiHXrZfz2Al+o= -go.etcd.io/etcd/client/v3 v3.6.9/go.mod h1:KO7H1HLYh1qaljuVZJQwBFk1lRce6pJzt+C81GEnrlM= +go.etcd.io/etcd/api/v3 v3.6.10 h1:jlwjtELjA8yi2VWpOFH+0w0lGr3K6mVDyn0RDB9aaAY= +go.etcd.io/etcd/api/v3 v3.6.10/go.mod h1:pdV4VeFmvhdNjB4LWRkC8ReLyRBAxUOze3GarMhE2sk= +go.etcd.io/etcd/client/pkg/v3 v3.6.10 h1:tBT7podcPhuVbCVkAEzx8bC5I+aqxfLwBN8/As1arrA= +go.etcd.io/etcd/client/pkg/v3 v3.6.10/go.mod h1:WEy3PpwbbEBVRdh1NVJYsuUe/8eyI21PNJRazeD8z/Y= +go.etcd.io/etcd/client/v3 v3.6.10 h1:J598zJ+C/ZPvImypmq5waj84+bovePrlZERHklf34y0= +go.etcd.io/etcd/client/v3 v3.6.10/go.mod h1:iHhUDUcEwaKs1YFq3MgmI9U4zhTVasp/vgdVbFf1RS8= 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.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= From 5b5b467f868bf9ceb1f4fe947e298e9ac1f9f52d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:33:34 +0200 Subject: [PATCH 217/222] build(deps): bump github.com/go-jose/go-jose/v4 (#4718) --- examples/go.mod | 2 +- examples/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 4e2e12a0..bf51713b 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -11,7 +11,7 @@ require ( ) require ( - github.com/go-jose/go-jose/v4 v4.1.3 // 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.49.0 // indirect diff --git a/examples/go.sum b/examples/go.sum index de668978..5b87a121 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -5,8 +5,8 @@ github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSw github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dexidp/dex/api/v2 v2.4.0 h1:gNba7n6BKVp8X4Jp24cxYn5rIIGhM6kDOXcZoL6tr9A= github.com/dexidp/dex/api/v2 v2.4.0/go.mod h1:/p550ADvFFh7K95VmhUD+jgm15VdaNnab9td8DHOpyI= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +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= From 7bd2f5798b065af0414a602f666fc6b98ba4e870 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:34:07 +0200 Subject: [PATCH 218/222] build(deps): bump github.com/mattn/go-sqlite3 from 1.14.38 to 1.14.39 (#4717) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b847f3c6..9067994c 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/kylelemons/godebug v1.1.0 github.com/lib/pq v1.12.1 github.com/mattermost/xml-roundtrip-validator v0.1.0 - github.com/mattn/go-sqlite3 v1.14.38 + github.com/mattn/go-sqlite3 v1.14.39 github.com/oklog/run v1.2.0 github.com/openbao/openbao/api/v2 v2.5.1 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 9c93605f..67fc92f7 100644 --- a/go.sum +++ b/go.sum @@ -197,8 +197,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= -github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.39 h1:sIwSjlJGOaRJjw44/HXaeTblZMjseqr6OOio1tz/+JI= +github.com/mattn/go-sqlite3 v1.14.39/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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= From af47557b69ae75226f23aa2a77a9ff19613f8ce4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:34:41 +0200 Subject: [PATCH 219/222] build(deps): bump github.com/go-webauthn/webauthn from 0.16.1 to 0.16.2 (#4715) --- go.mod | 6 ++++-- go.sum | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 9067994c..284d0cdd 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-sql-driver/mysql v1.9.3 - github.com/go-webauthn/webauthn v0.16.1 + github.com/go-webauthn/webauthn v0.16.2 github.com/google/cel-go v0.27.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 @@ -71,7 +71,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -111,6 +111,7 @@ require ( 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.66.1 // indirect @@ -119,6 +120,7 @@ require ( 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.3 // 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 diff --git a/go.sum b/go.sum index 67fc92f7..ce16eab5 100644 --- a/go.sum +++ b/go.sum @@ -75,8 +75,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/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.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= @@ -98,8 +98,8 @@ 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.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU= -github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ= +github.com/go-webauthn/webauthn v0.16.2 h1:n116UuvIa7nUVGFP2hO9U24gBqhJTcmbU3ph0wgVzFM= +github.com/go-webauthn/webauthn v0.16.2/go.mod h1:R2xjJxSPat5PYKg5r6cUmqXgbHtbv4GmF6uGkqFMLNI= github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk= github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -221,6 +221,8 @@ github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLy github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/openbao/openbao/api/v2 v2.5.1 h1:Br79D6L20SbAa5P7xqENxmvv8LyI4HoKosPy7klhn4o= github.com/openbao/openbao/api/v2 v2.5.1/go.mod h1:Dh5un77tqGgMbmlVEqjqN+8/dMyUohnkaQVg/wXW0Ig= +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= @@ -262,6 +264,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/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= From 503e461ee8cdc32ee31d9ed70843aa89e6069b44 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:23:23 +0200 Subject: [PATCH 220/222] build(deps): bump github.com/lib/pq from 1.12.1 to 1.12.2 (#4716) Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.12.1 to 1.12.2. - [Release notes](https://github.com/lib/pq/releases) - [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md) - [Commits](https://github.com/lib/pq/compare/v1.12.1...v1.12.2) --- updated-dependencies: - dependency-name: github.com/lib/pq dependency-version: 1.12.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 284d0cdd..15bb5da6 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 github.com/kylelemons/godebug v1.1.0 - github.com/lib/pq v1.12.1 + github.com/lib/pq v1.12.2 github.com/mattermost/xml-roundtrip-validator v0.1.0 github.com/mattn/go-sqlite3 v1.14.39 github.com/oklog/run v1.2.0 diff --git a/go.sum b/go.sum index ce16eab5..b0b3cbf2 100644 --- a/go.sum +++ b/go.sum @@ -187,8 +187,8 @@ 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 v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lib/pq v1.12.1 h1:x1nbl/338GLqeDJ/FAiILallhAsqubLzEZu/pXtHUow= -github.com/lib/pq v1.12.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.2 h1:ajJNv84limnK3aPbDIhLtcjrUbqAw/5XNdkuI6KNe/Q= +github.com/lib/pq v1.12.2/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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= From 6e7a983d166f0cae6585b0ce6a8d5f04ae3dad76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:23:39 +0200 Subject: [PATCH 221/222] build(deps): bump docker/login-action from 4.0.0 to 4.1.0 (#4713) Bumps [docker/login-action](https://github.com/docker/login-action) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/b45d80f862d83dbcd57f89517bcf500b2ab88fb2...4907a6ddec9925e35a0a9e82d7399ccc52663121) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/artifacts.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index b4aea86b..d9e4fe1a 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -109,7 +109,7 @@ jobs: if_false: type=oci,dest=image.tar - name: Login to GitHub Container Registry - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -117,7 +117,7 @@ jobs: if: inputs.publish - name: Login to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} From a10dd9bbab62ccdb4324e5b30135eb4f4d96c277 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:23:53 +0200 Subject: [PATCH 222/222] build(deps): bump google.golang.org/api from 0.273.1 to 0.274.0 (#4714) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.273.1 to 0.274.0. - [Release notes](https://github.com/googleapis/google-api-go-client/releases) - [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md) - [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.273.1...v0.274.0) --- updated-dependencies: - dependency-name: google.golang.org/api dependency-version: 0.274.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 15bb5da6..c5a314ee 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 - google.golang.org/api v0.273.1 + google.golang.org/api v0.274.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index b0b3cbf2..5baf3d2f 100644 --- a/go.sum +++ b/go.sum @@ -360,8 +360,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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.273.1 h1:L7G/TmpAMz0nKx/ciAVssVmWQiOF6+pOuXeKrWVsquY= -google.golang.org/api v0.273.1/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= +google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js=