├── .codecov └── codecov.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ ├── config.yml │ ├── docs.yaml │ ├── improvement.yaml │ └── proposal.yaml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── codeql-analysis.yml │ ├── issue.yml │ └── release.yml ├── .gitignore ├── .releaserc.js ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.md ├── SECURITY.md ├── UPGRADING.md ├── doc.go ├── example ├── client │ ├── api │ │ └── api.go │ ├── app │ │ └── app.go │ ├── device │ │ └── device.go │ ├── github │ │ └── github.go │ └── service │ │ └── service.go ├── doc.go └── server │ ├── config │ ├── config.go │ └── config_test.go │ ├── dynamic │ ├── login.go │ └── op.go │ ├── exampleop │ ├── device.go │ ├── login.go │ ├── op.go │ ├── templates.go │ └── templates │ │ ├── confirm_device.html │ │ ├── device_login.html │ │ ├── login.html │ │ └── usercode.html │ ├── main.go │ ├── service-key1.json │ └── storage │ ├── client.go │ ├── oidc.go │ ├── storage.go │ ├── storage_dynamic.go │ ├── token.go │ ├── user.go │ └── user_test.go ├── go.mod ├── go.sum ├── internal └── testutil │ ├── gen │ └── gen.go │ └── token.go └── pkg ├── client ├── client.go ├── client_test.go ├── errors.go ├── integration_test.go ├── jwt_profile.go ├── key.go ├── profile │ └── jwt_profile.go ├── rp │ ├── cli │ │ ├── browser.go │ │ └── cli.go │ ├── delegation.go │ ├── device.go │ ├── errors.go │ ├── jwks.go │ ├── log.go │ ├── relying_party.go │ ├── relying_party_test.go │ ├── tockenexchange.go │ ├── userinfo_example_test.go │ ├── verifier.go │ ├── verifier_test.go │ └── verifier_tokens_example_test.go ├── rs │ ├── introspect_example_test.go │ ├── resource_server.go │ └── resource_server_test.go └── tokenexchange │ └── tokenexchange.go ├── crypto ├── crypto.go ├── hash.go ├── key.go ├── key_test.go └── sign.go ├── http ├── cookie.go ├── http.go ├── marshal.go └── marshal_test.go ├── oidc ├── authorization.go ├── authorization_test.go ├── code_challenge.go ├── device_authorization.go ├── device_authorization_test.go ├── discovery.go ├── error.go ├── error_test.go ├── grants │ ├── client_credentials.go │ └── tokenexchange │ │ └── tokenexchange.go ├── introspection.go ├── introspection_test.go ├── jwt_profile.go ├── keyset.go ├── keyset_test.go ├── regression_assert_test.go ├── regression_create_test.go ├── regression_data │ ├── oidc.AccessTokenClaims.json │ ├── oidc.IDTokenClaims.json │ ├── oidc.IntrospectionResponse.json │ ├── oidc.JWTProfileAssertionClaims.json │ └── oidc.UserInfo.json ├── regression_test.go ├── revocation.go ├── session.go ├── token.go ├── token_request.go ├── token_test.go ├── types.go ├── types_test.go ├── userinfo.go ├── userinfo_test.go ├── util.go ├── util_test.go ├── verifier.go ├── verifier_parse_test.go └── verifier_test.go ├── op ├── applicationtype_enumer.go ├── auth_request.go ├── auth_request_test.go ├── client.go ├── client_test.go ├── config.go ├── config_test.go ├── context.go ├── context_test.go ├── crypto.go ├── device.go ├── device_test.go ├── discovery.go ├── discovery_test.go ├── endpoint.go ├── endpoint_test.go ├── error.go ├── error_test.go ├── form_post.html.tmpl ├── keys.go ├── keys_test.go ├── mock │ ├── authorizer.mock.go │ ├── authorizer.mock.impl.go │ ├── client.go │ ├── client.mock.go │ ├── configuration.mock.go │ ├── discovery.mock.go │ ├── generate.go │ ├── glob.go │ ├── glob.mock.go │ ├── key.mock.go │ ├── signer.mock.go │ ├── storage.mock.go │ └── storage.mock.impl.go ├── op.go ├── op_test.go ├── probes.go ├── server.go ├── server_http.go ├── server_http_routes_test.go ├── server_http_test.go ├── server_legacy.go ├── server_test.go ├── session.go ├── signer.go ├── storage.go ├── token.go ├── token_client_credentials.go ├── token_code.go ├── token_exchange.go ├── token_intospection.go ├── token_jwt_profile.go ├── token_refresh.go ├── token_request.go ├── token_request_test.go ├── token_revocation.go ├── userinfo.go ├── verifier_access_token.go ├── verifier_access_token_example_test.go ├── verifier_access_token_test.go ├── verifier_id_token_hint.go ├── verifier_id_token_hint_test.go ├── verifier_jwt_profile.go └── verifier_jwt_profile_test.go └── strings ├── strings.go └── strings_test.go /.codecov/codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | branch: main 3 | notify: 4 | require_ci_to_pass: yes 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "70...100" 9 | status: 10 | project: yes 11 | patch: yes 12 | changes: no 13 | parsers: 14 | gcov: 15 | branch_detection: 16 | conditional: yes 17 | loop: yes 18 | method: no 19 | macro: no 20 | comment: 21 | layout: "header, diff" 22 | behavior: default 23 | require_changes: no 24 | ignore: 25 | - "example" 26 | - "**/mock" 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | 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." 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: checkboxes 11 | id: preflight 12 | attributes: 13 | label: Preflight Checklist 14 | options: 15 | - label: 16 | I could not find a solution in the documentation, the existing issues or discussions 17 | required: true 18 | - label: 19 | I have joined the [ZITADEL chat](https://zitadel.com/chat) 20 | - type: input 21 | id: version 22 | attributes: 23 | label: Version 24 | description: Which version of the OIDC library are you using. 25 | - type: textarea 26 | id: impact 27 | attributes: 28 | label: Describe the problem caused by this bug 29 | description: A clear and concise description of the problem you have and what the bug is. 30 | validations: 31 | required: true 32 | - type: textarea 33 | id: reproduce 34 | attributes: 35 | label: To reproduce 36 | description: Steps to reproduce the behaviour 37 | placeholder: | 38 | Steps to reproduce the behavior: 39 | validations: 40 | required: true 41 | - type: textarea 42 | id: screenshots 43 | attributes: 44 | label: Screenshots 45 | description: If applicable, add screenshots to help explain your problem. 46 | - type: textarea 47 | id: expected 48 | attributes: 49 | label: Expected behavior 50 | description: A clear and concise description of what you expected to happen. 51 | placeholder: As a [type of user], I want [some goal] so that [some reason]. 52 | - type: textarea 53 | id: additional 54 | attributes: 55 | label: Additional Context 56 | description: Please add any other infos that could be useful. 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/docs.yaml: -------------------------------------------------------------------------------- 1 | name: 📄 Documentation 2 | description: Create an issue for missing or wrong documentation. 3 | labels: ["docs"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this issue. 9 | - type: checkboxes 10 | id: preflight 11 | attributes: 12 | label: Preflight Checklist 13 | options: 14 | - label: 15 | I could not find a solution in the existing issues, docs, nor discussions 16 | required: true 17 | - label: 18 | I have joined the [ZITADEL chat](https://zitadel.com/chat) 19 | - type: textarea 20 | id: docs 21 | attributes: 22 | label: Describe the docs your are missing or that are wrong 23 | placeholder: As a [type of user], I want [some goal] so that [some reason]. 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: additional 28 | attributes: 29 | label: Additional Context 30 | description: Please add any other infos that could be useful. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/improvement.yaml: -------------------------------------------------------------------------------- 1 | name: 🛠️ Improvement 2 | description: "Create an new issue for an improvment in ZITADEL" 3 | labels: ["improvement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this improvement request 9 | - type: checkboxes 10 | id: preflight 11 | attributes: 12 | label: Preflight Checklist 13 | options: 14 | - label: 15 | I could not find a solution in the existing issues, docs, nor discussions 16 | required: true 17 | - label: 18 | I have joined the [ZITADEL chat](https://zitadel.com/chat) 19 | - type: textarea 20 | id: problem 21 | attributes: 22 | label: Describe your problem 23 | description: Please describe your problem this improvement is supposed to solve. 24 | placeholder: Describe the problem you have 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: solution 29 | attributes: 30 | label: Describe your ideal solution 31 | description: Which solution do you propose? 32 | placeholder: As a [type of user], I want [some goal] so that [some reason]. 33 | validations: 34 | required: true 35 | - type: input 36 | id: version 37 | attributes: 38 | label: Version 39 | description: Which version of the OIDC Library are you using. 40 | - type: dropdown 41 | id: environment 42 | attributes: 43 | label: Environment 44 | description: How do you use ZITADEL? 45 | options: 46 | - ZITADEL Cloud 47 | - Self-hosted 48 | validations: 49 | required: true 50 | - type: textarea 51 | id: additional 52 | attributes: 53 | label: Additional Context 54 | description: Please add any other infos that could be useful. 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/proposal.yaml: -------------------------------------------------------------------------------- 1 | name: 💡 Proposal / Feature request 2 | description: "Create an issue for a feature request/proposal." 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this proposal / feature reqeust 9 | - type: checkboxes 10 | id: preflight 11 | attributes: 12 | label: Preflight Checklist 13 | options: 14 | - label: 15 | I could not find a solution in the existing issues, docs, nor discussions 16 | required: true 17 | - label: 18 | I have joined the [ZITADEL chat](https://zitadel.com/chat) 19 | - type: textarea 20 | id: problem 21 | attributes: 22 | label: Describe your problem 23 | description: Please describe your problem this proposal / feature is supposed to solve. 24 | placeholder: Describe the problem you have. 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: solution 29 | attributes: 30 | label: Describe your ideal solution 31 | description: Which solution do you propose? 32 | placeholder: As a [type of user], I want [some goal] so that [some reason]. 33 | validations: 34 | required: true 35 | - type: input 36 | id: version 37 | attributes: 38 | label: Version 39 | description: Which version of the OIDC Library are you using. 40 | - type: textarea 41 | id: additional 42 | attributes: 43 | label: Additional Context 44 | description: Please add any other infos that could be useful. 45 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: '04:00' 8 | open-pull-requests-limit: 10 9 | commit-message: 10 | prefix: chore 11 | include: scope 12 | - package-ecosystem: gomod 13 | target-branch: "2.12.x" 14 | directory: "/" 15 | schedule: 16 | interval: daily 17 | time: '04:00' 18 | open-pull-requests-limit: 10 19 | commit-message: 20 | prefix: chore 21 | include: scope 22 | - package-ecosystem: "github-actions" 23 | directory: "/" 24 | schedule: 25 | interval: weekly -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Definition of Ready 2 | 3 | - [ ] I am happy with the code 4 | - [ ] Short description of the feature/issue is added in the pr description 5 | - [ ] PR is linked to the corresponding user story 6 | - [ ] Acceptance criteria are met 7 | - [ ] All open todos and follow ups are defined in a new ticket and justified 8 | - [ ] Deviations from the acceptance criteria and design are agreed with the PO and documented. 9 | - [ ] No debug or dead code 10 | - [ ] My code has no repetitions 11 | - [ ] Critical parts are tested automatically 12 | - [ ] Where possible E2E tests are implemented 13 | - [ ] Documentation/examples are up-to-date 14 | - [ ] All non-functional requirements are met 15 | - [ ] Functionality of the acceptance criteria is checked manually on the dev system. 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | branches: [main,next] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [main,next] 9 | schedule: 10 | - cron: '0 11 * * 0' 11 | 12 | jobs: 13 | CodeQL-Build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v3 33 | # Override language selection by uncommenting this and choosing your languages 34 | with: 35 | languages: go 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v3 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v3 55 | -------------------------------------------------------------------------------- /.github/workflows/issue.yml: -------------------------------------------------------------------------------- 1 | name: Add new issues to product management project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request_target: 8 | types: 9 | - opened 10 | 11 | jobs: 12 | add-to-project: 13 | name: Add issue and community pr to project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: add issue 17 | uses: actions/add-to-project@v1.0.2 18 | if: ${{ github.event_name == 'issues' }} 19 | with: 20 | # You can target a repository in a different organization 21 | # to the issue 22 | project-url: https://github.com/orgs/zitadel/projects/2 23 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 24 | - uses: tspascoal/get-user-teams-membership@v3 25 | id: checkUserMember 26 | if: github.actor != 'dependabot[bot]' 27 | with: 28 | username: ${{ github.actor }} 29 | GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} 30 | - name: add pr 31 | uses: actions/add-to-project@v1.0.2 32 | if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}} 33 | with: 34 | # You can target a repository in a different organization 35 | # to the issue 36 | project-url: https://github.com/orgs/zitadel/projects/2 37 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 38 | - uses: actions-ecosystem/action-add-labels@v1.1.3 39 | if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}} 40 | with: 41 | github_token: ${{ secrets.ADD_TO_PROJECT_PAT }} 42 | labels: | 43 | os-contribution 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - "2.11.x" 6 | - main 7 | - next 8 | tags-ignore: 9 | - '**' 10 | pull_request: 11 | branches: 12 | - '**' 13 | workflow_dispatch: 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-24.04 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | go: ['1.23', '1.24'] 22 | name: Go ${{ matrix.go }} test 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Setup go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ matrix.go }} 29 | - run: go test -race -v -coverprofile=profile.cov -coverpkg=./pkg/... ./pkg/... 30 | - uses: codecov/codecov-action@v5.4.3 31 | with: 32 | file: ./profile.cov 33 | name: codecov-go 34 | release: 35 | runs-on: ubuntu-24.04 36 | needs: [test] 37 | if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' }} 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | steps: 41 | - name: Source checkout 42 | uses: actions/checkout@v4 43 | - name: Semantic Release 44 | uses: cycjimmy/semantic-release-action@v4 45 | with: 46 | dry_run: false 47 | semantic_version: 18.0.1 48 | extra_plugins: | 49 | @semantic-release/exec@6.0.3 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | **/__debug_bin 15 | .vscode 16 | .DS_Store 17 | .idea 18 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: [ 3 | {name: "2.11.x"}, 4 | {name: "main"}, 5 | {name: "next", prerelease: true}, 6 | ], 7 | plugins: [ 8 | "@semantic-release/commit-analyzer", 9 | "@semantic-release/release-notes-generator", 10 | "@semantic-release/github" 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to the OIDC SDK for Go 2 | 3 | ## Did you find a bug? 4 | 5 | Please file an issue [here](https://github.com/zitadel/oidc/issues/new?assignees=&labels=bug&template=bug_report.md&title=). 6 | 7 | Bugs are evaluated every day as soon as possible. 8 | 9 | ## Enhancement 10 | 11 | Do you miss a feature? Please file an issue [here](https://github.com/zitadel/oidc/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=) 12 | 13 | Enhancements are discussed and evaluated every Wednesday by the ZITADEL core team. 14 | 15 | ## Grab an Issues 16 | 17 | We add the label "good first issue" for problems we think are a good starting point to contribute to the OIDC SDK. 18 | 19 | * [Issues for first time contributors](https://github.com/zitadel/oidc/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) 20 | * [All issues](https://github.com/zitadel/oidc/issues) 21 | 22 | ### Make a PR 23 | 24 | If you like to contribute fork the OIDC repository. After you implemented the new feature create a PullRequest in the OIDC reposiotry. 25 | 26 | Make sure you use semantic release: 27 | 28 | * feat: New Feature 29 | * fix: Bug Fix 30 | * docs: Documentation 31 | 32 | ## Want to use the library? 33 | 34 | Checkout the [examples folder](example) for different client and server implementations. 35 | 36 | Or checkout how we use it ourselves in our OpenSource Identity and Access Management [ZITADEL](https://github.com/zitadel/zitadel). 37 | 38 | ## **Did you find a security flaw?** 39 | 40 | * Please read [Security Policy](SECURITY.md). -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright The zitadel/oidc Contributors 2 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | 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. 4 | 5 | ## Supported Versions 6 | 7 | We currently support the following version of the OIDC framework: 8 | 9 | | Version | Supported | Branch | Details | 10 | | -------- | ------------------ | ----------- | ------------------------------------ | 11 | | 0.x.x | :x: | | not maintained | 12 | | <2.11 | :x: | | not maintained | 13 | | 2.11.x | :lock: :warning: | [2.11.x][1] | security only, [community effort][2] | 14 | | 3.x.x | :heavy_check_mark: | [main][3] | supported | 15 | | 4.0.0-xx | :white_check_mark: | [next][4] | [development branch] | 16 | 17 | [1]: https://github.com/zitadel/oidc/tree/2.11.x 18 | [2]: https://github.com/zitadel/oidc/discussions/458 19 | [3]: https://github.com/zitadel/oidc/tree/main 20 | [4]: https://github.com/zitadel/oidc/tree/next 21 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | -------------------------------------------------------------------------------- /example/client/api/api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/go-chi/chi/v5" 14 | "github.com/sirupsen/logrus" 15 | 16 | "github.com/zitadel/oidc/v3/pkg/client/rs" 17 | "github.com/zitadel/oidc/v3/pkg/oidc" 18 | ) 19 | 20 | const ( 21 | publicURL string = "/public" 22 | protectedURL string = "/protected" 23 | protectedClaimURL string = "/protected/{claim}/{value}" 24 | ) 25 | 26 | func main() { 27 | keyPath := os.Getenv("KEY") 28 | port := os.Getenv("PORT") 29 | issuer := os.Getenv("ISSUER") 30 | 31 | provider, err := rs.NewResourceServerFromKeyFile(context.TODO(), issuer, keyPath) 32 | if err != nil { 33 | logrus.Fatalf("error creating provider %s", err.Error()) 34 | } 35 | 36 | router := chi.NewRouter() 37 | 38 | // public url accessible without any authorization 39 | // will print `OK` and current timestamp 40 | router.HandleFunc(publicURL, func(w http.ResponseWriter, r *http.Request) { 41 | w.Write([]byte("OK " + time.Now().String())) 42 | }) 43 | 44 | // protected url which needs an active token 45 | // will print the result of the introspection endpoint on success 46 | router.HandleFunc(protectedURL, func(w http.ResponseWriter, r *http.Request) { 47 | ok, token := checkToken(w, r) 48 | if !ok { 49 | return 50 | } 51 | resp, err := rs.Introspect[*oidc.IntrospectionResponse](r.Context(), provider, token) 52 | if err != nil { 53 | http.Error(w, err.Error(), http.StatusForbidden) 54 | return 55 | } 56 | data, err := json.Marshal(resp) 57 | if err != nil { 58 | http.Error(w, err.Error(), http.StatusInternalServerError) 59 | return 60 | } 61 | w.Write(data) 62 | }) 63 | 64 | // protected url which needs an active token and checks if the response of the introspect endpoint 65 | // contains a requested claim with the required (string) value 66 | // e.g. /protected/username/livio@zitadel.example 67 | router.HandleFunc(protectedClaimURL, func(w http.ResponseWriter, r *http.Request) { 68 | ok, token := checkToken(w, r) 69 | if !ok { 70 | return 71 | } 72 | resp, err := rs.Introspect[*oidc.IntrospectionResponse](r.Context(), provider, token) 73 | if err != nil { 74 | http.Error(w, err.Error(), http.StatusForbidden) 75 | return 76 | } 77 | requestedClaim := chi.URLParam(r, "claim") 78 | requestedValue := chi.URLParam(r, "value") 79 | 80 | value, ok := resp.Claims[requestedClaim].(string) 81 | if !ok || value == "" || value != requestedValue { 82 | http.Error(w, "claim does not match", http.StatusForbidden) 83 | return 84 | } 85 | w.Write([]byte("authorized with value " + value)) 86 | }) 87 | 88 | lis := fmt.Sprintf("127.0.0.1:%s", port) 89 | log.Printf("listening on http://%s/", lis) 90 | log.Fatal(http.ListenAndServe(lis, router)) 91 | } 92 | 93 | func checkToken(w http.ResponseWriter, r *http.Request) (bool, string) { 94 | auth := r.Header.Get("authorization") 95 | if auth == "" { 96 | http.Error(w, "auth header missing", http.StatusUnauthorized) 97 | return false, "" 98 | } 99 | if !strings.HasPrefix(auth, oidc.PrefixBearer) { 100 | http.Error(w, "invalid header", http.StatusUnauthorized) 101 | return false, "" 102 | } 103 | return true, strings.TrimPrefix(auth, oidc.PrefixBearer) 104 | } 105 | -------------------------------------------------------------------------------- /example/client/device/device.go: -------------------------------------------------------------------------------- 1 | // Command device is an example Oauth2 Device Authorization Grant app. 2 | // It creates a new Device Authorization request on the Issuer and then polls for tokens. 3 | // The user is then prompted to visit a URL and enter the user code. 4 | // Or, the complete URL can be used instead to omit manual entry. 5 | // In practice then can be a "magic link" in the form or a QR. 6 | // 7 | // The following environment variables are used for configuration: 8 | // 9 | // ISSUER: URL to the OP, required. 10 | // CLIENT_ID: ID of the application, required. 11 | // CLIENT_SECRET: Secret to authenticate the app using basic auth. Only required if the OP expects this type of authentication. 12 | // 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. 13 | // SCOPES: Scopes of the Authentication Request. Optional. 14 | // 15 | // Basic usage: 16 | // 17 | // cd example/client/device 18 | // export ISSUER="http://localhost:9000" CLIENT_ID="246048465824634593@demo" 19 | // 20 | // Get an Access Token: 21 | // 22 | // SCOPES="email profile" go run . 23 | // 24 | // Get an Access Token and ID Token: 25 | // 26 | // SCOPES="email profile openid" go run . 27 | // 28 | // Get an Access Token and Refresh Token 29 | // 30 | // SCOPES="email profile offline_access" go run . 31 | // 32 | // Get Access, Refresh and ID Tokens: 33 | // 34 | // SCOPES="email profile offline_access openid" go run . 35 | package main 36 | 37 | import ( 38 | "context" 39 | "fmt" 40 | "os" 41 | "os/signal" 42 | "strings" 43 | "syscall" 44 | "time" 45 | 46 | "github.com/sirupsen/logrus" 47 | 48 | "github.com/zitadel/oidc/v3/pkg/client/rp" 49 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 50 | ) 51 | 52 | var ( 53 | key = []byte("test1234test1234") 54 | ) 55 | 56 | func main() { 57 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT) 58 | defer stop() 59 | 60 | clientID := os.Getenv("CLIENT_ID") 61 | clientSecret := os.Getenv("CLIENT_SECRET") 62 | keyPath := os.Getenv("KEY_PATH") 63 | issuer := os.Getenv("ISSUER") 64 | scopes := strings.Split(os.Getenv("SCOPES"), " ") 65 | 66 | cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) 67 | 68 | var options []rp.Option 69 | if clientSecret == "" { 70 | options = append(options, rp.WithPKCE(cookieHandler)) 71 | } 72 | if keyPath != "" { 73 | options = append(options, rp.WithJWTProfile(rp.SignerFromKeyPath(keyPath))) 74 | } 75 | 76 | provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, "", scopes, options...) 77 | if err != nil { 78 | logrus.Fatalf("error creating provider %s", err.Error()) 79 | } 80 | 81 | logrus.Info("starting device authorization flow") 82 | resp, err := rp.DeviceAuthorization(ctx, scopes, provider, nil) 83 | if err != nil { 84 | logrus.Fatal(err) 85 | } 86 | logrus.Info("resp", resp) 87 | fmt.Printf("\nPlease browse to %s and enter code %s\n", resp.VerificationURI, resp.UserCode) 88 | 89 | logrus.Info("start polling") 90 | token, err := rp.DeviceAccessToken(ctx, resp.DeviceCode, time.Duration(resp.Interval)*time.Second, provider) 91 | if err != nil { 92 | logrus.Fatal(err) 93 | } 94 | logrus.Infof("successfully obtained token: %#v", token) 95 | } 96 | -------------------------------------------------------------------------------- /example/client/github/github.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/google/go-github/v31/github" 9 | "github.com/google/uuid" 10 | "golang.org/x/oauth2" 11 | githubOAuth "golang.org/x/oauth2/github" 12 | 13 | "github.com/zitadel/oidc/v3/pkg/client/rp" 14 | "github.com/zitadel/oidc/v3/pkg/client/rp/cli" 15 | "github.com/zitadel/oidc/v3/pkg/http" 16 | "github.com/zitadel/oidc/v3/pkg/oidc" 17 | ) 18 | 19 | var ( 20 | callbackPath = "/orbctl/github/callback" 21 | key = []byte("test1234test1234") 22 | ) 23 | 24 | func main() { 25 | clientID := os.Getenv("CLIENT_ID") 26 | clientSecret := os.Getenv("CLIENT_SECRET") 27 | port := os.Getenv("PORT") 28 | 29 | rpConfig := &oauth2.Config{ 30 | ClientID: clientID, 31 | ClientSecret: clientSecret, 32 | RedirectURL: fmt.Sprintf("http://localhost:%v%v", port, callbackPath), 33 | Scopes: []string{"repo", "repo_deployment"}, 34 | Endpoint: githubOAuth.Endpoint, 35 | } 36 | 37 | ctx := context.Background() 38 | cookieHandler := http.NewCookieHandler(key, key, http.WithUnsecure()) 39 | relyingParty, err := rp.NewRelyingPartyOAuth(rpConfig, rp.WithCookieHandler(cookieHandler)) 40 | if err != nil { 41 | fmt.Printf("error creating relaying party: %v", err) 42 | return 43 | } 44 | state := func() string { 45 | return uuid.New().String() 46 | } 47 | token := cli.CodeFlow[*oidc.IDTokenClaims](ctx, relyingParty, callbackPath, port, state) 48 | 49 | client := github.NewClient(relyingParty.OAuthConfig().Client(ctx, token.Token)) 50 | 51 | _, _, err = client.Users.Get(ctx, "") 52 | if err != nil { 53 | fmt.Printf("error %v", err) 54 | return 55 | } 56 | fmt.Println("call succeeded") 57 | } 58 | -------------------------------------------------------------------------------- /example/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package example contains some example of the various use of this library: 3 | 4 | /api example of an api / resource server implementation using token introspection 5 | /app web app / RP demonstrating authorization code flow using various authentication methods (code, PKCE, JWT profile) 6 | /github example of the extended OAuth2 library, providing an HTTP client with a reuse token source 7 | /service demonstration of JWT Profile Authorization Grant 8 | /server examples of an OpenID Provider implementations (including dynamic) with some very basic 9 | */ 10 | package example 11 | -------------------------------------------------------------------------------- /example/server/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | // default port for the http server to run 10 | DefaultIssuerPort = "9998" 11 | ) 12 | 13 | type Config struct { 14 | Port string 15 | RedirectURI []string 16 | UsersFile string 17 | } 18 | 19 | // FromEnvVars loads configuration parameters from environment variables. 20 | // If there is no such variable defined, then use default values. 21 | func FromEnvVars(defaults *Config) *Config { 22 | if defaults == nil { 23 | defaults = &Config{} 24 | } 25 | cfg := &Config{ 26 | Port: defaults.Port, 27 | RedirectURI: defaults.RedirectURI, 28 | UsersFile: defaults.UsersFile, 29 | } 30 | if value, ok := os.LookupEnv("PORT"); ok { 31 | cfg.Port = value 32 | } 33 | if value, ok := os.LookupEnv("USERS_FILE"); ok { 34 | cfg.UsersFile = value 35 | } 36 | if value, ok := os.LookupEnv("REDIRECT_URI"); ok { 37 | cfg.RedirectURI = strings.Split(value, ",") 38 | } 39 | return cfg 40 | } 41 | -------------------------------------------------------------------------------- /example/server/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestFromEnvVars(t *testing.T) { 10 | 11 | for _, tc := range []struct { 12 | name string 13 | env map[string]string 14 | defaults *Config 15 | want *Config 16 | }{ 17 | { 18 | name: "no vars, no default values", 19 | env: map[string]string{}, 20 | want: &Config{}, 21 | }, 22 | { 23 | name: "no vars, only defaults", 24 | env: map[string]string{}, 25 | defaults: &Config{ 26 | Port: "6666", 27 | UsersFile: "/default/user/path", 28 | RedirectURI: []string{"re", "direct", "uris"}, 29 | }, 30 | want: &Config{ 31 | Port: "6666", 32 | UsersFile: "/default/user/path", 33 | RedirectURI: []string{"re", "direct", "uris"}, 34 | }, 35 | }, 36 | { 37 | name: "overriding default values", 38 | env: map[string]string{ 39 | "PORT": "1234", 40 | "USERS_FILE": "/path/to/users", 41 | "REDIRECT_URI": "http://redirect/redirect", 42 | }, 43 | defaults: &Config{ 44 | Port: "6666", 45 | UsersFile: "/default/user/path", 46 | RedirectURI: []string{"re", "direct", "uris"}, 47 | }, 48 | want: &Config{ 49 | Port: "1234", 50 | UsersFile: "/path/to/users", 51 | RedirectURI: []string{"http://redirect/redirect"}, 52 | }, 53 | }, 54 | { 55 | name: "multiple redirect uris", 56 | env: map[string]string{ 57 | "REDIRECT_URI": "http://host_1,http://host_2,http://host_3", 58 | }, 59 | want: &Config{ 60 | RedirectURI: []string{ 61 | "http://host_1", "http://host_2", "http://host_3", 62 | }, 63 | }, 64 | }, 65 | } { 66 | t.Run(tc.name, func(t *testing.T) { 67 | os.Clearenv() 68 | for k, v := range tc.env { 69 | os.Setenv(k, v) 70 | } 71 | cfg := FromEnvVars(tc.defaults) 72 | if fmt.Sprint(cfg) != fmt.Sprint(tc.want) { 73 | t.Errorf("Expected FromEnvVars()=%q, but got %q", tc.want, cfg) 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /example/server/dynamic/login.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "net/http" 8 | 9 | "github.com/go-chi/chi/v5" 10 | 11 | "github.com/zitadel/oidc/v3/pkg/op" 12 | ) 13 | 14 | const ( 15 | queryAuthRequestID = "authRequestID" 16 | ) 17 | 18 | var ( 19 | loginTmpl, _ = template.New("login").Parse(` 20 | 21 | 22 |
23 | 24 |19 | You are about to grant device {{.ClientID}} access to the following scopes: {{.Scopes}}. 20 |
21 | 22 | 23 | 24 | 25 | {{- end }} 26 | -------------------------------------------------------------------------------- /example/server/exampleop/templates/device_login.html: -------------------------------------------------------------------------------- 1 | {{ define "device_login" -}} 2 | 3 | 4 | 5 | 6 |Success!
" 25 | msg = msg + "You are authenticated and can now return to the CLI.
" 26 | w.Write([]byte(msg)) 27 | } 28 | http.Handle(loginPath, rp.AuthURLHandler(stateProvider, relyingParty)) 29 | http.Handle(callbackPath, rp.CodeExchangeHandler(callback, relyingParty)) 30 | 31 | httphelper.StartServer(codeflowCtx, ":"+port) 32 | 33 | OpenBrowser("http://localhost:" + port + loginPath) 34 | 35 | return <-tokenChan 36 | } 37 | -------------------------------------------------------------------------------- /pkg/client/rp/delegation.go: -------------------------------------------------------------------------------- 1 | package rp 2 | 3 | import ( 4 | "github.com/zitadel/oidc/v3/pkg/oidc/grants/tokenexchange" 5 | ) 6 | 7 | // DelegationTokenRequest is an implementation of TokenExchangeRequest 8 | // it exchanges an "urn:ietf:params:oauth:token-type:access_token" with an optional 9 | // "urn:ietf:params:oauth:token-type:access_token" actor token for an 10 | // "urn:ietf:params:oauth:token-type:access_token" delegation token 11 | func DelegationTokenRequest(subjectToken string, opts ...tokenexchange.TokenExchangeOption) *tokenexchange.TokenExchangeRequest { 12 | return tokenexchange.NewTokenExchangeRequest(subjectToken, tokenexchange.AccessTokenType, opts...) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/client/rp/device.go: -------------------------------------------------------------------------------- 1 | package rp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/zitadel/oidc/v3/pkg/client" 9 | "github.com/zitadel/oidc/v3/pkg/oidc" 10 | ) 11 | 12 | func newDeviceClientCredentialsRequest(scopes []string, rp RelyingParty) (*oidc.ClientCredentialsRequest, error) { 13 | confg := rp.OAuthConfig() 14 | req := &oidc.ClientCredentialsRequest{ 15 | Scope: scopes, 16 | ClientID: confg.ClientID, 17 | ClientSecret: confg.ClientSecret, 18 | } 19 | 20 | if signer := rp.Signer(); signer != nil { 21 | assertion, err := client.SignedJWTProfileAssertion(rp.OAuthConfig().ClientID, []string{rp.Issuer()}, time.Hour, signer) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to build assertion: %w", err) 24 | } 25 | req.ClientAssertion = assertion 26 | req.ClientAssertionType = oidc.ClientAssertionTypeJWTAssertion 27 | } 28 | 29 | return req, nil 30 | } 31 | 32 | // DeviceAuthorization starts a new Device Authorization flow as defined 33 | // in RFC 8628, section 3.1 and 3.2: 34 | // https://www.rfc-editor.org/rfc/rfc8628#section-3.1 35 | func DeviceAuthorization(ctx context.Context, scopes []string, rp RelyingParty, authFn any) (*oidc.DeviceAuthorizationResponse, error) { 36 | ctx, span := client.Tracer.Start(ctx, "DeviceAuthorization") 37 | defer span.End() 38 | 39 | ctx = logCtxWithRPData(ctx, rp, "function", "DeviceAuthorization") 40 | req, err := newDeviceClientCredentialsRequest(scopes, rp) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return client.CallDeviceAuthorizationEndpoint(ctx, req, rp, authFn) 46 | } 47 | 48 | // DeviceAccessToken attempts to obtain tokens from a Device Authorization, 49 | // by means of polling as defined in RFC, section 3.3 and 3.4: 50 | // https://www.rfc-editor.org/rfc/rfc8628#section-3.4 51 | func DeviceAccessToken(ctx context.Context, deviceCode string, interval time.Duration, rp RelyingParty) (resp *oidc.AccessTokenResponse, err error) { 52 | ctx, span := client.Tracer.Start(ctx, "DeviceAccessToken") 53 | defer span.End() 54 | 55 | ctx = logCtxWithRPData(ctx, rp, "function", "DeviceAccessToken") 56 | req := &client.DeviceAccessTokenRequest{ 57 | DeviceAccessTokenRequest: oidc.DeviceAccessTokenRequest{ 58 | GrantType: oidc.GrantTypeDeviceCode, 59 | DeviceCode: deviceCode, 60 | }, 61 | } 62 | 63 | req.ClientCredentialsRequest, err = newDeviceClientCredentialsRequest(nil, rp) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | return client.PollDeviceAccessTokenEndpoint(ctx, interval, req, tokenEndpointCaller{rp}) 69 | } 70 | -------------------------------------------------------------------------------- /pkg/client/rp/errors.go: -------------------------------------------------------------------------------- 1 | package rp 2 | 3 | import "errors" 4 | 5 | var ErrRelyingPartyNotSupportRevokeCaller = errors.New("RelyingParty does not support RevokeCaller") 6 | -------------------------------------------------------------------------------- /pkg/client/rp/log.go: -------------------------------------------------------------------------------- 1 | package rp 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/zitadel/logging" 8 | ) 9 | 10 | func logCtxWithRPData(ctx context.Context, rp RelyingParty, attrs ...any) context.Context { 11 | logger, ok := rp.Logger(ctx) 12 | if !ok { 13 | return ctx 14 | } 15 | logger = logger.With(slog.Group("rp", attrs...)) 16 | return logging.ToContext(ctx, logger) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/client/rp/relying_party_test.go: -------------------------------------------------------------------------------- 1 | package rp 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | tu "github.com/zitadel/oidc/v3/internal/testutil" 11 | "github.com/zitadel/oidc/v3/pkg/oidc" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | func Test_verifyTokenResponse(t *testing.T) { 16 | verifier := &IDTokenVerifier{ 17 | Issuer: tu.ValidIssuer, 18 | MaxAgeIAT: 2 * time.Minute, 19 | ClientID: tu.ValidClientID, 20 | Offset: time.Second, 21 | SupportedSignAlgs: []string{string(tu.SignatureAlgorithm)}, 22 | KeySet: tu.KeySet{}, 23 | MaxAge: 2 * time.Minute, 24 | ACR: tu.ACRVerify, 25 | Nonce: func(context.Context) string { return tu.ValidNonce }, 26 | } 27 | tests := []struct { 28 | name string 29 | oauth2Only bool 30 | tokens func() (token *oauth2.Token, want *oidc.Tokens[*oidc.IDTokenClaims]) 31 | wantErr error 32 | }{ 33 | { 34 | name: "succes, oauth2 only", 35 | oauth2Only: true, 36 | tokens: func() (*oauth2.Token, *oidc.Tokens[*oidc.IDTokenClaims]) { 37 | accesToken, _ := tu.ValidAccessToken() 38 | token := &oauth2.Token{ 39 | AccessToken: accesToken, 40 | } 41 | return token, &oidc.Tokens[*oidc.IDTokenClaims]{ 42 | Token: token, 43 | } 44 | }, 45 | }, 46 | { 47 | name: "id_token missing error", 48 | oauth2Only: false, 49 | tokens: func() (*oauth2.Token, *oidc.Tokens[*oidc.IDTokenClaims]) { 50 | accesToken, _ := tu.ValidAccessToken() 51 | token := &oauth2.Token{ 52 | AccessToken: accesToken, 53 | } 54 | return token, &oidc.Tokens[*oidc.IDTokenClaims]{ 55 | Token: token, 56 | } 57 | }, 58 | wantErr: ErrMissingIDToken, 59 | }, 60 | { 61 | name: "verify tokens error", 62 | oauth2Only: false, 63 | tokens: func() (*oauth2.Token, *oidc.Tokens[*oidc.IDTokenClaims]) { 64 | accesToken, _ := tu.ValidAccessToken() 65 | token := &oauth2.Token{ 66 | AccessToken: accesToken, 67 | } 68 | token = token.WithExtra(map[string]any{ 69 | "id_token": "foobar", 70 | }) 71 | return token, nil 72 | }, 73 | wantErr: oidc.ErrParse, 74 | }, 75 | { 76 | name: "success, with id_token", 77 | oauth2Only: false, 78 | tokens: func() (*oauth2.Token, *oidc.Tokens[*oidc.IDTokenClaims]) { 79 | accesToken, _ := tu.ValidAccessToken() 80 | token := &oauth2.Token{ 81 | AccessToken: accesToken, 82 | } 83 | idToken, claims := tu.ValidIDToken() 84 | token = token.WithExtra(map[string]any{ 85 | "id_token": idToken, 86 | }) 87 | return token, &oidc.Tokens[*oidc.IDTokenClaims]{ 88 | Token: token, 89 | IDTokenClaims: claims, 90 | IDToken: idToken, 91 | } 92 | }, 93 | }, 94 | } 95 | for _, tt := range tests { 96 | t.Run(tt.name, func(t *testing.T) { 97 | rp := &relyingParty{ 98 | oauth2Only: tt.oauth2Only, 99 | idTokenVerifier: verifier, 100 | } 101 | token, want := tt.tokens() 102 | got, err := verifyTokenResponse[*oidc.IDTokenClaims](context.Background(), token, rp) 103 | require.ErrorIs(t, err, tt.wantErr) 104 | assert.Equal(t, want, got) 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pkg/client/rp/tockenexchange.go: -------------------------------------------------------------------------------- 1 | package rp 2 | 3 | import ( 4 | "context" 5 | 6 | "golang.org/x/oauth2" 7 | 8 | "github.com/zitadel/oidc/v3/pkg/oidc/grants/tokenexchange" 9 | ) 10 | 11 | // TokenExchangeRP extends the `RelyingParty` interface for the *draft* oauth2 `Token Exchange` 12 | type TokenExchangeRP interface { 13 | RelyingParty 14 | 15 | // TokenExchange implement the `Token Exchange Grant` exchanging some token for an other 16 | TokenExchange(context.Context, *tokenexchange.TokenExchangeRequest) (*oauth2.Token, error) 17 | } 18 | 19 | // DelegationTokenExchangeRP extends the `TokenExchangeRP` interface 20 | // for the specific `delegation token` request 21 | type DelegationTokenExchangeRP interface { 22 | TokenExchangeRP 23 | 24 | // DelegationTokenExchange implement the `Token Exchange Grant` 25 | // providing an access token in request for a `delegation` token for a given resource / audience 26 | DelegationTokenExchange(context.Context, string, ...tokenexchange.TokenExchangeOption) (*oauth2.Token, error) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/client/rp/userinfo_example_test.go: -------------------------------------------------------------------------------- 1 | package rp_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/zitadel/oidc/v3/pkg/client/rp" 8 | "github.com/zitadel/oidc/v3/pkg/oidc" 9 | ) 10 | 11 | type UserInfo struct { 12 | Subject string `json:"sub,omitempty"` 13 | oidc.UserInfoProfile 14 | oidc.UserInfoEmail 15 | oidc.UserInfoPhone 16 | Address *oidc.UserInfoAddress `json:"address,omitempty"` 17 | 18 | // Foo and Bar are custom claims 19 | Foo string `json:"foo,omitempty"` 20 | Bar struct { 21 | Val1 string `json:"val_1,omitempty"` 22 | Val2 string `json:"val_2,omitempty"` 23 | } `json:"bar,omitempty"` 24 | 25 | // Claims are all the combined claims, including custom. 26 | Claims map[string]any `json:"-,omitempty"` 27 | } 28 | 29 | func (u *UserInfo) GetSubject() string { 30 | return u.Subject 31 | } 32 | 33 | func ExampleUserinfo_custom() { 34 | rpo, err := rp.NewRelyingPartyOIDC(context.TODO(), "http://localhost:8080", "clientid", "clientsecret", "http://example.com/redirect", []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopePhone}) 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | info, err := rp.Userinfo[*UserInfo](context.TODO(), "accesstokenstring", "Bearer", "userid", rpo) 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | fmt.Println(info) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/client/rp/verifier_tokens_example_test.go: -------------------------------------------------------------------------------- 1 | package rp_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tu "github.com/zitadel/oidc/v3/internal/testutil" 8 | "github.com/zitadel/oidc/v3/pkg/client/rp" 9 | "github.com/zitadel/oidc/v3/pkg/oidc" 10 | ) 11 | 12 | // MyCustomClaims extends the TokenClaims base, 13 | // so it implmeents the oidc.Claims interface. 14 | // Instead of carrying a map, we add needed fields// to the struct for type safe access. 15 | type MyCustomClaims struct { 16 | oidc.TokenClaims 17 | NotBefore oidc.Time `json:"nbf,omitempty"` 18 | AccessTokenHash string `json:"at_hash,omitempty"` 19 | Foo string `json:"foo,omitempty"` 20 | Bar *Nested `json:"bar,omitempty"` 21 | } 22 | 23 | // GetAccessTokenHash is required to implement 24 | // the oidc.IDClaims interface. 25 | func (c *MyCustomClaims) GetAccessTokenHash() string { 26 | return c.AccessTokenHash 27 | } 28 | 29 | // Nested struct types are also possible. 30 | type Nested struct { 31 | Count int `json:"count,omitempty"` 32 | Tags []string `json:"tags,omitempty"` 33 | } 34 | 35 | /* 36 | idToken carries the following claims. foo and bar are custom claims 37 | 38 | { 39 | "acr": "something", 40 | "amr": [ 41 | "foo", 42 | "bar" 43 | ], 44 | "at_hash": "2dzbm_vIxy-7eRtqUIGPPw", 45 | "aud": [ 46 | "unit", 47 | "test", 48 | "555666" 49 | ], 50 | "auth_time": 1678100961, 51 | "azp": "555666", 52 | "bar": { 53 | "count": 22, 54 | "tags": [ 55 | "some", 56 | "tags" 57 | ] 58 | }, 59 | "client_id": "555666", 60 | "exp": 4802238682, 61 | "foo": "Hello, World!", 62 | "iat": 1678101021, 63 | "iss": "local.com", 64 | "jti": "9876", 65 | "nbf": 1678101021, 66 | "nonce": "12345", 67 | "sub": "tim@local.com" 68 | } 69 | */ 70 | const idToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhY3IiOiJzb21ldGhpbmciLCJhbXIiOlsiZm9vIiwiYmFyIl0sImF0X2hhc2giOiIyZHpibV92SXh5LTdlUnRxVUlHUFB3IiwiYXVkIjpbInVuaXQiLCJ0ZXN0IiwiNTU1NjY2Il0sImF1dGhfdGltZSI6MTY3ODEwMDk2MSwiYXpwIjoiNTU1NjY2IiwiYmFyIjp7ImNvdW50IjoyMiwidGFncyI6WyJzb21lIiwidGFncyJdfSwiY2xpZW50X2lkIjoiNTU1NjY2IiwiZXhwIjo0ODAyMjM4NjgyLCJmb28iOiJIZWxsbywgV29ybGQhIiwiaWF0IjoxNjc4MTAxMDIxLCJpc3MiOiJsb2NhbC5jb20iLCJqdGkiOiI5ODc2IiwibmJmIjoxNjc4MTAxMDIxLCJub25jZSI6IjEyMzQ1Iiwic3ViIjoidGltQGxvY2FsLmNvbSJ9.t3GXSfVNNwiW1Suv9_84v0sdn2_-RWHVxhphhRozDXnsO7SDNOlGnEioemXABESxSzMclM7gB7mYy5Qah2ZUNx7eP5t2njoxEYfavgHwx7UJZ2NCg8NDPQyr-hlxelEcfdXK-I0oTd-FRDvF4rqPkD9Us52IpnplChCxnHFgh4wKwPqZZjv2IXVCtn0ilKW3hff1rMOYKEuLRcN2YP0gkyuqyHvcf2dMmjod0t4sLOTJ82rsCbMBC5CLpqv3nIC9HOGITkt1Kd-Am0n1LrdZvWwTo6RFe8AnzF0gpqjcB5Wg4Qeh58DIjZOz4f_8wnmJ_gCqyRh5vfSW4XHdbum0Tw` 71 | const accessToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOlsidW5pdCIsInRlc3QiXSwiYmFyIjp7ImNvdW50IjoyMiwidGFncyI6WyJzb21lIiwidGFncyJdfSwiZXhwIjo0ODAyMjM4NjgyLCJmb28iOiJIZWxsbywgV29ybGQhIiwiaWF0IjoxNjc4MTAxMDIxLCJpc3MiOiJsb2NhbC5jb20iLCJqdGkiOiI5ODc2IiwibmJmIjoxNjc4MTAxMDIxLCJzdWIiOiJ0aW1AbG9jYWwuY29tIn0.Zrz3LWSRjCMJZUMaI5dUbW4vGdSmEeJQ3ouhaX0bcW9rdFFLgBI4K2FWJhNivq8JDmCGSxwLu3mI680GWmDaEoAx1M5sCO9lqfIZHGZh-lfAXk27e6FPLlkTDBq8Bx4o4DJ9Fw0hRJGjUTjnYv5cq1vo2-UqldasL6CwTbkzNC_4oQFfRtuodC4Ql7dZ1HRv5LXuYx7KPkOssLZtV9cwtJp5nFzKjcf2zEE_tlbjcpynMwypornRUp1EhCWKRUGkJhJeiP71ECY5pQhShfjBu9Nc5wDpSnZmnk2S4YsPrRK3QkE-iEkas8BfsOCrGoErHjEJexAIDjasGO5PFLWfCA` 72 | 73 | func ExampleVerifyTokens_customClaims() { 74 | v := rp.NewIDTokenVerifier("local.com", "555666", tu.KeySet{}, 75 | rp.WithNonce(func(ctx context.Context) string { return "12345" }), 76 | ) 77 | 78 | // VerifyAccessToken can be called with the *MyCustomClaims. 79 | claims, err := rp.VerifyTokens[*MyCustomClaims](context.TODO(), accessToken, idToken, v) 80 | if err != nil { 81 | panic(err) 82 | } 83 | // Here we have typesafe access to the custom claims 84 | fmt.Println(claims.Foo, claims.Bar.Count, claims.Bar.Tags) 85 | // Output: Hello, World! 22 [some tags] 86 | } 87 | -------------------------------------------------------------------------------- /pkg/client/rs/introspect_example_test.go: -------------------------------------------------------------------------------- 1 | package rs_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/zitadel/oidc/v3/pkg/client/rs" 8 | "github.com/zitadel/oidc/v3/pkg/oidc" 9 | ) 10 | 11 | type IntrospectionResponse struct { 12 | Active bool `json:"active"` 13 | Scope oidc.SpaceDelimitedArray `json:"scope,omitempty"` 14 | ClientID string `json:"client_id,omitempty"` 15 | TokenType string `json:"token_type,omitempty"` 16 | Expiration oidc.Time `json:"exp,omitempty"` 17 | IssuedAt oidc.Time `json:"iat,omitempty"` 18 | NotBefore oidc.Time `json:"nbf,omitempty"` 19 | Subject string `json:"sub,omitempty"` 20 | Audience oidc.Audience `json:"aud,omitempty"` 21 | Issuer string `json:"iss,omitempty"` 22 | JWTID string `json:"jti,omitempty"` 23 | Username string `json:"username,omitempty"` 24 | oidc.UserInfoProfile 25 | oidc.UserInfoEmail 26 | oidc.UserInfoPhone 27 | Address *oidc.UserInfoAddress `json:"address,omitempty"` 28 | 29 | // Foo and Bar are custom claims 30 | Foo string `json:"foo,omitempty"` 31 | Bar struct { 32 | Val1 string `json:"val_1,omitempty"` 33 | Val2 string `json:"val_2,omitempty"` 34 | } `json:"bar,omitempty"` 35 | 36 | // Claims are all the combined claims, including custom. 37 | Claims map[string]any `json:"-,omitempty"` 38 | } 39 | 40 | func ExampleIntrospect_custom() { 41 | rss, err := rs.NewResourceServerClientCredentials(context.TODO(), "http://localhost:8080", "clientid", "clientsecret") 42 | if err != nil { 43 | panic(err) 44 | } 45 | 46 | resp, err := rs.Introspect[*IntrospectionResponse](context.TODO(), rss, "accesstokenstring") 47 | if err != nil { 48 | panic(err) 49 | } 50 | 51 | fmt.Println(resp) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/client/rs/resource_server.go: -------------------------------------------------------------------------------- 1 | package rs 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/zitadel/oidc/v3/pkg/client" 10 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 11 | "github.com/zitadel/oidc/v3/pkg/oidc" 12 | ) 13 | 14 | type ResourceServer interface { 15 | IntrospectionURL() string 16 | TokenEndpoint() string 17 | HttpClient() *http.Client 18 | AuthFn() (any, error) 19 | } 20 | 21 | type resourceServer struct { 22 | issuer string 23 | tokenURL string 24 | introspectURL string 25 | httpClient *http.Client 26 | authFn func() (any, error) 27 | } 28 | 29 | func (r *resourceServer) IntrospectionURL() string { 30 | return r.introspectURL 31 | } 32 | 33 | func (r *resourceServer) TokenEndpoint() string { 34 | return r.tokenURL 35 | } 36 | 37 | func (r *resourceServer) HttpClient() *http.Client { 38 | return r.httpClient 39 | } 40 | 41 | func (r *resourceServer) AuthFn() (any, error) { 42 | return r.authFn() 43 | } 44 | 45 | func NewResourceServerClientCredentials(ctx context.Context, issuer, clientID, clientSecret string, option ...Option) (ResourceServer, error) { 46 | authorizer := func() (any, error) { 47 | return httphelper.AuthorizeBasic(clientID, clientSecret), nil 48 | } 49 | return newResourceServer(ctx, issuer, authorizer, option...) 50 | } 51 | 52 | func NewResourceServerJWTProfile(ctx context.Context, issuer, clientID, keyID string, key []byte, options ...Option) (ResourceServer, error) { 53 | signer, err := client.NewSignerFromPrivateKeyByte(key, keyID) 54 | if err != nil { 55 | return nil, err 56 | } 57 | authorizer := func() (any, error) { 58 | assertion, err := client.SignedJWTProfileAssertion(clientID, []string{issuer}, time.Hour, signer) 59 | if err != nil { 60 | return nil, err 61 | } 62 | return client.ClientAssertionFormAuthorization(assertion), nil 63 | } 64 | return newResourceServer(ctx, issuer, authorizer, options...) 65 | } 66 | 67 | func newResourceServer(ctx context.Context, issuer string, authorizer func() (any, error), options ...Option) (*resourceServer, error) { 68 | rs := &resourceServer{ 69 | issuer: issuer, 70 | httpClient: httphelper.DefaultHTTPClient, 71 | } 72 | for _, optFunc := range options { 73 | optFunc(rs) 74 | } 75 | if rs.introspectURL == "" || rs.tokenURL == "" { 76 | config, err := client.Discover(ctx, rs.issuer, rs.httpClient) 77 | if err != nil { 78 | return nil, err 79 | } 80 | if rs.tokenURL == "" { 81 | rs.tokenURL = config.TokenEndpoint 82 | } 83 | if rs.introspectURL == "" { 84 | rs.introspectURL = config.IntrospectionEndpoint 85 | } 86 | } 87 | if rs.tokenURL == "" { 88 | return nil, errors.New("tokenURL is empty: please provide with either `WithStaticEndpoints` or a discovery url") 89 | } 90 | rs.authFn = authorizer 91 | return rs, nil 92 | } 93 | 94 | func NewResourceServerFromKeyFile(ctx context.Context, issuer, path string, options ...Option) (ResourceServer, error) { 95 | c, err := client.ConfigFromKeyFile(path) 96 | if err != nil { 97 | return nil, err 98 | } 99 | return NewResourceServerJWTProfile(ctx, issuer, c.ClientID, c.KeyID, []byte(c.Key), options...) 100 | } 101 | 102 | type Option func(*resourceServer) 103 | 104 | // WithClient provides the ability to set an http client to be used for the resource server 105 | func WithClient(client *http.Client) Option { 106 | return func(server *resourceServer) { 107 | server.httpClient = client 108 | } 109 | } 110 | 111 | // WithStaticEndpoints provides the ability to set static token and introspect URL 112 | func WithStaticEndpoints(tokenURL, introspectURL string) Option { 113 | return func(server *resourceServer) { 114 | server.tokenURL = tokenURL 115 | server.introspectURL = introspectURL 116 | } 117 | } 118 | 119 | // Introspect calls the [RFC7662] Token Introspection 120 | // endpoint and returns the response in an instance of type R. 121 | // [*oidc.IntrospectionResponse] can be used as a good example, or use a custom type if type-safe 122 | // access to custom claims is needed. 123 | // 124 | // [RFC7662]: https://www.rfc-editor.org/rfc/rfc7662 125 | func Introspect[R any](ctx context.Context, rp ResourceServer, token string) (resp R, err error) { 126 | ctx, span := client.Tracer.Start(ctx, "Introspect") 127 | defer span.End() 128 | 129 | if rp.IntrospectionURL() == "" { 130 | return resp, errors.New("resource server: introspection URL is empty") 131 | } 132 | authFn, err := rp.AuthFn() 133 | if err != nil { 134 | return resp, err 135 | } 136 | req, err := httphelper.FormRequest(ctx, rp.IntrospectionURL(), &oidc.IntrospectionRequest{Token: token}, client.Encoder, authFn) 137 | if err != nil { 138 | return resp, err 139 | } 140 | 141 | if err := httphelper.HttpRequest(rp.HttpClient(), req, &resp); err != nil { 142 | return resp, err 143 | } 144 | return resp, nil 145 | } 146 | -------------------------------------------------------------------------------- /pkg/client/tokenexchange/tokenexchange.go: -------------------------------------------------------------------------------- 1 | package tokenexchange 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-jose/go-jose/v4" 10 | "github.com/zitadel/oidc/v3/pkg/client" 11 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 12 | "github.com/zitadel/oidc/v3/pkg/oidc" 13 | ) 14 | 15 | type TokenExchanger interface { 16 | TokenEndpoint() string 17 | HttpClient() *http.Client 18 | AuthFn() (any, error) 19 | } 20 | 21 | type OAuthTokenExchange struct { 22 | httpClient *http.Client 23 | tokenEndpoint string 24 | authFn func() (any, error) 25 | } 26 | 27 | func NewTokenExchanger(ctx context.Context, issuer string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) { 28 | return newOAuthTokenExchange(ctx, issuer, nil, options...) 29 | } 30 | 31 | func NewTokenExchangerClientCredentials(ctx context.Context, issuer, clientID, clientSecret string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) { 32 | authorizer := func() (any, error) { 33 | return httphelper.AuthorizeBasic(clientID, clientSecret), nil 34 | } 35 | return newOAuthTokenExchange(ctx, issuer, authorizer, options...) 36 | } 37 | 38 | func NewTokenExchangerJWTProfile(ctx context.Context, issuer, clientID string, signer jose.Signer, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) { 39 | authorizer := func() (any, error) { 40 | assertion, err := client.SignedJWTProfileAssertion(clientID, []string{issuer}, time.Hour, signer) 41 | if err != nil { 42 | return nil, err 43 | } 44 | return client.ClientAssertionFormAuthorization(assertion), nil 45 | } 46 | return newOAuthTokenExchange(ctx, issuer, authorizer, options...) 47 | } 48 | 49 | func newOAuthTokenExchange(ctx context.Context, issuer string, authorizer func() (any, error), options ...func(source *OAuthTokenExchange)) (*OAuthTokenExchange, error) { 50 | te := &OAuthTokenExchange{ 51 | httpClient: httphelper.DefaultHTTPClient, 52 | } 53 | for _, opt := range options { 54 | opt(te) 55 | } 56 | 57 | if te.tokenEndpoint == "" { 58 | config, err := client.Discover(ctx, issuer, te.httpClient) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | te.tokenEndpoint = config.TokenEndpoint 64 | } 65 | 66 | if te.tokenEndpoint == "" { 67 | return nil, errors.New("tokenURL is empty: please provide with either `WithStaticTokenEndpoint` or a discovery url") 68 | } 69 | 70 | te.authFn = authorizer 71 | 72 | return te, nil 73 | } 74 | 75 | func WithHTTPClient(client *http.Client) func(*OAuthTokenExchange) { 76 | return func(source *OAuthTokenExchange) { 77 | source.httpClient = client 78 | } 79 | } 80 | 81 | func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(*OAuthTokenExchange) { 82 | return func(source *OAuthTokenExchange) { 83 | source.tokenEndpoint = tokenEndpoint 84 | } 85 | } 86 | 87 | func (te *OAuthTokenExchange) TokenEndpoint() string { 88 | return te.tokenEndpoint 89 | } 90 | 91 | func (te *OAuthTokenExchange) HttpClient() *http.Client { 92 | return te.httpClient 93 | } 94 | 95 | func (te *OAuthTokenExchange) AuthFn() (any, error) { 96 | if te.authFn != nil { 97 | return te.authFn() 98 | } 99 | 100 | return nil, nil 101 | } 102 | 103 | // ExchangeToken sends a token exchange request (rfc 8693) to te's token endpoint. 104 | // SubjectToken and SubjectTokenType are required parameters. 105 | func ExchangeToken( 106 | ctx context.Context, 107 | te TokenExchanger, 108 | SubjectToken string, 109 | SubjectTokenType oidc.TokenType, 110 | ActorToken string, 111 | ActorTokenType oidc.TokenType, 112 | Resource []string, 113 | Audience []string, 114 | Scopes []string, 115 | RequestedTokenType oidc.TokenType, 116 | ) (*oidc.TokenExchangeResponse, error) { 117 | ctx, span := client.Tracer.Start(ctx, "ExchangeToken") 118 | defer span.End() 119 | 120 | if SubjectToken == "" { 121 | return nil, errors.New("empty subject_token") 122 | } 123 | if SubjectTokenType == "" { 124 | return nil, errors.New("empty subject_token_type") 125 | } 126 | 127 | authFn, err := te.AuthFn() 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | request := oidc.TokenExchangeRequest{ 133 | GrantType: oidc.GrantTypeTokenExchange, 134 | SubjectToken: SubjectToken, 135 | SubjectTokenType: SubjectTokenType, 136 | ActorToken: ActorToken, 137 | ActorTokenType: ActorTokenType, 138 | Resource: Resource, 139 | Audience: Audience, 140 | Scopes: Scopes, 141 | RequestedTokenType: RequestedTokenType, 142 | } 143 | 144 | return client.CallTokenExchangeEndpoint(ctx, request, authFn, te) 145 | } 146 | -------------------------------------------------------------------------------- /pkg/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "errors" 9 | "io" 10 | ) 11 | 12 | var ErrCipherTextBlockSize = errors.New("ciphertext block size is too short") 13 | 14 | func EncryptAES(data string, key string) (string, error) { 15 | encrypted, err := EncryptBytesAES([]byte(data), key) 16 | if err != nil { 17 | return "", err 18 | } 19 | 20 | return base64.RawURLEncoding.EncodeToString(encrypted), nil 21 | } 22 | 23 | func EncryptBytesAES(plainText []byte, key string) ([]byte, error) { 24 | block, err := aes.NewCipher([]byte(key)) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | cipherText := make([]byte, aes.BlockSize+len(plainText)) 30 | iv := cipherText[:aes.BlockSize] 31 | if _, err = io.ReadFull(rand.Reader, iv); err != nil { 32 | return nil, err 33 | } 34 | 35 | stream := cipher.NewCFBEncrypter(block, iv) 36 | stream.XORKeyStream(cipherText[aes.BlockSize:], plainText) 37 | 38 | return cipherText, nil 39 | } 40 | 41 | func DecryptAES(data string, key string) (string, error) { 42 | text, err := base64.RawURLEncoding.DecodeString(data) 43 | if err != nil { 44 | return "", err 45 | } 46 | decrypted, err := DecryptBytesAES(text, key) 47 | if err != nil { 48 | return "", err 49 | } 50 | return string(decrypted), nil 51 | } 52 | 53 | func DecryptBytesAES(cipherText []byte, key string) ([]byte, error) { 54 | block, err := aes.NewCipher([]byte(key)) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | if len(cipherText) < aes.BlockSize { 60 | return nil, ErrCipherTextBlockSize 61 | } 62 | iv := cipherText[:aes.BlockSize] 63 | cipherText = cipherText[aes.BlockSize:] 64 | 65 | stream := cipher.NewCFBDecrypter(block, iv) 66 | stream.XORKeyStream(cipherText, cipherText) 67 | 68 | return cipherText, err 69 | } 70 | -------------------------------------------------------------------------------- /pkg/crypto/hash.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/sha256" 5 | "crypto/sha512" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "hash" 10 | 11 | jose "github.com/go-jose/go-jose/v4" 12 | ) 13 | 14 | var ErrUnsupportedAlgorithm = errors.New("unsupported signing algorithm") 15 | 16 | func GetHashAlgorithm(sigAlgorithm jose.SignatureAlgorithm) (hash.Hash, error) { 17 | switch sigAlgorithm { 18 | case jose.RS256, jose.ES256, jose.PS256: 19 | return sha256.New(), nil 20 | case jose.RS384, jose.ES384, jose.PS384: 21 | return sha512.New384(), nil 22 | case jose.RS512, jose.ES512, jose.PS512: 23 | return sha512.New(), nil 24 | 25 | // There is no published spec for this yet, but we have confirmation it will get published. 26 | // There is consensus here: https://bitbucket.org/openid/connect/issues/1125/_hash-algorithm-for-eddsa-id-tokens 27 | // Currently Go and go-jose only supports the ed25519 curve key for EdDSA, so we can safely assume sha512 here. 28 | // It is unlikely ed448 will ever be supported: https://github.com/golang/go/issues/29390 29 | case jose.EdDSA: 30 | return sha512.New(), nil 31 | 32 | default: 33 | return nil, fmt.Errorf("%w: %q", ErrUnsupportedAlgorithm, sigAlgorithm) 34 | } 35 | } 36 | 37 | func HashString(hash hash.Hash, s string, firstHalf bool) string { 38 | if hash == nil { 39 | return s 40 | } 41 | //nolint:errcheck 42 | hash.Write([]byte(s)) 43 | size := hash.Size() 44 | if firstHalf { 45 | size = size / 2 46 | } 47 | sum := hash.Sum(nil)[:size] 48 | return base64.RawURLEncoding.EncodeToString(sum) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/crypto/key.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/ed25519" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "errors" 11 | 12 | "github.com/go-jose/go-jose/v4" 13 | ) 14 | 15 | var ( 16 | ErrPEMDecode = errors.New("PEM decode failed") 17 | ErrUnsupportedFormat = errors.New("key is neither in PKCS#1 nor PKCS#8 format") 18 | ErrUnsupportedPrivateKey = errors.New("unsupported key type, must be RSA, ECDSA or ED25519 private key") 19 | ) 20 | 21 | func BytesToPrivateKey(b []byte) (crypto.PublicKey, jose.SignatureAlgorithm, error) { 22 | block, _ := pem.Decode(b) 23 | if block == nil { 24 | return nil, "", ErrPEMDecode 25 | } 26 | 27 | privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) 28 | if err == nil { 29 | return privateKey, jose.RS256, nil 30 | } 31 | key, err := x509.ParsePKCS8PrivateKey(block.Bytes) 32 | if err != nil { 33 | return nil, "", ErrUnsupportedFormat 34 | } 35 | switch privateKey := key.(type) { 36 | case *rsa.PrivateKey: 37 | return privateKey, jose.RS256, nil 38 | case ed25519.PrivateKey: 39 | return privateKey, jose.EdDSA, nil 40 | case *ecdsa.PrivateKey: 41 | return privateKey, jose.ES256, nil 42 | default: 43 | return nil, "", ErrUnsupportedPrivateKey 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/crypto/key_test.go: -------------------------------------------------------------------------------- 1 | package crypto_test 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/ed25519" 7 | "crypto/rsa" 8 | "testing" 9 | 10 | "github.com/go-jose/go-jose/v4" 11 | "github.com/stretchr/testify/assert" 12 | 13 | zcrypto "github.com/zitadel/oidc/v3/pkg/crypto" 14 | ) 15 | 16 | func TestBytesToPrivateKey(t *testing.T) { 17 | type args struct { 18 | key []byte 19 | } 20 | type want struct { 21 | key crypto.Signer 22 | algorithm jose.SignatureAlgorithm 23 | err error 24 | } 25 | tests := []struct { 26 | name string 27 | args args 28 | want want 29 | }{ 30 | { 31 | name: "PEMDecodeError", 32 | args: args{ 33 | key: []byte("The non-PEM sequence"), 34 | }, 35 | want: want{ 36 | err: zcrypto.ErrPEMDecode, 37 | }, 38 | }, 39 | { 40 | name: "PKCS#1 RSA", 41 | args: args{ 42 | key: []byte(`-----BEGIN RSA PRIVATE KEY----- 43 | MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Qu 44 | KUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm 45 | o3qGy0t6z09AIJtH+5OeRV1be+N4cDYJKffGzDa88vQENZiRm0GRq6a+HPGQMd2k 46 | TQIhAKMSvzIBnni7ot/OSie2TmJLY4SwTQAevXysE2RbFDYdAiEBCUEaRQnMnbp7 47 | 9mxDXDf6AU0cN/RPBjb9qSHDcWZHGzUCIG2Es59z8ugGrDY+pxLQnwfotadxd+Uy 48 | v/Ow5T0q5gIJAiEAyS4RaI9YG8EWx/2w0T67ZUVAw8eOMB6BIUg0Xcu+3okCIBOs 49 | /5OiPgoTdSy7bcF9IGpSE8ZgGKzgYQVZeN97YE00 50 | -----END RSA PRIVATE KEY-----`), 51 | }, 52 | want: want{ 53 | key: &rsa.PrivateKey{}, 54 | algorithm: jose.RS256, 55 | err: nil, 56 | }, 57 | }, 58 | { 59 | name: "PKCS#8 RSA", 60 | args: args{ 61 | key: []byte(`-----BEGIN PRIVATE KEY----- 62 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCfaDB7pK/fmP/I 63 | 7IusSK8lTCBnPZghqIbVLt2QHYAMoEF1CaF4F4rxo2vl1Mt8gwsq4T3osQFZMvnL 64 | YHb7KNyUoJgTjLxJQADv2u4Q3U38heAzK5Tp4ry4MCnuyJIqAPK1GiruwEq4zQrx 65 | +WzVix8otO37SuW9tzklqlNGMiAYBL0TBKHvS5XMbjP1idBMB8erMz29w/TVQnEB 66 | Kj0vCdZjrbVPKygptt5kcSrL5f4xCZwU+ufz7cp0GLwpRMJ+shG9YJJFBxb0itPF 67 | sy51vAyEtdBC7jgAU96ZVeQ06nryDq1D2EpoVMElqNyL46Jo3lnKbGquGKzXzQYU 68 | BN32/scDAgMBAAECggEBAJE/mo3PLgILo2YtQ8ekIxNVHmF0Gl7w9IrjvTdH6hmX 69 | HI3MTLjkmtI7GmG9V/0IWvCjdInGX3grnrjWGRQZ04QKIQgPQLFuBGyJjEsJm7nx 70 | MqztlS7YTyV1nX/aenSTkJO8WEpcJLnm+4YoxCaAMdAhrIdBY71OamALpv1bRysa 71 | FaiCGcemT2yqZn0GqIS8O26Tz5zIqrTN2G1eSmgh7DG+7FoddMz35cute8R10xUG 72 | hF5YU+6fcXiRQ/Kh7nlxelPGqdZFPMk7LpVHzkQKwdJ+N0P23lPDIfNsvpG1n0OP 73 | 3g5km7gHSrSU2yZ3eFl6DB9x1IFNS9BaQQuSxYJtKwECgYEA1C8jjzpXZDLvlYsV 74 | 2jlMzkrbsIrX2dzblVrNsPs2jRbjYU8mg2DUDO6lOhtxHfqZG6sO+gmWi/zvoy9l 75 | yolGbXe1Jqx66p9fznIcecSwar8+ACa356Wk74Nt1PlBOfCMqaJnYLOLaFJa29Vy 76 | u5ClZVzKd5AVXl7yFVd4XfLv/WECgYEAwFMMtFoasdF92c0d31rZ1uoPOtFz6xq6 77 | uQggdm5zzkhnfwUAGqppS/u1CHcJ7T/74++jLbFTsaohGr4jEzWSGvJpomEUChy3 78 | r25YofMclUhJ5pCEStsLtqiCR1Am6LlI8HMdBEP1QDgEC5q8bQW4+UHuew1E1zxz 79 | osZOhe09WuMCgYEA0G9aFCnwjUqIFjQiDFP7gi8BLqTFs4uE3Wvs4W11whV42i+B 80 | ms90nxuTjchFT3jMDOT1+mOO0wdudLRr3xEI8SIF/u6ydGaJG+j21huEXehtxIJE 81 | aDdNFcfbDbqo+3y1ATK7MMBPMvSrsoY0hdJq127WqasNgr3sO1DIuima3SECgYEA 82 | nkM5TyhekzlbIOHD1UsDu/D7+2DkzPE/+oePfyXBMl0unb3VqhvVbmuBO6gJiSx/ 83 | 8b//PdiQkMD5YPJaFrKcuoQFHVRZk0CyfzCEyzAts0K7XXpLAvZiGztriZeRjSz7 84 | srJnjF0H8oKmAY6hw+1Tm/n/b08p+RyL48TgVSE2vhUCgYA3BWpkD4PlCcn/FZsq 85 | OrLFyFXI6jIaxskFtsRW1IxxIlAdZmxfB26P/2gx6VjLdxJI/RRPkJyEN2dP7CbR 86 | BDjb565dy1O9D6+UrY70Iuwjz+OcALRBBGTaiF2pLn6IhSzNI2sy/tXX8q8dBlg9 87 | OFCrqT/emes3KytTPfa5NZtYeQ== 88 | -----END PRIVATE KEY-----`), 89 | }, 90 | want: want{ 91 | key: &rsa.PrivateKey{}, 92 | algorithm: jose.RS256, 93 | err: nil, 94 | }, 95 | }, 96 | { 97 | name: "PKCS#8 ECDSA", 98 | args: args{ 99 | key: []byte(`-----BEGIN PRIVATE KEY----- 100 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwwOZSU4GlP7ps/Wp 101 | V6o0qRwxultdfYo/uUuj48QZjSuhRANCAATMiI2Han+ABKmrk5CNlxRAGC61w4d3 102 | G4TAeuBpyzqJ7x/6NjCxoQzJzZHtNjIfjVATI59XFZWF59GhtSZbShAr 103 | -----END PRIVATE KEY-----`), 104 | }, 105 | want: want{ 106 | key: &ecdsa.PrivateKey{}, 107 | algorithm: jose.ES256, 108 | err: nil, 109 | }, 110 | }, 111 | { 112 | name: "PKCS#8 ED25519", 113 | args: args{ 114 | key: []byte(`-----BEGIN PRIVATE KEY----- 115 | MC4CAQAwBQYDK2VwBCIEIHu6ZtDsjjauMasBxnS9Fg87UJwKfcT/oiq6S0ktbky8 116 | -----END PRIVATE KEY-----`), 117 | }, 118 | want: want{ 119 | key: ed25519.PrivateKey{}, 120 | algorithm: jose.EdDSA, 121 | err: nil, 122 | }, 123 | }, 124 | } 125 | for _, tt := range tests { 126 | t.Run(tt.name, func(t *testing.T) { 127 | key, algorithm, err := zcrypto.BytesToPrivateKey(tt.args.key) 128 | assert.IsType(t, tt.want.key, key) 129 | assert.Equal(t, tt.want.algorithm, algorithm) 130 | assert.ErrorIs(t, tt.want.err, err) 131 | }) 132 | 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /pkg/crypto/sign.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | jose "github.com/go-jose/go-jose/v4" 8 | ) 9 | 10 | func Sign(object any, signer jose.Signer) (string, error) { 11 | payload, err := json.Marshal(object) 12 | if err != nil { 13 | return "", err 14 | } 15 | return SignPayload(payload, signer) 16 | } 17 | 18 | func SignPayload(payload []byte, signer jose.Signer) (string, error) { 19 | if signer == nil { 20 | return "", errors.New("missing signer") 21 | } 22 | result, err := signer.Sign(payload) 23 | if err != nil { 24 | return "", err 25 | } 26 | return result.CompactSerialize() 27 | } 28 | -------------------------------------------------------------------------------- /pkg/http/cookie.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gorilla/securecookie" 8 | ) 9 | 10 | type CookieHandler struct { 11 | securecookie *securecookie.SecureCookie 12 | secureOnly bool 13 | sameSite http.SameSite 14 | maxAge int 15 | domain string 16 | path string 17 | } 18 | 19 | func NewCookieHandler(hashKey, encryptKey []byte, opts ...CookieHandlerOpt) *CookieHandler { 20 | c := &CookieHandler{ 21 | securecookie: securecookie.New(hashKey, encryptKey), 22 | secureOnly: true, 23 | sameSite: http.SameSiteLaxMode, 24 | path: "/", 25 | } 26 | 27 | for _, opt := range opts { 28 | opt(c) 29 | } 30 | return c 31 | } 32 | 33 | type CookieHandlerOpt func(*CookieHandler) 34 | 35 | func WithUnsecure() CookieHandlerOpt { 36 | return func(c *CookieHandler) { 37 | c.secureOnly = false 38 | } 39 | } 40 | 41 | func WithSameSite(sameSite http.SameSite) CookieHandlerOpt { 42 | return func(c *CookieHandler) { 43 | c.sameSite = sameSite 44 | } 45 | } 46 | 47 | func WithMaxAge(maxAge int) CookieHandlerOpt { 48 | return func(c *CookieHandler) { 49 | c.maxAge = maxAge 50 | c.securecookie.MaxAge(maxAge) 51 | } 52 | } 53 | 54 | func WithDomain(domain string) CookieHandlerOpt { 55 | return func(c *CookieHandler) { 56 | c.domain = domain 57 | } 58 | } 59 | 60 | func WithPath(path string) CookieHandlerOpt { 61 | return func(c *CookieHandler) { 62 | c.path = path 63 | } 64 | } 65 | 66 | func (c *CookieHandler) CheckCookie(r *http.Request, name string) (string, error) { 67 | cookie, err := r.Cookie(name) 68 | if err != nil { 69 | return "", err 70 | } 71 | var value string 72 | if err := c.securecookie.Decode(name, cookie.Value, &value); err != nil { 73 | return "", err 74 | } 75 | return value, nil 76 | } 77 | 78 | func (c *CookieHandler) CheckQueryCookie(r *http.Request, name string) (string, error) { 79 | value, err := c.CheckCookie(r, name) 80 | if err != nil { 81 | return "", err 82 | } 83 | if value != r.FormValue(name) { 84 | return "", errors.New(name + " does not compare") 85 | } 86 | return value, nil 87 | } 88 | 89 | func (c *CookieHandler) SetCookie(w http.ResponseWriter, name, value string) error { 90 | encoded, err := c.securecookie.Encode(name, value) 91 | if err != nil { 92 | return err 93 | } 94 | http.SetCookie(w, &http.Cookie{ 95 | Name: name, 96 | Value: encoded, 97 | Domain: c.domain, 98 | Path: c.path, 99 | MaxAge: c.maxAge, 100 | HttpOnly: true, 101 | Secure: c.secureOnly, 102 | SameSite: c.sameSite, 103 | }) 104 | return nil 105 | } 106 | 107 | func (c *CookieHandler) DeleteCookie(w http.ResponseWriter, name string) { 108 | http.SetCookie(w, &http.Cookie{ 109 | Name: name, 110 | Value: "", 111 | Domain: c.domain, 112 | Path: c.path, 113 | MaxAge: -1, 114 | HttpOnly: true, 115 | Secure: c.secureOnly, 116 | SameSite: c.sameSite, 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /pkg/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "time" 13 | 14 | "github.com/zitadel/oidc/v3/pkg/oidc" 15 | ) 16 | 17 | var DefaultHTTPClient = &http.Client{ 18 | Timeout: 30 * time.Second, 19 | } 20 | 21 | type Decoder interface { 22 | Decode(dst any, src map[string][]string) error 23 | } 24 | 25 | type Encoder interface { 26 | Encode(src any, dst map[string][]string) error 27 | } 28 | 29 | type FormAuthorization func(url.Values) 30 | type RequestAuthorization func(*http.Request) 31 | 32 | func AuthorizeBasic(user, password string) RequestAuthorization { 33 | return func(req *http.Request) { 34 | req.SetBasicAuth(url.QueryEscape(user), url.QueryEscape(password)) 35 | } 36 | } 37 | 38 | func FormRequest(ctx context.Context, endpoint string, request any, encoder Encoder, authFn any) (*http.Request, error) { 39 | form := url.Values{} 40 | if err := encoder.Encode(request, form); err != nil { 41 | return nil, err 42 | } 43 | if fn, ok := authFn.(FormAuthorization); ok { 44 | fn(form) 45 | } 46 | body := strings.NewReader(form.Encode()) 47 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) 48 | if err != nil { 49 | return nil, err 50 | } 51 | if fn, ok := authFn.(RequestAuthorization); ok { 52 | fn(req) 53 | } 54 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 55 | return req, nil 56 | } 57 | 58 | func HttpRequest(client *http.Client, req *http.Request, response any) error { 59 | resp, err := client.Do(req) 60 | if err != nil { 61 | return err 62 | } 63 | defer resp.Body.Close() 64 | 65 | body, err := io.ReadAll(resp.Body) 66 | if err != nil { 67 | return fmt.Errorf("unable to read response body: %v", err) 68 | } 69 | 70 | if resp.StatusCode != http.StatusOK { 71 | var oidcErr oidc.Error 72 | err = json.Unmarshal(body, &oidcErr) 73 | if err != nil || oidcErr.ErrorType == "" { 74 | return fmt.Errorf("http status not ok: %s %s", resp.Status, body) 75 | } 76 | return &oidcErr 77 | } 78 | 79 | err = json.Unmarshal(body, response) 80 | if err != nil { 81 | return fmt.Errorf("failed to unmarshal response: %v %s", err, body) 82 | } 83 | return nil 84 | } 85 | 86 | func URLEncodeParams(resp any, encoder Encoder) (url.Values, error) { 87 | values := make(map[string][]string) 88 | err := encoder.Encode(resp, values) 89 | if err != nil { 90 | return nil, err 91 | } 92 | return values, nil 93 | } 94 | 95 | func StartServer(ctx context.Context, port string) { 96 | server := &http.Server{Addr: port} 97 | go func() { 98 | if err := server.ListenAndServe(); err != http.ErrServerClosed { 99 | log.Fatalf("ListenAndServe(): %v", err) 100 | } 101 | }() 102 | 103 | go func() { 104 | <-ctx.Done() 105 | ctxShutdown, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second) 106 | defer cancelShutdown() 107 | err := server.Shutdown(ctxShutdown) 108 | if err != nil { 109 | log.Fatalf("Shutdown(): %v", err) 110 | } 111 | }() 112 | } 113 | -------------------------------------------------------------------------------- /pkg/http/marshal.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "reflect" 9 | ) 10 | 11 | func MarshalJSON(w http.ResponseWriter, i any) { 12 | MarshalJSONWithStatus(w, i, http.StatusOK) 13 | } 14 | 15 | func MarshalJSONWithStatus(w http.ResponseWriter, i any, status int) { 16 | w.Header().Set("content-type", "application/json") 17 | w.WriteHeader(status) 18 | if i == nil || (reflect.ValueOf(i).Kind() == reflect.Ptr && reflect.ValueOf(i).IsNil()) { 19 | return 20 | } 21 | err := json.NewEncoder(w).Encode(i) 22 | if err != nil { 23 | http.Error(w, err.Error(), http.StatusInternalServerError) 24 | } 25 | } 26 | 27 | func ConcatenateJSON(first, second []byte) ([]byte, error) { 28 | if !bytes.HasSuffix(first, []byte{'}'}) { 29 | return nil, fmt.Errorf("jws: invalid JSON %s", first) 30 | } 31 | if !bytes.HasPrefix(second, []byte{'{'}) { 32 | return nil, fmt.Errorf("jws: invalid JSON %s", second) 33 | } 34 | // check empty 35 | if len(first) == 2 { 36 | return second, nil 37 | } 38 | if len(second) == 2 { 39 | return first, nil 40 | } 41 | 42 | first[len(first)-1] = ',' 43 | first = append(first, second[1:]...) 44 | return first, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/http/marshal_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestConcatenateJSON(t *testing.T) { 12 | type args struct { 13 | first []byte 14 | second []byte 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | want []byte 20 | wantErr bool 21 | }{ 22 | { 23 | "invalid first part, error", 24 | args{ 25 | []byte(`invalid`), 26 | []byte(`{"some": "thing"}`), 27 | }, 28 | nil, 29 | true, 30 | }, 31 | { 32 | "invalid second part, error", 33 | args{ 34 | []byte(`{"some": "thing"}`), 35 | []byte(`invalid`), 36 | }, 37 | nil, 38 | true, 39 | }, 40 | { 41 | "both valid, merged", 42 | args{ 43 | []byte(`{"some": "thing"}`), 44 | []byte(`{"another": "thing"}`), 45 | }, 46 | 47 | []byte(`{"some": "thing","another": "thing"}`), 48 | false, 49 | }, 50 | { 51 | "first empty", 52 | args{ 53 | []byte(`{}`), 54 | []byte(`{"some": "thing"}`), 55 | }, 56 | 57 | []byte(`{"some": "thing"}`), 58 | false, 59 | }, 60 | { 61 | "second empty", 62 | args{ 63 | []byte(`{"some": "thing"}`), 64 | []byte(`{}`), 65 | }, 66 | 67 | []byte(`{"some": "thing"}`), 68 | false, 69 | }, 70 | { 71 | "both empty", 72 | args{ 73 | []byte(`{}`), 74 | []byte(`{}`), 75 | }, 76 | 77 | []byte(`{}`), 78 | false, 79 | }, 80 | } 81 | for _, tt := range tests { 82 | t.Run(tt.name, func(t *testing.T) { 83 | got, err := ConcatenateJSON(tt.args.first, tt.args.second) 84 | if (err != nil) != tt.wantErr { 85 | t.Errorf("ConcatenateJSON() error = %v, wantErr %v", err, tt.wantErr) 86 | return 87 | } 88 | if !bytes.Equal(got, tt.want) { 89 | t.Errorf("ConcatenateJSON() got = %v, want %v", string(got), tt.want) 90 | } 91 | }) 92 | } 93 | } 94 | 95 | func TestMarshalJSONWithStatus(t *testing.T) { 96 | type args struct { 97 | i any 98 | status int 99 | } 100 | type res struct { 101 | statusCode int 102 | body string 103 | } 104 | tests := []struct { 105 | name string 106 | args args 107 | res res 108 | }{ 109 | { 110 | "empty ok", 111 | args{ 112 | nil, 113 | 200, 114 | }, 115 | res{ 116 | 200, 117 | "", 118 | }, 119 | }, 120 | { 121 | "string ok", 122 | args{ 123 | "ok", 124 | 200, 125 | }, 126 | res{ 127 | 200, 128 | `"ok" 129 | `, 130 | }, 131 | }, 132 | { 133 | "struct ok", 134 | args{ 135 | struct { 136 | Test string `json:"test"` 137 | }{"ok"}, 138 | 200, 139 | }, 140 | res{ 141 | 200, 142 | `{"test":"ok"} 143 | `, 144 | }, 145 | }, 146 | } 147 | for _, tt := range tests { 148 | t.Run(tt.name, func(t *testing.T) { 149 | w := httptest.NewRecorder() 150 | MarshalJSONWithStatus(w, tt.args.i, tt.args.status) 151 | assert.Equal(t, tt.res.statusCode, w.Result().StatusCode) 152 | assert.Equal(t, "application/json", w.Header().Get("content-type")) 153 | assert.Equal(t, tt.res.body, w.Body.String()) 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /pkg/oidc/authorization_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.20 2 | 3 | package oidc 4 | 5 | import ( 6 | "log/slog" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestAuthRequest_LogValue(t *testing.T) { 13 | a := &AuthRequest{ 14 | Scopes: SpaceDelimitedArray{"a", "b"}, 15 | ResponseType: "respType", 16 | ClientID: "123", 17 | RedirectURI: "http://example.com/callback", 18 | } 19 | want := slog.GroupValue( 20 | slog.Any("scopes", SpaceDelimitedArray{"a", "b"}), 21 | slog.String("response_type", "respType"), 22 | slog.String("client_id", "123"), 23 | slog.String("redirect_uri", "http://example.com/callback"), 24 | ) 25 | got := a.LogValue() 26 | assert.Equal(t, want, got) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/oidc/code_challenge.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "crypto/sha256" 5 | 6 | "github.com/zitadel/oidc/v3/pkg/crypto" 7 | ) 8 | 9 | const ( 10 | CodeChallengeMethodPlain CodeChallengeMethod = "plain" 11 | CodeChallengeMethodS256 CodeChallengeMethod = "S256" 12 | ) 13 | 14 | type CodeChallengeMethod string 15 | 16 | type CodeChallenge struct { 17 | Challenge string 18 | Method CodeChallengeMethod 19 | } 20 | 21 | func NewSHACodeChallenge(code string) string { 22 | return crypto.HashString(sha256.New(), code, false) 23 | } 24 | 25 | func VerifyCodeChallenge(c *CodeChallenge, codeVerifier string) bool { 26 | if c == nil { 27 | return false 28 | } 29 | if c.Method == CodeChallengeMethodS256 { 30 | codeVerifier = NewSHACodeChallenge(codeVerifier) 31 | } 32 | return codeVerifier == c.Challenge 33 | } 34 | -------------------------------------------------------------------------------- /pkg/oidc/device_authorization.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import "encoding/json" 4 | 5 | // DeviceAuthorizationRequest implements 6 | // https://www.rfc-editor.org/rfc/rfc8628#section-3.1, 7 | // 3.1 Device Authorization Request. 8 | type DeviceAuthorizationRequest struct { 9 | Scopes SpaceDelimitedArray `schema:"scope"` 10 | ClientID string `schema:"client_id"` 11 | } 12 | 13 | // DeviceAuthorizationResponse implements 14 | // https://www.rfc-editor.org/rfc/rfc8628#section-3.2 15 | // 3.2. Device Authorization Response. 16 | type DeviceAuthorizationResponse struct { 17 | DeviceCode string `json:"device_code"` 18 | UserCode string `json:"user_code"` 19 | VerificationURI string `json:"verification_uri"` 20 | VerificationURIComplete string `json:"verification_uri_complete,omitempty"` 21 | ExpiresIn int `json:"expires_in"` 22 | Interval int `json:"interval,omitempty"` 23 | } 24 | 25 | func (resp *DeviceAuthorizationResponse) UnmarshalJSON(data []byte) error { 26 | type Alias DeviceAuthorizationResponse 27 | aux := &struct { 28 | // workaround misspelling of verification_uri 29 | // https://stackoverflow.com/q/76696956/5690223 30 | // https://developers.google.com/identity/protocols/oauth2/limited-input-device?hl=fr#success-response 31 | VerificationURL string `json:"verification_url"` 32 | *Alias 33 | }{ 34 | Alias: (*Alias)(resp), 35 | } 36 | if err := json.Unmarshal(data, &aux); err != nil { 37 | return err 38 | } 39 | if resp.VerificationURI == "" { 40 | resp.VerificationURI = aux.VerificationURL 41 | } 42 | return nil 43 | } 44 | 45 | // DeviceAccessTokenRequest implements 46 | // https://www.rfc-editor.org/rfc/rfc8628#section-3.4, 47 | // Device Access Token Request. 48 | type DeviceAccessTokenRequest struct { 49 | GrantType GrantType `json:"grant_type" schema:"grant_type"` 50 | DeviceCode string `json:"device_code" schema:"device_code"` 51 | } 52 | -------------------------------------------------------------------------------- /pkg/oidc/device_authorization_test.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDeviceAuthorizationResponse_UnmarshalJSON(t *testing.T) { 10 | jsonStr := `{ 11 | "device_code": "deviceCode", 12 | "user_code": "userCode", 13 | "verification_url": "http://example.com/verify", 14 | "expires_in": 3600, 15 | "interval": 5 16 | }` 17 | 18 | expected := &DeviceAuthorizationResponse{ 19 | DeviceCode: "deviceCode", 20 | UserCode: "userCode", 21 | VerificationURI: "http://example.com/verify", 22 | ExpiresIn: 3600, 23 | Interval: 5, 24 | } 25 | 26 | var resp DeviceAuthorizationResponse 27 | err := resp.UnmarshalJSON([]byte(jsonStr)) 28 | assert.NoError(t, err) 29 | assert.Equal(t, expected, &resp) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/oidc/error_test.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "log/slog" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestDefaultToServerError(t *testing.T) { 15 | type args struct { 16 | err error 17 | description string 18 | } 19 | tests := []struct { 20 | name string 21 | args args 22 | want *Error 23 | }{ 24 | { 25 | name: "default", 26 | args: args{ 27 | err: io.ErrClosedPipe, 28 | description: "oops", 29 | }, 30 | want: &Error{ 31 | ErrorType: ServerError, 32 | Description: "oops", 33 | Parent: io.ErrClosedPipe, 34 | }, 35 | }, 36 | { 37 | name: "our Error", 38 | args: args{ 39 | err: ErrAccessDenied(), 40 | description: "oops", 41 | }, 42 | want: &Error{ 43 | ErrorType: AccessDenied, 44 | Description: "The authorization request was denied.", 45 | }, 46 | }, 47 | } 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | got := DefaultToServerError(tt.args.err, tt.args.description) 51 | assert.ErrorIs(t, got, tt.want) 52 | }) 53 | } 54 | } 55 | 56 | func TestError_LogLevel(t *testing.T) { 57 | tests := []struct { 58 | name string 59 | err *Error 60 | want slog.Level 61 | }{ 62 | { 63 | name: "server error", 64 | err: ErrServerError(), 65 | want: slog.LevelError, 66 | }, 67 | { 68 | name: "authorization pending", 69 | err: ErrAuthorizationPending(), 70 | want: slog.LevelInfo, 71 | }, 72 | { 73 | name: "some other error", 74 | err: ErrAccessDenied(), 75 | want: slog.LevelWarn, 76 | }, 77 | } 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | got := tt.err.LogLevel() 81 | assert.Equal(t, tt.want, got) 82 | }) 83 | } 84 | } 85 | 86 | func TestError_LogValue(t *testing.T) { 87 | type fields struct { 88 | Parent error 89 | ErrorType errorType 90 | Description string 91 | State string 92 | redirectDisabled bool 93 | } 94 | tests := []struct { 95 | name string 96 | fields fields 97 | want slog.Value 98 | }{ 99 | { 100 | name: "parent", 101 | fields: fields{ 102 | Parent: io.EOF, 103 | }, 104 | want: slog.GroupValue(slog.Any("parent", io.EOF)), 105 | }, 106 | { 107 | name: "description", 108 | fields: fields{ 109 | Description: "oops", 110 | }, 111 | want: slog.GroupValue(slog.String("description", "oops")), 112 | }, 113 | { 114 | name: "errorType", 115 | fields: fields{ 116 | ErrorType: ExpiredToken, 117 | }, 118 | want: slog.GroupValue(slog.String("type", string(ExpiredToken))), 119 | }, 120 | { 121 | name: "state", 122 | fields: fields{ 123 | State: "123", 124 | }, 125 | want: slog.GroupValue(slog.String("state", "123")), 126 | }, 127 | { 128 | name: "all fields", 129 | fields: fields{ 130 | Parent: io.EOF, 131 | Description: "oops", 132 | ErrorType: ExpiredToken, 133 | State: "123", 134 | }, 135 | want: slog.GroupValue( 136 | slog.Any("parent", io.EOF), 137 | slog.String("description", "oops"), 138 | slog.String("type", string(ExpiredToken)), 139 | slog.String("state", "123"), 140 | ), 141 | }, 142 | } 143 | for _, tt := range tests { 144 | t.Run(tt.name, func(t *testing.T) { 145 | e := &Error{ 146 | Parent: tt.fields.Parent, 147 | ErrorType: tt.fields.ErrorType, 148 | Description: tt.fields.Description, 149 | State: tt.fields.State, 150 | redirectDisabled: tt.fields.redirectDisabled, 151 | } 152 | got := e.LogValue() 153 | assert.Equal(t, tt.want, got) 154 | }) 155 | } 156 | } 157 | 158 | func TestError_MarshalJSON(t *testing.T) { 159 | tests := []struct { 160 | name string 161 | e *Error 162 | want string 163 | }{ 164 | { 165 | name: "simple error", 166 | e: ErrAccessDenied(), 167 | want: `{"error":"access_denied","error_description":"The authorization request was denied."}`, 168 | }, 169 | { 170 | name: "with description", 171 | e: ErrAccessDenied().WithDescription("oops"), 172 | want: `{"error":"access_denied","error_description":"oops"}`, 173 | }, 174 | { 175 | name: "with parent", 176 | e: ErrServerError().WithParent(errors.New("oops")), 177 | want: `{"error":"server_error"}`, 178 | }, 179 | { 180 | name: "with return parent", 181 | e: ErrServerError().WithParent(errors.New("oops")).WithReturnParentToClient(true), 182 | want: `{"error":"server_error","parent":"oops"}`, 183 | }, 184 | } 185 | for _, tt := range tests { 186 | t.Run(tt.name, func(t *testing.T) { 187 | got, err := json.Marshal(tt.e) 188 | require.NoError(t, err) 189 | assert.JSONEq(t, tt.want, string(got)) 190 | }) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /pkg/oidc/grants/client_credentials.go: -------------------------------------------------------------------------------- 1 | package grants 2 | 3 | import "strings" 4 | 5 | type clientCredentialsGrantBasic struct { 6 | grantType string `schema:"grant_type"` 7 | scope string `schema:"scope"` 8 | } 9 | 10 | type clientCredentialsGrant struct { 11 | *clientCredentialsGrantBasic 12 | clientID string `schema:"client_id"` 13 | clientSecret string `schema:"client_secret"` 14 | } 15 | 16 | // ClientCredentialsGrantBasic creates an oauth2 `Client Credentials` Grant 17 | // sending client_id and client_secret as basic auth header 18 | func ClientCredentialsGrantBasic(scopes ...string) *clientCredentialsGrantBasic { 19 | return &clientCredentialsGrantBasic{ 20 | grantType: "client_credentials", 21 | scope: strings.Join(scopes, " "), 22 | } 23 | } 24 | 25 | // ClientCredentialsGrantValues creates an oauth2 `Client Credentials` Grant 26 | // sending client_id and client_secret as form values 27 | func ClientCredentialsGrantValues(clientID, clientSecret string, scopes ...string) *clientCredentialsGrant { 28 | return &clientCredentialsGrant{ 29 | clientCredentialsGrantBasic: ClientCredentialsGrantBasic(scopes...), 30 | clientID: clientID, 31 | clientSecret: clientSecret, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/oidc/grants/tokenexchange/tokenexchange.go: -------------------------------------------------------------------------------- 1 | package tokenexchange 2 | 3 | const ( 4 | AccessTokenType = "urn:ietf:params:oauth:token-type:access_token" 5 | RefreshTokenType = "urn:ietf:params:oauth:token-type:refresh_token" 6 | IDTokenType = "urn:ietf:params:oauth:token-type:id_token" 7 | JWTTokenType = "urn:ietf:params:oauth:token-type:jwt" 8 | DelegationTokenType = AccessTokenType 9 | 10 | TokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" 11 | ) 12 | 13 | type TokenExchangeRequest struct { 14 | grantType string `schema:"grant_type"` 15 | subjectToken string `schema:"subject_token"` 16 | subjectTokenType string `schema:"subject_token_type"` 17 | actorToken string `schema:"actor_token"` 18 | actorTokenType string `schema:"actor_token_type"` 19 | resource []string `schema:"resource"` 20 | audience []string `schema:"audience"` 21 | scope []string `schema:"scope"` 22 | requestedTokenType string `schema:"requested_token_type"` 23 | } 24 | 25 | func NewTokenExchangeRequest(subjectToken, subjectTokenType string, opts ...TokenExchangeOption) *TokenExchangeRequest { 26 | t := &TokenExchangeRequest{ 27 | grantType: TokenExchangeGrantType, 28 | subjectToken: subjectToken, 29 | subjectTokenType: subjectTokenType, 30 | requestedTokenType: AccessTokenType, 31 | } 32 | for _, opt := range opts { 33 | opt(t) 34 | } 35 | return t 36 | } 37 | 38 | type TokenExchangeOption func(*TokenExchangeRequest) 39 | 40 | func WithActorToken(token, tokenType string) func(*TokenExchangeRequest) { 41 | return func(req *TokenExchangeRequest) { 42 | req.actorToken = token 43 | req.actorTokenType = tokenType 44 | } 45 | } 46 | 47 | func WithAudience(audience []string) func(*TokenExchangeRequest) { 48 | return func(req *TokenExchangeRequest) { 49 | req.audience = audience 50 | } 51 | } 52 | 53 | func WithGrantType(grantType string) TokenExchangeOption { 54 | return func(req *TokenExchangeRequest) { 55 | req.grantType = grantType 56 | } 57 | } 58 | 59 | func WithRequestedTokenType(tokenType string) func(*TokenExchangeRequest) { 60 | return func(req *TokenExchangeRequest) { 61 | req.requestedTokenType = tokenType 62 | } 63 | } 64 | 65 | func WithResource(resource []string) func(*TokenExchangeRequest) { 66 | return func(req *TokenExchangeRequest) { 67 | req.resource = resource 68 | } 69 | } 70 | 71 | func WithScope(scope []string) func(*TokenExchangeRequest) { 72 | return func(req *TokenExchangeRequest) { 73 | req.scope = scope 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /pkg/oidc/introspection.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import "github.com/muhlemmer/gu" 4 | 5 | type IntrospectionRequest struct { 6 | Token string `schema:"token"` 7 | } 8 | 9 | type ClientAssertionParams struct { 10 | ClientAssertion string `schema:"client_assertion"` 11 | ClientAssertionType string `schema:"client_assertion_type"` 12 | } 13 | 14 | // IntrospectionResponse implements RFC 7662, section 2.2 and 15 | // OpenID Connect Core 1.0, section 5.1 (UserInfo). 16 | // https://www.rfc-editor.org/rfc/rfc7662.html#section-2.2. 17 | // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims. 18 | type IntrospectionResponse struct { 19 | Active bool `json:"active"` 20 | Scope SpaceDelimitedArray `json:"scope,omitempty"` 21 | ClientID string `json:"client_id,omitempty"` 22 | TokenType string `json:"token_type,omitempty"` 23 | Expiration Time `json:"exp,omitempty"` 24 | IssuedAt Time `json:"iat,omitempty"` 25 | AuthTime Time `json:"auth_time,omitempty"` 26 | NotBefore Time `json:"nbf,omitempty"` 27 | Subject string `json:"sub,omitempty"` 28 | Audience Audience `json:"aud,omitempty"` 29 | AuthenticationMethodsReferences []string `json:"amr,omitempty"` 30 | Issuer string `json:"iss,omitempty"` 31 | JWTID string `json:"jti,omitempty"` 32 | Username string `json:"username,omitempty"` 33 | Actor *ActorClaims `json:"act,omitempty"` 34 | UserInfoProfile 35 | UserInfoEmail 36 | UserInfoPhone 37 | 38 | Address *UserInfoAddress `json:"address,omitempty"` 39 | Claims map[string]any `json:"-"` 40 | } 41 | 42 | // SetUserInfo copies all relevant fields from UserInfo 43 | // into the IntroSpectionResponse. 44 | func (i *IntrospectionResponse) SetUserInfo(u *UserInfo) { 45 | i.Subject = u.Subject 46 | i.Username = u.PreferredUsername 47 | i.Address = gu.PtrCopy(u.Address) 48 | i.UserInfoProfile = u.UserInfoProfile 49 | i.UserInfoEmail = u.UserInfoEmail 50 | i.UserInfoPhone = u.UserInfoPhone 51 | if i.Claims == nil { 52 | i.Claims = gu.MapCopy(u.Claims) 53 | } else { 54 | gu.MapMerge(u.Claims, i.Claims) 55 | } 56 | } 57 | 58 | // GetAddress is a safe getter that takes 59 | // care of a possible nil value. 60 | func (i *IntrospectionResponse) GetAddress() *UserInfoAddress { 61 | if i.Address == nil { 62 | return new(UserInfoAddress) 63 | } 64 | return i.Address 65 | } 66 | 67 | // introspectionResponseAlias prevents loops on the JSON methods 68 | type introspectionResponseAlias IntrospectionResponse 69 | 70 | func (i *IntrospectionResponse) MarshalJSON() ([]byte, error) { 71 | if i.Username == "" { 72 | i.Username = i.PreferredUsername 73 | } 74 | return mergeAndMarshalClaims((*introspectionResponseAlias)(i), i.Claims) 75 | } 76 | 77 | func (i *IntrospectionResponse) UnmarshalJSON(data []byte) error { 78 | return unmarshalJSONMulti(data, (*introspectionResponseAlias)(i), &i.Claims) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/oidc/introspection_test.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/muhlemmer/gu" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestIntrospectionResponse_SetUserInfo(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | start *IntrospectionResponse 16 | want *IntrospectionResponse 17 | }{ 18 | { 19 | 20 | name: "nil claims", 21 | start: &IntrospectionResponse{}, 22 | want: &IntrospectionResponse{ 23 | Subject: userInfoData.Subject, 24 | Username: userInfoData.PreferredUsername, 25 | Address: userInfoData.Address, 26 | UserInfoProfile: userInfoData.UserInfoProfile, 27 | UserInfoEmail: userInfoData.UserInfoEmail, 28 | UserInfoPhone: userInfoData.UserInfoPhone, 29 | Claims: gu.MapCopy(userInfoData.Claims), 30 | }, 31 | }, 32 | { 33 | 34 | name: "merge claims", 35 | start: &IntrospectionResponse{ 36 | Claims: map[string]any{ 37 | "hello": "world", 38 | }, 39 | }, 40 | want: &IntrospectionResponse{ 41 | Subject: userInfoData.Subject, 42 | Username: userInfoData.PreferredUsername, 43 | Address: userInfoData.Address, 44 | UserInfoProfile: userInfoData.UserInfoProfile, 45 | UserInfoEmail: userInfoData.UserInfoEmail, 46 | UserInfoPhone: userInfoData.UserInfoPhone, 47 | Claims: map[string]any{ 48 | "foo": "bar", 49 | "hello": "world", 50 | }, 51 | }, 52 | }, 53 | } 54 | for _, tt := range tests { 55 | t.Run(tt.name, func(t *testing.T) { 56 | tt.start.SetUserInfo(userInfoData) 57 | assert.Equal(t, tt.want, tt.start) 58 | }) 59 | } 60 | } 61 | 62 | func TestIntrospectionResponse_GetAddress(t *testing.T) { 63 | // nil address 64 | i := new(IntrospectionResponse) 65 | assert.Equal(t, &UserInfoAddress{}, i.GetAddress()) 66 | 67 | i.Address = &UserInfoAddress{PostalCode: "1234"} 68 | assert.Equal(t, i.Address, i.GetAddress()) 69 | } 70 | 71 | func TestIntrospectionResponse_MarshalJSON(t *testing.T) { 72 | got, err := json.Marshal(&IntrospectionResponse{ 73 | UserInfoProfile: UserInfoProfile{ 74 | PreferredUsername: "muhlemmer", 75 | }, 76 | }) 77 | require.NoError(t, err) 78 | assert.Equal(t, string(got), `{"active":false,"username":"muhlemmer","preferred_username":"muhlemmer"}`) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/oidc/jwt_profile.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | type JWTProfileGrantRequest struct { 4 | Assertion string `schema:"assertion"` 5 | Scope SpaceDelimitedArray `schema:"scope"` 6 | GrantType GrantType `schema:"grant_type"` 7 | } 8 | 9 | // NewJWTProfileGrantRequest creates an oauth2 `JSON Web Token (JWT) Profile` Grant 10 | //`urn:ietf:params:oauth:grant-type:jwt-bearer` 11 | // sending a self-signed jwt as assertion 12 | func NewJWTProfileGrantRequest(assertion string, scopes ...string) *JWTProfileGrantRequest { 13 | return &JWTProfileGrantRequest{ 14 | GrantType: GrantTypeBearer, 15 | Assertion: assertion, 16 | Scope: scopes, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pkg/oidc/keyset.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "context" 5 | "crypto/ecdsa" 6 | "crypto/ed25519" 7 | "crypto/rsa" 8 | "errors" 9 | "strings" 10 | 11 | jose "github.com/go-jose/go-jose/v4" 12 | ) 13 | 14 | const ( 15 | KeyUseSignature = "sig" 16 | ) 17 | 18 | var ( 19 | ErrKeyMultiple = errors.New("multiple possible keys match") 20 | ErrKeyNone = errors.New("no possible keys matches") 21 | ) 22 | 23 | // KeySet represents a set of JSON Web Keys 24 | // - remotely fetch via discovery and jwks_uri -> `remoteKeySet` 25 | // - held by the OP itself in storage -> `openIDKeySet` 26 | // - dynamically aggregated by request for OAuth JWT Profile Assertion -> `jwtProfileKeySet` 27 | type KeySet interface { 28 | // VerifySignature verifies the signature with the given keyset and returns the raw payload 29 | VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) 30 | } 31 | 32 | // GetKeyIDAndAlg returns the `kid` and `alg` claim from the JWS header 33 | func GetKeyIDAndAlg(jws *jose.JSONWebSignature) (string, string) { 34 | keyID := "" 35 | alg := "" 36 | for _, sig := range jws.Signatures { 37 | keyID = sig.Header.KeyID 38 | alg = sig.Header.Algorithm 39 | break 40 | } 41 | return keyID, alg 42 | } 43 | 44 | // FindKey searches the given JSON Web Keys for the requested key ID, usage and key type 45 | // 46 | // will return the key immediately if matches exact (id, usage, type) 47 | // 48 | // will return false none or multiple match 49 | // 50 | // deprecated: use FindMatchingKey which will return an error (more specific) instead of just a bool 51 | // moved implementation already to FindMatchingKey 52 | func FindKey(keyID, use, expectedAlg string, keys ...jose.JSONWebKey) (jose.JSONWebKey, bool) { 53 | key, err := FindMatchingKey(keyID, use, expectedAlg, keys...) 54 | return key, err == nil 55 | } 56 | 57 | // FindMatchingKey searches the given JSON Web Keys for the requested key ID, usage and alg type 58 | // 59 | // will return the key immediately if matches exact (id, usage, type) 60 | // 61 | // will return a specific error if none (ErrKeyNone) or multiple (ErrKeyMultiple) match 62 | func FindMatchingKey(keyID, use, expectedAlg string, keys ...jose.JSONWebKey) (key jose.JSONWebKey, err error) { 63 | var validKeys []jose.JSONWebKey 64 | for _, k := range keys { 65 | // ignore all keys with wrong use (let empty use of published key pass) 66 | if k.Use != use && k.Use != "" { 67 | continue 68 | } 69 | // ignore all keys with wrong algorithm type 70 | if !algToKeyType(k.Key, expectedAlg) { 71 | continue 72 | } 73 | // if we get here, use and alg match, so an equal (not empty) keyID is an exact match 74 | if k.KeyID == keyID && keyID != "" { 75 | return k, nil 76 | } 77 | // keyIDs did not match or at least one was empty (if later, then it could be a match) 78 | if k.KeyID == "" || keyID == "" { 79 | validKeys = append(validKeys, k) 80 | } 81 | } 82 | // if we get here, no match was possible at all (use / alg) or no exact match due to 83 | // the signed JWT and / or the published keys didn't have a kid 84 | // if later applies and only one key could be found, we'll return it 85 | // otherwise a corresponding error will be thrown 86 | if len(validKeys) == 1 { 87 | return validKeys[0], nil 88 | } 89 | if len(validKeys) > 1 { 90 | return key, ErrKeyMultiple 91 | } 92 | return key, ErrKeyNone 93 | } 94 | 95 | func algToKeyType(key any, alg string) bool { 96 | if strings.HasPrefix(alg, "RS") || strings.HasPrefix(alg, "PS") { 97 | _, ok := key.(*rsa.PublicKey) 98 | return ok 99 | } 100 | if strings.HasPrefix(alg, "ES") { 101 | _, ok := key.(*ecdsa.PublicKey) 102 | return ok 103 | } 104 | if alg == string(jose.EdDSA) { 105 | _, ok := key.(ed25519.PublicKey) 106 | return ok 107 | } 108 | return false 109 | } 110 | -------------------------------------------------------------------------------- /pkg/oidc/regression_assert_test.go: -------------------------------------------------------------------------------- 1 | //go:build !create_regression_data 2 | 3 | package oidc 4 | 5 | import ( 6 | "encoding/json" 7 | "io" 8 | "os" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | // Test_assert_regression verifies current output from 18 | // json.Marshal to stored regression data. 19 | // These tests are only ran when the create_regression_data 20 | // tag is NOT set. 21 | func Test_assert_regression(t *testing.T) { 22 | buf := new(strings.Builder) 23 | 24 | for _, obj := range regressionData { 25 | name := jsonFilename(obj) 26 | t.Run(name, func(t *testing.T) { 27 | file, err := os.Open(name) 28 | require.NoError(t, err) 29 | defer file.Close() 30 | 31 | _, err = io.Copy(buf, file) 32 | require.NoError(t, err) 33 | want := buf.String() 34 | buf.Reset() 35 | 36 | encodeJSON(t, buf, obj) 37 | first := buf.String() 38 | buf.Reset() 39 | 40 | assert.JSONEq(t, want, first) 41 | 42 | target := reflect.New(reflect.TypeOf(obj).Elem()).Interface() 43 | 44 | require.NoError(t, 45 | json.Unmarshal([]byte(first), target), 46 | ) 47 | second, err := json.Marshal(target) 48 | require.NoError(t, err) 49 | 50 | assert.JSONEq(t, want, string(second)) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/oidc/regression_create_test.go: -------------------------------------------------------------------------------- 1 | //go:build create_regression_data 2 | 3 | package oidc 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | // Test_create_regression generates the regression data. 13 | // It is excluded from regular testing, unless 14 | // called with the create_regression_data tag: 15 | // go test -tags="create_regression_data" ./pkg/oidc 16 | func Test_create_regression(t *testing.T) { 17 | for _, obj := range regressionData { 18 | file, err := os.Create(jsonFilename(obj)) 19 | require.NoError(t, err) 20 | defer file.Close() 21 | 22 | encodeJSON(t, file, obj) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/oidc/regression_data/oidc.AccessTokenClaims.json: -------------------------------------------------------------------------------- 1 | { 2 | "iss": "zitadel", 3 | "sub": "hello@me.com", 4 | "aud": [ 5 | "foo", 6 | "bar" 7 | ], 8 | "jti": "900", 9 | "azp": "just@me.com", 10 | "nonce": "6969", 11 | "acr": "something", 12 | "amr": [ 13 | "some", 14 | "methods" 15 | ], 16 | "scope": "email phone", 17 | "client_id": "777", 18 | "exp": 12345, 19 | "iat": 12000, 20 | "nbf": 12000, 21 | "auth_time": 12000, 22 | "foo": "bar" 23 | } 24 | -------------------------------------------------------------------------------- /pkg/oidc/regression_data/oidc.IDTokenClaims.json: -------------------------------------------------------------------------------- 1 | { 2 | "iss": "zitadel", 3 | "aud": [ 4 | "foo", 5 | "bar" 6 | ], 7 | "jti": "900", 8 | "azp": "just@me.com", 9 | "nonce": "6969", 10 | "at_hash": "acthashhash", 11 | "c_hash": "hashhash", 12 | "acr": "something", 13 | "amr": [ 14 | "some", 15 | "methods" 16 | ], 17 | "sid": "666", 18 | "client_id": "777", 19 | "exp": 12345, 20 | "iat": 12000, 21 | "nbf": 12000, 22 | "auth_time": 12000, 23 | "address": { 24 | "country": "Moon", 25 | "formatted": "Sesame street 666\n666-666, Smallvile\nMoon", 26 | "locality": "Smallvile", 27 | "postal_code": "666-666", 28 | "region": "Outer space", 29 | "street_address": "Sesame street 666" 30 | }, 31 | "birthdate": "1st of April", 32 | "email": "tim@zitadel.com", 33 | "email_verified": true, 34 | "family_name": "Möhlmann", 35 | "foo": "bar", 36 | "gender": "male", 37 | "given_name": "Tim", 38 | "locale": "nl", 39 | "middle_name": "Danger", 40 | "name": "Tim Möhlmann", 41 | "nickname": "muhlemmer", 42 | "phone_number": "+1234567890", 43 | "phone_number_verified": true, 44 | "picture": "https://avatars.githubusercontent.com/u/5411563?v=4", 45 | "preferred_username": "muhlemmer", 46 | "profile": "https://github.com/muhlemmer", 47 | "sub": "hello@me.com", 48 | "updated_at": 1, 49 | "website": "https://zitadel.com", 50 | "zoneinfo": "Europe/Amsterdam" 51 | } 52 | -------------------------------------------------------------------------------- /pkg/oidc/regression_data/oidc.IntrospectionResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "active": true, 3 | "address": { 4 | "country": "Moon", 5 | "formatted": "Sesame street 666\n666-666, Smallvile\nMoon", 6 | "locality": "Smallvile", 7 | "postal_code": "666-666", 8 | "region": "Outer space", 9 | "street_address": "Sesame street 666" 10 | }, 11 | "aud": [ 12 | "foo", 13 | "bar" 14 | ], 15 | "birthdate": "1st of April", 16 | "client_id": "777", 17 | "email": "tim@zitadel.com", 18 | "email_verified": true, 19 | "exp": 12345, 20 | "family_name": "Möhlmann", 21 | "foo": "bar", 22 | "gender": "male", 23 | "given_name": "Tim", 24 | "iat": 12000, 25 | "iss": "zitadel", 26 | "jti": "900", 27 | "locale": "nl", 28 | "middle_name": "Danger", 29 | "name": "Tim Möhlmann", 30 | "nbf": 12000, 31 | "nickname": "muhlemmer", 32 | "phone_number": "+1234567890", 33 | "phone_number_verified": true, 34 | "picture": "https://avatars.githubusercontent.com/u/5411563?v=4", 35 | "preferred_username": "muhlemmer", 36 | "profile": "https://github.com/muhlemmer", 37 | "scope": "email phone", 38 | "sub": "hello@me.com", 39 | "token_type": "idtoken", 40 | "updated_at": 1, 41 | "username": "muhlemmer", 42 | "website": "https://zitadel.com", 43 | "zoneinfo": "Europe/Amsterdam" 44 | } 45 | -------------------------------------------------------------------------------- /pkg/oidc/regression_data/oidc.JWTProfileAssertionClaims.json: -------------------------------------------------------------------------------- 1 | { 2 | "aud": [ 3 | "foo", 4 | "bar" 5 | ], 6 | "exp": 12345, 7 | "foo": "bar", 8 | "iat": 12000, 9 | "iss": "zitadel", 10 | "sub": "hello@me.com" 11 | } 12 | -------------------------------------------------------------------------------- /pkg/oidc/regression_data/oidc.UserInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": { 3 | "country": "Moon", 4 | "formatted": "Sesame street 666\n666-666, Smallvile\nMoon", 5 | "locality": "Smallvile", 6 | "postal_code": "666-666", 7 | "region": "Outer space", 8 | "street_address": "Sesame street 666" 9 | }, 10 | "birthdate": "1st of April", 11 | "email": "tim@zitadel.com", 12 | "email_verified": true, 13 | "family_name": "Möhlmann", 14 | "foo": "bar", 15 | "gender": "male", 16 | "given_name": "Tim", 17 | "locale": "nl", 18 | "middle_name": "Danger", 19 | "name": "Tim Möhlmann", 20 | "nickname": "muhlemmer", 21 | "phone_number": "+1234567890", 22 | "phone_number_verified": true, 23 | "picture": "https://avatars.githubusercontent.com/u/5411563?v=4", 24 | "preferred_username": "muhlemmer", 25 | "profile": "https://github.com/muhlemmer", 26 | "sub": "hello@me.com", 27 | "updated_at": 1, 28 | "website": "https://zitadel.com", 29 | "zoneinfo": "Europe/Amsterdam" 30 | } 31 | -------------------------------------------------------------------------------- /pkg/oidc/regression_test.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | // This file contains common functions and data for regression testing 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "path" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | const dataDir = "regression_data" 17 | 18 | // jsonFilename builds a filename for the regression testdata. 19 | // dataDir/