diff --git a/.forgejo.bak/ISSUE_TEMPLATE/bug_report.yaml b/.forgejo.bak/ISSUE_TEMPLATE/bug_report.yaml
new file mode 100644
index 0000000..d024341
--- /dev/null
+++ b/.forgejo.bak/ISSUE_TEMPLATE/bug_report.yaml
@@ -0,0 +1,57 @@
+name: Bug Report
+description: "Create a bug report to help us improve ZITADEL. Click [here](https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#product-management) to see how we process your issue."
+title: "[Bug]: "
+labels: ["bug"]
+type: Bug
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this bug report!
+ - type: checkboxes
+ id: preflight
+ attributes:
+ label: Preflight Checklist
+ options:
+ - label:
+ I could not find a solution in the documentation, the existing issues or discussions
+ required: true
+ - label:
+ I have joined the [ZITADEL chat](https://zitadel.com/chat)
+ - type: input
+ id: version
+ attributes:
+ label: Version
+ description: Which version of the OIDC library are you using.
+ - type: textarea
+ id: impact
+ attributes:
+ label: Describe the problem caused by this bug
+ description: A clear and concise description of the problem you have and what the bug is.
+ validations:
+ required: true
+ - type: textarea
+ id: reproduce
+ attributes:
+ label: To reproduce
+ description: Steps to reproduce the behaviour
+ placeholder: |
+ Steps to reproduce the behavior:
+ validations:
+ required: true
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots
+ description: If applicable, add screenshots to help explain your problem.
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ description: A clear and concise description of what you expected to happen.
+ placeholder: As a [type of user], I want [some goal] so that [some reason].
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional Context
+ description: Please add any other infos that could be useful.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.forgejo.bak/ISSUE_TEMPLATE/config.yml
similarity index 100%
rename from .github/ISSUE_TEMPLATE/config.yml
rename to .forgejo.bak/ISSUE_TEMPLATE/config.yml
diff --git a/.forgejo.bak/ISSUE_TEMPLATE/docs.yaml b/.forgejo.bak/ISSUE_TEMPLATE/docs.yaml
new file mode 100644
index 0000000..d3f82b9
--- /dev/null
+++ b/.forgejo.bak/ISSUE_TEMPLATE/docs.yaml
@@ -0,0 +1,31 @@
+name: đ Documentation
+description: Create an issue for missing or wrong documentation.
+labels: ["docs"]
+type: task
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this issue.
+ - type: checkboxes
+ id: preflight
+ attributes:
+ label: Preflight Checklist
+ options:
+ - label:
+ I could not find a solution in the existing issues, docs, nor discussions
+ required: true
+ - label:
+ I have joined the [ZITADEL chat](https://zitadel.com/chat)
+ - type: textarea
+ id: docs
+ attributes:
+ label: Describe the docs your are missing or that are wrong
+ placeholder: As a [type of user], I want [some goal] so that [some reason].
+ validations:
+ required: true
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional Context
+ description: Please add any other infos that could be useful.
diff --git a/.forgejo.bak/ISSUE_TEMPLATE/enhancement.yaml b/.forgejo.bak/ISSUE_TEMPLATE/enhancement.yaml
new file mode 100644
index 0000000..ef2103e
--- /dev/null
+++ b/.forgejo.bak/ISSUE_TEMPLATE/enhancement.yaml
@@ -0,0 +1,55 @@
+name: đ ī¸ Improvement
+description: "Create an new issue for an improvment in ZITADEL"
+labels: ["enhancement"]
+type: enhancement
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this proposal / feature reqeust
+ - type: checkboxes
+ id: preflight
+ attributes:
+ label: Preflight Checklist
+ options:
+ - label:
+ I could not find a solution in the existing issues, docs, nor discussions
+ required: true
+ - label:
+ I have joined the [ZITADEL chat](https://zitadel.com/chat)
+ - type: textarea
+ id: problem
+ attributes:
+ label: Describe your problem
+ description: Please describe your problem this improvement is supposed to solve.
+ placeholder: Describe the problem you have
+ validations:
+ required: true
+ - type: textarea
+ id: solution
+ attributes:
+ label: Describe your ideal solution
+ description: Which solution do you propose?
+ placeholder: As a [type of user], I want [some goal] so that [some reason].
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Version
+ description: Which version of the OIDC Library are you using.
+ - type: dropdown
+ id: environment
+ attributes:
+ label: Environment
+ description: How do you use ZITADEL?
+ options:
+ - ZITADEL Cloud
+ - Self-hosted
+ validations:
+ required: true
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional Context
+ description: Please add any other infos that could be useful.
diff --git a/.github/dependabot.yml b/.forgejo.bak/dependabot.yml
similarity index 58%
rename from .github/dependabot.yml
rename to .forgejo.bak/dependabot.yml
index 79ff704..1efdcf8 100644
--- a/.github/dependabot.yml
+++ b/.forgejo.bak/dependabot.yml
@@ -9,6 +9,16 @@ updates:
commit-message:
prefix: chore
include: scope
+- package-ecosystem: gomod
+ target-branch: "2.12.x"
+ directory: "/"
+ schedule:
+ interval: daily
+ time: '04:00'
+ open-pull-requests-limit: 10
+ commit-message:
+ prefix: chore
+ include: scope
- package-ecosystem: "github-actions"
directory: "/"
schedule:
diff --git a/.forgejo.bak/pull_request_template.md b/.forgejo.bak/pull_request_template.md
new file mode 100644
index 0000000..6c4ae58
--- /dev/null
+++ b/.forgejo.bak/pull_request_template.md
@@ -0,0 +1,16 @@
+### Definition of Ready
+
+- [ ] I am happy with the code
+- [ ] Short description of the feature/issue is added in the pr description
+- [ ] PR is linked to the corresponding user story
+- [ ] Acceptance criteria are met
+- [ ] All open todos and follow ups are defined in a new ticket and justified
+- [ ] Deviations from the acceptance criteria and design are agreed with the PO and documented.
+- [ ] No debug or dead code
+- [ ] My code has no repetitions
+- [ ] Critical parts are tested automatically
+- [ ] Where possible E2E tests are implemented
+- [ ] Documentation/examples are up-to-date
+- [ ] All non-functional requirements are met
+- [ ] Functionality of the acceptance criteria is checked manually on the dev system.
+
diff --git a/.github/workflows/codeql-analysis.yml b/.forgejo.bak/workflows/codeql-analysis.yml
similarity index 86%
rename from .github/workflows/codeql-analysis.yml
rename to .forgejo.bak/workflows/codeql-analysis.yml
index 85ea2ca..27fa244 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.forgejo.bak/workflows/codeql-analysis.yml
@@ -2,10 +2,10 @@ name: "Code scanning - action"
on:
push:
- branches: [main, ]
+ branches: [main,next]
pull_request:
# The branches below must be a subset of the branches above
- branches: [main]
+ branches: [main,next]
schedule:
- cron: '0 11 * * 0'
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -29,7 +29,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
# Override language selection by uncommenting this and choosing your languages
with:
languages: go
@@ -37,7 +37,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@v3
# âšī¸ Command-line programs to run using the OS shell.
# đ https://git.io/JvXDl
@@ -51,4 +51,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
diff --git a/.forgejo.bak/workflows/issue.yml b/.forgejo.bak/workflows/issue.yml
new file mode 100644
index 0000000..480c339
--- /dev/null
+++ b/.forgejo.bak/workflows/issue.yml
@@ -0,0 +1,43 @@
+name: Add new issues to product management project
+
+on:
+ issues:
+ types:
+ - opened
+ pull_request_target:
+ types:
+ - opened
+
+jobs:
+ add-to-project:
+ name: Add issue and community pr to project
+ runs-on: ubuntu-latest
+ steps:
+ - name: add issue
+ uses: actions/add-to-project@v1.0.2
+ if: ${{ github.event_name == 'issues' }}
+ with:
+ # You can target a repository in a different organization
+ # to the issue
+ project-url: https://github.com/orgs/zitadel/projects/2
+ github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
+ - uses: tspascoal/get-user-teams-membership@v3
+ id: checkUserMember
+ if: github.actor != 'dependabot[bot]'
+ with:
+ username: ${{ github.actor }}
+ GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }}
+ - name: add pr
+ uses: actions/add-to-project@v1.0.2
+ if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}}
+ with:
+ # You can target a repository in a different organization
+ # to the issue
+ project-url: https://github.com/orgs/zitadel/projects/2
+ github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
+ - uses: actions-ecosystem/action-add-labels@v1.1.3
+ if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}}
+ with:
+ github_token: ${{ secrets.ADD_TO_PROJECT_PAT }}
+ labels: |
+ os-contribution
diff --git a/.github/workflows/release.yml b/.forgejo.bak/workflows/release.yml
similarity index 66%
rename from .github/workflows/release.yml
rename to .forgejo.bak/workflows/release.yml
index d97d41a..00063e4 100644
--- a/.github/workflows/release.yml
+++ b/.forgejo.bak/workflows/release.yml
@@ -2,7 +2,9 @@ name: Release
on:
push:
branches:
+ - "2.11.x"
- main
+ - next
tags-ignore:
- '**'
pull_request:
@@ -12,33 +14,34 @@ on:
jobs:
test:
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-24.04
strategy:
+ fail-fast: false
matrix:
- go: ['1.16', '1.17', '1.18', '1.19', '1.20']
+ go: ['1.23', '1.24']
name: Go ${{ matrix.go }} test
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup go
- uses: actions/setup-go@v3
+ uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- - run: go test -race -v -coverprofile=profile.cov -coverpkg=github.com/zitadel/oidc/... ./pkg/...
- - uses: codecov/codecov-action@v3.1.1
+ - run: go test -race -v -coverprofile=profile.cov -coverpkg=./pkg/... ./pkg/...
+ - uses: codecov/codecov-action@v5.4.3
with:
file: ./profile.cov
name: codecov-go
release:
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-24.04
needs: [test]
- if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
+ if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Source checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Semantic Release
- uses: cycjimmy/semantic-release-action@v3
+ uses: cycjimmy/semantic-release-action@v4
with:
dry_run: false
semantic_version: 18.0.1
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 49ccc49..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,38 +0,0 @@
----
-name: đ Bug report
-about: Create a report to help us improve
-title: ''
-labels: bug
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Desktop (please complete the following information):**
-- OS: [e.g. iOS]
-- Browser [e.g. chrome, safari]
-- Version [e.g. 22]
-
-**Smartphone (please complete the following information):**
-- Device: [e.g. iPhone6]
-- OS: [e.g. iOS8.1]
-- Browser [e.g. stock browser, safari]
-- Version [e.g. 22]
-
-**Additional context**
-Add any other context about the problem here.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 118d30e..0000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: đ Feature request
-about: Suggest an idea for this project
-title: ''
-labels: enhancement
-assignees: ''
-
----
-
-**Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-
-**Describe the solution you'd like**
-A clear and concise description of what you want to happen.
-
-**Describe alternatives you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Additional context**
-Add any other context or screenshots about the feature request here.
diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml
deleted file mode 100644
index 8671820..0000000
--- a/.github/workflows/issue.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-name: Add new issues to product management project
-
-on:
- issues:
- types:
- - opened
-
-jobs:
- add-to-project:
- name: Add issue to project
- runs-on: ubuntu-latest
- steps:
- - uses: actions/add-to-project@v0.4.1
- with:
- # You can target a repository in a different organization
- # to the issue
- project-url: https://github.com/orgs/zitadel/projects/2
- github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
diff --git a/.releaserc.js b/.releaserc.js
index 6500ace..c87b1d1 100644
--- a/.releaserc.js
+++ b/.releaserc.js
@@ -1,5 +1,9 @@
module.exports = {
- branches: ["main"],
+ branches: [
+ {name: "2.11.x"},
+ {name: "main"},
+ {name: "next", prerelease: true},
+ ],
plugins: [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
diff --git a/NEXT_RELEASE.md b/NEXT_RELEASE.md
deleted file mode 100644
index 91f7f5d..0000000
--- a/NEXT_RELEASE.md
+++ /dev/null
@@ -1,7 +0,0 @@
-
-# Backwards-incompatible changes to be made in the next major release
-
-- Add `rp/RelyingParty.GetRevokeEndpoint`
-- Rename `op/OpStorage.GetKeyByIDAndUserID` to `op/OpStorage.GetKeyByIDAndClientID`
-- Add `CanRefreshTokenInfo` (`GetRefreshTokenInfo()`) to `op.Storage`
-
diff --git a/README.md b/README.md
index 1e4c26f..bc346f5 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,13 @@
[](https://github.com/semantic-release/semantic-release)
[](https://github.com/zitadel/oidc/actions)
-[](https://pkg.go.dev/github.com/zitadel/oidc)
+[](https://pkg.go.dev/github.com/zitadel/oidc/v3)
[](https://github.com/zitadel/oidc/blob/master/LICENSE)
[](https://github.com/zitadel/oidc/releases)
-[](https://goreportcard.com/report/github.com/zitadel/oidc)
+[](https://goreportcard.com/report/github.com/zitadel/oidc/v3)
[](https://codecov.io/gh/zitadel/oidc)
-
+[](https://openid.net/certification/)
## What Is It
@@ -21,9 +21,10 @@ Whenever possible we tried to reuse / extend existing packages like `OAuth2 for
## Basic Overview
The most important packages of the library:
+
/pkg
- /client clients using the OP for retrieving, exchanging and verifying tokens
+ /client clients using the OP for retrieving, exchanging and verifying tokens
/rp definition and implementation of an OIDC Relying Party (client)
/rs definition and implementation of an OAuth Resource Server (API)
/op definition and implementation of an OIDC OpenID Provider (server)
@@ -34,9 +35,13 @@ The most important packages of the library:
/client/app web app / RP demonstrating authorization code flow using various authentication methods (code, PKCE, JWT profile)
/client/github example of the extended OAuth2 library, providing an HTTP client with a reuse token source
/client/service demonstration of JWT Profile Authorization Grant
- /server example of an OpenID Provider implementation including some very basic login UI
+ /server examples of an OpenID Provider implementations (including dynamic) with some very basic login UI
+### Semver
+
+This package uses [semver](https://semver.org/) for [releases](https://github.com/zitadel/oidc/releases). Major releases ship breaking changes. Starting with the `v2` to `v3` increment we provide an [upgrade guide](UPGRADING.md) to ease migration to a newer version.
+
## How To Use It
Check the `/example` folder where example code for different scenarios is located.
@@ -44,22 +49,90 @@ Check the `/example` folder where example code for different scenarios is locate
```bash
# start oidc op server
# oidc discovery http://localhost:9998/.well-known/openid-configuration
-go run github.com/zitadel/oidc/example/server
+go run github.com/zitadel/oidc/v3/example/server
# start oidc web client (in a new terminal)
-CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998 SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/example/client/app
+CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998/ SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/v3/example/client/app
```
- open http://localhost:9999/login in your browser
-- you will be redirected to op server and the login UI
-- login with user `test-user` and password `verysecure`
+- you will be redirected to op server and the login UI
+- login with user `test-user@localhost` and password `verysecure`
- the OP will redirect you to the client app, which displays the user info
+for the dynamic issuer, just start it with:
+
+```bash
+go run github.com/zitadel/oidc/v3/example/server/dynamic
+```
+
+the oidc web client above will still work, but if you add `oidc.local` (pointing to 127.0.0.1) in your hosts file you can also start it with:
+
+```bash
+CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://oidc.local:9998/ SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/v3/example/client/app
+```
+
+> Note: Usernames are suffixed with the hostname (`test-user@localhost` or `test-user@oidc.local`)
+
+### Server configuration
+
+Example server allows extra configuration using environment variables and could be used for end to
+end testing of your services.
+
+| Name | Format | Description |
+| ------------ | -------------------------------- | ------------------------------------- |
+| PORT | Number between 1 and 65535 | OIDC listen port |
+| REDIRECT_URI | Comma-separated URIs | List of allowed redirect URIs |
+| USERS_FILE | Path to json in local filesystem | Users with their data and credentials |
+
+Here is json equivalent for one of the default users
+
+```json
+{
+ "id2": {
+ "ID": "id2",
+ "Username": "test-user2",
+ "Password": "verysecure",
+ "FirstName": "Test",
+ "LastName": "User2",
+ "Email": "test-user2@zitadel.ch",
+ "EmailVerified": true,
+ "Phone": "",
+ "PhoneVerified": false,
+ "PreferredLanguage": "DE",
+ "IsAdmin": false
+ }
+}
+```
+
## Features
-| | Code Flow | Implicit Flow | Hybrid Flow | Discovery | PKCE | Token Exchange | mTLS | JWT Profile | Refresh Token | Client Credentials |
-|------------------|-----------|---------------|-------------|-----------|------|----------------|---------|-------------|---------------|--------------------|
-| Relying Party | yes | no[^1] | no | yes | yes | partial | not yet | yes | yes | not yet |
-| OpenID Provider | yes | yes | not yet | yes | yes | not yet | not yet | yes | yes | yes |
+| | Relying party | OpenID Provider | Specification |
+| -------------------- | ------------- | --------------- | -------------------------------------------- |
+| Code Flow | yes | yes | OpenID Connect Core 1.0, [Section 3.1][1] |
+| Implicit Flow | no[^1] | yes | OpenID Connect Core 1.0, [Section 3.2][2] |
+| Hybrid Flow | no | not yet | OpenID Connect Core 1.0, [Section 3.3][3] |
+| Client Credentials | yes | yes | OpenID Connect Core 1.0, [Section 9][4] |
+| Refresh Token | yes | yes | OpenID Connect Core 1.0, [Section 12][5] |
+| Discovery | yes | yes | OpenID Connect [Discovery][6] 1.0 |
+| JWT Profile | yes | yes | [RFC 7523][7] |
+| PKCE | yes | yes | [RFC 7636][8] |
+| Token Exchange | yes | yes | [RFC 8693][9] |
+| Device Authorization | yes | yes | [RFC 8628][10] |
+| mTLS | not yet | not yet | [RFC 8705][11] |
+| Back-Channel Logout | not yet | yes | OpenID Connect [Back-Channel Logout][12] 1.0 |
+
+[1]: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth "3.1. Authentication using the Authorization Code Flow"
+[2]: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth "3.2. Authentication using the Implicit Flow"
+[3]: https://openid.net/specs/openid-connect-core-1_0.html#HybridFlowAuth "3.3. Authentication using the Hybrid Flow"
+[4]: https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication "9. Client Authentication"
+[5]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens "12. Using Refresh Tokens"
+[6]: https://openid.net/specs/openid-connect-discovery-1_0.html "OpenID Connect Discovery 1.0 incorporating errata set 1"
+[7]: https://www.rfc-editor.org/rfc/rfc7523.html "JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants"
+[8]: https://www.rfc-editor.org/rfc/rfc7636.html "Proof Key for Code Exchange by OAuth Public Clients"
+[9]: https://www.rfc-editor.org/rfc/rfc8693.html "OAuth 2.0 Token Exchange"
+[10]: https://www.rfc-editor.org/rfc/rfc8628.html "OAuth 2.0 Device Authorization Grant"
+[11]: https://www.rfc-editor.org/rfc/rfc8705.html "OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens"
+[12]: https://openid.net/specs/openid-connect-backchannel-1_0.html "OpenID Connect Back-Channel Logout 1.0 incorporating errata set 1"
## Contributors
@@ -71,28 +144,21 @@ Made with [contrib.rocks](https://contrib.rocks).
### Resources
-For your convenience you can find the relevant standards linked below.
+For your convenience you can find the relevant guides linked below.
- [OpenID Connect Core 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-core-1_0.html)
-- [Proof Key for Code Exchange by OAuth Public Clients](https://tools.ietf.org/html/rfc7636)
-- [OAuth 2.0 Token Exchange](https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-19)
-- [OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-mtls-17)
-- [JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants](https://tools.ietf.org/html/rfc7523)
- [OIDC/OAuth Flow in Zitadel (using this library)](https://zitadel.com/docs/guides/integrate/login-users)
## Supported Go Versions
-For security reasons, we only support and recommend the use of one of the latest two Go versions (:white_check_mark:).
+For security reasons, we only support and recommend the use of one of the latest two Go versions (:white_check_mark:).
Versions that also build are marked with :warning:.
| Version | Supported |
-|---------|--------------------|
-| <1.16 | :x: |
-| 1.16 | :warning: |
-| 1.17 | :warning: |
-| 1.18 | :warning: |
-| 1.19 | :white_check_mark: |
-| 1.20 | :white_check_mark: |
+| ------- | ------------------ |
+| <1.23 | :x: |
+| 1.23 | :white_check_mark: |
+| 1.24 | :white_check_mark: |
## Why another library
@@ -123,5 +189,4 @@ Unless required by applicable law or agreed to in writing, software distributed
AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
-
[^1]: https://github.com/zitadel/oidc/issues/135#issuecomment-950563892
diff --git a/SECURITY.md b/SECURITY.md
index 934426a..a32b842 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,43 +1,20 @@
# Security Policy
-At ZITADEL we are extremely grateful for security aware people that disclose vulnerabilities to us and the open source community. All reports will be investigated by our team.
+Please refer to the security policy [on zitadel/zitadel](https://github.com/zitadel/zitadel/blob/main/SECURITY.md) which is applicable for all open source repositories of our organization.
## Supported Versions
-After the initial Release the following version support will apply
+We currently support the following version of the OIDC framework:
-| Version | Supported |
-| ------- | ------------------ |
-| 0.x.x | :x: |
-| 1.x.x | :white_check_mark: |
-| 2.x.x | :white_check_mark: (not released) |
+| Version | Supported | Branch | Details |
+| -------- | ------------------ | ----------- | ------------------------------------ |
+| 0.x.x | :x: | | not maintained |
+| <2.11 | :x: | | not maintained |
+| 2.11.x | :lock: :warning: | [2.11.x][1] | security only, [community effort][2] |
+| 3.x.x | :heavy_check_mark: | [main][3] | supported |
+| 4.0.0-xx | :white_check_mark: | [next][4] | [development branch] |
-## Reporting a vulnerability
-
-To file a incident, please disclose by email to security@zitadel.com with the security details.
-
-At the moment GPG encryption is no yet supported, however you may sign your message at will.
-
-### When should I report a vulnerability
-
-* You think you discovered a ...
- * ... potential security vulnerability in the SDK
- * ... vulnerability in another project that this SDK bases on
-* For projects with their own vulnerability reporting and disclosure process, please report it directly there
-
-### When should I NOT report a vulnerability
-
-* You need help applying security related updates
-* Your issue is not security related
-
-## Security Vulnerability Response
-
-TBD
-
-## Public Disclosure
-
-All accepted and mitigated vulnerabilities will be published on the [Github Security Page](https://github.com/zitadel/oidc/security/advisories)
-
-### Timing
-
-We think it is crucial to publish advisories `ASAP` as mitigations are ready. But due to the unknown nature of the disclosures the time frame can range from 7 to 90 days.
+[1]: https://github.com/zitadel/oidc/tree/2.11.x
+[2]: https://github.com/zitadel/oidc/discussions/458
+[3]: https://github.com/zitadel/oidc/tree/main
+[4]: https://github.com/zitadel/oidc/tree/next
diff --git a/UPGRADING.md b/UPGRADING.md
new file mode 100644
index 0000000..6b5a41d
--- /dev/null
+++ b/UPGRADING.md
@@ -0,0 +1,370 @@
+# Upgrading
+
+All commands are executed from the root of the project that imports oidc packages.
+`sed` commands are created with **GNU sed** in mind and might need alternate syntax
+on non-GNU systems, such as MacOS.
+Alternatively, GNU sed can be installed on such systems. (`coreutils` package?).
+
+## V2 to V3
+
+**TL;DR** at the [bottom](#full-script) of this chapter is a full `sed` script
+containing all automatic steps at once.
+
+
+As first steps we will:
+1. Download the latest v3 module;
+2. Replace imports in all Go files;
+3. Tidy the module file;
+
+```bash
+go get -u github.com/zitadel/oidc/v3
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/github\.com\/zitadel\/oidc\/v2/github.com\/zitadel\/oidc\/v3/g'
+go mod tidy
+```
+
+### global
+
+#### go-jose package
+
+`gopkg.in/square/go-jose.v2` import has been changed to `github.com/go-jose/go-jose/v3`.
+That means that the imported types are also changed and imports need to be adapted.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/gopkg.in\/square\/go-jose\.v2/github.com\/go-jose\/go-jose\/v3/g'
+go mod tidy
+```
+
+### op
+
+```go
+import "github.com/zitadel/oidc/v3/pkg/op"
+```
+
+#### Logger
+
+This version of OIDC adds logging to the framework. For this we use the new Go standard library `log/slog`. (Until v3.12.0 we used `x/exp/slog`).
+Mostly OIDC will use error level logs where it's returning an error through a HTTP handler. OIDC errors that are user facing don't carry much context, also for security reasons. With logging we are now able to print the error context, so that developers can more easily find the source of their issues. Previously we just discarded such context.
+
+Most users of the OP package with the storage interface will not experience breaking changes. However if you use `RequestError()` directly in your code, you now need to give it a `Logger` as final argument.
+
+The `OpenIDProvider` and sub-interfaces like `Authorizer` and `Exchanger` got a `Logger()` method to return the configured logger. This logger is in turn used by `AuthRequestError()`. You configure the logger with the `WithLogger()` for the `Provider`. By default the `slog.Default()` is used.
+
+We also provide a new optional interface: [`LogAuthRequest`](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/op#LogAuthRequest). If an `AuthRequest` implements this interface, it is completely passed into the logger after an error. Its `LogValue()` will be used by `slog` to print desired fields. This allows omitting sensitive fields you wish not no print. If the interface is not implemented, no `AuthRequest` details will ever be printed.
+
+#### Server interface
+
+We've added a new [`Server`](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/op#Server) interface. This interface is experimental and subject to change. See [issue 440](https://github.com/zitadel/oidc/issues/440) for the motivation and discussion around this new interface.
+Usage of the new interface is not required, but may be used for advanced scenarios when working with the `Storage` interface isn't the optimal solution for your app (like we experienced in [Zitadel](https://github.com/zitadel/zitadel)).
+
+#### AuthRequestError
+
+`AuthRequestError` now takes the complete `Authorizer` as final argument, instead of only the encoder.
+This is to facilitate the use of the `Logger` as described above.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/\bAuthRequestError(w, r, authReq, err, authorizer.Encoder())/AuthRequestError(w, r, authReq, err, authorizer)/g'
+```
+
+Note: the sed regex might not find all uses if the local variables of the passed arguments use different names.
+
+#### AccessTokenVerifier
+
+`AccessTokenVerifier` interface has become a struct type. `NewAccessTokenVerifier` now returns a pointer to `AccessTokenVerifier`.
+Variable and struct fields declarations need to be changed from `op.AccessTokenVerifier` to `*op.AccessTokenVerifier`.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/\bop\.AccessTokenVerifier\b/*op.AccessTokenVerifier/g'
+```
+
+#### JWTProfileVerifier
+
+`JWTProfileVerifier` interface has become a struct type. `NewJWTProfileVerifier` now returns a pointer to `JWTProfileVerifier`.
+Variable and struct fields declarations need to be changed from `op.JWTProfileVerifier` to `*op.JWTProfileVerifier`.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/\bop\.JWTProfileVerifier\b/*op.JWTProfileVerifier/g'
+```
+
+#### IDTokenHintVerifier
+
+`IDTokenHintVerifier` interface has become a struct type. `NewIDTokenHintVerifier` now returns a pointer to `IDTokenHintVerifier`.
+Variable and struct fields declarations need to be changed from `op.IDTokenHintVerifier` to `*op.IDTokenHintVerifier`.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/\bop\.IDTokenHintVerifier\b/*op.IDTokenHintVerifier/g'
+```
+
+#### ParseRequestObject
+
+`ParseRequestObject` no longer returns `*oidc.AuthRequest` as it already operates on the pointer for the passed `authReq` argument. As such the argument and the return value were the same pointer. Callers can just use the original `*oidc.AuthRequest` now.
+
+#### Endpoint Configuration
+
+`Endpoint`s returned from `Configuration` interface methods are now pointers. Usually, `op.Provider` is the main implementation of the `Configuration` interface. However, if a custom implementation is used, you should be able to update it using the following:
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/AuthorizationEndpoint() Endpoint/AuthorizationEndpoint() *Endpoint/g' \
+ -e 's/TokenEndpoint() Endpoint/TokenEndpoint() *Endpoint/g' \
+ -e 's/IntrospectionEndpoint() Endpoint/IntrospectionEndpoint() *Endpoint/g' \
+ -e 's/UserinfoEndpoint() Endpoint/UserinfoEndpoint() *Endpoint/g' \
+ -e 's/RevocationEndpoint() Endpoint/RevocationEndpoint() *Endpoint/g' \
+ -e 's/EndSessionEndpoint() Endpoint/EndSessionEndpoint() *Endpoint/g' \
+ -e 's/KeysEndpoint() Endpoint/KeysEndpoint() *Endpoint/g' \
+ -e 's/DeviceAuthorizationEndpoint() Endpoint/DeviceAuthorizationEndpoint() *Endpoint/g'
+```
+
+#### CreateDiscoveryConfig
+
+`CreateDiscoveryConfig` now takes a context as first argument. The following adds `context.TODO()` to the function:
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/op\.CreateDiscoveryConfig(/op.CreateDiscoveryConfig(context.TODO(), /g'
+```
+
+It now takes the issuer out of the context using the [`IssuerFromContext`](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/op#IssuerFromContext) functionality,
+instead of the `config.IssuerFromRequest()` method.
+
+#### CreateRouter
+
+`CreateRouter` now returns a `chi.Router` instead of `*mux.Router`.
+Usually this function is called when the Provider is constructed and not by package consumers.
+However if your project does call this function directly, manual update of the code is required.
+
+#### DeviceAuthorizationStorage
+
+`DeviceAuthorizationStorage` dropped the following methods:
+
+- `GetDeviceAuthorizationByUserCode`
+- `CompleteDeviceAuthorization`
+- `DenyDeviceAuthorization`
+
+These methods proved not to be required from a library point of view.
+Implementations of a device authorization flow may take care of these calls in a way they see fit.
+
+#### AuthorizeCodeChallenge
+
+The `AuthorizeCodeChallenge` function now only takes the `CodeVerifier` argument, instead of the complete `*oidc.AccessTokenRequest`.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/op\.AuthorizeCodeChallenge(tokenReq/op.AuthorizeCodeChallenge(tokenReq.CodeVerifier/g'
+```
+
+### client
+
+```go
+import "github.com/zitadel/oidc/v3/pkg/client"
+```
+
+#### Context
+
+All client calls now take a context as first argument. The following adds `context.TODO()` to all the affected functions:
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/client\.Discover(/client.Discover(context.TODO(), /g' \
+ -e 's/client\.CallTokenEndpoint(/client.CallTokenEndpoint(context.TODO(), /g' \
+ -e 's/client\.CallEndSessionEndpoint(/client.CallEndSessionEndpoint(context.TODO(), /g' \
+ -e 's/client\.CallRevokeEndpoint(/client.CallRevokeEndpoint(context.TODO(), /g' \
+ -e 's/client\.CallTokenExchangeEndpoint(/client.CallTokenExchangeEndpoint(context.TODO(), /g' \
+ -e 's/client\.CallDeviceAuthorizationEndpoint(/client.CallDeviceAuthorizationEndpoint(context.TODO(), /g' \
+ -e 's/client\.JWTProfileExchange(/client.JWTProfileExchange(context.TODO(), /g'
+```
+
+#### keyFile type
+
+The `keyFile` struct type is now exported a `KeyFile` and returned by the `ConfigFromKeyFile` and `ConfigFromKeyFileData`. No changes are needed on the caller's side.
+
+### client/profile
+
+The package now defines a new interface `TokenSource` which compliments the `oauth2.TokenSource` with a `TokenCtx` method, so that a context can be explicitly added on each call. Users can migrate to the new method when they whish.
+
+`NewJWTProfileTokenSource` now takes a context as first argument, so do the related `NewJWTProfileTokenSourceFromKeyFile` and `NewJWTProfileTokenSourceFromKeyFileData`. The context is used for the Discovery request.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/profile\.NewJWTProfileTokenSource(/profile.NewJWTProfileTokenSource(context.TODO(), /g' \
+ -e 's/profile\.NewJWTProfileTokenSourceFromKeyFileData(/profile.NewJWTProfileTokenSourceFromKeyFileData(context.TODO(), /g' \
+ -e 's/profile\.NewJWTProfileTokenSourceFromKeyFile(/profile.NewJWTProfileTokenSourceFromKeyFile(context.TODO(), /g'
+```
+
+
+### client/rp
+
+```go
+import "github.com/zitadel/oidc/v3/pkg/client/rs"
+```
+
+#### Discover
+
+The `Discover` function has been removed. Use `client.Discover` instead.
+
+#### Context
+
+Most `rp` functions now require a context as first argument. The following adds `context.TODO()` to the function that have no additional changes. Functions with more complex changes are documented below.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/rp\.NewRelyingPartyOIDC(/rp.NewRelyingPartyOIDC(context.TODO(), /g' \
+ -e 's/rp\.EndSession(/rp.EndSession(context.TODO(), /g' \
+ -e 's/rp\.RevokeToken(/rp.RevokeToken(context.TODO(), /g' \
+ -e 's/rp\.DeviceAuthorization(/rp.DeviceAuthorization(context.TODO(), /g'
+```
+
+Remember to replace `context.TODO()` with a context that is applicable for your app, where possible.
+
+#### RefreshAccessToken
+
+1. Renamed to `RefreshTokens`;
+2. A context must be passed;
+3. An `*oidc.Tokens` object is now returned, which included an ID Token if it was returned by the server;
+4. The function is now generic and requires a type argument for the `IDTokenClaims` implementation inside the returned `oidc.Tokens` object;
+
+For most use cases `*oidc.IDTokenClaims` can be used as type argument. A custom implementation of `oidc.IDClaims` can be used if type-safe access to custom claims is required.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/rp\.RefreshAccessToken(/rp.RefreshTokens[*oidc.IDTokenClaims](context.TODO(), /g'
+```
+
+Users that called `tokens.Extra("id_token").(string)` and a subsequent `VerifyTokens` to get the claims, no longer need to do this. The ID token is verified (when present) by `RefreshTokens` already.
+
+
+#### Userinfo
+
+1. A context must be passed as first argument;
+2. The function is now generic and requires a type argument for the returned user info object;
+
+For most use cases `*oidc.UserInfo` can be used a type argument. A [custom implementation](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/client/rp#example-Userinfo-Custom) of `rp.SubjectGetter` can be used if type-safe access to custom claims is required.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/rp\.Userinfo(/rp.Userinfo[*oidc.UserInfo](context.TODO(), /g'
+```
+
+#### UserinfoCallback
+
+`UserinfoCallback` has an additional type argument fot the `UserInfo` object. Typically the type argument can be inferred by the compiler, by the function that is passed. The actual code update cannot be done by a simple `sed` script and depends on how the caller implemented the function.
+
+
+#### IDTokenVerifier
+
+`IDTokenVerifier` interface has become a struct type. `NewIDTokenVerifier` now returns a pointer to `IDTokenVerifier`.
+Variable and struct fields declarations need to be changed from `rp.IDTokenVerifier` to `*rp.AccessTokenVerifier`.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/\brp\.IDTokenVerifier\b/*rp.IDTokenVerifier/g'
+```
+
+### client/rs
+
+```go
+import "github.com/zitadel/oidc/v3/pkg/client/rs"
+```
+
+#### NewResourceServer
+
+The `NewResourceServerClientCredentials` and `NewResourceServerJWTProfile` constructor functions now take a context as first argument.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/rs\.NewResourceServerClientCredentials(/rs.NewResourceServerClientCredentials(context.TODO(), /g' \
+ -e 's/rs\.NewResourceServerJWTProfile(/rs.NewResourceServerJWTProfile(context.TODO(), /g'
+```
+
+#### Introspect
+
+`Introspect` is now generic and requires a type argument for the returned introspection response. For most use cases `*oidc.IntrospectionResponse` can be used as type argument. Any other response type if type-safe access to [custom claims](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/client/rs#example-Introspect-Custom) is required.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/rs\.Introspect(/rs.Introspect[*oidc.IntrospectionResponse](/g'
+```
+
+### client/tokenexchange
+
+The `TokenExchanger` constructor functions `NewTokenExchanger` and `NewTokenExchangerClientCredentials` now take a context as first argument.
+As well as the `ExchangeToken` function.
+
+```bash
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/tokenexchange\.NewTokenExchanger(/tokenexchange.NewTokenExchanger(context.TODO(), /g' \
+ -e 's/tokenexchange\.NewTokenExchangerClientCredentials(/tokenexchange.NewTokenExchangerClientCredentials(context.TODO(), /g' \
+ -e 's/tokenexchange\.ExchangeToken(/tokenexchange.ExchangeToken(context.TODO(), /g'
+```
+
+### oidc
+
+#### SpaceDelimitedArray
+
+The `SpaceDelimitedArray` type's `Encode()` function has been renamed to `String()` so it implements the `fmt.Stringer` interface. If the `Encode` method was called by a package consumer, it should be changed manually.
+
+#### Verifier
+
+The `Verifier` interface as been changed into a struct type. The struct type is aliased in the `op` and `rp` packages for the specific token use cases. See the relevant section above.
+
+### Full script
+
+For the courageous this is the full `sed` script which combines all the steps described above.
+It should migrate most of the code in a repository to a more-or-less compilable state,
+using defaults such as `context.TODO()` where possible.
+
+Warnings:
+- Again, this is written for **GNU sed** not the posix variant.
+- Assumes imports that use the package names, not aliases.
+- Do this on a project with version control (eg Git), that allows you to rollback if things went wrong.
+- The script has been tested on the [ZITADEL](https://github.com/zitadel/zitadel) project, but we do not use all affected symbols. Parts of the script are mere guesswork.
+
+```bash
+go get -u github.com/zitadel/oidc/v3
+find . -type f -name '*.go' | xargs sed -i \
+ -e 's/github\.com\/zitadel\/oidc\/v2/github.com\/zitadel\/oidc\/v3/g' \
+ -e 's/gopkg.in\/square\/go-jose\.v2/github.com\/go-jose\/go-jose\/v3/g' \
+ -e 's/\bAuthRequestError(w, r, authReq, err, authorizer.Encoder())/AuthRequestError(w, r, authReq, err, authorizer)/g' \
+ -e 's/\bop\.AccessTokenVerifier\b/*op.AccessTokenVerifier/g' \
+ -e 's/\bop\.JWTProfileVerifier\b/*op.JWTProfileVerifier/g' \
+ -e 's/\bop\.IDTokenHintVerifier\b/*op.IDTokenHintVerifier/g' \
+ -e 's/AuthorizationEndpoint() Endpoint/AuthorizationEndpoint() *Endpoint/g' \
+ -e 's/TokenEndpoint() Endpoint/TokenEndpoint() *Endpoint/g' \
+ -e 's/IntrospectionEndpoint() Endpoint/IntrospectionEndpoint() *Endpoint/g' \
+ -e 's/UserinfoEndpoint() Endpoint/UserinfoEndpoint() *Endpoint/g' \
+ -e 's/RevocationEndpoint() Endpoint/RevocationEndpoint() *Endpoint/g' \
+ -e 's/EndSessionEndpoint() Endpoint/EndSessionEndpoint() *Endpoint/g' \
+ -e 's/KeysEndpoint() Endpoint/KeysEndpoint() *Endpoint/g' \
+ -e 's/DeviceAuthorizationEndpoint() Endpoint/DeviceAuthorizationEndpoint() *Endpoint/g' \
+ -e 's/op\.CreateDiscoveryConfig(/op.CreateDiscoveryConfig(context.TODO(), /g' \
+ -e 's/op\.AuthorizeCodeChallenge(tokenReq/op.AuthorizeCodeChallenge(tokenReq.CodeVerifier/g' \
+ -e 's/client\.Discover(/client.Discover(context.TODO(), /g' \
+ -e 's/client\.CallTokenEndpoint(/client.CallTokenEndpoint(context.TODO(), /g' \
+ -e 's/client\.CallEndSessionEndpoint(/client.CallEndSessionEndpoint(context.TODO(), /g' \
+ -e 's/client\.CallRevokeEndpoint(/client.CallRevokeEndpoint(context.TODO(), /g' \
+ -e 's/client\.CallTokenExchangeEndpoint(/client.CallTokenExchangeEndpoint(context.TODO(), /g' \
+ -e 's/client\.CallDeviceAuthorizationEndpoint(/client.CallDeviceAuthorizationEndpoint(context.TODO(), /g' \
+ -e 's/client\.JWTProfileExchange(/client.JWTProfileExchange(context.TODO(), /g' \
+ -e 's/profile\.NewJWTProfileTokenSource(/profile.NewJWTProfileTokenSource(context.TODO(), /g' \
+ -e 's/profile\.NewJWTProfileTokenSourceFromKeyFileData(/profile.NewJWTProfileTokenSourceFromKeyFileData(context.TODO(), /g' \
+ -e 's/profile\.NewJWTProfileTokenSourceFromKeyFile(/profile.NewJWTProfileTokenSourceFromKeyFile(context.TODO(), /g' \
+ -e 's/rp\.NewRelyingPartyOIDC(/rp.NewRelyingPartyOIDC(context.TODO(), /g' \
+ -e 's/rp\.EndSession(/rp.EndSession(context.TODO(), /g' \
+ -e 's/rp\.RevokeToken(/rp.RevokeToken(context.TODO(), /g' \
+ -e 's/rp\.DeviceAuthorization(/rp.DeviceAuthorization(context.TODO(), /g' \
+ -e 's/rp\.RefreshAccessToken(/rp.RefreshTokens[*oidc.IDTokenClaims](context.TODO(), /g' \
+ -e 's/rp\.Userinfo(/rp.Userinfo[*oidc.UserInfo](context.TODO(), /g' \
+ -e 's/\brp\.IDTokenVerifier\b/*rp.IDTokenVerifier/g' \
+ -e 's/rs\.NewResourceServerClientCredentials(/rs.NewResourceServerClientCredentials(context.TODO(), /g' \
+ -e 's/rs\.NewResourceServerJWTProfile(/rs.NewResourceServerJWTProfile(context.TODO(), /g' \
+ -e 's/rs\.Introspect(/rs.Introspect[*oidc.IntrospectionResponse](/g' \
+ -e 's/tokenexchange\.NewTokenExchanger(/tokenexchange.NewTokenExchanger(context.TODO(), /g' \
+ -e 's/tokenexchange\.NewTokenExchangerClientCredentials(/tokenexchange.NewTokenExchangerClientCredentials(context.TODO(), /g' \
+ -e 's/tokenexchange\.ExchangeToken(/tokenexchange.ExchangeToken(context.TODO(), /g'
+go mod tidy
+```
\ No newline at end of file
diff --git a/example/client/api/api.go b/example/client/api/api.go
index 0ab669d..69f9466 100644
--- a/example/client/api/api.go
+++ b/example/client/api/api.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"encoding/json"
"fmt"
"log"
@@ -9,11 +10,11 @@ import (
"strings"
"time"
- "github.com/gorilla/mux"
+ "github.com/go-chi/chi/v5"
"github.com/sirupsen/logrus"
- "github.com/zitadel/oidc/pkg/client/rs"
- "github.com/zitadel/oidc/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rs"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
)
const (
@@ -27,12 +28,12 @@ func main() {
port := os.Getenv("PORT")
issuer := os.Getenv("ISSUER")
- provider, err := rs.NewResourceServerFromKeyFile(issuer, keyPath)
+ provider, err := rs.NewResourceServerFromKeyFile(context.TODO(), issuer, keyPath)
if err != nil {
logrus.Fatalf("error creating provider %s", err.Error())
}
- router := mux.NewRouter()
+ router := chi.NewRouter()
// public url accessible without any authorization
// will print `OK` and current timestamp
@@ -47,7 +48,7 @@ func main() {
if !ok {
return
}
- resp, err := rs.Introspect(r.Context(), provider, token)
+ resp, err := rs.Introspect[*oidc.IntrospectionResponse](r.Context(), provider, token)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
@@ -68,15 +69,15 @@ func main() {
if !ok {
return
}
- resp, err := rs.Introspect(r.Context(), provider, token)
+ resp, err := rs.Introspect[*oidc.IntrospectionResponse](r.Context(), provider, token)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
- params := mux.Vars(r)
- requestedClaim := params["claim"]
- requestedValue := params["value"]
- value, ok := resp.GetClaim(requestedClaim).(string)
+ requestedClaim := chi.URLParam(r, "claim")
+ requestedValue := chi.URLParam(r, "value")
+
+ value, ok := resp.Claims[requestedClaim].(string)
if !ok || value == "" || value != requestedValue {
http.Error(w, "claim does not match", http.StatusForbidden)
return
diff --git a/example/client/app/app.go b/example/client/app/app.go
index e7be491..90b1969 100644
--- a/example/client/app/app.go
+++ b/example/client/app/app.go
@@ -1,19 +1,23 @@
package main
import (
+ "context"
"encoding/json"
"fmt"
+ "log/slog"
"net/http"
"os"
"strings"
+ "sync/atomic"
"time"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
- "github.com/zitadel/oidc/pkg/client/rp"
- httphelper "github.com/zitadel/oidc/pkg/http"
- "github.com/zitadel/oidc/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
+ httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
+ "github.com/zitadel/logging"
)
var (
@@ -28,13 +32,31 @@ func main() {
issuer := os.Getenv("ISSUER")
port := os.Getenv("PORT")
scopes := strings.Split(os.Getenv("SCOPES"), " ")
+ responseMode := os.Getenv("RESPONSE_MODE")
redirectURI := fmt.Sprintf("http://localhost:%v%v", port, callbackPath)
cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
+ logger := slog.New(
+ slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+ AddSource: true,
+ Level: slog.LevelDebug,
+ }),
+ )
+ client := &http.Client{
+ Timeout: time.Minute,
+ }
+ // enable outgoing request logging
+ logging.EnableHTTPClient(client,
+ logging.WithClientGroup("client"),
+ )
+
options := []rp.Option{
rp.WithCookieHandler(cookieHandler),
rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
+ rp.WithHTTPClient(client),
+ rp.WithLogger(logger),
+ rp.WithSigningAlgsFromDiscovery(),
}
if clientSecret == "" {
options = append(options, rp.WithPKCE(cookieHandler))
@@ -43,7 +65,10 @@ func main() {
options = append(options, rp.WithJWTProfile(rp.SignerFromKeyPath(keyPath)))
}
- provider, err := rp.NewRelyingPartyOIDC(issuer, clientID, clientSecret, redirectURI, scopes, options...)
+ // One can add a logger to the context,
+ // pre-defining log attributes as required.
+ ctx := logging.ToContext(context.TODO(), logger)
+ provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopes, options...)
if err != nil {
logrus.Fatalf("error creating provider %s", err.Error())
}
@@ -54,20 +79,37 @@ func main() {
return uuid.New().String()
}
+ urlOptions := []rp.URLParamOpt{
+ rp.WithPromptURLParam("Welcome back!"),
+ }
+
+ if responseMode != "" {
+ urlOptions = append(urlOptions, rp.WithResponseModeURLParam(oidc.ResponseMode(responseMode)))
+ }
+
// register the AuthURLHandler at your preferred path.
// the AuthURLHandler creates the auth request and redirects the user to the auth server.
// including state handling with secure cookie and the possibility to use PKCE.
// Prompts can optionally be set to inform the server of
// any messages that need to be prompted back to the user.
- http.Handle("/login", rp.AuthURLHandler(state, provider, rp.WithPromptURLParam("Welcome back!")))
+ http.Handle("/login", rp.AuthURLHandler(
+ state,
+ provider,
+ urlOptions...,
+ ))
// for demonstration purposes the returned userinfo response is written as JSON object onto response
- marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) {
+ marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) {
+ fmt.Println("access token", tokens.AccessToken)
+ fmt.Println("refresh token", tokens.RefreshToken)
+ fmt.Println("id token", tokens.IDToken)
+
data, err := json.Marshal(info)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+ w.Header().Set("content-type", "application/json")
w.Write(data)
}
@@ -82,6 +124,31 @@ func main() {
// w.Write(data)
//}
+ // you can also try token exchange flow
+ //
+ // requestTokenExchange := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) {
+ // data := make(url.Values)
+ // data.Set("grant_type", string(oidc.GrantTypeTokenExchange))
+ // data.Set("requested_token_type", string(oidc.IDTokenType))
+ // data.Set("subject_token", tokens.RefreshToken)
+ // data.Set("subject_token_type", string(oidc.RefreshTokenType))
+ // data.Add("scope", "profile custom_scope:impersonate:id2")
+
+ // client := &http.Client{}
+ // r2, _ := http.NewRequest(http.MethodPost, issuer+"/oauth/token", strings.NewReader(data.Encode()))
+ // // r2.Header.Add("Authorization", "Basic "+"d2ViOnNlY3JldA==")
+ // r2.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ // r2.SetBasicAuth("web", "secret")
+
+ // resp, _ := client.Do(r2)
+ // fmt.Println(resp.Status)
+
+ // b, _ := io.ReadAll(resp.Body)
+ // resp.Body.Close()
+
+ // w.Write(b)
+ // }
+
// register the CodeExchangeHandler at the callbackPath
// the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function
// with the returned tokens from the token endpoint
@@ -93,8 +160,22 @@ func main() {
//
// http.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, provider))
+ // simple counter for request IDs
+ var counter atomic.Int64
+ // enable incomming request logging
+ mw := logging.Middleware(
+ logging.WithLogger(logger),
+ logging.WithGroup("server"),
+ logging.WithIDFunc(func() slog.Attr {
+ return slog.Int64("id", counter.Add(1))
+ }),
+ )
+
lis := fmt.Sprintf("127.0.0.1:%s", port)
- logrus.Infof("listening on http://%s/", lis)
- logrus.Info("press ctrl+c to stop")
- logrus.Fatal(http.ListenAndServe(lis, nil))
+ logger.Info("server listening, press ctrl+c to stop", "addr", lis)
+ err = http.ListenAndServe(lis, mw(http.DefaultServeMux))
+ if err != http.ErrServerClosed {
+ logger.Error("server terminated", "error", err)
+ os.Exit(1)
+ }
}
diff --git a/example/client/device/device.go b/example/client/device/device.go
new file mode 100644
index 0000000..33bc570
--- /dev/null
+++ b/example/client/device/device.go
@@ -0,0 +1,95 @@
+// Command device is an example Oauth2 Device Authorization Grant app.
+// It creates a new Device Authorization request on the Issuer and then polls for tokens.
+// The user is then prompted to visit a URL and enter the user code.
+// Or, the complete URL can be used instead to omit manual entry.
+// In practice then can be a "magic link" in the form or a QR.
+//
+// The following environment variables are used for configuration:
+//
+// ISSUER: URL to the OP, required.
+// CLIENT_ID: ID of the application, required.
+// CLIENT_SECRET: Secret to authenticate the app using basic auth. Only required if the OP expects this type of authentication.
+// KEY_PATH: Path to a private key file, used to for JWT authentication of the App. Only required if the OP expects this type of authentication.
+// SCOPES: Scopes of the Authentication Request. Optional.
+//
+// Basic usage:
+//
+// cd example/client/device
+// export ISSUER="http://localhost:9000" CLIENT_ID="246048465824634593@demo"
+//
+// Get an Access Token:
+//
+// SCOPES="email profile" go run .
+//
+// Get an Access Token and ID Token:
+//
+// SCOPES="email profile openid" go run .
+//
+// Get an Access Token and Refresh Token
+//
+// SCOPES="email profile offline_access" go run .
+//
+// Get Access, Refresh and ID Tokens:
+//
+// SCOPES="email profile offline_access openid" go run .
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/sirupsen/logrus"
+
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
+ httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
+)
+
+var (
+ key = []byte("test1234test1234")
+)
+
+func main() {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT)
+ defer stop()
+
+ clientID := os.Getenv("CLIENT_ID")
+ clientSecret := os.Getenv("CLIENT_SECRET")
+ keyPath := os.Getenv("KEY_PATH")
+ issuer := os.Getenv("ISSUER")
+ scopes := strings.Split(os.Getenv("SCOPES"), " ")
+
+ cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
+
+ var options []rp.Option
+ if clientSecret == "" {
+ options = append(options, rp.WithPKCE(cookieHandler))
+ }
+ if keyPath != "" {
+ options = append(options, rp.WithJWTProfile(rp.SignerFromKeyPath(keyPath)))
+ }
+
+ provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, "", scopes, options...)
+ if err != nil {
+ logrus.Fatalf("error creating provider %s", err.Error())
+ }
+
+ logrus.Info("starting device authorization flow")
+ resp, err := rp.DeviceAuthorization(ctx, scopes, provider, nil)
+ if err != nil {
+ logrus.Fatal(err)
+ }
+ logrus.Info("resp", resp)
+ fmt.Printf("\nPlease browse to %s and enter code %s\n", resp.VerificationURI, resp.UserCode)
+
+ logrus.Info("start polling")
+ token, err := rp.DeviceAccessToken(ctx, resp.DeviceCode, time.Duration(resp.Interval)*time.Second, provider)
+ if err != nil {
+ logrus.Fatal(err)
+ }
+ logrus.Infof("successfully obtained token: %#v", token)
+}
diff --git a/example/client/github/github.go b/example/client/github/github.go
index feb3e26..f6c536b 100644
--- a/example/client/github/github.go
+++ b/example/client/github/github.go
@@ -10,9 +10,10 @@ import (
"golang.org/x/oauth2"
githubOAuth "golang.org/x/oauth2/github"
- "github.com/zitadel/oidc/pkg/client/rp"
- "github.com/zitadel/oidc/pkg/client/rp/cli"
- "github.com/zitadel/oidc/pkg/http"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp/cli"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
)
var (
@@ -43,7 +44,7 @@ func main() {
state := func() string {
return uuid.New().String()
}
- token := cli.CodeFlow(ctx, relyingParty, callbackPath, port, state)
+ token := cli.CodeFlow[*oidc.IDTokenClaims](ctx, relyingParty, callbackPath, port, state)
client := github.NewClient(relyingParty.OAuthConfig().Client(ctx, token.Token))
diff --git a/example/client/service/service.go b/example/client/service/service.go
index b3819d5..a88ab2f 100644
--- a/example/client/service/service.go
+++ b/example/client/service/service.go
@@ -13,7 +13,7 @@ import (
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
- "github.com/zitadel/oidc/pkg/client/profile"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/profile"
)
var client = http.DefaultClient
@@ -25,7 +25,7 @@ func main() {
scopes := strings.Split(os.Getenv("SCOPES"), " ")
if keyPath != "" {
- ts, err := profile.NewJWTProfileTokenSourceFromKeyFile(issuer, keyPath, scopes)
+ ts, err := profile.NewJWTProfileTokenSourceFromKeyFile(context.TODO(), issuer, keyPath, scopes)
if err != nil {
logrus.Fatalf("error creating token source %s", err.Error())
}
@@ -76,7 +76,7 @@ func main() {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- ts, err := profile.NewJWTProfileTokenSourceFromKeyFileData(issuer, key, scopes)
+ ts, err := profile.NewJWTProfileTokenSourceFromKeyFileData(context.TODO(), issuer, key, scopes)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -125,7 +125,7 @@ func main() {
testURL := r.Form.Get("url")
var data struct {
URL string
- Response interface{}
+ Response any
}
if testURL != "" {
data.URL = testURL
@@ -149,7 +149,7 @@ func main() {
logrus.Fatal(http.ListenAndServe("127.0.0.1:"+port, nil))
}
-func callExampleEndpoint(client *http.Client, testURL string) (interface{}, error) {
+func callExampleEndpoint(client *http.Client, testURL string) (any, error) {
req, err := http.NewRequest("GET", testURL, nil)
if err != nil {
return nil, err
diff --git a/example/doc.go b/example/doc.go
index 7212a7d..fd4f038 100644
--- a/example/doc.go
+++ b/example/doc.go
@@ -5,7 +5,6 @@ Package example contains some example of the various use of this library:
/app web app / RP demonstrating authorization code flow using various authentication methods (code, PKCE, JWT profile)
/github example of the extended OAuth2 library, providing an HTTP client with a reuse token source
/service demonstration of JWT Profile Authorization Grant
-/server example of an OpenID Provider implementation including some very basic login UI
-
+/server examples of an OpenID Provider implementations (including dynamic) with some very basic
*/
package example
diff --git a/example/server/config/config.go b/example/server/config/config.go
new file mode 100644
index 0000000..96837d4
--- /dev/null
+++ b/example/server/config/config.go
@@ -0,0 +1,40 @@
+package config
+
+import (
+ "os"
+ "strings"
+)
+
+const (
+ // default port for the http server to run
+ DefaultIssuerPort = "9998"
+)
+
+type Config struct {
+ Port string
+ RedirectURI []string
+ UsersFile string
+}
+
+// FromEnvVars loads configuration parameters from environment variables.
+// If there is no such variable defined, then use default values.
+func FromEnvVars(defaults *Config) *Config {
+ if defaults == nil {
+ defaults = &Config{}
+ }
+ cfg := &Config{
+ Port: defaults.Port,
+ RedirectURI: defaults.RedirectURI,
+ UsersFile: defaults.UsersFile,
+ }
+ if value, ok := os.LookupEnv("PORT"); ok {
+ cfg.Port = value
+ }
+ if value, ok := os.LookupEnv("USERS_FILE"); ok {
+ cfg.UsersFile = value
+ }
+ if value, ok := os.LookupEnv("REDIRECT_URI"); ok {
+ cfg.RedirectURI = strings.Split(value, ",")
+ }
+ return cfg
+}
diff --git a/example/server/config/config_test.go b/example/server/config/config_test.go
new file mode 100644
index 0000000..3b73c0b
--- /dev/null
+++ b/example/server/config/config_test.go
@@ -0,0 +1,77 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "testing"
+)
+
+func TestFromEnvVars(t *testing.T) {
+
+ for _, tc := range []struct {
+ name string
+ env map[string]string
+ defaults *Config
+ want *Config
+ }{
+ {
+ name: "no vars, no default values",
+ env: map[string]string{},
+ want: &Config{},
+ },
+ {
+ name: "no vars, only defaults",
+ env: map[string]string{},
+ defaults: &Config{
+ Port: "6666",
+ UsersFile: "/default/user/path",
+ RedirectURI: []string{"re", "direct", "uris"},
+ },
+ want: &Config{
+ Port: "6666",
+ UsersFile: "/default/user/path",
+ RedirectURI: []string{"re", "direct", "uris"},
+ },
+ },
+ {
+ name: "overriding default values",
+ env: map[string]string{
+ "PORT": "1234",
+ "USERS_FILE": "/path/to/users",
+ "REDIRECT_URI": "http://redirect/redirect",
+ },
+ defaults: &Config{
+ Port: "6666",
+ UsersFile: "/default/user/path",
+ RedirectURI: []string{"re", "direct", "uris"},
+ },
+ want: &Config{
+ Port: "1234",
+ UsersFile: "/path/to/users",
+ RedirectURI: []string{"http://redirect/redirect"},
+ },
+ },
+ {
+ name: "multiple redirect uris",
+ env: map[string]string{
+ "REDIRECT_URI": "http://host_1,http://host_2,http://host_3",
+ },
+ want: &Config{
+ RedirectURI: []string{
+ "http://host_1", "http://host_2", "http://host_3",
+ },
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ os.Clearenv()
+ for k, v := range tc.env {
+ os.Setenv(k, v)
+ }
+ cfg := FromEnvVars(tc.defaults)
+ if fmt.Sprint(cfg) != fmt.Sprint(tc.want) {
+ t.Errorf("Expected FromEnvVars()=%q, but got %q", tc.want, cfg)
+ }
+ })
+ }
+}
diff --git a/example/server/dynamic/login.go b/example/server/dynamic/login.go
new file mode 100644
index 0000000..05f0e34
--- /dev/null
+++ b/example/server/dynamic/login.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
+)
+
+const (
+ queryAuthRequestID = "authRequestID"
+)
+
+var (
+ loginTmpl, _ = template.New("login").Parse(`
+
+
+
+
+ Login
+
+
+
+
+ `)
+)
+
+type login struct {
+ authenticate authenticate
+ router chi.Router
+ callback func(context.Context, string) string
+}
+
+func NewLogin(authenticate authenticate, callback func(context.Context, string) string, issuerInterceptor *op.IssuerInterceptor) *login {
+ l := &login{
+ authenticate: authenticate,
+ callback: callback,
+ }
+ l.createRouter(issuerInterceptor)
+ return l
+}
+
+func (l *login) createRouter(issuerInterceptor *op.IssuerInterceptor) {
+ l.router = chi.NewRouter()
+ l.router.Get("/username", l.loginHandler)
+ l.router.With(issuerInterceptor.Handler).Post("/username", l.checkLoginHandler)
+}
+
+type authenticate interface {
+ CheckUsernamePassword(ctx context.Context, username, password, id string) error
+}
+
+func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError)
+ return
+ }
+ //the oidc package will pass the id of the auth request as query parameter
+ //we will use this id through the login process and therefore pass it to the login page
+ renderLogin(w, r.FormValue(queryAuthRequestID), nil)
+}
+
+func renderLogin(w http.ResponseWriter, id string, err error) {
+ var errMsg string
+ if err != nil {
+ errMsg = err.Error()
+ }
+ data := &struct {
+ ID string
+ Error string
+ }{
+ ID: id,
+ Error: errMsg,
+ }
+ err = loginTmpl.Execute(w, data)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func (l *login) checkLoginHandler(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError)
+ return
+ }
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+ id := r.FormValue("id")
+ err = l.authenticate.CheckUsernamePassword(r.Context(), username, password, id)
+ if err != nil {
+ renderLogin(w, id, err)
+ return
+ }
+ http.Redirect(w, r, l.callback(r.Context(), id), http.StatusFound)
+}
diff --git a/example/server/dynamic/op.go b/example/server/dynamic/op.go
new file mode 100644
index 0000000..2c00e41
--- /dev/null
+++ b/example/server/dynamic/op.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "log"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "golang.org/x/text/language"
+
+ "git.christmann.info/LARA/zitadel-oidc/v3/example/server/storage"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
+)
+
+const (
+ pathLoggedOut = "/logged-out"
+)
+
+var (
+ hostnames = []string{
+ "localhost", //note that calling 127.0.0.1 / ::1 won't work as the hostname does not match
+ "oidc.local", //add this to your hosts file (pointing to 127.0.0.1)
+ //feel free to add more...
+ }
+)
+
+func init() {
+ storage.RegisterClients(
+ storage.NativeClient("native"),
+ storage.WebClient("web", "secret"),
+ storage.WebClient("api", "secret"),
+ )
+}
+
+func main() {
+ ctx := context.Background()
+
+ port := "9998"
+ issuers := make([]string, len(hostnames))
+ for i, hostname := range hostnames {
+ issuers[i] = fmt.Sprintf("http://%s:%s/", hostname, port)
+ }
+
+ //the OpenID Provider requires a 32-byte key for (token) encryption
+ //be sure to create a proper crypto random key and manage it securely!
+ key := sha256.Sum256([]byte("test"))
+
+ router := chi.NewRouter()
+
+ //for simplicity, we provide a very small default page for users who have signed out
+ router.HandleFunc(pathLoggedOut, func(w http.ResponseWriter, req *http.Request) {
+ _, err := w.Write([]byte("signed out successfully"))
+ if err != nil {
+ log.Printf("error serving logged out page: %v", err)
+ }
+ })
+
+ //the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations
+ //this might be the layer for accessing your database
+ //in this example it will be handled in-memory
+ //the NewMultiStorage is able to handle multiple issuers
+ storage := storage.NewMultiStorage(issuers)
+
+ //creation of the OpenIDProvider with the just created in-memory Storage
+ provider, err := newDynamicOP(ctx, storage, key)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ //the provider will only take care of the OpenID Protocol, so there must be some sort of UI for the login process
+ //for the simplicity of the example this means a simple page with username and password field
+ //be sure to provide an IssuerInterceptor with the IssuerFromRequest from the OP so the login can select / and pass it to the storage
+ l := NewLogin(storage, op.AuthCallbackURL(provider), op.NewIssuerInterceptor(provider.IssuerFromRequest))
+
+ //regardless of how many pages / steps there are in the process, the UI must be registered in the router,
+ //so we will direct all calls to /login to the login UI
+ router.Mount("/login/", http.StripPrefix("/login", l.router))
+
+ //we register the http handler of the OP on the root, so that the discovery endpoint (/.well-known/openid-configuration)
+ //is served on the correct path
+ //
+ //if your issuer ends with a path (e.g. http://localhost:9998/custom/path/),
+ //then you would have to set the path prefix (/custom/path/):
+ //router.PathPrefix("/custom/path/").Handler(http.StripPrefix("/custom/path", provider.HttpHandler()))
+ router.Mount("/", provider)
+
+ server := &http.Server{
+ Addr: ":" + port,
+ Handler: router,
+ }
+ err = server.ListenAndServe()
+ if err != nil {
+ log.Fatal(err)
+ }
+ <-ctx.Done()
+}
+
+// newDynamicOP will create an OpenID Provider for localhost on a specified port with a given encryption key
+// and a predefined default logout uri
+// it will enable all options (see descriptions)
+func newDynamicOP(ctx context.Context, storage op.Storage, key [32]byte) (*op.Provider, error) {
+ config := &op.Config{
+ CryptoKey: key,
+
+ //will be used if the end_session endpoint is called without a post_logout_redirect_uri
+ DefaultLogoutRedirectURI: pathLoggedOut,
+
+ //enables code_challenge_method S256 for PKCE (and therefore PKCE in general)
+ CodeMethodS256: true,
+
+ //enables additional client_id/client_secret authentication by form post (not only HTTP Basic Auth)
+ AuthMethodPost: true,
+
+ //enables additional authentication by using private_key_jwt
+ AuthMethodPrivateKeyJWT: true,
+
+ //enables refresh_token grant use
+ GrantTypeRefreshToken: true,
+
+ //enables use of the `request` Object parameter
+ RequestObjectSupported: true,
+
+ //this example has only static texts (in English), so we'll set the here accordingly
+ SupportedUILocales: []language.Tag{language.English},
+ }
+ handler, err := op.NewDynamicOpenIDProvider("/", config, storage,
+ //we must explicitly allow the use of the http issuer
+ op.WithAllowInsecure(),
+ //as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth
+ op.WithCustomAuthEndpoint(op.NewEndpoint("auth")),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return handler, nil
+}
diff --git a/example/server/exampleop/device.go b/example/server/exampleop/device.go
new file mode 100644
index 0000000..99505e4
--- /dev/null
+++ b/example/server/exampleop/device.go
@@ -0,0 +1,204 @@
+package exampleop
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
+ "github.com/go-chi/chi/v5"
+ "github.com/gorilla/securecookie"
+ "github.com/sirupsen/logrus"
+)
+
+type deviceAuthenticate interface {
+ CheckUsernamePasswordSimple(username, password string) error
+ op.DeviceAuthorizationStorage
+
+ // GetDeviceAuthorizationByUserCode resturns the current state of the device authorization flow,
+ // identified by the user code.
+ GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*op.DeviceAuthorizationState, error)
+
+ // CompleteDeviceAuthorization marks a device authorization entry as Completed,
+ // identified by userCode. The Subject is added to the state, so that
+ // GetDeviceAuthorizatonState can use it to create a new Access Token.
+ CompleteDeviceAuthorization(ctx context.Context, userCode, subject string) error
+
+ // DenyDeviceAuthorization marks a device authorization entry as Denied.
+ DenyDeviceAuthorization(ctx context.Context, userCode string) error
+}
+
+type deviceLogin struct {
+ storage deviceAuthenticate
+ cookie *securecookie.SecureCookie
+}
+
+func registerDeviceAuth(storage deviceAuthenticate, router chi.Router) {
+ l := &deviceLogin{
+ storage: storage,
+ cookie: securecookie.New(securecookie.GenerateRandomKey(32), nil),
+ }
+
+ router.HandleFunc("/", l.userCodeHandler)
+ router.Post("/login", l.loginHandler)
+ router.HandleFunc("/confirm", l.confirmHandler)
+}
+
+func renderUserCode(w io.Writer, err error) {
+ data := struct {
+ Error string
+ }{
+ Error: errMsg(err),
+ }
+
+ if err := templates.ExecuteTemplate(w, "usercode", data); err != nil {
+ logrus.Error(err)
+ }
+}
+
+func renderDeviceLogin(w http.ResponseWriter, userCode string, err error) {
+ data := &struct {
+ UserCode string
+ Error string
+ }{
+ UserCode: userCode,
+ Error: errMsg(err),
+ }
+ if err = templates.ExecuteTemplate(w, "device_login", data); err != nil {
+ logrus.Error(err)
+ }
+}
+
+func renderConfirmPage(w http.ResponseWriter, username, clientID string, scopes []string) {
+ data := &struct {
+ Username string
+ ClientID string
+ Scopes []string
+ }{
+ Username: username,
+ ClientID: clientID,
+ Scopes: scopes,
+ }
+ if err := templates.ExecuteTemplate(w, "confirm_device", data); err != nil {
+ logrus.Error(err)
+ }
+}
+
+func (d *deviceLogin) userCodeHandler(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ renderUserCode(w, err)
+ return
+ }
+ userCode := r.Form.Get("user_code")
+ if userCode == "" {
+ if prompt, _ := url.QueryUnescape(r.Form.Get("prompt")); prompt != "" {
+ err = errors.New(prompt)
+ }
+ renderUserCode(w, err)
+ return
+ }
+
+ renderDeviceLogin(w, userCode, nil)
+}
+
+func redirectBack(w http.ResponseWriter, r *http.Request, prompt string) {
+ values := make(url.Values)
+ values.Set("prompt", url.QueryEscape(prompt))
+
+ url := url.URL{
+ Path: "/device",
+ RawQuery: values.Encode(),
+ }
+ http.Redirect(w, r, url.String(), http.StatusSeeOther)
+}
+
+const userCodeCookieName = "user_code"
+
+type userCodeCookie struct {
+ UserCode string
+ UserName string
+}
+
+func (d *deviceLogin) loginHandler(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ redirectBack(w, r, err.Error())
+ return
+ }
+
+ userCode := r.PostForm.Get("user_code")
+ if userCode == "" {
+ redirectBack(w, r, "missing user_code in request")
+ return
+ }
+ username := r.PostForm.Get("username")
+ if username == "" {
+ redirectBack(w, r, "missing username in request")
+ return
+ }
+ password := r.PostForm.Get("password")
+ if password == "" {
+ redirectBack(w, r, "missing password in request")
+ return
+ }
+
+ if err := d.storage.CheckUsernamePasswordSimple(username, password); err != nil {
+ redirectBack(w, r, err.Error())
+ return
+ }
+ state, err := d.storage.GetDeviceAuthorizationByUserCode(r.Context(), userCode)
+ if err != nil {
+ redirectBack(w, r, err.Error())
+ return
+ }
+
+ encoded, err := d.cookie.Encode(userCodeCookieName, userCodeCookie{userCode, username})
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ cookie := &http.Cookie{
+ Name: userCodeCookieName,
+ Value: encoded,
+ Path: "/",
+ }
+ http.SetCookie(w, cookie)
+ renderConfirmPage(w, username, state.ClientID, state.Scopes)
+}
+
+func (d *deviceLogin) confirmHandler(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie(userCodeCookieName)
+ if err != nil {
+ redirectBack(w, r, err.Error())
+ return
+ }
+ data := new(userCodeCookie)
+ if err = d.cookie.Decode(userCodeCookieName, cookie.Value, &data); err != nil {
+ redirectBack(w, r, err.Error())
+ return
+ }
+ if err = r.ParseForm(); err != nil {
+ redirectBack(w, r, err.Error())
+ return
+ }
+
+ action := r.Form.Get("action")
+ switch action {
+ case "allowed":
+ err = d.storage.CompleteDeviceAuthorization(r.Context(), data.UserCode, data.UserName)
+ case "denied":
+ err = d.storage.DenyDeviceAuthorization(r.Context(), data.UserCode)
+ default:
+ err = errors.New("action must be one of \"allow\" or \"deny\"")
+ }
+ if err != nil {
+ redirectBack(w, r, err.Error())
+ return
+ }
+
+ fmt.Fprintf(w, "Device authorization %s. You can now return to the device", action)
+}
diff --git a/example/server/exampleop/login.go b/example/server/exampleop/login.go
index fd3dead..77a6189 100644
--- a/example/server/exampleop/login.go
+++ b/example/server/exampleop/login.go
@@ -1,65 +1,33 @@
package exampleop
import (
+ "context"
"fmt"
- "html/template"
"net/http"
- "github.com/gorilla/mux"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
+ "github.com/go-chi/chi/v5"
)
-const (
- queryAuthRequestID = "authRequestID"
-)
-
-var loginTmpl, _ = template.New("login").Parse(`
-
-
-
-
- Login
-
-
-
-
- `)
-
type login struct {
authenticate authenticate
- router *mux.Router
- callback func(string) string
+ router chi.Router
+ callback func(context.Context, string) string
}
-func NewLogin(authenticate authenticate, callback func(string) string) *login {
+func NewLogin(authenticate authenticate, callback func(context.Context, string) string, issuerInterceptor *op.IssuerInterceptor) *login {
l := &login{
authenticate: authenticate,
callback: callback,
}
- l.createRouter()
+ l.createRouter(issuerInterceptor)
return l
}
-func (l *login) createRouter() {
- l.router = mux.NewRouter()
- l.router.Path("/username").Methods("GET").HandlerFunc(l.loginHandler)
- l.router.Path("/username").Methods("POST").HandlerFunc(l.checkLoginHandler)
+func (l *login) createRouter(issuerInterceptor *op.IssuerInterceptor) {
+ l.router = chi.NewRouter()
+ l.router.Get("/username", l.loginHandler)
+ l.router.Post("/username", issuerInterceptor.HandlerFunc(l.checkLoginHandler))
}
type authenticate interface {
@@ -73,23 +41,19 @@ func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) {
return
}
// the oidc package will pass the id of the auth request as query parameter
- // we will use this id through the login process and therefore pass it to the login page
+ // we will use this id through the login process and therefore pass it to the login page
renderLogin(w, r.FormValue(queryAuthRequestID), nil)
}
func renderLogin(w http.ResponseWriter, id string, err error) {
- var errMsg string
- if err != nil {
- errMsg = err.Error()
- }
data := &struct {
ID string
Error string
}{
ID: id,
- Error: errMsg,
+ Error: errMsg(err),
}
- err = loginTmpl.Execute(w, data)
+ err = templates.ExecuteTemplate(w, "login", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -109,5 +73,5 @@ func (l *login) checkLoginHandler(w http.ResponseWriter, r *http.Request) {
renderLogin(w, id, err)
return
}
- http.Redirect(w, r, l.callback(id), http.StatusFound)
+ http.Redirect(w, r, l.callback(r.Context(), id), http.StatusFound)
}
diff --git a/example/server/exampleop/op.go b/example/server/exampleop/op.go
index 4794d8a..e12c755 100644
--- a/example/server/exampleop/op.go
+++ b/example/server/exampleop/op.go
@@ -1,77 +1,85 @@
package exampleop
import (
- "context"
"crypto/sha256"
"log"
+ "log/slog"
"net/http"
- "os"
+ "sync/atomic"
+ "time"
- "github.com/gorilla/mux"
+ "github.com/go-chi/chi/v5"
+ "github.com/zitadel/logging"
"golang.org/x/text/language"
- "github.com/zitadel/oidc/example/server/storage"
- "github.com/zitadel/oidc/pkg/op"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
)
const (
pathLoggedOut = "/logged-out"
)
-func init() {
- storage.RegisterClients(
- storage.NativeClient("native"),
- storage.WebClient("web", "secret"),
- storage.WebClient("api", "secret"),
- )
-}
-
type Storage interface {
op.Storage
- CheckUsernamePassword(username, password, id string) error
+ authenticate
+ deviceAuthenticate
}
+// simple counter for request IDs
+var counter atomic.Int64
+
// SetupServer creates an OIDC server with Issuer=http://localhost:
//
// Use one of the pre-made clients in storage/clients.go or register a new one.
-func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Router {
- // this will allow us to use an issuer with http:// instead of https://
- os.Setenv(op.OidcDevMode, "true")
-
+func SetupServer(issuer string, storage Storage, logger *slog.Logger, wrapServer bool, extraOptions ...op.Option) chi.Router {
// the OpenID Provider requires a 32-byte key for (token) encryption
// be sure to create a proper crypto random key and manage it securely!
key := sha256.Sum256([]byte("test"))
- router := mux.NewRouter()
+ router := chi.NewRouter()
+ router.Use(logging.Middleware(
+ logging.WithLogger(logger),
+ logging.WithIDFunc(func() slog.Attr {
+ return slog.Int64("id", counter.Add(1))
+ }),
+ ))
// for simplicity, we provide a very small default page for users who have signed out
router.HandleFunc(pathLoggedOut, func(w http.ResponseWriter, req *http.Request) {
- _, err := w.Write([]byte("signed out successfully"))
- if err != nil {
- log.Printf("error serving logged out page: %v", err)
- }
+ w.Write([]byte("signed out successfully"))
+ // no need to check/log error, this will be handled by the middleware.
})
// creation of the OpenIDProvider with the just created in-memory Storage
- provider, err := newOP(ctx, storage, issuer, key)
+ provider, err := newOP(storage, issuer, key, logger, extraOptions...)
if err != nil {
log.Fatal(err)
}
- // the provider will only take care of the OpenID Protocol, so there must be some sort of UI for the login process
- // for the simplicity of the example this means a simple page with username and password field
- l := NewLogin(storage, op.AuthCallbackURL(provider))
+ //the provider will only take care of the OpenID Protocol, so there must be some sort of UI for the login process
+ //for the simplicity of the example this means a simple page with username and password field
+ //be sure to provide an IssuerInterceptor with the IssuerFromRequest from the OP so the login can select / and pass it to the storage
+ l := NewLogin(storage, op.AuthCallbackURL(provider), op.NewIssuerInterceptor(provider.IssuerFromRequest))
// regardless of how many pages / steps there are in the process, the UI must be registered in the router,
// so we will direct all calls to /login to the login UI
- router.PathPrefix("/login/").Handler(http.StripPrefix("/login", l.router))
+ router.Mount("/login/", http.StripPrefix("/login", l.router))
+
+ router.Route("/device", func(r chi.Router) {
+ registerDeviceAuth(storage, r)
+ })
+
+ handler := http.Handler(provider)
+ if wrapServer {
+ handler = op.RegisterLegacyServer(op.NewLegacyServer(provider, *op.DefaultEndpoints), op.AuthorizeCallbackHandler(provider))
+ }
// we register the http handler of the OP on the root, so that the discovery endpoint (/.well-known/openid-configuration)
// is served on the correct path
//
// if your issuer ends with a path (e.g. http://localhost:9998/custom/path/),
// then you would have to set the path prefix (/custom/path/)
- router.PathPrefix("/").Handler(provider.HttpHandler())
+ router.Mount("/", handler)
return router
}
@@ -79,9 +87,8 @@ func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Route
// newOP will create an OpenID Provider for localhost on a specified port with a given encryption key
// and a predefined default logout uri
// it will enable all options (see descriptions)
-func newOP(ctx context.Context, storage op.Storage, issuer string, key [32]byte) (op.OpenIDProvider, error) {
+func newOP(storage op.Storage, issuer string, key [32]byte, logger *slog.Logger, extraOptions ...op.Option) (op.OpenIDProvider, error) {
config := &op.Config{
- Issuer: issuer,
CryptoKey: key,
// will be used if the end_session endpoint is called without a post_logout_redirect_uri
@@ -104,10 +111,23 @@ func newOP(ctx context.Context, storage op.Storage, issuer string, key [32]byte)
// this example has only static texts (in English), so we'll set the here accordingly
SupportedUILocales: []language.Tag{language.English},
+
+ DeviceAuthorization: op.DeviceAuthorizationConfig{
+ Lifetime: 5 * time.Minute,
+ PollInterval: 5 * time.Second,
+ UserFormPath: "/device",
+ UserCode: op.UserCodeBase20,
+ },
}
- handler, err := op.NewOpenIDProvider(ctx, config, storage,
- // as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth
- op.WithCustomAuthEndpoint(op.NewEndpoint("auth")),
+ handler, err := op.NewOpenIDProvider(issuer, config, storage,
+ append([]op.Option{
+ //we must explicitly allow the use of the http issuer
+ op.WithAllowInsecure(),
+ // as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth
+ op.WithCustomAuthEndpoint(op.NewEndpoint("auth")),
+ // Pass our logger to the OP
+ op.WithLogger(logger.WithGroup("op")),
+ }, extraOptions...)...,
)
if err != nil {
return nil, err
diff --git a/example/server/exampleop/templates.go b/example/server/exampleop/templates.go
new file mode 100644
index 0000000..5b5c966
--- /dev/null
+++ b/example/server/exampleop/templates.go
@@ -0,0 +1,26 @@
+package exampleop
+
+import (
+ "embed"
+ "html/template"
+
+ "github.com/sirupsen/logrus"
+)
+
+var (
+ //go:embed templates
+ templateFS embed.FS
+ templates = template.Must(template.ParseFS(templateFS, "templates/*.html"))
+)
+
+const (
+ queryAuthRequestID = "authRequestID"
+)
+
+func errMsg(err error) string {
+ if err == nil {
+ return ""
+ }
+ logrus.Error(err)
+ return err.Error()
+}
diff --git a/example/server/exampleop/templates/confirm_device.html b/example/server/exampleop/templates/confirm_device.html
new file mode 100644
index 0000000..a6bcdad
--- /dev/null
+++ b/example/server/exampleop/templates/confirm_device.html
@@ -0,0 +1,25 @@
+{{ define "confirm_device" -}}
+
+
+
+
+ Confirm device authorization
+
+
+
+
Welcome back {{.Username}}!
+
+ You are about to grant device {{.ClientID}} access to the following scopes: {{.Scopes}}.
+
+
+
+
+
+{{- end }}
diff --git a/example/server/exampleop/templates/device_login.html b/example/server/exampleop/templates/device_login.html
new file mode 100644
index 0000000..cc5b00b
--- /dev/null
+++ b/example/server/exampleop/templates/device_login.html
@@ -0,0 +1,29 @@
+{{ define "device_login" -}}
+
+
+
+
+ Login
+
+
+
+
+
+{{- end }}
diff --git a/example/server/exampleop/templates/login.html b/example/server/exampleop/templates/login.html
new file mode 100644
index 0000000..b048211
--- /dev/null
+++ b/example/server/exampleop/templates/login.html
@@ -0,0 +1,29 @@
+{{ define "login" -}}
+
+
+
+
+ Login
+
+
+
+
+`
+{{- end }}
\ No newline at end of file
diff --git a/example/server/exampleop/templates/usercode.html b/example/server/exampleop/templates/usercode.html
new file mode 100644
index 0000000..fb8fa7f
--- /dev/null
+++ b/example/server/exampleop/templates/usercode.html
@@ -0,0 +1,21 @@
+{{ define "usercode" -}}
+
+
+
+
+ Device authorization
+
+
+
+
+
+{{- end }}
diff --git a/example/server/main.go b/example/server/main.go
index 3cfd20d..5bdbb05 100644
--- a/example/server/main.go
+++ b/example/server/main.go
@@ -1,34 +1,59 @@
package main
import (
- "context"
- "log"
+ "fmt"
+ "log/slog"
"net/http"
+ "os"
- "github.com/zitadel/oidc/example/server/exampleop"
- "github.com/zitadel/oidc/example/server/storage"
+ "git.christmann.info/LARA/zitadel-oidc/v3/example/server/config"
+ "git.christmann.info/LARA/zitadel-oidc/v3/example/server/exampleop"
+ "git.christmann.info/LARA/zitadel-oidc/v3/example/server/storage"
)
+func getUserStore(cfg *config.Config) (storage.UserStore, error) {
+ if cfg.UsersFile == "" {
+ return storage.NewUserStore(fmt.Sprintf("http://localhost:%s/", cfg.Port)), nil
+ }
+ return storage.StoreFromFile(cfg.UsersFile)
+}
+
func main() {
- ctx := context.Background()
+ cfg := config.FromEnvVars(&config.Config{Port: "9998"})
+ logger := slog.New(
+ slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+ AddSource: true,
+ Level: slog.LevelDebug,
+ }),
+ )
+
+ //which gives us the issuer: http://localhost:9998/
+ issuer := fmt.Sprintf("http://localhost:%s/", cfg.Port)
+
+ storage.RegisterClients(
+ storage.NativeClient("native", cfg.RedirectURI...),
+ storage.WebClient("web", "secret", cfg.RedirectURI...),
+ storage.WebClient("api", "secret", cfg.RedirectURI...),
+ )
// the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations
// this might be the layer for accessing your database
// in this example it will be handled in-memory
- storage := storage.NewStorage(storage.NewUserStore())
-
- port := "9998"
- router := exampleop.SetupServer(ctx, "http://localhost:"+port, storage)
+ store, err := getUserStore(cfg)
+ if err != nil {
+ logger.Error("cannot create UserStore", "error", err)
+ os.Exit(1)
+ }
+ storage := storage.NewStorage(store)
+ router := exampleop.SetupServer(issuer, storage, logger, false)
server := &http.Server{
- Addr: ":" + port,
+ Addr: ":" + cfg.Port,
Handler: router,
}
- log.Printf("server listening on http://localhost:%s/", port)
- log.Println("press ctrl+c to stop")
- err := server.ListenAndServe()
- if err != nil {
- log.Fatal(err)
+ logger.Info("server listening, press ctrl+c to stop", "addr", issuer)
+ if server.ListenAndServe() != http.ErrServerClosed {
+ logger.Error("server terminated", "error", err)
+ os.Exit(1)
}
- <-ctx.Done()
}
diff --git a/example/server/storage/client.go b/example/server/storage/client.go
index 0b98679..2b836c0 100644
--- a/example/server/storage/client.go
+++ b/example/server/storage/client.go
@@ -3,8 +3,8 @@ package storage
import (
"time"
- "github.com/zitadel/oidc/pkg/oidc"
- "github.com/zitadel/oidc/pkg/op"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
)
var (
@@ -32,6 +32,8 @@ type Client struct {
devMode bool
idTokenUserinfoClaimsAssertion bool
clockSkew time.Duration
+ postLogoutRedirectURIGlobs []string
+ redirectURIGlobs []string
}
// GetID must return the client_id
@@ -44,21 +46,11 @@ func (c *Client) RedirectURIs() []string {
return c.redirectURIs
}
-// RedirectURIGlobs provide wildcarding for additional valid redirects
-func (c *Client) RedirectURIGlobs() []string {
- return nil
-}
-
// PostLogoutRedirectURIs must return the registered post_logout_redirect_uris for sign-outs
func (c *Client) PostLogoutRedirectURIs() []string {
return []string{}
}
-// PostLogoutRedirectURIGlobs provide extra wildcarding for additional valid redirects
-func (c *Client) PostLogoutRedirectURIGlobs() []string {
- return nil
-}
-
// ApplicationType must return the type of the client (app, native, user agent)
func (c *Client) ApplicationType() op.ApplicationType {
return c.applicationType
@@ -168,7 +160,7 @@ func NativeClient(id string, redirectURIs ...string) *Client {
loginURL: defaultLoginURL,
responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode},
grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken},
- accessTokenType: 0,
+ accessTokenType: op.AccessTokenTypeBearer,
devMode: false,
idTokenUserinfoClaimsAssertion: false,
clockSkew: 0,
@@ -192,11 +184,52 @@ func WebClient(id, secret string, redirectURIs ...string) *Client {
applicationType: op.ApplicationTypeWeb,
authMethod: oidc.AuthMethodBasic,
loginURL: defaultLoginURL,
+ responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode, oidc.ResponseTypeIDTokenOnly, oidc.ResponseTypeIDToken},
+ grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken, oidc.GrantTypeTokenExchange},
+ accessTokenType: op.AccessTokenTypeBearer,
+ devMode: true,
+ idTokenUserinfoClaimsAssertion: false,
+ clockSkew: 0,
+ }
+}
+
+// DeviceClient creates a device client with Basic authentication.
+func DeviceClient(id, secret string) *Client {
+ return &Client{
+ id: id,
+ secret: secret,
+ redirectURIs: nil,
+ applicationType: op.ApplicationTypeWeb,
+ authMethod: oidc.AuthMethodBasic,
+ loginURL: defaultLoginURL,
responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode},
- grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken},
- accessTokenType: 0,
+ grantTypes: []oidc.GrantType{oidc.GrantTypeDeviceCode},
+ accessTokenType: op.AccessTokenTypeBearer,
devMode: false,
idTokenUserinfoClaimsAssertion: false,
clockSkew: 0,
}
}
+
+type hasRedirectGlobs struct {
+ *Client
+}
+
+// RedirectURIGlobs provide wildcarding for additional valid redirects
+func (c hasRedirectGlobs) RedirectURIGlobs() []string {
+ return c.redirectURIGlobs
+}
+
+// PostLogoutRedirectURIGlobs provide extra wildcarding for additional valid redirects
+func (c hasRedirectGlobs) PostLogoutRedirectURIGlobs() []string {
+ return c.postLogoutRedirectURIGlobs
+}
+
+// RedirectGlobsClient wraps the client in a op.HasRedirectGlobs
+// only if DevMode is enabled.
+func RedirectGlobsClient(client *Client) op.Client {
+ if client.devMode {
+ return hasRedirectGlobs{client}
+ }
+ return client
+}
diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go
index 91afd90..9c7f544 100644
--- a/example/server/storage/oidc.go
+++ b/example/server/storage/oidc.go
@@ -1,13 +1,13 @@
package storage
import (
+ "log/slog"
"time"
"golang.org/x/text/language"
- "github.com/zitadel/oidc/pkg/op"
-
- "github.com/zitadel/oidc/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
)
const (
@@ -17,6 +17,9 @@ const (
// CustomClaim is an example for how to return custom claims with this library
CustomClaim = "custom_claim"
+
+ // CustomScopeImpersonatePrefix is an example scope prefix for passing user id to impersonate using token exchage
+ CustomScopeImpersonatePrefix = "custom_scope:impersonate:"
)
type AuthRequest struct {
@@ -32,11 +35,25 @@ type AuthRequest struct {
UserID string
Scopes []string
ResponseType oidc.ResponseType
+ ResponseMode oidc.ResponseMode
Nonce string
CodeChallenge *OIDCCodeChallenge
- passwordChecked bool
- authTime time.Time
+ done bool
+ authTime time.Time
+}
+
+// LogValue allows you to define which fields will be logged.
+// Implements the [slog.LogValuer]
+func (a *AuthRequest) LogValue() slog.Value {
+ return slog.GroupValue(
+ slog.String("id", a.ID),
+ slog.Time("creation_date", a.CreationDate),
+ slog.Any("scopes", a.Scopes),
+ slog.String("response_type", string(a.ResponseType)),
+ slog.String("app_id", a.ApplicationID),
+ slog.String("callback_uri", a.CallbackURI),
+ )
}
func (a *AuthRequest) GetID() string {
@@ -49,7 +66,7 @@ func (a *AuthRequest) GetACR() string {
func (a *AuthRequest) GetAMR() []string {
// this example only uses password for authentication
- if a.passwordChecked {
+ if a.done {
return []string{"pwd"}
}
return nil
@@ -84,7 +101,7 @@ func (a *AuthRequest) GetResponseType() oidc.ResponseType {
}
func (a *AuthRequest) GetResponseMode() oidc.ResponseMode {
- return "" // we won't handle response mode in this example
+ return a.ResponseMode
}
func (a *AuthRequest) GetScopes() []string {
@@ -100,11 +117,11 @@ func (a *AuthRequest) GetSubject() string {
}
func (a *AuthRequest) Done() bool {
- return a.passwordChecked // this example only uses password for authentication
+ return a.done
}
func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []string {
- prompts := make([]string, len(oidcPrompt))
+ prompts := make([]string, 0, len(oidcPrompt))
for _, oidcPrompt := range oidcPrompt {
switch oidcPrompt {
case oidc.PromptNone,
@@ -138,6 +155,7 @@ func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthReques
UserID: userID,
Scopes: authReq.Scopes,
ResponseType: authReq.ResponseType,
+ ResponseMode: authReq.ResponseMode,
Nonce: authReq.Nonce,
CodeChallenge: &OIDCCodeChallenge{
Challenge: authReq.CodeChallenge,
@@ -146,6 +164,15 @@ func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthReques
}
}
+type AuthRequestWithSessionState struct {
+ *AuthRequest
+ SessionState string
+}
+
+func (a *AuthRequestWithSessionState) GetSessionState() string {
+ return a.SessionState
+}
+
type OIDCCodeChallenge struct {
Challenge string
Method string
diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go
index 130822e..d4315c6 100644
--- a/example/server/storage/storage.go
+++ b/example/server/storage/storage.go
@@ -4,16 +4,18 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
+ "errors"
"fmt"
"math/big"
+ "strings"
"sync"
"time"
+ jose "github.com/go-jose/go-jose/v4"
"github.com/google/uuid"
- "gopkg.in/square/go-jose.v2"
- "github.com/zitadel/oidc/pkg/oidc"
- "github.com/zitadel/oidc/pkg/op"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
)
// serviceKey1 is a public key which will be used for the JWT Profile Authorization Grant
@@ -26,8 +28,10 @@ var serviceKey1 = &rsa.PublicKey{
E: 65537,
}
-// var _ op.Storage = &storage{}
-// var _ op.ClientCredentialsStorage = &storage{}
+var (
+ _ op.Storage = &Storage{}
+ _ op.ClientCredentialsStorage = &Storage{}
+)
// storage implements the op.Storage interface
// typically you would implement this as a layer on top of your database
@@ -42,15 +46,54 @@ type Storage struct {
services map[string]Service
refreshTokens map[string]*RefreshToken
signingKey signingKey
+ deviceCodes map[string]deviceAuthorizationEntry
+ userCodes map[string]string
+ serviceUsers map[string]*Client
}
type signingKey struct {
- ID string
- Algorithm string
- Key *rsa.PrivateKey
+ id string
+ algorithm jose.SignatureAlgorithm
+ key *rsa.PrivateKey
+}
+
+func (s *signingKey) SignatureAlgorithm() jose.SignatureAlgorithm {
+ return s.algorithm
+}
+
+func (s *signingKey) Key() any {
+ return s.key
+}
+
+func (s *signingKey) ID() string {
+ return s.id
+}
+
+type publicKey struct {
+ signingKey
+}
+
+func (s *publicKey) ID() string {
+ return s.id
+}
+
+func (s *publicKey) Algorithm() jose.SignatureAlgorithm {
+ return s.algorithm
+}
+
+func (s *publicKey) Use() string {
+ return "sig"
+}
+
+func (s *publicKey) Key() any {
+ return &s.key.PublicKey
}
func NewStorage(userStore UserStore) *Storage {
+ return NewStorageWithClients(userStore, clients)
+}
+
+func NewStorageWithClients(userStore UserStore, clients map[string]*Client) *Storage {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
return &Storage{
authRequests: make(map[string]*AuthRequest),
@@ -67,9 +110,21 @@ func NewStorage(userStore UserStore) *Storage {
},
},
signingKey: signingKey{
- ID: "id",
- Algorithm: "RS256",
- Key: key,
+ id: uuid.NewString(),
+ algorithm: jose.RS256,
+ key: key,
+ },
+ deviceCodes: make(map[string]deviceAuthorizationEntry),
+ userCodes: make(map[string]string),
+ serviceUsers: map[string]*Client{
+ "sid1": {
+ id: "sid1",
+ secret: "verysecret",
+ grantTypes: []oidc.GrantType{
+ oidc.GrantTypeClientCredentials,
+ },
+ accessTokenType: op.AccessTokenTypeBearer,
+ },
},
}
}
@@ -95,7 +150,21 @@ func (s *Storage) CheckUsernamePassword(username, password, id string) error {
// you will have to change some state on the request to guide the user through possible multiple steps of the login process
// in this example we'll simply check the username / password and set a boolean to true
// therefore we will also just check this boolean if the request / login has been finished
- request.passwordChecked = true
+ request.done = true
+
+ request.authTime = time.Now()
+
+ return nil
+ }
+ return fmt.Errorf("username or password wrong")
+}
+
+func (s *Storage) CheckUsernamePasswordSimple(username, password string) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ user := s.userStore.GetUserByUsername(username)
+ if user != nil && user.Password == password {
return nil
}
return fmt.Errorf("username or password wrong")
@@ -107,6 +176,12 @@ func (s *Storage) CreateAuthRequest(ctx context.Context, authReq *oidc.AuthReque
s.lock.Lock()
defer s.lock.Unlock()
+ if len(authReq.Prompt) == 1 && authReq.Prompt[0] == "none" {
+ // With prompt=none, there is no way for the user to log in
+ // so return error right away.
+ return nil, oidc.ErrLoginRequired()
+ }
+
// typically, you'll fill your storage / storage model with the information of the passed object
request := authRequestToInternal(authReq, userID)
@@ -181,11 +256,14 @@ func (s *Storage) DeleteAuthRequest(ctx context.Context, id string) error {
// it will be called for all requests able to return an access token (Authorization Code Flow, Implicit Flow, JWT Profile, ...)
func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) {
var applicationID string
- // if authenticated for an app (auth code / implicit flow) we must save the client_id to the token
- authReq, ok := request.(*AuthRequest)
- if ok {
- applicationID = authReq.ApplicationID
+ switch req := request.(type) {
+ case *AuthRequest:
+ // if authenticated for an app (auth code / implicit flow) we must save the client_id to the token
+ applicationID = req.ApplicationID
+ case op.TokenExchangeRequest:
+ applicationID = req.GetClientID()
}
+
token, err := s.accessToken(applicationID, "", request.GetSubject(), request.GetAudience(), request.GetScopes())
if err != nil {
return "", time.Time{}, err
@@ -196,6 +274,11 @@ func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest
// CreateAccessAndRefreshTokens implements the op.Storage interface
// it will be called for all requests able to return an access and refresh token (Authorization Code Flow, Refresh Token Request)
func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) {
+ // generate tokens via token exchange flow if request is relevant
+ if teReq, ok := request.(op.TokenExchangeRequest); ok {
+ return s.exchangeRefreshToken(ctx, teReq)
+ }
+
// get the information depending on the request type / implementation
applicationID, authTime, amr := getInfoFromRequest(request)
@@ -215,14 +298,36 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T
// if we get here, the currentRefreshToken was not empty, so the call is a refresh token request
// we therefore will have to check the currentRefreshToken and renew the refresh token
- refreshToken, refreshTokenID, err := s.renewRefreshToken(currentRefreshToken)
+
+ newRefreshToken = uuid.NewString()
+
+ accessToken, err := s.accessToken(applicationID, newRefreshToken, request.GetSubject(), request.GetAudience(), request.GetScopes())
if err != nil {
return "", "", time.Time{}, err
}
+
+ if err := s.renewRefreshToken(currentRefreshToken, newRefreshToken, accessToken.ID); err != nil {
+ return "", "", time.Time{}, err
+ }
+
+ return accessToken.ID, newRefreshToken, accessToken.Expiration, nil
+}
+
+func (s *Storage) exchangeRefreshToken(ctx context.Context, request op.TokenExchangeRequest) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) {
+ applicationID := request.GetClientID()
+ authTime := request.GetAuthTime()
+
+ refreshTokenID := uuid.NewString()
accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes())
if err != nil {
return "", "", time.Time{}, err
}
+
+ refreshToken, err := s.createRefreshToken(accessToken, nil, authTime)
+ if err != nil {
+ return "", "", time.Time{}, err
+ }
+
return accessToken.ID, refreshToken, accessToken.Expiration, nil
}
@@ -252,6 +357,16 @@ func (s *Storage) TerminateSession(ctx context.Context, userID string, clientID
return nil
}
+// GetRefreshTokenInfo looks up a refresh token and returns the token id and user id.
+// If given something that is not a refresh token, it must return error.
+func (s *Storage) GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) {
+ refreshToken, ok := s.refreshTokens[token]
+ if !ok {
+ return "", "", op.ErrInvalidRefreshToken
+ }
+ return refreshToken.UserID, refreshToken.ID, nil
+}
+
// RevokeToken implements the op.Storage interface
// it will be called after parsing and validation of the token revocation request
func (s *Storage) RevokeToken(ctx context.Context, tokenIDOrToken string, userID string, clientID string) *oidc.Error {
@@ -277,52 +392,35 @@ func (s *Storage) RevokeToken(ctx context.Context, tokenIDOrToken string, userID
if refreshToken.ApplicationID != clientID {
return oidc.ErrInvalidClient().WithDescription("token was not issued for this client")
}
- // if it is a refresh token, you will have to remove the access token as well
delete(s.refreshTokens, refreshToken.ID)
- for _, accessToken := range s.tokens {
- if accessToken.RefreshTokenID == refreshToken.ID {
- delete(s.tokens, accessToken.ID)
- return nil
- }
- }
+ // if it is a refresh token, you will have to remove the access token as well
+ delete(s.tokens, refreshToken.AccessToken)
return nil
}
-// GetSigningKey implements the op.Storage interface
+// SigningKey implements the op.Storage interface
// it will be called when creating the OpenID Provider
-func (s *Storage) GetSigningKey(ctx context.Context, keyCh chan<- jose.SigningKey) {
+func (s *Storage) SigningKey(ctx context.Context) (op.SigningKey, error) {
// in this example the signing key is a static rsa.PrivateKey and the algorithm used is RS256
// you would obviously have a more complex implementation and store / retrieve the key from your database as well
- //
- // the idea of the signing key channel is, that you can (with what ever mechanism) rotate your signing key and
- // switch the key of the signer via this channel
- keyCh <- jose.SigningKey{
- Algorithm: jose.SignatureAlgorithm(s.signingKey.Algorithm), // always tell the signer with algorithm to use
- Key: jose.JSONWebKey{
- KeyID: s.signingKey.ID, // always give the key an id so, that it will include it in the token header as `kid` claim
- Key: s.signingKey.Key,
- },
- }
+ return &s.signingKey, nil
}
-// GetKeySet implements the op.Storage interface
+// SignatureAlgorithms implements the op.Storage interface
+// it will be called to get the sign
+func (s *Storage) SignatureAlgorithms(context.Context) ([]jose.SignatureAlgorithm, error) {
+ return []jose.SignatureAlgorithm{s.signingKey.algorithm}, nil
+}
+
+// KeySet implements the op.Storage interface
// it will be called to get the current (public) keys, among others for the keys_endpoint or for validating access_tokens on the userinfo_endpoint, ...
-func (s *Storage) GetKeySet(ctx context.Context) (*jose.JSONWebKeySet, error) {
+func (s *Storage) KeySet(ctx context.Context) ([]op.Key, error) {
// as mentioned above, this example only has a single signing key without key rotation,
// so it will directly use its public key
//
// when using key rotation you typically would store the public keys alongside the private keys in your database
- // and give both of them an expiration date, with the public key having a longer lifetime (e.g. rotate private key every
- return &jose.JSONWebKeySet{
- Keys: []jose.JSONWebKey{
- {
- KeyID: s.signingKey.ID,
- Algorithm: s.signingKey.Algorithm,
- Use: oidc.KeyUseSignature,
- Key: &s.signingKey.Key.PublicKey,
- },
- },
- }, nil
+ // and give both of them an expiration date, with the public key having a longer lifetime
+ return []op.Key{&publicKey{s.signingKey}}, nil
}
// GetClientByClientID implements the op.Storage interface
@@ -334,7 +432,7 @@ func (s *Storage) GetClientByClientID(ctx context.Context, clientID string) (op.
if !ok {
return nil, fmt.Errorf("client not found")
}
- return client, nil
+ return RedirectGlobsClient(client), nil
}
// AuthorizeClientIDSecret implements the op.Storage interface
@@ -354,15 +452,22 @@ func (s *Storage) AuthorizeClientIDSecret(ctx context.Context, clientID, clientS
return nil
}
-// SetUserinfoFromScopes implements the op.Storage interface
-// it will be called for the creation of an id_token, so we'll just pass it to the private function without any further check
-func (s *Storage) SetUserinfoFromScopes(ctx context.Context, userinfo oidc.UserInfoSetter, userID, clientID string, scopes []string) error {
- return s.setUserinfo(ctx, userinfo, userID, clientID, scopes)
+// SetUserinfoFromScopes implements the op.Storage interface.
+// Provide an empty implementation and use SetUserinfoFromRequest instead.
+func (s *Storage) SetUserinfoFromScopes(ctx context.Context, userinfo *oidc.UserInfo, userID, clientID string, scopes []string) error {
+ return nil
+}
+
+// SetUserinfoFromRequests implements the op.CanSetUserinfoFromRequest interface. In the
+// next major release, it will be required for op.Storage.
+// It will be called for the creation of an id_token, so we'll just pass it to the private function without any further check
+func (s *Storage) SetUserinfoFromRequest(ctx context.Context, userinfo *oidc.UserInfo, token op.IDTokenRequest, scopes []string) error {
+ return s.setUserinfo(ctx, userinfo, token.GetSubject(), token.GetClientID(), scopes)
}
// SetUserinfoFromToken implements the op.Storage interface
// it will be called for the userinfo endpoint, so we read the token and pass the information from that to the private function
-func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, tokenID, subject, origin string) error {
+func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserInfo, tokenID, subject, origin string) error {
token, ok := func() (*Token, bool) {
s.lock.Lock()
defer s.lock.Unlock()
@@ -385,12 +490,15 @@ func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserIn
// return err
// }
//}
+ if token.Expiration.Before(time.Now()) {
+ return fmt.Errorf("token is expired")
+ }
return s.setUserinfo(ctx, userinfo, token.Subject, token.ApplicationID, token.Scopes)
}
// SetIntrospectionFromToken implements the op.Storage interface
// it will be called for the introspection endpoint, so we read the token and pass the information from that to the private function
-func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection oidc.IntrospectionResponse, tokenID, subject, clientID string) error {
+func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) error {
token, ok := func() (*Token, bool) {
s.lock.Lock()
defer s.lock.Unlock()
@@ -407,14 +515,17 @@ func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection o
// this will automatically be done by the library if you don't return an error
// you can also return further information about the user / associated token
// e.g. the userinfo (equivalent to userinfo endpoint)
- err := s.setUserinfo(ctx, introspection, subject, clientID, token.Scopes)
+
+ userInfo := new(oidc.UserInfo)
+ err := s.setUserinfo(ctx, userInfo, subject, clientID, token.Scopes)
if err != nil {
return err
}
+ introspection.SetUserInfo(userInfo)
//...and also the requested scopes...
- introspection.SetScopes(token.Scopes)
+ introspection.Scope = token.Scopes
//...and the client the token was issued to
- introspection.SetClientID(token.ApplicationID)
+ introspection.ClientID = token.ApplicationID
return nil
}
}
@@ -423,7 +534,11 @@ func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection o
// GetPrivateClaimsFromScopes implements the op.Storage interface
// it will be called for the creation of a JWT access token to assert claims for custom scopes
-func (s *Storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) {
+func (s *Storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]any, err error) {
+ return s.getPrivateClaimsFromScopes(ctx, userID, clientID, scopes)
+}
+
+func (s *Storage) getPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]any, err error) {
for _, scope := range scopes {
switch scope {
case CustomScope:
@@ -433,9 +548,9 @@ func (s *Storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, client
return claims, nil
}
-// GetKeyByIDAndUserID implements the op.Storage interface
+// GetKeyByIDAndClientID implements the op.Storage interface
// it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication)
-func (s *Storage) GetKeyByIDAndUserID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) {
+func (s *Storage) GetKeyByIDAndClientID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) {
s.lock.Lock()
defer s.lock.Unlock()
service, ok := s.services[clientID]
@@ -484,33 +599,41 @@ func (s *Storage) createRefreshToken(accessToken *Token, amr []string, authTime
Audience: accessToken.Audience,
Expiration: time.Now().Add(5 * time.Hour),
Scopes: accessToken.Scopes,
+ AccessToken: accessToken.ID,
}
s.refreshTokens[token.ID] = token
return token.Token, nil
}
// renewRefreshToken checks the provided refresh_token and creates a new one based on the current
-func (s *Storage) renewRefreshToken(currentRefreshToken string) (string, string, error) {
+//
+// [Refresh Token Rotation] is implemented.
+//
+// [Refresh Token Rotation]: https://www.rfc-editor.org/rfc/rfc6819#section-5.2.2.3
+func (s *Storage) renewRefreshToken(currentRefreshToken, newRefreshToken, newAccessToken string) error {
s.lock.Lock()
defer s.lock.Unlock()
refreshToken, ok := s.refreshTokens[currentRefreshToken]
if !ok {
- return "", "", fmt.Errorf("invalid refresh token")
+ return fmt.Errorf("invalid refresh token")
}
- // deletes the refresh token and all access tokens which were issued based on this refresh token
+ // deletes the refresh token
delete(s.refreshTokens, currentRefreshToken)
- for _, token := range s.tokens {
- if token.RefreshTokenID == currentRefreshToken {
- delete(s.tokens, token.ID)
- break
- }
+
+ // delete the access token which was issued based on this refresh token
+ delete(s.tokens, refreshToken.AccessToken)
+
+ if refreshToken.Expiration.Before(time.Now()) {
+ return fmt.Errorf("expired refresh token")
}
+
// creates a new refresh token based on the current one
- token := uuid.NewString()
- refreshToken.Token = token
- refreshToken.ID = token
- s.refreshTokens[token] = refreshToken
- return token, refreshToken.ID, nil
+ refreshToken.Token = newRefreshToken
+ refreshToken.ID = newRefreshToken
+ refreshToken.Expiration = time.Now().Add(5 * time.Hour)
+ refreshToken.AccessToken = newAccessToken
+ s.refreshTokens[newRefreshToken] = refreshToken
+ return nil
}
// accessToken will store an access_token in-memory based on the provided information
@@ -531,7 +654,7 @@ func (s *Storage) accessToken(applicationID, refreshTokenID, subject string, aud
}
// setUserinfo sets the info based on the user, scopes and if necessary the clientID
-func (s *Storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter, userID, clientID string, scopes []string) (err error) {
+func (s *Storage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, userID, clientID string, scopes []string) (err error) {
s.lock.Lock()
defer s.lock.Unlock()
user := s.userStore.GetUserByID(userID)
@@ -541,17 +664,19 @@ func (s *Storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter,
for _, scope := range scopes {
switch scope {
case oidc.ScopeOpenID:
- userInfo.SetSubject(user.ID)
+ userInfo.Subject = user.ID
case oidc.ScopeEmail:
- userInfo.SetEmail(user.Email, user.EmailVerified)
+ userInfo.Email = user.Email
+ userInfo.EmailVerified = oidc.Bool(user.EmailVerified)
case oidc.ScopeProfile:
- userInfo.SetPreferredUsername(user.Username)
- userInfo.SetName(user.FirstName + " " + user.LastName)
- userInfo.SetFamilyName(user.LastName)
- userInfo.SetGivenName(user.FirstName)
- userInfo.SetLocale(user.PreferredLanguage)
+ userInfo.PreferredUsername = user.Username
+ userInfo.Name = user.FirstName + " " + user.LastName
+ userInfo.FamilyName = user.LastName
+ userInfo.GivenName = user.FirstName
+ userInfo.Locale = oidc.NewLocale(user.PreferredLanguage)
case oidc.ScopePhone:
- userInfo.SetPhone(user.Phone, user.PhoneVerified)
+ userInfo.PhoneNumber = user.Phone
+ userInfo.PhoneNumberVerified = user.PhoneVerified
case CustomScope:
// you can also have a custom scope and assert public or custom claims based on that
userInfo.AppendClaims(CustomClaim, customClaim(clientID))
@@ -560,6 +685,101 @@ func (s *Storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter,
return nil
}
+// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface
+// it will be called to validate parsed Token Exchange Grant request
+func (s *Storage) ValidateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error {
+ if request.GetRequestedTokenType() == "" {
+ request.SetRequestedTokenType(oidc.RefreshTokenType)
+ }
+
+ // Just an example, some use cases might need this use case
+ if request.GetExchangeSubjectTokenType() == oidc.IDTokenType && request.GetRequestedTokenType() == oidc.RefreshTokenType {
+ return errors.New("exchanging id_token to refresh_token is not supported")
+ }
+
+ // Check impersonation permissions
+ if request.GetExchangeActor() == "" && !s.userStore.GetUserByID(request.GetExchangeSubject()).IsAdmin {
+ return errors.New("user doesn't have impersonation permission")
+ }
+
+ allowedScopes := make([]string, 0)
+ for _, scope := range request.GetScopes() {
+ if scope == oidc.ScopeAddress {
+ continue
+ }
+
+ if strings.HasPrefix(scope, CustomScopeImpersonatePrefix) {
+ subject := strings.TrimPrefix(scope, CustomScopeImpersonatePrefix)
+ request.SetSubject(subject)
+ }
+
+ allowedScopes = append(allowedScopes, scope)
+ }
+
+ request.SetCurrentScopes(allowedScopes)
+
+ return nil
+}
+
+// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface
+// Common use case is to store request for audit purposes. For this example we skip the storing.
+func (s *Storage) CreateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error {
+ return nil
+}
+
+// GetPrivateClaimsFromScopesForTokenExchange implements the op.TokenExchangeStorage interface
+// it will be called for the creation of an exchanged JWT access token to assert claims for custom scopes
+// plus adding token exchange specific claims related to delegation or impersonation
+func (s *Storage) GetPrivateClaimsFromTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]any, err error) {
+ claims, err = s.getPrivateClaimsFromScopes(ctx, "", request.GetClientID(), request.GetScopes())
+ if err != nil {
+ return nil, err
+ }
+
+ for k, v := range s.getTokenExchangeClaims(ctx, request) {
+ claims = appendClaim(claims, k, v)
+ }
+
+ return claims, nil
+}
+
+// SetUserinfoFromScopesForTokenExchange implements the op.TokenExchangeStorage interface
+// it will be called for the creation of an id_token - we are using the same private function as for other flows,
+// plus adding token exchange specific claims related to delegation or impersonation
+func (s *Storage) SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo *oidc.UserInfo, request op.TokenExchangeRequest) error {
+ err := s.setUserinfo(ctx, userinfo, request.GetSubject(), request.GetClientID(), request.GetScopes())
+ if err != nil {
+ return err
+ }
+
+ for k, v := range s.getTokenExchangeClaims(ctx, request) {
+ userinfo.AppendClaims(k, v)
+ }
+
+ return nil
+}
+
+func (s *Storage) getTokenExchangeClaims(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]any) {
+ for _, scope := range request.GetScopes() {
+ switch {
+ case strings.HasPrefix(scope, CustomScopeImpersonatePrefix) && request.GetExchangeActor() == "":
+ // Set actor subject claim for impersonation flow
+ claims = appendClaim(claims, "act", map[string]any{
+ "sub": request.GetExchangeSubject(),
+ })
+ }
+ }
+
+ // Set actor subject claim for delegation flow
+ // if request.GetExchangeActor() != "" {
+ // claims = appendClaim(claims, "act", map[string]any{
+ // "sub": request.GetExchangeActor(),
+ // })
+ // }
+
+ return claims
+}
+
// getInfoFromRequest returns the clientID, authTime and amr depending on the op.TokenRequest type / implementation
func getInfoFromRequest(req op.TokenRequest) (clientID string, authTime time.Time, amr []string) {
authReq, ok := req.(*AuthRequest) // Code Flow (with scope offline_access)
@@ -574,17 +794,140 @@ func getInfoFromRequest(req op.TokenRequest) (clientID string, authTime time.Tim
}
// customClaim demonstrates how to return custom claims based on provided information
-func customClaim(clientID string) map[string]interface{} {
- return map[string]interface{}{
+func customClaim(clientID string) map[string]any {
+ return map[string]any{
"client": clientID,
"other": "stuff",
}
}
-func appendClaim(claims map[string]interface{}, claim string, value interface{}) map[string]interface{} {
+func appendClaim(claims map[string]any, claim string, value any) map[string]any {
if claims == nil {
- claims = make(map[string]interface{})
+ claims = make(map[string]any)
}
claims[claim] = value
return claims
}
+
+type deviceAuthorizationEntry struct {
+ deviceCode string
+ userCode string
+ state *op.DeviceAuthorizationState
+}
+
+func (s *Storage) StoreDeviceAuthorization(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes []string) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ if _, ok := s.clients[clientID]; !ok {
+ return errors.New("client not found")
+ }
+
+ if _, ok := s.userCodes[userCode]; ok {
+ return op.ErrDuplicateUserCode
+ }
+
+ s.deviceCodes[deviceCode] = deviceAuthorizationEntry{
+ deviceCode: deviceCode,
+ userCode: userCode,
+ state: &op.DeviceAuthorizationState{
+ ClientID: clientID,
+ Scopes: scopes,
+ Expires: expires,
+ },
+ }
+
+ s.userCodes[userCode] = deviceCode
+ return nil
+}
+
+func (s *Storage) GetDeviceAuthorizatonState(ctx context.Context, clientID, deviceCode string) (*op.DeviceAuthorizationState, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ entry, ok := s.deviceCodes[deviceCode]
+ if !ok || entry.state.ClientID != clientID {
+ return nil, errors.New("device code not found for client") // is there a standard not found error in the framework?
+ }
+
+ return entry.state, nil
+}
+
+func (s *Storage) GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*op.DeviceAuthorizationState, error) {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ entry, ok := s.deviceCodes[s.userCodes[userCode]]
+ if !ok {
+ return nil, errors.New("user code not found")
+ }
+
+ return entry.state, nil
+}
+
+func (s *Storage) CompleteDeviceAuthorization(ctx context.Context, userCode, subject string) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ entry, ok := s.deviceCodes[s.userCodes[userCode]]
+ if !ok {
+ return errors.New("user code not found")
+ }
+
+ entry.state.Subject = subject
+ entry.state.Done = true
+ return nil
+}
+
+func (s *Storage) DenyDeviceAuthorization(ctx context.Context, userCode string) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ s.deviceCodes[s.userCodes[userCode]].state.Denied = true
+ return nil
+}
+
+// AuthRequestDone is used by testing and is not required to implement op.Storage
+func (s *Storage) AuthRequestDone(id string) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ if req, ok := s.authRequests[id]; ok {
+ req.done = true
+ return nil
+ }
+
+ return errors.New("request not found")
+}
+
+func (s *Storage) ClientCredentials(ctx context.Context, clientID, clientSecret string) (op.Client, error) {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ client, ok := s.serviceUsers[clientID]
+ if !ok {
+ return nil, errors.New("wrong service user or password")
+ }
+ if client.secret != clientSecret {
+ return nil, errors.New("wrong service user or password")
+ }
+
+ return client, nil
+}
+
+func (s *Storage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (op.TokenRequest, error) {
+ client, ok := s.serviceUsers[clientID]
+ if !ok {
+ return nil, errors.New("wrong service user or password")
+ }
+
+ return &oidc.JWTTokenRequest{
+ Subject: client.id,
+ Audience: []string{clientID},
+ Scopes: scopes,
+ }, nil
+}
diff --git a/example/server/storage/storage_dynamic.go b/example/server/storage/storage_dynamic.go
new file mode 100644
index 0000000..765d29a
--- /dev/null
+++ b/example/server/storage/storage_dynamic.go
@@ -0,0 +1,281 @@
+package storage
+
+import (
+ "context"
+ "time"
+
+ jose "github.com/go-jose/go-jose/v4"
+
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
+)
+
+type multiStorage struct {
+ issuers map[string]*Storage
+}
+
+// NewMultiStorage implements the op.Storage interface by wrapping multiple storage structs
+// and selecting them by the calling issuer
+func NewMultiStorage(issuers []string) *multiStorage {
+ s := make(map[string]*Storage)
+ for _, issuer := range issuers {
+ s[issuer] = NewStorage(NewUserStore(issuer))
+ }
+ return &multiStorage{issuers: s}
+}
+
+// CheckUsernamePassword implements the `authenticate` interface of the login
+func (s *multiStorage) CheckUsernamePassword(ctx context.Context, username, password, id string) error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.CheckUsernamePassword(username, password, id)
+}
+
+// CreateAuthRequest implements the op.Storage interface
+// it will be called after parsing and validation of the authentication request
+func (s *multiStorage) CreateAuthRequest(ctx context.Context, authReq *oidc.AuthRequest, userID string) (op.AuthRequest, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.CreateAuthRequest(ctx, authReq, userID)
+}
+
+// AuthRequestByID implements the op.Storage interface
+// it will be called after the Login UI redirects back to the OIDC endpoint
+func (s *multiStorage) AuthRequestByID(ctx context.Context, id string) (op.AuthRequest, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.AuthRequestByID(ctx, id)
+}
+
+// AuthRequestByCode implements the op.Storage interface
+// it will be called after parsing and validation of the token request (in an authorization code flow)
+func (s *multiStorage) AuthRequestByCode(ctx context.Context, code string) (op.AuthRequest, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.AuthRequestByCode(ctx, code)
+}
+
+// SaveAuthCode implements the op.Storage interface
+// it will be called after the authentication has been successful and before redirecting the user agent to the redirect_uri
+// (in an authorization code flow)
+func (s *multiStorage) SaveAuthCode(ctx context.Context, id string, code string) error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.SaveAuthCode(ctx, id, code)
+}
+
+// DeleteAuthRequest implements the op.Storage interface
+// it will be called after creating the token response (id and access tokens) for a valid
+// - authentication request (in an implicit flow)
+// - token request (in an authorization code flow)
+func (s *multiStorage) DeleteAuthRequest(ctx context.Context, id string) error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.DeleteAuthRequest(ctx, id)
+}
+
+// CreateAccessToken implements the op.Storage interface
+// it will be called for all requests able to return an access token (Authorization Code Flow, Implicit Flow, JWT Profile, ...)
+func (s *multiStorage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return "", time.Time{}, err
+ }
+ return storage.CreateAccessToken(ctx, request)
+}
+
+// CreateAccessAndRefreshTokens implements the op.Storage interface
+// it will be called for all requests able to return an access and refresh token (Authorization Code Flow, Refresh Token Request)
+func (s *multiStorage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return "", "", time.Time{}, err
+ }
+ return storage.CreateAccessAndRefreshTokens(ctx, request, currentRefreshToken)
+}
+
+// TokenRequestByRefreshToken implements the op.Storage interface
+// it will be called after parsing and validation of the refresh token request
+func (s *multiStorage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.TokenRequestByRefreshToken(ctx, refreshToken)
+}
+
+// TerminateSession implements the op.Storage interface
+// it will be called after the user signed out, therefore the access and refresh token of the user of this client must be removed
+func (s *multiStorage) TerminateSession(ctx context.Context, userID string, clientID string) error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.TerminateSession(ctx, userID, clientID)
+}
+
+// GetRefreshTokenInfo looks up a refresh token and returns the token id and user id.
+// If given something that is not a refresh token, it must return error.
+func (s *multiStorage) GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return "", "", err
+ }
+ return storage.GetRefreshTokenInfo(ctx, clientID, token)
+}
+
+// RevokeToken implements the op.Storage interface
+// it will be called after parsing and validation of the token revocation request
+func (s *multiStorage) RevokeToken(ctx context.Context, token string, userID string, clientID string) *oidc.Error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.RevokeToken(ctx, token, userID, clientID)
+}
+
+// SigningKey implements the op.Storage interface
+// it will be called when creating the OpenID Provider
+func (s *multiStorage) SigningKey(ctx context.Context) (op.SigningKey, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.SigningKey(ctx)
+}
+
+// SignatureAlgorithms implements the op.Storage interface
+// it will be called to get the sign
+func (s *multiStorage) SignatureAlgorithms(ctx context.Context) ([]jose.SignatureAlgorithm, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.SignatureAlgorithms(ctx)
+}
+
+// KeySet implements the op.Storage interface
+// it will be called to get the current (public) keys, among others for the keys_endpoint or for validating access_tokens on the userinfo_endpoint, ...
+func (s *multiStorage) KeySet(ctx context.Context) ([]op.Key, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.KeySet(ctx)
+}
+
+// GetClientByClientID implements the op.Storage interface
+// it will be called whenever information (type, redirect_uris, ...) about the client behind the client_id is needed
+func (s *multiStorage) GetClientByClientID(ctx context.Context, clientID string) (op.Client, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.GetClientByClientID(ctx, clientID)
+}
+
+// AuthorizeClientIDSecret implements the op.Storage interface
+// it will be called for validating the client_id, client_secret on token or introspection requests
+func (s *multiStorage) AuthorizeClientIDSecret(ctx context.Context, clientID, clientSecret string) error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.AuthorizeClientIDSecret(ctx, clientID, clientSecret)
+}
+
+// SetUserinfoFromScopes implements the op.Storage interface.
+// Provide an empty implementation and use SetUserinfoFromRequest instead.
+func (s *multiStorage) SetUserinfoFromScopes(ctx context.Context, userinfo *oidc.UserInfo, userID, clientID string, scopes []string) error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.SetUserinfoFromScopes(ctx, userinfo, userID, clientID, scopes)
+}
+
+// SetUserinfoFromRequests implements the op.CanSetUserinfoFromRequest interface. In the
+// next major release, it will be required for op.Storage.
+// It will be called for the creation of an id_token, so we'll just pass it to the private function without any further check
+func (s *multiStorage) SetUserinfoFromRequest(ctx context.Context, userinfo *oidc.UserInfo, token op.IDTokenRequest, scopes []string) error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.SetUserinfoFromRequest(ctx, userinfo, token, scopes)
+}
+
+// SetUserinfoFromToken implements the op.Storage interface
+// it will be called for the userinfo endpoint, so we read the token and pass the information from that to the private function
+func (s *multiStorage) SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserInfo, tokenID, subject, origin string) error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.SetUserinfoFromToken(ctx, userinfo, tokenID, subject, origin)
+}
+
+// SetIntrospectionFromToken implements the op.Storage interface
+// it will be called for the introspection endpoint, so we read the token and pass the information from that to the private function
+func (s *multiStorage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) error {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return err
+ }
+ return storage.SetIntrospectionFromToken(ctx, introspection, tokenID, subject, clientID)
+}
+
+// GetPrivateClaimsFromScopes implements the op.Storage interface
+// it will be called for the creation of a JWT access token to assert claims for custom scopes
+func (s *multiStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]any, err error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.GetPrivateClaimsFromScopes(ctx, userID, clientID, scopes)
+}
+
+// GetKeyByIDAndClientID implements the op.Storage interface
+// it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication)
+func (s *multiStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.GetKeyByIDAndClientID(ctx, keyID, userID)
+}
+
+// ValidateJWTProfileScopes implements the op.Storage interface
+// it will be called to validate the scopes of a JWT Profile Authorization Grant request
+func (s *multiStorage) ValidateJWTProfileScopes(ctx context.Context, userID string, scopes []string) ([]string, error) {
+ storage, err := s.storageFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return storage.ValidateJWTProfileScopes(ctx, userID, scopes)
+}
+
+// Health implements the op.Storage interface
+func (s *multiStorage) Health(ctx context.Context) error {
+ return nil
+}
+
+func (s *multiStorage) storageFromContext(ctx context.Context) (*Storage, *oidc.Error) {
+ storage, ok := s.issuers[op.IssuerFromContext(ctx)]
+ if !ok {
+ return nil, oidc.ErrInvalidRequest().WithDescription("invalid issuer")
+ }
+ return storage, nil
+}
diff --git a/example/server/storage/token.go b/example/server/storage/token.go
index ad907e3..beab38c 100644
--- a/example/server/storage/token.go
+++ b/example/server/storage/token.go
@@ -22,4 +22,5 @@ type RefreshToken struct {
ApplicationID string
Expiration time.Time
Scopes []string
+ AccessToken string // Token.ID
}
diff --git a/example/server/storage/user.go b/example/server/storage/user.go
index 423af59..ed8cdfa 100644
--- a/example/server/storage/user.go
+++ b/example/server/storage/user.go
@@ -2,6 +2,9 @@ package storage
import (
"crypto/rsa"
+ "encoding/json"
+ "os"
+ "strings"
"golang.org/x/text/language"
)
@@ -17,6 +20,7 @@ type User struct {
Phone string
PhoneVerified bool
PreferredLanguage language.Tag
+ IsAdmin bool
}
type Service struct {
@@ -33,12 +37,25 @@ type userStore struct {
users map[string]*User
}
-func NewUserStore() UserStore {
+func StoreFromFile(path string) (UserStore, error) {
+ users := map[string]*User{}
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(data, &users); err != nil {
+ return nil, err
+ }
+ return userStore{users}, nil
+}
+
+func NewUserStore(issuer string) UserStore {
+ hostname := strings.Split(strings.Split(issuer, "://")[1], ":")[0]
return userStore{
users: map[string]*User{
"id1": {
ID: "id1",
- Username: "test-user",
+ Username: "test-user@" + hostname,
Password: "verysecure",
FirstName: "Test",
LastName: "User",
@@ -47,6 +64,20 @@ func NewUserStore() UserStore {
Phone: "",
PhoneVerified: false,
PreferredLanguage: language.German,
+ IsAdmin: true,
+ },
+ "id2": {
+ ID: "id2",
+ Username: "test-user2",
+ Password: "verysecure",
+ FirstName: "Test",
+ LastName: "User2",
+ Email: "test-user2@zitadel.ch",
+ EmailVerified: true,
+ Phone: "",
+ PhoneVerified: false,
+ PreferredLanguage: language.German,
+ IsAdmin: false,
},
},
}
diff --git a/example/server/storage/user_test.go b/example/server/storage/user_test.go
new file mode 100644
index 0000000..c2e2212
--- /dev/null
+++ b/example/server/storage/user_test.go
@@ -0,0 +1,70 @@
+package storage
+
+import (
+ "os"
+ "path"
+ "reflect"
+ "testing"
+
+ "golang.org/x/text/language"
+)
+
+func TestStoreFromFile(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ pathToFile string
+ content string
+ want UserStore
+ wantErr bool
+ }{
+ {
+ name: "normal user file",
+ pathToFile: "userfile.json",
+ content: `{
+ "id1": {
+ "ID": "id1",
+ "EmailVerified": true,
+ "PreferredLanguage": "DE"
+ }
+ }`,
+ want: userStore{map[string]*User{
+ "id1": {
+ ID: "id1",
+ EmailVerified: true,
+ PreferredLanguage: language.German,
+ },
+ }},
+ },
+ {
+ name: "malformed file",
+ pathToFile: "whatever",
+ content: "not a json just a text",
+ wantErr: true,
+ },
+ {
+ name: "not existing file",
+ pathToFile: "what/ever/file",
+ wantErr: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ actualPath := path.Join(t.TempDir(), tc.pathToFile)
+
+ if tc.content != "" && tc.pathToFile != "" {
+ if err := os.WriteFile(actualPath, []byte(tc.content), 0666); err != nil {
+ t.Fatalf("cannot create file with test content: %q", tc.content)
+ }
+ }
+ result, err := StoreFromFile(actualPath)
+ if err != nil && !tc.wantErr {
+ t.Errorf("StoreFromFile(%q) returned unexpected error %q", tc.pathToFile, err)
+ } else if err == nil && tc.wantErr {
+ t.Errorf("StoreFromFile(%q) did not return an expected error", tc.pathToFile)
+ }
+ if !tc.wantErr && !reflect.DeepEqual(tc.want, result.(userStore)) {
+ t.Errorf("expected StoreFromFile(%q) = %v, but got %v",
+ tc.pathToFile, tc.want, result)
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
index d8b4465..a0f42c4 100644
--- a/go.mod
+++ b/go.mod
@@ -1,23 +1,40 @@
-module github.com/zitadel/oidc
+module git.christmann.info/LARA/zitadel-oidc/v3
-go 1.16
+go 1.23.7
+
+toolchain go1.24.1
require (
+ github.com/bmatcuk/doublestar/v4 v4.8.1
+ github.com/go-chi/chi/v5 v5.2.1
+ github.com/go-jose/go-jose/v4 v4.0.5
github.com/golang/mock v1.6.0
- github.com/google/go-cmp v0.5.2 // indirect
github.com/google/go-github/v31 v31.0.0
- github.com/google/uuid v1.3.0
- github.com/gorilla/mux v1.8.0
- github.com/gorilla/schema v1.2.0
- github.com/gorilla/securecookie v1.1.1
- github.com/jeremija/gosubmit v0.2.7
- github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
- github.com/rs/cors v1.8.3
- github.com/sirupsen/logrus v1.9.0
- github.com/stretchr/testify v1.8.2
- github.com/zitadel/logging v0.3.4
- golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
- golang.org/x/text v0.7.0
- gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
- gopkg.in/square/go-jose.v2 v2.6.0
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/securecookie v1.1.2
+ github.com/jeremija/gosubmit v0.2.8
+ github.com/muhlemmer/gu v0.3.1
+ github.com/muhlemmer/httpforwarded v0.1.0
+ github.com/rs/cors v1.11.1
+ github.com/sirupsen/logrus v1.9.3
+ github.com/stretchr/testify v1.10.0
+ github.com/zitadel/logging v0.6.2
+ github.com/zitadel/schema v1.3.1
+ go.opentelemetry.io/otel v1.29.0
+ golang.org/x/oauth2 v0.30.0
+ golang.org/x/text v0.26.0
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/go-querystring v1.1.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ go.opentelemetry.io/otel/metric v1.29.0 // indirect
+ go.opentelemetry.io/otel/trace v1.29.0 // indirect
+ golang.org/x/crypto v0.36.0 // indirect
+ golang.org/x/net v0.38.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 6ee11f2..4835505 100644
--- a/go.sum
+++ b/go.sum
@@ -1,438 +1,108 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
-cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
-cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
-cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
-cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
-cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
-cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
-cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
-cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
-cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
-cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
-cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
-cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
-cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
-cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
-cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
+github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
-github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
+github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
-github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v31 v31.0.0 h1:JJUxlP9lFK+ziXKimTCprajMApV1ecWD4NB6CCb0plo=
github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM=
-github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
-github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
-github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
-github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
-github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
-github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
-github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc=
-github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+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/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA=
+github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI=
+github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
+github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
+github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY=
+github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/rs/cors v1.8.3 h1:O+qNyWn7Z+F9M0ILBHgMVPuB1xTOucVd5gtaYyXBpRo=
-github.com/rs/cors v1.8.3/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
-github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
-github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
+github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/zitadel/logging v0.3.4 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM=
-github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0=
-go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU=
+github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4=
+github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU=
+github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU=
+go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
+go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
+go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
+go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
+go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
+go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
-golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
-golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 h1:ld7aEMNHoBnnDAX15v1T6z31v8HwR2A9FYOuAhWqkwc=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220207234003-57398862261d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
-golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
-google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
-google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
-google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
-google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
-google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
-gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
-gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
-rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
-rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/internal/testutil/gen/gen.go b/internal/testutil/gen/gen.go
new file mode 100644
index 0000000..3e44b7d
--- /dev/null
+++ b/internal/testutil/gen/gen.go
@@ -0,0 +1,58 @@
+// Package gen allows generating of example tokens and claims.
+//
+// go run ./internal/testutil/gen
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ tu "git.christmann.info/LARA/zitadel-oidc/v3/internal/testutil"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
+)
+
+var custom = map[string]any{
+ "foo": "Hello, World!",
+ "bar": struct {
+ Count int `json:"count,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ }{
+ Count: 22,
+ Tags: []string{"some", "tags"},
+ },
+}
+
+func main() {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+
+ accessToken, atClaims := tu.NewAccessTokenCustom(
+ tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
+ tu.ValidExpiration.AddDate(99, 0, 0), tu.ValidJWTID,
+ tu.ValidClientID, tu.ValidSkew, custom,
+ )
+ atHash, err := oidc.ClaimHash(accessToken, tu.SignatureAlgorithm)
+ if err != nil {
+ panic(err)
+ }
+
+ idToken, idClaims := tu.NewIDTokenCustom(
+ tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
+ tu.ValidExpiration.AddDate(99, 0, 0), tu.ValidAuthTime,
+ tu.ValidNonce, tu.ValidACR, tu.ValidAMR, tu.ValidClientID,
+ tu.ValidSkew, atHash, custom,
+ )
+
+ fmt.Println("access token claims:")
+ if err := enc.Encode(atClaims); err != nil {
+ panic(err)
+ }
+ fmt.Printf("access token:\n%s\n", accessToken)
+
+ fmt.Println("ID token claims:")
+ if err := enc.Encode(idClaims); err != nil {
+ panic(err)
+ }
+ fmt.Printf("ID token:\n%s\n", idToken)
+}
diff --git a/internal/testutil/token.go b/internal/testutil/token.go
new file mode 100644
index 0000000..72d08c5
--- /dev/null
+++ b/internal/testutil/token.go
@@ -0,0 +1,180 @@
+// Package testuril helps setting up required data for testing,
+// such as tokens, claims and verifiers.
+package testutil
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "time"
+
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
+ jose "github.com/go-jose/go-jose/v4"
+ "github.com/muhlemmer/gu"
+)
+
+// KeySet implements oidc.Keys
+type KeySet struct{}
+
+// VerifySignature implments op.KeySet.
+func (KeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) {
+ if err = ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ return jws.Verify(WebKey.Public())
+}
+
+// use a reproducible signing key
+const webkeyJSON = `{"kty":"RSA","kid":"1","alg":"PS512","n":"x6JoG8t2Li68JSwPwnh51TvHYFf3z72tQ3wmJG3VosU6MdJF0gSTCIwflOJ38OWE6hYtN1WAeyBy2CYdnXd1QZzkK_apGK4M7hsNA9jCTg8NOZjLPL0ww1jp7313Skla7mbm90uNdg4TUNp2n_r-sCYywI-9cfSlhzLSksxKK_BRdzy6xW20daAcI-mErQXIcvdYIguunJk_uTb8kJedsWMcQ4Mb57QujUok2Z2YabWyb9Fi1_StixXJvd_WEu93SHNMORB0u6ymnO3aZJdATLdhtcP-qsVicQhffpqVazmZQPf7K-7n4I5vJE4g9XXzZ2dSKSp3Ewe_nna_2kvbCw","e":"AQAB","d":"sl3F_QeF2O-CxQegMRYpbL6Tfd47GM6VDxXOkn_cACmNvFPudB4ILPvdf830cjTv06Lq1WS8fcZZNgygK0A_cNc3-pvRK67e-KMMtuIlgU7rdwmwlN1Iw1Ee-w6z1ZjC-PzR4iQMCW28DmKS2I-OnV4TvH7xOe7nMmvTPrvujV__YKfUxvAWXJG7_wtaJBGplezn5nNsKG2Ot9h0mhMdYUgGC36wLxo3Q5d4m79EXQYdhm89EfxogwvMmHRes5PNpHRuDZRHGAI4RZi2KvgmqF07e1Qdq4TqbQnY5pCYrdjqvEFFjGC6jTE-ak_b21FcSVy-9aZHyf04U4g5-cIUEQ","p":"7AaicFryJCHRekdSkx8tfPxaSiyEuN8jhP9cLqs4rLkIbrSHmanPhjnLe-Tlh3icQ8hPoy6WC8ktLwsrzbfGIh4U_zgAfvtD1Y_lZM-YSWZsxqlrGiI5do11iVzzoy4a1XdkgOjHQz9y6J-uoA9jY8ILG7VaEZQnaYwWZV3cspk","q":"2Ide9hlwthXJQJYqI0mibM5BiGBxJ4CafPmF1DYNXggBCczZ6ERGReNTGM_AEhy5mvLXUH6uBSOJlfHTYzx49C1GgIO3hEWVEGAKAytVRL6RfAkVSOXMQUp-HjXKpGg_Nx1SJxQf3rulbW8HXO4KqIlloyIXpPQSK7jB8A4hJUM","dp":"1nmc6F4sRNsaQHRJO_mL21RxM4_KtzfFThjCCoJ6iLHHUNnpkp_1PTKNjrLMRFM8JHgErfMqU-FmlqYfEtvZRq1xRQ39nWX0GT-eIwJljuVtGQVglqnc77bRxJXbqz-9EJdik6VzVM92Op7IDxiMp1zvvSkJhInNWqL6wvgNEZk","dq":"dlHizlAwiw90ndpwxD-khhhfLwqkSpW31br0KnYu78cn6hcKrCVC0UXbTp-XsU4JDmbMyauvpBc7Q7iVbpDI94UWFXvkeF8diYkxb3HqclpAXasI-oC4EKWILTHvvc9JW_Clx7zzfV7Ekvws5dcd8-LAq1gh232TwFiBgY_3BMk","qi":"E1k_9W3odXgcmIP2PCJztE7hB7jeuAL1ElAY88VJBBPY670uwOEjKL2VfQuz9q9IjzLAvcgf7vS9blw2RHP_XqHqSOlJWGwvMQTF0Q8zLknCgKt8q7HQQNWIJcBZ8qdUVn02-qf4E3tgZ3JHaHNs8imA_L-__WoUmzC4z5jH_lM"}`
+
+const SignatureAlgorithm = jose.RS256
+
+var (
+ WebKey jose.JSONWebKey
+ Signer jose.Signer
+)
+
+func init() {
+ err := json.Unmarshal([]byte(webkeyJSON), &WebKey)
+ if err != nil {
+ panic(err)
+ }
+ Signer, err = jose.NewSigner(jose.SigningKey{Algorithm: SignatureAlgorithm, Key: WebKey}, nil)
+ if err != nil {
+ panic(err)
+ }
+}
+
+type JWTProfileKeyStorage struct{}
+
+func (JWTProfileKeyStorage) GetKeyByIDAndClientID(ctx context.Context, keyID string, clientID string) (*jose.JSONWebKey, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ return gu.Ptr(WebKey.Public()), nil
+}
+
+func signEncodeTokenClaims(claims any) string {
+ payload, err := json.Marshal(claims)
+ if err != nil {
+ panic(err)
+ }
+ object, err := Signer.Sign(payload)
+ if err != nil {
+ panic(err)
+ }
+ token, err := object.CompactSerialize()
+ if err != nil {
+ panic(err)
+ }
+ return token
+}
+
+func claimsMap(claims any) map[string]any {
+ data, err := json.Marshal(claims)
+ if err != nil {
+ panic(err)
+ }
+ dst := make(map[string]any)
+ if err = json.Unmarshal(data, &dst); err != nil {
+ panic(err)
+ }
+ return dst
+}
+
+func NewIDTokenCustom(issuer, subject string, audience []string, expiration, authTime time.Time, nonce string, acr string, amr []string, clientID string, skew time.Duration, atHash string, custom map[string]any) (string, *oidc.IDTokenClaims) {
+ claims := oidc.NewIDTokenClaims(issuer, subject, audience, expiration, authTime, nonce, acr, amr, clientID, skew)
+ claims.AccessTokenHash = atHash
+ claims.Claims = custom
+ token := signEncodeTokenClaims(claims)
+
+ // set this so that assertion in tests will work
+ claims.SignatureAlg = SignatureAlgorithm
+ claims.Claims = claimsMap(claims)
+ return token, claims
+}
+
+// NewIDToken creates a new IDTokenClaims with passed data and returns a signed token and claims.
+func NewIDToken(issuer, subject string, audience []string, expiration, authTime time.Time, nonce string, acr string, amr []string, clientID string, skew time.Duration, atHash string) (string, *oidc.IDTokenClaims) {
+ return NewIDTokenCustom(issuer, subject, audience, expiration, authTime, nonce, acr, amr, clientID, skew, atHash, nil)
+}
+
+func NewAccessTokenCustom(issuer, subject string, audience []string, expiration time.Time, jwtid, clientID string, skew time.Duration, custom map[string]any) (string, *oidc.AccessTokenClaims) {
+ claims := oidc.NewAccessTokenClaims(issuer, subject, audience, expiration, jwtid, clientID, skew)
+ claims.Claims = custom
+ token := signEncodeTokenClaims(claims)
+
+ // set this so that assertion in tests will work
+ claims.SignatureAlg = SignatureAlgorithm
+ claims.Claims = claimsMap(claims)
+ return token, claims
+}
+
+// NewAcccessToken creates a new AccessTokenClaims with passed data and returns a signed token and claims.
+func NewAccessToken(issuer, subject string, audience []string, expiration time.Time, jwtid, clientID string, skew time.Duration) (string, *oidc.AccessTokenClaims) {
+ return NewAccessTokenCustom(issuer, subject, audience, expiration, jwtid, clientID, skew, nil)
+}
+
+func NewJWTProfileAssertion(issuer, clientID string, audience []string, issuedAt, expiration time.Time) (string, *oidc.JWTTokenRequest) {
+ req := &oidc.JWTTokenRequest{
+ Issuer: issuer,
+ Subject: clientID,
+ Audience: audience,
+ ExpiresAt: oidc.FromTime(expiration),
+ IssuedAt: oidc.FromTime(issuedAt),
+ }
+ // make sure the private claim map is set correctly
+ data, err := json.Marshal(req)
+ if err != nil {
+ panic(err)
+ }
+ if err = json.Unmarshal(data, req); err != nil {
+ panic(err)
+ }
+ return signEncodeTokenClaims(req), req
+}
+
+const InvalidSignatureToken = `eyJhbGciOiJQUzUxMiJ9.eyJpc3MiOiJsb2NhbC5jb20iLCJzdWIiOiJ0aW1AbG9jYWwuY29tIiwiYXVkIjpbInVuaXQiLCJ0ZXN0IiwiNTU1NjY2Il0sImV4cCI6MTY3Nzg0MDQzMSwiaWF0IjoxNjc3ODQwMzcwLCJhdXRoX3RpbWUiOjE2Nzc4NDAzMTAsIm5vbmNlIjoiMTIzNDUiLCJhY3IiOiJzb21ldGhpbmciLCJhbXIiOlsiZm9vIiwiYmFyIl0sImF6cCI6IjU1NTY2NiJ9.DtZmvVkuE4Hw48ijBMhRJbxEWCr_WEYuPQBMY73J9TP6MmfeNFkjVJf4nh4omjB9gVLnQ-xhEkNOe62FS5P0BB2VOxPuHZUj34dNspCgG3h98fGxyiMb5vlIYAHDF9T-w_LntlYItohv63MmdYR-hPpAqjXE7KOfErf-wUDGE9R3bfiQ4HpTdyFJB1nsToYrZ9lhP2mzjTCTs58ckZfQ28DFHn_lfHWpR4rJBgvLx7IH4rMrUayr09Ap-PxQLbv0lYMtmgG1z3JK8MXnuYR0UJdZnEIezOzUTlThhCXB-nvuAXYjYxZZTR0FtlgZUHhIpYK0V2abf_Q_Or36akNCUg`
+
+// These variables always result in a valid token
+var (
+ ValidIssuer = "local.com"
+ ValidSubject = "tim@local.com"
+ ValidAudience = []string{"unit", "test"}
+ ValidAuthTime = time.Now().Add(-time.Minute) // authtime is always 1 minute in the past
+ ValidExpiration = ValidAuthTime.Add(2 * time.Minute) // token is always 1 more minute available
+ ValidJWTID = "9876"
+ ValidNonce = "12345"
+ ValidACR = "something"
+ ValidAMR = []string{"foo", "bar"}
+ ValidClientID = "555666"
+ ValidSkew = time.Second
+)
+
+// ValidIDToken returns a token and claims that are in the token.
+// It uses the Valid* global variables and the token will always
+// pass verification.
+func ValidIDToken() (string, *oidc.IDTokenClaims) {
+ return NewIDToken(ValidIssuer, ValidSubject, ValidAudience, ValidExpiration, ValidAuthTime, ValidNonce, ValidACR, ValidAMR, ValidClientID, ValidSkew, "")
+}
+
+// ValidAccessToken returns a token and claims that are in the token.
+// It uses the Valid* global variables and the token always passes
+// verification within the same test run.
+func ValidAccessToken() (string, *oidc.AccessTokenClaims) {
+ return NewAccessToken(ValidIssuer, ValidSubject, ValidAudience, ValidExpiration, ValidJWTID, ValidClientID, ValidSkew)
+}
+
+func ValidJWTProfileAssertion() (string, *oidc.JWTTokenRequest) {
+ return NewJWTProfileAssertion(ValidClientID, ValidClientID, []string{ValidIssuer}, time.Now(), ValidExpiration)
+}
+
+// ACRVerify is a oidc.ACRVerifier func.
+func ACRVerify(acr string) error {
+ if acr != ValidACR {
+ return errors.New("invalid acr")
+ }
+ return nil
+}
diff --git a/pkg/client/client.go b/pkg/client/client.go
index 62f1019..2e1f536 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -1,48 +1,53 @@
package client
import (
+ "context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
- "reflect"
"strings"
"time"
- "github.com/gorilla/schema"
+ "github.com/go-jose/go-jose/v4"
+ "github.com/zitadel/logging"
+ "go.opentelemetry.io/otel"
"golang.org/x/oauth2"
- "gopkg.in/square/go-jose.v2"
- "github.com/zitadel/oidc/pkg/crypto"
- httphelper "github.com/zitadel/oidc/pkg/http"
- "github.com/zitadel/oidc/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/crypto"
+ httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
)
-var Encoder = func() httphelper.Encoder {
- e := schema.NewEncoder()
- e.RegisterEncoder(oidc.SpaceDelimitedArray{}, func(value reflect.Value) string {
- return value.Interface().(oidc.SpaceDelimitedArray).Encode()
- })
- return e
-}()
+var (
+ Encoder = httphelper.Encoder(oidc.NewEncoder())
+ Tracer = otel.Tracer("github.com/zitadel/oidc/pkg/client")
+)
// Discover calls the discovery endpoint of the provided issuer and returns its configuration
// It accepts an optional argument "wellknownUrl" which can be used to overide the dicovery endpoint url
-func Discover(issuer string, httpClient *http.Client, wellKnownUrl ...string) (*oidc.DiscoveryConfiguration, error) {
+func Discover(ctx context.Context, issuer string, httpClient *http.Client, wellKnownUrl ...string) (*oidc.DiscoveryConfiguration, error) {
+ ctx, span := Tracer.Start(ctx, "Discover")
+ defer span.End()
+
wellKnown := strings.TrimSuffix(issuer, "/") + oidc.DiscoveryEndpoint
if len(wellKnownUrl) == 1 && wellKnownUrl[0] != "" {
wellKnown = wellKnownUrl[0]
}
- req, err := http.NewRequest("GET", wellKnown, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil)
if err != nil {
return nil, err
}
discoveryConfig := new(oidc.DiscoveryConfiguration)
err = httphelper.HttpRequest(httpClient, req, &discoveryConfig)
if err != nil {
- return nil, err
+ return nil, errors.Join(oidc.ErrDiscoveryFailed, err)
}
+ if logger, ok := logging.FromContext(ctx); ok {
+ logger.Debug("discover", "config", discoveryConfig)
+ }
+
if discoveryConfig.Issuer != issuer {
return nil, oidc.ErrIssuerInvalid
}
@@ -54,12 +59,15 @@ type TokenEndpointCaller interface {
HttpClient() *http.Client
}
-func CallTokenEndpoint(request interface{}, caller TokenEndpointCaller) (newToken *oauth2.Token, err error) {
- return callTokenEndpoint(request, nil, caller)
+func CallTokenEndpoint(ctx context.Context, request any, caller TokenEndpointCaller) (newToken *oauth2.Token, err error) {
+ return callTokenEndpoint(ctx, request, nil, caller)
}
-func callTokenEndpoint(request interface{}, authFn interface{}, caller TokenEndpointCaller) (newToken *oauth2.Token, err error) {
- req, err := httphelper.FormRequest(caller.TokenEndpoint(), request, Encoder, authFn)
+func callTokenEndpoint(ctx context.Context, request any, authFn any, caller TokenEndpointCaller) (newToken *oauth2.Token, err error) {
+ ctx, span := Tracer.Start(ctx, "callTokenEndpoint")
+ defer span.End()
+
+ req, err := httphelper.FormRequest(ctx, caller.TokenEndpoint(), request, Encoder, authFn)
if err != nil {
return nil, err
}
@@ -67,12 +75,18 @@ func callTokenEndpoint(request interface{}, authFn interface{}, caller TokenEndp
if err := httphelper.HttpRequest(caller.HttpClient(), req, &tokenRes); err != nil {
return nil, err
}
- return &oauth2.Token{
+ token := &oauth2.Token{
AccessToken: tokenRes.AccessToken,
TokenType: tokenRes.TokenType,
RefreshToken: tokenRes.RefreshToken,
Expiry: time.Now().UTC().Add(time.Duration(tokenRes.ExpiresIn) * time.Second),
- }, nil
+ }
+ if tokenRes.IDToken != "" {
+ token = token.WithExtra(map[string]any{
+ "id_token": tokenRes.IDToken,
+ })
+ }
+ return token, nil
}
type EndSessionCaller interface {
@@ -80,8 +94,16 @@ type EndSessionCaller interface {
HttpClient() *http.Client
}
-func CallEndSessionEndpoint(request interface{}, authFn interface{}, caller EndSessionCaller) (*url.URL, error) {
- req, err := httphelper.FormRequest(caller.GetEndSessionEndpoint(), request, Encoder, authFn)
+func CallEndSessionEndpoint(ctx context.Context, request any, authFn any, caller EndSessionCaller) (*url.URL, error) {
+ ctx, span := Tracer.Start(ctx, "CallEndSessionEndpoint")
+ defer span.End()
+
+ endpoint := caller.GetEndSessionEndpoint()
+ if endpoint == "" {
+ return nil, fmt.Errorf("end session %w", ErrEndpointNotSet)
+ }
+
+ req, err := httphelper.FormRequest(ctx, endpoint, request, Encoder, authFn)
if err != nil {
return nil, err
}
@@ -90,6 +112,9 @@ func CallEndSessionEndpoint(request interface{}, authFn interface{}, caller EndS
return http.ErrUseLastResponse
}
resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
body, err := io.ReadAll(resp.Body)
@@ -120,8 +145,16 @@ type RevokeRequest struct {
ClientSecret string `schema:"client_secret"`
}
-func CallRevokeEndpoint(request interface{}, authFn interface{}, caller RevokeCaller) error {
- req, err := httphelper.FormRequest(caller.GetRevokeEndpoint(), request, Encoder, authFn)
+func CallRevokeEndpoint(ctx context.Context, request any, authFn any, caller RevokeCaller) error {
+ ctx, span := Tracer.Start(ctx, "CallRevokeEndpoint")
+ defer span.End()
+
+ endpoint := caller.GetRevokeEndpoint()
+ if endpoint == "" {
+ return fmt.Errorf("revoke %w", ErrEndpointNotSet)
+ }
+
+ req, err := httphelper.FormRequest(ctx, endpoint, request, Encoder, authFn)
if err != nil {
return err
}
@@ -148,13 +181,28 @@ func CallRevokeEndpoint(request interface{}, authFn interface{}, caller RevokeCa
return nil
}
+func CallTokenExchangeEndpoint(ctx context.Context, request any, authFn any, caller TokenEndpointCaller) (resp *oidc.TokenExchangeResponse, err error) {
+ ctx, span := Tracer.Start(ctx, "CallTokenExchangeEndpoint")
+ defer span.End()
+
+ req, err := httphelper.FormRequest(ctx, caller.TokenEndpoint(), request, Encoder, authFn)
+ if err != nil {
+ return nil, err
+ }
+ tokenRes := new(oidc.TokenExchangeResponse)
+ if err := httphelper.HttpRequest(caller.HttpClient(), req, &tokenRes); err != nil {
+ return nil, err
+ }
+ return tokenRes, nil
+}
+
func NewSignerFromPrivateKeyByte(key []byte, keyID string) (jose.Signer, error) {
- privateKey, err := crypto.BytesToPrivateKey(key)
+ privateKey, algorithm, err := crypto.BytesToPrivateKey(key)
if err != nil {
return nil, err
}
signingKey := jose.SigningKey{
- Algorithm: jose.RS256,
+ Algorithm: algorithm,
Key: &jose.JSONWebKey{Key: privateKey, KeyID: keyID},
}
return jose.NewSigner(signingKey, &jose.SignerOptions{})
@@ -167,7 +215,98 @@ func SignedJWTProfileAssertion(clientID string, audience []string, expiration ti
Issuer: clientID,
Subject: clientID,
Audience: audience,
- ExpiresAt: oidc.Time(exp),
- IssuedAt: oidc.Time(iat),
+ ExpiresAt: oidc.FromTime(exp),
+ IssuedAt: oidc.FromTime(iat),
}, signer)
}
+
+type DeviceAuthorizationCaller interface {
+ GetDeviceAuthorizationEndpoint() string
+ HttpClient() *http.Client
+}
+
+func CallDeviceAuthorizationEndpoint(ctx context.Context, request *oidc.ClientCredentialsRequest, caller DeviceAuthorizationCaller, authFn any) (*oidc.DeviceAuthorizationResponse, error) {
+ ctx, span := Tracer.Start(ctx, "CallDeviceAuthorizationEndpoint")
+ defer span.End()
+
+ endpoint := caller.GetDeviceAuthorizationEndpoint()
+ if endpoint == "" {
+ return nil, fmt.Errorf("device authorization %w", ErrEndpointNotSet)
+ }
+
+ req, err := httphelper.FormRequest(ctx, endpoint, request, Encoder, authFn)
+ if err != nil {
+ return nil, err
+ }
+ if request.ClientSecret != "" {
+ req.SetBasicAuth(request.ClientID, request.ClientSecret)
+ }
+
+ resp := new(oidc.DeviceAuthorizationResponse)
+ if err := httphelper.HttpRequest(caller.HttpClient(), req, &resp); err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
+type DeviceAccessTokenRequest struct {
+ *oidc.ClientCredentialsRequest
+ oidc.DeviceAccessTokenRequest
+}
+
+func CallDeviceAccessTokenEndpoint(ctx context.Context, request *DeviceAccessTokenRequest, caller TokenEndpointCaller) (*oidc.AccessTokenResponse, error) {
+ ctx, span := Tracer.Start(ctx, "CallDeviceAccessTokenEndpoint")
+ defer span.End()
+
+ req, err := httphelper.FormRequest(ctx, caller.TokenEndpoint(), request, Encoder, nil)
+ if err != nil {
+ return nil, err
+ }
+ if request.ClientSecret != "" {
+ req.SetBasicAuth(request.ClientID, request.ClientSecret)
+ }
+
+ resp := new(oidc.AccessTokenResponse)
+ if err := httphelper.HttpRequest(caller.HttpClient(), req, &resp); err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
+func PollDeviceAccessTokenEndpoint(ctx context.Context, interval time.Duration, request *DeviceAccessTokenRequest, caller TokenEndpointCaller) (*oidc.AccessTokenResponse, error) {
+ ctx, span := Tracer.Start(ctx, "PollDeviceAccessTokenEndpoint")
+ defer span.End()
+
+ for {
+ timer := time.After(interval)
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-timer:
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, interval)
+ defer cancel()
+
+ resp, err := CallDeviceAccessTokenEndpoint(ctx, request, caller)
+ if err == nil {
+ return resp, nil
+ }
+ if errors.Is(err, context.DeadlineExceeded) {
+ interval += 5 * time.Second
+ }
+ var target *oidc.Error
+ if !errors.As(err, &target) {
+ return nil, err
+ }
+ switch target.ErrorType {
+ case oidc.AuthorizationPending:
+ continue
+ case oidc.SlowDown:
+ interval += 5 * time.Second
+ continue
+ default:
+ return nil, err
+ }
+ }
+}
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
new file mode 100644
index 0000000..9e21e8e
--- /dev/null
+++ b/pkg/client/client_test.go
@@ -0,0 +1,59 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDiscover(t *testing.T) {
+ type wantFields struct {
+ UILocalesSupported bool
+ }
+
+ type args struct {
+ issuer string
+ wellKnownUrl []string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantFields *wantFields
+ wantErr error
+ }{
+ {
+ name: "spotify", // https://github.com/zitadel/oidc/issues/406
+ args: args{
+ issuer: "https://accounts.spotify.com",
+ },
+ wantFields: &wantFields{
+ UILocalesSupported: true,
+ },
+ wantErr: nil,
+ },
+ {
+ name: "discovery failed",
+ args: args{
+ issuer: "https://example.com",
+ },
+ wantErr: oidc.ErrDiscoveryFailed,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Discover(context.Background(), tt.args.issuer, http.DefaultClient, tt.args.wellKnownUrl...)
+ require.ErrorIs(t, err, tt.wantErr)
+ if tt.wantFields == nil {
+ return
+ }
+ assert.Equal(t, tt.args.issuer, got.Issuer)
+ if tt.wantFields.UILocalesSupported {
+ assert.NotEmpty(t, got.UILocalesSupported)
+ }
+ })
+ }
+}
diff --git a/pkg/client/errors.go b/pkg/client/errors.go
new file mode 100644
index 0000000..47210e5
--- /dev/null
+++ b/pkg/client/errors.go
@@ -0,0 +1,5 @@
+package client
+
+import "errors"
+
+var ErrEndpointNotSet = errors.New("endpoint not set")
diff --git a/pkg/client/integration_test.go b/pkg/client/integration_test.go
new file mode 100644
index 0000000..86a9ab7
--- /dev/null
+++ b/pkg/client/integration_test.go
@@ -0,0 +1,594 @@
+package client_test
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "math/rand"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/signal"
+ "strconv"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/jeremija/gosubmit"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/oauth2"
+
+ "git.christmann.info/LARA/zitadel-oidc/v3/example/server/exampleop"
+ "git.christmann.info/LARA/zitadel-oidc/v3/example/server/storage"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rs"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/tokenexchange"
+ httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
+)
+
+var Logger = slog.New(
+ slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+ AddSource: true,
+ Level: slog.LevelDebug,
+ }),
+)
+
+var CTX context.Context
+
+func TestMain(m *testing.M) {
+ os.Exit(func() int {
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT)
+ defer cancel()
+ CTX, cancel = context.WithTimeout(ctx, time.Minute)
+ defer cancel()
+ return m.Run()
+ }())
+}
+
+func TestRelyingPartySession(t *testing.T) {
+ for _, wrapServer := range []bool{false, true} {
+ t.Run(fmt.Sprint("wrapServer ", wrapServer), func(t *testing.T) {
+ testRelyingPartySession(t, wrapServer)
+ })
+ }
+}
+
+func testRelyingPartySession(t *testing.T, wrapServer bool) {
+ t.Log("------- start example OP ------")
+ targetURL := "http://local-site"
+ exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
+ var dh deferredHandler
+ opServer := httptest.NewServer(&dh)
+ defer opServer.Close()
+ t.Logf("auth server at %s", opServer.URL)
+ dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, wrapServer)
+
+ seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
+ clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25)
+
+ t.Log("------- run authorization code flow ------")
+ provider, tokens := RunAuthorizationCodeFlow(t, opServer, clientID, "secret")
+
+ t.Log("------- refresh tokens ------")
+
+ newTokens, err := rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, tokens.RefreshToken, "", "")
+ require.NoError(t, err, "refresh token")
+ assert.NotNil(t, newTokens, "access token")
+ t.Logf("new access token %s", newTokens.AccessToken)
+ t.Logf("new refresh token %s", newTokens.RefreshToken)
+ t.Logf("new token type %s", newTokens.TokenType)
+ t.Logf("new expiry %s", newTokens.Expiry.Format(time.RFC3339))
+ require.NotEmpty(t, newTokens.AccessToken, "new accessToken")
+ assert.NotEmpty(t, newTokens.IDToken, "new idToken")
+ assert.NotNil(t, newTokens.IDTokenClaims)
+ assert.Equal(t, newTokens.IDTokenClaims.Subject, tokens.IDTokenClaims.Subject)
+
+ t.Log("------ end session (logout) ------")
+
+ newLoc, err := rp.EndSession(CTX, provider, tokens.IDToken, "", "")
+ require.NoError(t, err, "logout")
+ if newLoc != nil {
+ t.Logf("redirect to %s", newLoc)
+ } else {
+ t.Logf("no redirect")
+ }
+
+ t.Log("------ attempt refresh again (should fail) ------")
+ t.Log("trying original refresh token", tokens.RefreshToken)
+ _, err = rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, tokens.RefreshToken, "", "")
+ assert.Errorf(t, err, "refresh with original")
+ if newTokens.RefreshToken != "" {
+ t.Log("trying replacement refresh token", newTokens.RefreshToken)
+ _, err = rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, newTokens.RefreshToken, "", "")
+ assert.Errorf(t, err, "refresh with replacement")
+ }
+}
+
+func TestRelyingPartyWithSigningAlgsFromDiscovery(t *testing.T) {
+ targetURL := "http://local-site"
+ localURL, err := url.Parse(targetURL + "/login?requestID=1234")
+ require.NoError(t, err, "local url")
+
+ t.Log("------- start example OP ------")
+ seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
+ clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25)
+ clientSecret := "secret"
+ client := storage.WebClient(clientID, clientSecret, targetURL)
+ storage.RegisterClients(client)
+ exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
+ var dh deferredHandler
+ opServer := httptest.NewServer(&dh)
+ defer opServer.Close()
+ dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, true)
+
+ t.Log("------- create RP ------")
+ provider, err := rp.NewRelyingPartyOIDC(
+ CTX,
+ opServer.URL,
+ clientID,
+ clientSecret,
+ targetURL,
+ []string{"openid"},
+ rp.WithSigningAlgsFromDiscovery(),
+ )
+ require.NoError(t, err, "new rp")
+
+ t.Log("------- run authorization code flow ------")
+ jar, err := cookiejar.New(nil)
+ require.NoError(t, err, "create cookie jar")
+ httpClient := &http.Client{
+ Timeout: time.Second * 5,
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ Jar: jar,
+ }
+ state := "state-" + strconv.FormatInt(seed.Int63(), 25)
+ capturedW := httptest.NewRecorder()
+ get := httptest.NewRequest("GET", localURL.String(), nil)
+ rp.AuthURLHandler(func() string { return state }, provider,
+ rp.WithPromptURLParam("Hello, World!", "Goodbye, World!"),
+ rp.WithURLParam("custom", "param"),
+ )(capturedW, get)
+ defer func() {
+ if t.Failed() {
+ t.Log("response body (redirect from RP to OP)", capturedW.Body.String())
+ }
+ }()
+ resp := capturedW.Result()
+ startAuthURL, err := resp.Location()
+ require.NoError(t, err, "get redirect")
+ loginPageURL := getRedirect(t, "get redirect to login page", httpClient, startAuthURL)
+ form := getForm(t, "get login form", httpClient, loginPageURL)
+ defer func() {
+ if t.Failed() {
+ t.Logf("login form (unfilled): %s", string(form))
+ }
+ }()
+ postLoginRedirectURL := fillForm(t, "fill login form", httpClient, form, loginPageURL,
+ gosubmit.Set("username", "test-user@local-site"),
+ gosubmit.Set("password", "verysecure"),
+ )
+ codeBearingURL := getRedirect(t, "get redirect with code", httpClient, postLoginRedirectURL)
+ capturedW = httptest.NewRecorder()
+ get = httptest.NewRequest("GET", codeBearingURL.String(), nil)
+ var idToken string
+ redirect := func(w http.ResponseWriter, r *http.Request, newTokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) {
+ idToken = newTokens.IDToken
+ http.Redirect(w, r, targetURL, http.StatusFound)
+ }
+ rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider)(capturedW, get)
+ defer func() {
+ if t.Failed() {
+ t.Log("token exchange response body", capturedW.Body.String())
+ require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
+ }
+ }()
+
+ t.Log("------- verify id token ------")
+ _, err = rp.VerifyIDToken[*oidc.IDTokenClaims](CTX, idToken, provider.IDTokenVerifier())
+ require.NoError(t, err, "verify id token")
+}
+
+func TestResourceServerTokenExchange(t *testing.T) {
+ for _, wrapServer := range []bool{false, true} {
+ t.Run(fmt.Sprint("wrapServer ", wrapServer), func(t *testing.T) {
+ testResourceServerTokenExchange(t, wrapServer)
+ })
+ }
+}
+
+func testResourceServerTokenExchange(t *testing.T, wrapServer bool) {
+ t.Log("------- start example OP ------")
+ targetURL := "http://local-site"
+ exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
+ var dh deferredHandler
+ opServer := httptest.NewServer(&dh)
+ defer opServer.Close()
+ t.Logf("auth server at %s", opServer.URL)
+ dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, wrapServer)
+
+ seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
+ clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25)
+ clientSecret := "secret"
+
+ t.Log("------- run authorization code flow ------")
+ provider, tokens := RunAuthorizationCodeFlow(t, opServer, clientID, clientSecret)
+
+ resourceServer, err := rs.NewResourceServerClientCredentials(CTX, opServer.URL, clientID, clientSecret)
+ require.NoError(t, err, "new resource server")
+
+ t.Log("------- exchage refresh tokens (impersonation) ------")
+
+ tokenExchangeResponse, err := tokenexchange.ExchangeToken(
+ CTX,
+ resourceServer,
+ tokens.RefreshToken,
+ oidc.RefreshTokenType,
+ "",
+ "",
+ []string{},
+ []string{},
+ []string{"profile", "custom_scope:impersonate:id2"},
+ oidc.RefreshTokenType,
+ )
+ require.NoError(t, err, "refresh token")
+ require.NotNil(t, tokenExchangeResponse, "token exchange response")
+ assert.Equal(t, tokenExchangeResponse.IssuedTokenType, oidc.RefreshTokenType)
+ assert.NotEmpty(t, tokenExchangeResponse.AccessToken, "access token")
+ assert.NotEmpty(t, tokenExchangeResponse.RefreshToken, "refresh token")
+ assert.Equal(t, []string(tokenExchangeResponse.Scopes), []string{"profile", "custom_scope:impersonate:id2"})
+
+ t.Log("------ end session (logout) ------")
+
+ newLoc, err := rp.EndSession(CTX, provider, tokens.IDToken, "", "")
+ require.NoError(t, err, "logout")
+ if newLoc != nil {
+ t.Logf("redirect to %s", newLoc)
+ } else {
+ t.Logf("no redirect")
+ }
+
+ t.Log("------- attempt exchage again (should fail) ------")
+
+ tokenExchangeResponse, err = tokenexchange.ExchangeToken(
+ CTX,
+ resourceServer,
+ tokens.RefreshToken,
+ oidc.RefreshTokenType,
+ "",
+ "",
+ []string{},
+ []string{},
+ []string{"profile", "custom_scope:impersonate:id2"},
+ oidc.RefreshTokenType,
+ )
+ require.Error(t, err, "refresh token")
+ assert.Contains(t, err.Error(), "subject_token is invalid")
+ require.Nil(t, tokenExchangeResponse, "token exchange response")
+}
+
+func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, clientSecret string) (provider rp.RelyingParty, tokens *oidc.Tokens[*oidc.IDTokenClaims]) {
+ targetURL := "http://local-site"
+ localURL, err := url.Parse(targetURL + "/login?requestID=1234")
+ require.NoError(t, err, "local url")
+
+ client := storage.WebClient(clientID, clientSecret, targetURL)
+ storage.RegisterClients(client)
+
+ jar, err := cookiejar.New(nil)
+ require.NoError(t, err, "create cookie jar")
+ httpClient := &http.Client{
+ Timeout: time.Second * 5,
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ Jar: jar,
+ }
+
+ t.Log("------- create RP ------")
+ key := []byte("test1234test1234")
+ cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
+ provider, err = rp.NewRelyingPartyOIDC(
+ CTX,
+ opServer.URL,
+ clientID,
+ clientSecret,
+ targetURL,
+ []string{"openid", "email", "profile", "offline_access"},
+ rp.WithPKCE(cookieHandler),
+ rp.WithAuthStyle(oauth2.AuthStyleInHeader),
+ rp.WithVerifierOpts(
+ rp.WithIssuedAtOffset(5*time.Second),
+ rp.WithSupportedSigningAlgorithms("RS256", "RS384", "RS512", "ES256", "ES384", "ES512"),
+ ),
+ )
+ require.NoError(t, err, "new rp")
+
+ t.Log("------- get redirect from local client (rp) to OP ------")
+ seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
+ state := "state-" + strconv.FormatInt(seed.Int63(), 25)
+ capturedW := httptest.NewRecorder()
+ get := httptest.NewRequest("GET", localURL.String(), nil)
+ rp.AuthURLHandler(func() string { return state }, provider,
+ rp.WithPromptURLParam("Hello, World!", "Goodbye, World!"),
+ rp.WithURLParam("custom", "param"),
+ )(capturedW, get)
+
+ defer func() {
+ if t.Failed() {
+ t.Log("response body (redirect from RP to OP)", capturedW.Body.String())
+ }
+ }()
+ require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
+ require.Less(t, capturedW.Code, 400, "captured response code")
+ require.Contains(t, capturedW.Body.String(), `prompt=Hello%2C+World%21+Goodbye%2C+World%21`)
+ require.Contains(t, capturedW.Body.String(), `custom=param`)
+
+ //nolint:bodyclose
+ resp := capturedW.Result()
+ jar.SetCookies(localURL, resp.Cookies())
+
+ startAuthURL, err := resp.Location()
+ require.NoError(t, err, "get redirect")
+ assert.NotEmpty(t, startAuthURL, "login url")
+ t.Log("Starting auth at", startAuthURL)
+
+ t.Log("------- get redirect to OP to login page ------")
+ loginPageURL := getRedirect(t, "get redirect to login page", httpClient, startAuthURL)
+ t.Log("login page URL", loginPageURL)
+
+ t.Log("------- get login form ------")
+ form := getForm(t, "get login form", httpClient, loginPageURL)
+ t.Log("login form (unfilled)", string(form))
+ defer func() {
+ if t.Failed() {
+ t.Logf("login form (unfilled): %s", string(form))
+ }
+ }()
+
+ t.Log("------- post to login form, get redirect to OP ------")
+ postLoginRedirectURL := fillForm(t, "fill login form", httpClient, form, loginPageURL,
+ gosubmit.Set("username", "test-user@local-site"),
+ gosubmit.Set("password", "verysecure"))
+ t.Logf("Get redirect from %s", postLoginRedirectURL)
+
+ t.Log("------- redirect from OP back to RP ------")
+ codeBearingURL := getRedirect(t, "get redirect with code", httpClient, postLoginRedirectURL)
+ t.Logf("Redirect with code %s", codeBearingURL)
+
+ t.Log("------- exchange code for tokens ------")
+ capturedW = httptest.NewRecorder()
+ get = httptest.NewRequest("GET", codeBearingURL.String(), nil)
+ for _, cookie := range jar.Cookies(codeBearingURL) {
+ get.Header["Cookie"] = append(get.Header["Cookie"], cookie.String())
+ t.Logf("setting cookie %s", cookie)
+ }
+
+ var email string
+ redirect := func(w http.ResponseWriter, r *http.Request, newTokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) {
+ tokens = newTokens
+ require.NotNil(t, tokens, "tokens")
+ require.NotNil(t, info, "info")
+ t.Log("access token", tokens.AccessToken)
+ t.Log("refresh token", tokens.RefreshToken)
+ t.Log("id token", tokens.IDToken)
+ t.Log("email", info.Email)
+
+ email = info.Email
+ http.Redirect(w, r, targetURL, 302)
+ }
+ rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider, rp.WithURLParam("custom", "param"))(capturedW, get)
+
+ defer func() {
+ if t.Failed() {
+ t.Log("token exchange response body", capturedW.Body.String())
+ require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
+ }
+ }()
+ require.Less(t, capturedW.Code, 400, "token exchange response code")
+ // TODO: how to check the custom header was sent to the server?
+
+ //nolint:bodyclose
+ resp = capturedW.Result()
+
+ authorizedURL, err := resp.Location()
+ require.NoError(t, err, "get fully-authorizied redirect location")
+ require.Equal(t, targetURL, authorizedURL.String(), "fully-authorizied redirect location")
+
+ require.NotEmpty(t, tokens.IDToken, "id token")
+ assert.NotEmpty(t, tokens.RefreshToken, "refresh token")
+ assert.NotEmpty(t, tokens.AccessToken, "access token")
+ assert.NotEmpty(t, email, "email")
+
+ return provider, tokens
+}
+
+func TestClientCredentials(t *testing.T) {
+ targetURL := "http://local-site"
+ exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
+ var dh deferredHandler
+ opServer := httptest.NewServer(&dh)
+ defer opServer.Close()
+ t.Logf("auth server at %s", opServer.URL)
+ dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, true)
+
+ provider, err := rp.NewRelyingPartyOIDC(
+ CTX,
+ opServer.URL,
+ "sid1",
+ "verysecret",
+ targetURL,
+ []string{"openid"},
+ )
+ require.NoError(t, err, "new rp")
+
+ token, err := rp.ClientCredentials(CTX, provider, nil)
+ require.NoError(t, err, "ClientCredentials call")
+ require.NotNil(t, token)
+ assert.NotEmpty(t, token.AccessToken)
+}
+
+func TestErrorFromPromptNone(t *testing.T) {
+ jar, err := cookiejar.New(nil)
+ require.NoError(t, err, "create cookie jar")
+ httpClient := &http.Client{
+ Timeout: time.Second * 5,
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ Jar: jar,
+ }
+
+ t.Log("------- start example OP ------")
+ targetURL := "http://local-site"
+ exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
+ var dh deferredHandler
+ opServer := httptest.NewServer(&dh)
+ defer opServer.Close()
+ t.Logf("auth server at %s", opServer.URL)
+ dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, false, op.WithHttpInterceptors(
+ func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Logf("request to %s", r.URL)
+ next.ServeHTTP(w, r)
+ })
+ },
+ ))
+ seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
+ clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25)
+ clientSecret := "secret"
+ client := storage.WebClient(clientID, clientSecret, targetURL)
+ storage.RegisterClients(client)
+
+ t.Log("------- create RP ------")
+ key := []byte("test1234test1234")
+ cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
+ provider, err := rp.NewRelyingPartyOIDC(
+ CTX,
+ opServer.URL,
+ clientID,
+ clientSecret,
+ targetURL,
+ []string{"openid", "email", "profile", "offline_access"},
+ rp.WithPKCE(cookieHandler),
+ rp.WithVerifierOpts(
+ rp.WithIssuedAtOffset(5*time.Second),
+ rp.WithSupportedSigningAlgorithms("RS256", "RS384", "RS512", "ES256", "ES384", "ES512"),
+ ),
+ )
+ require.NoError(t, err, "new rp")
+
+ t.Log("------- start auth flow with prompt=none ------- ")
+ state := "state-32892"
+ capturedW := httptest.NewRecorder()
+ localURL, err := url.Parse(targetURL + "/login")
+ require.NoError(t, err)
+
+ get := httptest.NewRequest("GET", localURL.String(), nil)
+ rp.AuthURLHandler(func() string { return state }, provider,
+ rp.WithPromptURLParam("none"),
+ rp.WithResponseModeURLParam(oidc.ResponseModeFragment),
+ )(capturedW, get)
+
+ defer func() {
+ if t.Failed() {
+ t.Log("response body (redirect from RP to OP)", capturedW.Body.String())
+ }
+ }()
+ require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
+ require.Less(t, capturedW.Code, 400, "captured response code")
+
+ //nolint:bodyclose
+ resp := capturedW.Result()
+ jar.SetCookies(localURL, resp.Cookies())
+
+ startAuthURL, err := resp.Location()
+ require.NoError(t, err, "get redirect")
+ assert.NotEmpty(t, startAuthURL, "login url")
+ t.Log("Starting auth at", startAuthURL)
+
+ t.Log("------- get redirect from OP ------")
+ loginPageURL := getRedirect(t, "get redirect to login page", httpClient, startAuthURL)
+ t.Log("login page URL", loginPageURL)
+
+ require.Contains(t, loginPageURL.String(), `error=login_required`, "prompt=none should error")
+ require.Contains(t, loginPageURL.String(), `local-site#error=`, "response_mode=fragment means '#' instead of '?'")
+}
+
+type deferredHandler struct {
+ http.Handler
+}
+
+func getRedirect(t *testing.T, desc string, httpClient *http.Client, uri *url.URL) *url.URL {
+ req := &http.Request{
+ Method: "GET",
+ URL: uri,
+ Header: make(http.Header),
+ }
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err, "GET "+uri.String())
+
+ defer func() {
+ if t.Failed() {
+ body, _ := io.ReadAll(resp.Body)
+ t.Logf("%s: GET %s: body: %s", desc, uri, string(body))
+ }
+ }()
+
+ //nolint:errcheck
+ defer resp.Body.Close()
+ redirect, err := resp.Location()
+ require.NoErrorf(t, err, "%s: get redirect %s", desc, uri)
+ require.NotEmptyf(t, redirect, "%s: get redirect %s", desc, uri)
+ return redirect
+}
+
+func getForm(t *testing.T, desc string, httpClient *http.Client, uri *url.URL) []byte {
+ req := &http.Request{
+ Method: "GET",
+ URL: uri,
+ Header: make(http.Header),
+ }
+ resp, err := httpClient.Do(req)
+ require.NoErrorf(t, err, "%s: GET %s", desc, uri)
+ //nolint:errcheck
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err, "%s: read GET %s", desc, uri)
+ return body
+}
+
+func fillForm(t *testing.T, desc string, httpClient *http.Client, body []byte, uri *url.URL, opts ...gosubmit.Option) *url.URL {
+ // TODO: switch to io.NopCloser when go1.15 support is dropped
+ req := gosubmit.ParseWithURL(io.NopCloser(bytes.NewReader(body)), uri.String()).FirstForm().Testing(t).NewTestRequest(
+ append([]gosubmit.Option{gosubmit.AutoFill()}, opts...)...,
+ )
+ if req.URL.Scheme == "" {
+ req.URL = uri
+ t.Log("request lost it's proto..., adding back... request now", req.URL)
+ }
+ req.RequestURI = "" // bug in gosubmit?
+ resp, err := httpClient.Do(req)
+ require.NoErrorf(t, err, "%s: POST %s", desc, uri)
+
+ //nolint:errcheck
+ defer resp.Body.Close()
+ defer func() {
+ if t.Failed() {
+ body, _ := io.ReadAll(resp.Body)
+ t.Logf("%s: GET %s: body: %s", desc, uri, string(body))
+ }
+ }()
+
+ redirect, err := resp.Location()
+ require.NoErrorf(t, err, "%s: redirect for POST %s", desc, uri)
+ return redirect
+}
diff --git a/pkg/client/jwt_profile.go b/pkg/client/jwt_profile.go
index a711de9..98a54fd 100644
--- a/pkg/client/jwt_profile.go
+++ b/pkg/client/jwt_profile.go
@@ -1,17 +1,18 @@
package client
import (
+ "context"
"net/url"
"golang.org/x/oauth2"
- "github.com/zitadel/oidc/pkg/http"
- "github.com/zitadel/oidc/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
)
// JWTProfileExchange handles the oauth2 jwt profile exchange
-func JWTProfileExchange(jwtProfileGrantRequest *oidc.JWTProfileGrantRequest, caller TokenEndpointCaller) (*oauth2.Token, error) {
- return CallTokenEndpoint(jwtProfileGrantRequest, caller)
+func JWTProfileExchange(ctx context.Context, jwtProfileGrantRequest *oidc.JWTProfileGrantRequest, caller TokenEndpointCaller) (*oauth2.Token, error) {
+ return CallTokenEndpoint(ctx, jwtProfileGrantRequest, caller)
}
func ClientAssertionCodeOptions(assertion string) []oauth2.AuthCodeOption {
diff --git a/pkg/client/key.go b/pkg/client/key.go
index 740c6d3..7f38311 100644
--- a/pkg/client/key.go
+++ b/pkg/client/key.go
@@ -2,7 +2,7 @@ package client
import (
"encoding/json"
- "io/ioutil"
+ "os"
)
const (
@@ -10,7 +10,7 @@ const (
applicationKey = "application"
)
-type keyFile struct {
+type KeyFile struct {
Type string `json:"type"` // serviceaccount or application
KeyID string `json:"keyId"`
Key string `json:"key"`
@@ -23,16 +23,16 @@ type keyFile struct {
ClientID string `json:"clientId"`
}
-func ConfigFromKeyFile(path string) (*keyFile, error) {
- data, err := ioutil.ReadFile(path)
+func ConfigFromKeyFile(path string) (*KeyFile, error) {
+ data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ConfigFromKeyFileData(data)
}
-func ConfigFromKeyFileData(data []byte) (*keyFile, error) {
- var f keyFile
+func ConfigFromKeyFileData(data []byte) (*KeyFile, error) {
+ var f KeyFile
if err := json.Unmarshal(data, &f); err != nil {
return nil, err
}
diff --git a/pkg/client/profile/jwt_profile.go b/pkg/client/profile/jwt_profile.go
index b29fcaa..fb351f0 100644
--- a/pkg/client/profile/jwt_profile.go
+++ b/pkg/client/profile/jwt_profile.go
@@ -1,19 +1,25 @@
package profile
import (
+ "context"
"net/http"
"time"
+ jose "github.com/go-jose/go-jose/v4"
"golang.org/x/oauth2"
- "gopkg.in/square/go-jose.v2"
- "github.com/zitadel/oidc/pkg/client"
- "github.com/zitadel/oidc/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
)
+type TokenSource interface {
+ oauth2.TokenSource
+ TokenCtx(context.Context) (*oauth2.Token, error)
+}
+
// jwtProfileTokenSource implement the oauth2.TokenSource
// it will request a token using the OAuth2 JWT Profile Grant
-// therefore sending an `assertion` by singing a JWT with the provided private key
+// therefore sending an `assertion` by signing a JWT with the provided private key
type jwtProfileTokenSource struct {
clientID string
audience []string
@@ -23,23 +29,38 @@ type jwtProfileTokenSource struct {
tokenEndpoint string
}
-func NewJWTProfileTokenSourceFromKeyFile(issuer, keyPath string, scopes []string, options ...func(source *jwtProfileTokenSource)) (oauth2.TokenSource, error) {
- keyData, err := client.ConfigFromKeyFile(keyPath)
+// NewJWTProfileTokenSourceFromKeyFile returns an implementation of TokenSource
+// It will request a token using the OAuth2 JWT Profile Grant,
+// therefore sending an `assertion` by singing a JWT with the provided private key from jsonFile.
+//
+// The passed context is only used for the call to the Discover endpoint.
+func NewJWTProfileTokenSourceFromKeyFile(ctx context.Context, issuer, jsonFile string, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) {
+ keyData, err := client.ConfigFromKeyFile(jsonFile)
if err != nil {
return nil, err
}
- return NewJWTProfileTokenSource(issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...)
+ return NewJWTProfileTokenSource(ctx, issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...)
}
-func NewJWTProfileTokenSourceFromKeyFileData(issuer string, data []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (oauth2.TokenSource, error) {
- keyData, err := client.ConfigFromKeyFileData(data)
+// NewJWTProfileTokenSourceFromKeyFileData returns an implementation of oauth2.TokenSource
+// It will request a token using the OAuth2 JWT Profile Grant,
+// therefore sending an `assertion` by singing a JWT with the provided private key in jsonData.
+//
+// The passed context is only used for the call to the Discover endpoint.
+func NewJWTProfileTokenSourceFromKeyFileData(ctx context.Context, issuer string, jsonData []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) {
+ keyData, err := client.ConfigFromKeyFileData(jsonData)
if err != nil {
return nil, err
}
- return NewJWTProfileTokenSource(issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...)
+ return NewJWTProfileTokenSource(ctx, issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...)
}
-func NewJWTProfileTokenSource(issuer, clientID, keyID string, key []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (oauth2.TokenSource, error) {
+// NewJWTProfileSource returns an implementation of oauth2.TokenSource
+// It will request a token using the OAuth2 JWT Profile Grant,
+// therefore sending an `assertion` by singing a JWT with the provided private key.
+//
+// The passed context is only used for the call to the Discover endpoint.
+func NewJWTProfileTokenSource(ctx context.Context, issuer, clientID, keyID string, key []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) {
signer, err := client.NewSignerFromPrivateKeyByte(key, keyID)
if err != nil {
return nil, err
@@ -55,7 +76,7 @@ func NewJWTProfileTokenSource(issuer, clientID, keyID string, key []byte, scopes
opt(source)
}
if source.tokenEndpoint == "" {
- config, err := client.Discover(issuer, source.httpClient)
+ config, err := client.Discover(ctx, issuer, source.httpClient)
if err != nil {
return nil, err
}
@@ -64,13 +85,13 @@ func NewJWTProfileTokenSource(issuer, clientID, keyID string, key []byte, scopes
return source, nil
}
-func WithHTTPClient(client *http.Client) func(*jwtProfileTokenSource) {
+func WithHTTPClient(client *http.Client) func(source *jwtProfileTokenSource) {
return func(source *jwtProfileTokenSource) {
source.httpClient = client
}
}
-func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(*jwtProfileTokenSource) {
+func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(source *jwtProfileTokenSource) {
return func(source *jwtProfileTokenSource) {
source.tokenEndpoint = tokenEndpoint
}
@@ -85,9 +106,13 @@ func (j *jwtProfileTokenSource) HttpClient() *http.Client {
}
func (j *jwtProfileTokenSource) Token() (*oauth2.Token, error) {
+ return j.TokenCtx(context.Background())
+}
+
+func (j *jwtProfileTokenSource) TokenCtx(ctx context.Context) (*oauth2.Token, error) {
assertion, err := client.SignedJWTProfileAssertion(j.clientID, j.audience, time.Hour, j.signer)
if err != nil {
return nil, err
}
- return client.JWTProfileExchange(oidc.NewJWTProfileGrantRequest(assertion, j.scopes...), j)
+ return client.JWTProfileExchange(ctx, oidc.NewJWTProfileGrantRequest(assertion, j.scopes...), j)
}
diff --git a/pkg/client/rp/cli/cli.go b/pkg/client/rp/cli/cli.go
index 6e30e4e..10edaa7 100644
--- a/pkg/client/rp/cli/cli.go
+++ b/pkg/client/rp/cli/cli.go
@@ -4,22 +4,22 @@ import (
"context"
"net/http"
- "github.com/zitadel/oidc/pkg/client/rp"
- httphelper "github.com/zitadel/oidc/pkg/http"
- "github.com/zitadel/oidc/pkg/oidc"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
+ httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
+ "git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
)
const (
loginPath = "/login"
)
-func CodeFlow(ctx context.Context, relyingParty rp.RelyingParty, callbackPath, port string, stateProvider func() string) *oidc.Tokens {
+func CodeFlow[C oidc.IDClaims](ctx context.Context, relyingParty rp.RelyingParty, callbackPath, port string, stateProvider func() string) *oidc.Tokens[C] {
codeflowCtx, codeflowCancel := context.WithCancel(ctx)
defer codeflowCancel()
- tokenChan := make(chan *oidc.Tokens, 1)
+ tokenChan := make(chan *oidc.Tokens[C], 1)
- callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty) {
+ callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, rp rp.RelyingParty) {
tokenChan <- tokens
msg := "
Success!
"
msg = msg + "
You are authenticated and can now return to the CLI.