├── .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 | Login 25 | 26 | 27 |
28 | 29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |

{{.Error}}

38 | 39 |
40 | 41 | `) 42 | ) 43 | 44 | type login struct { 45 | authenticate authenticate 46 | router chi.Router 47 | callback func(context.Context, string) string 48 | } 49 | 50 | func NewLogin(authenticate authenticate, callback func(context.Context, string) string, issuerInterceptor *op.IssuerInterceptor) *login { 51 | l := &login{ 52 | authenticate: authenticate, 53 | callback: callback, 54 | } 55 | l.createRouter(issuerInterceptor) 56 | return l 57 | } 58 | 59 | func (l *login) createRouter(issuerInterceptor *op.IssuerInterceptor) { 60 | l.router = chi.NewRouter() 61 | l.router.Get("/username", l.loginHandler) 62 | l.router.With(issuerInterceptor.Handler).Post("/username", l.checkLoginHandler) 63 | } 64 | 65 | type authenticate interface { 66 | CheckUsernamePassword(ctx context.Context, username, password, id string) error 67 | } 68 | 69 | func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) { 70 | err := r.ParseForm() 71 | if err != nil { 72 | http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError) 73 | return 74 | } 75 | //the oidc package will pass the id of the auth request as query parameter 76 | //we will use this id through the login process and therefore pass it to the login page 77 | renderLogin(w, r.FormValue(queryAuthRequestID), nil) 78 | } 79 | 80 | func renderLogin(w http.ResponseWriter, id string, err error) { 81 | var errMsg string 82 | if err != nil { 83 | errMsg = err.Error() 84 | } 85 | data := &struct { 86 | ID string 87 | Error string 88 | }{ 89 | ID: id, 90 | Error: errMsg, 91 | } 92 | err = loginTmpl.Execute(w, data) 93 | if err != nil { 94 | http.Error(w, err.Error(), http.StatusInternalServerError) 95 | } 96 | } 97 | 98 | func (l *login) checkLoginHandler(w http.ResponseWriter, r *http.Request) { 99 | err := r.ParseForm() 100 | if err != nil { 101 | http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError) 102 | return 103 | } 104 | username := r.FormValue("username") 105 | password := r.FormValue("password") 106 | id := r.FormValue("id") 107 | err = l.authenticate.CheckUsernamePassword(r.Context(), username, password, id) 108 | if err != nil { 109 | renderLogin(w, id, err) 110 | return 111 | } 112 | http.Redirect(w, r, l.callback(r.Context(), id), http.StatusFound) 113 | } 114 | -------------------------------------------------------------------------------- /example/server/exampleop/login.go: -------------------------------------------------------------------------------- 1 | package exampleop 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/zitadel/oidc/v3/pkg/op" 10 | ) 11 | 12 | type login struct { 13 | authenticate authenticate 14 | router chi.Router 15 | callback func(context.Context, string) string 16 | } 17 | 18 | func NewLogin(authenticate authenticate, callback func(context.Context, string) string, issuerInterceptor *op.IssuerInterceptor) *login { 19 | l := &login{ 20 | authenticate: authenticate, 21 | callback: callback, 22 | } 23 | l.createRouter(issuerInterceptor) 24 | return l 25 | } 26 | 27 | func (l *login) createRouter(issuerInterceptor *op.IssuerInterceptor) { 28 | l.router = chi.NewRouter() 29 | l.router.Get("/username", l.loginHandler) 30 | l.router.Post("/username", issuerInterceptor.HandlerFunc(l.checkLoginHandler)) 31 | } 32 | 33 | type authenticate interface { 34 | CheckUsernamePassword(username, password, id string) error 35 | } 36 | 37 | func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) { 38 | err := r.ParseForm() 39 | if err != nil { 40 | http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError) 41 | return 42 | } 43 | // the oidc package will pass the id of the auth request as query parameter 44 | // we will use this id through the login process and therefore pass it to the login page 45 | renderLogin(w, r.FormValue(queryAuthRequestID), nil) 46 | } 47 | 48 | func renderLogin(w http.ResponseWriter, id string, err error) { 49 | data := &struct { 50 | ID string 51 | Error string 52 | }{ 53 | ID: id, 54 | Error: errMsg(err), 55 | } 56 | err = templates.ExecuteTemplate(w, "login", data) 57 | if err != nil { 58 | http.Error(w, err.Error(), http.StatusInternalServerError) 59 | } 60 | } 61 | 62 | func (l *login) checkLoginHandler(w http.ResponseWriter, r *http.Request) { 63 | err := r.ParseForm() 64 | if err != nil { 65 | http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError) 66 | return 67 | } 68 | username := r.FormValue("username") 69 | password := r.FormValue("password") 70 | id := r.FormValue("id") 71 | err = l.authenticate.CheckUsernamePassword(username, password, id) 72 | if err != nil { 73 | renderLogin(w, id, err) 74 | return 75 | } 76 | http.Redirect(w, r, l.callback(r.Context(), id), http.StatusFound) 77 | } 78 | -------------------------------------------------------------------------------- /example/server/exampleop/templates.go: -------------------------------------------------------------------------------- 1 | package exampleop 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | var ( 11 | //go:embed templates 12 | templateFS embed.FS 13 | templates = template.Must(template.ParseFS(templateFS, "templates/*.html")) 14 | ) 15 | 16 | const ( 17 | queryAuthRequestID = "authRequestID" 18 | ) 19 | 20 | func errMsg(err error) string { 21 | if err == nil { 22 | return "" 23 | } 24 | logrus.Error(err) 25 | return err.Error() 26 | } 27 | -------------------------------------------------------------------------------- /example/server/exampleop/templates/confirm_device.html: -------------------------------------------------------------------------------- 1 | {{ define "confirm_device" -}} 2 | 3 | 4 | 5 | 6 | Confirm device authorization 7 | 15 | 16 | 17 |

Welcome back {{.Username}}!

18 |

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 | Login 7 | 8 | 9 |
10 | 11 | 12 | 13 |
14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 |
22 | 23 |

{{.Error}}

24 | 25 | 26 |
27 | 28 | 29 | {{- end }} 30 | -------------------------------------------------------------------------------- /example/server/exampleop/templates/login.html: -------------------------------------------------------------------------------- 1 | {{ define "login" -}} 2 | 3 | 4 | 5 | 6 | Login 7 | 8 | 9 |
10 | 11 | 12 | 13 |
14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 |
22 | 23 |

{{.Error}}

24 | 25 | 26 |
27 | 28 | 29 | {{- end }} 30 | -------------------------------------------------------------------------------- /example/server/exampleop/templates/usercode.html: -------------------------------------------------------------------------------- 1 | {{ define "usercode" -}} 2 | 3 | 4 | 5 | 6 | Device authorization 7 | 8 | 9 |
10 |

Device authorization

11 |
12 | 13 | 14 |
15 |

{{.Error}}

16 | 17 | 18 |
19 | 20 | 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /example/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/zitadel/oidc/v3/example/server/config" 10 | "github.com/zitadel/oidc/v3/example/server/exampleop" 11 | "github.com/zitadel/oidc/v3/example/server/storage" 12 | ) 13 | 14 | func getUserStore(cfg *config.Config) (storage.UserStore, error) { 15 | if cfg.UsersFile == "" { 16 | return storage.NewUserStore(fmt.Sprintf("http://localhost:%s/", cfg.Port)), nil 17 | } 18 | return storage.StoreFromFile(cfg.UsersFile) 19 | } 20 | 21 | func main() { 22 | cfg := config.FromEnvVars(&config.Config{Port: "9998"}) 23 | logger := slog.New( 24 | slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 25 | AddSource: true, 26 | Level: slog.LevelDebug, 27 | }), 28 | ) 29 | 30 | //which gives us the issuer: http://localhost:9998/ 31 | issuer := fmt.Sprintf("http://localhost:%s/", cfg.Port) 32 | 33 | storage.RegisterClients( 34 | storage.NativeClient("native", cfg.RedirectURI...), 35 | storage.WebClient("web", "secret", cfg.RedirectURI...), 36 | storage.WebClient("api", "secret", cfg.RedirectURI...), 37 | ) 38 | 39 | // the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations 40 | // this might be the layer for accessing your database 41 | // in this example it will be handled in-memory 42 | store, err := getUserStore(cfg) 43 | if err != nil { 44 | logger.Error("cannot create UserStore", "error", err) 45 | os.Exit(1) 46 | } 47 | storage := storage.NewStorage(store) 48 | router := exampleop.SetupServer(issuer, storage, logger, false) 49 | 50 | server := &http.Server{ 51 | Addr: ":" + cfg.Port, 52 | Handler: router, 53 | } 54 | logger.Info("server listening, press ctrl+c to stop", "addr", issuer) 55 | if server.ListenAndServe() != http.ErrServerClosed { 56 | logger.Error("server terminated", "error", err) 57 | os.Exit(1) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /example/server/service-key1.json: -------------------------------------------------------------------------------- 1 | {"type":"serviceaccount","keyId":"key1","key":"-----BEGIN RSA PRIVATE KEY-----\nMIICXgIBAAKBgQD21E+180rCAzp15zy2X/JOYYHtxYhF51pWCsITeChJd7sFWxp1\ntxSHTiomQYBiBWgcCavsdu/VLPQJhO3PTIyglxc1XRGsM48oDT5MkFsAVDvbjuWk\nF0lstQyw4pr8Wg0Ecf1aL6YlvVKB9h5rAgZ9T+elNJ7q5takMAvNhu7zMQIDAQAB\nAoGAeLRw2qjEaUZM43WWchVPmFcEw/MyZgTyX1tZd03uXacolUDtGp3ScyydXiHw\nF39PX063fabYOCaInNMdvJ9RsQz2OcZuS/K6NOmWhzBfLgs4Y1tU6ijoY/gBjHgu\nCV0KjvoWIfEtKl/On/wTrAnUStFzrc7U4dpKFP1fy2ZTTnECQQD8aP2QOxmKUyfg\nBAjfonpkrNeaTRNwTULTvEHFiLyaeFd1PAvsDiKZtpk6iHLb99mQZkVVtAK5qgQ4\n1OI72jkVAkEA+lcAamuZAM+gIiUhbHA7BfX9OVgyGDD2tx5g/kxhMUmK6hIiO6Ul\n0nw5KfrCEUU3AzrM7HejUg3q61SYcXTgrQJBALhrzbhwNf0HPP9Ec2dSw7KDRxSK\ndEV9bfJefn/hpEwI2X3i3aMfwNAmxlYqFCH8OY5z6vzvhX46ZtNPV+z7SPECQQDq\nApXi5P27YlpgULEzup2R7uZsymLZdjvJ5V3pmOBpwENYlublNnVqkrCk60CqADdy\nj26rxRIoS9ZDcWqm9AhpAkEAyrNXBMJh08ghBMb3NYPFfr/bftRJSrGjhBPuJ5qr\nXzWaXhYVMMh3OSAwzHBJbA1ffdQJuH2ebL99Ur5fpBcbVw==\n-----END RSA PRIVATE KEY-----\n","userId":"service"} 2 | -------------------------------------------------------------------------------- /example/server/storage/token.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "time" 4 | 5 | type Token struct { 6 | ID string 7 | ApplicationID string 8 | Subject string 9 | RefreshTokenID string 10 | Audience []string 11 | Expiration time.Time 12 | Scopes []string 13 | } 14 | 15 | type RefreshToken struct { 16 | ID string 17 | Token string 18 | AuthTime time.Time 19 | AMR []string 20 | Audience []string 21 | UserID string 22 | ApplicationID string 23 | Expiration time.Time 24 | Scopes []string 25 | AccessToken string // Token.ID 26 | } 27 | -------------------------------------------------------------------------------- /example/server/storage/user.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "crypto/rsa" 5 | "encoding/json" 6 | "os" 7 | "strings" 8 | 9 | "golang.org/x/text/language" 10 | ) 11 | 12 | type User struct { 13 | ID string 14 | Username string 15 | Password string 16 | FirstName string 17 | LastName string 18 | Email string 19 | EmailVerified bool 20 | Phone string 21 | PhoneVerified bool 22 | PreferredLanguage language.Tag 23 | IsAdmin bool 24 | } 25 | 26 | type Service struct { 27 | keys map[string]*rsa.PublicKey 28 | } 29 | 30 | type UserStore interface { 31 | GetUserByID(string) *User 32 | GetUserByUsername(string) *User 33 | ExampleClientID() string 34 | } 35 | 36 | type userStore struct { 37 | users map[string]*User 38 | } 39 | 40 | func StoreFromFile(path string) (UserStore, error) { 41 | users := map[string]*User{} 42 | data, err := os.ReadFile(path) 43 | if err != nil { 44 | return nil, err 45 | } 46 | if err := json.Unmarshal(data, &users); err != nil { 47 | return nil, err 48 | } 49 | return userStore{users}, nil 50 | } 51 | 52 | func NewUserStore(issuer string) UserStore { 53 | hostname := strings.Split(strings.Split(issuer, "://")[1], ":")[0] 54 | return userStore{ 55 | users: map[string]*User{ 56 | "id1": { 57 | ID: "id1", 58 | Username: "test-user@" + hostname, 59 | Password: "verysecure", 60 | FirstName: "Test", 61 | LastName: "User", 62 | Email: "test-user@zitadel.ch", 63 | EmailVerified: true, 64 | Phone: "", 65 | PhoneVerified: false, 66 | PreferredLanguage: language.German, 67 | IsAdmin: true, 68 | }, 69 | "id2": { 70 | ID: "id2", 71 | Username: "test-user2", 72 | Password: "verysecure", 73 | FirstName: "Test", 74 | LastName: "User2", 75 | Email: "test-user2@zitadel.ch", 76 | EmailVerified: true, 77 | Phone: "", 78 | PhoneVerified: false, 79 | PreferredLanguage: language.German, 80 | IsAdmin: false, 81 | }, 82 | }, 83 | } 84 | } 85 | 86 | // ExampleClientID is only used in the example server 87 | func (u userStore) ExampleClientID() string { 88 | return "service" 89 | } 90 | 91 | func (u userStore) GetUserByID(id string) *User { 92 | return u.users[id] 93 | } 94 | 95 | func (u userStore) GetUserByUsername(username string) *User { 96 | for _, user := range u.users { 97 | if user.Username == username { 98 | return user 99 | } 100 | } 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /example/server/storage/user_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "reflect" 7 | "testing" 8 | 9 | "golang.org/x/text/language" 10 | ) 11 | 12 | func TestStoreFromFile(t *testing.T) { 13 | for _, tc := range []struct { 14 | name string 15 | pathToFile string 16 | content string 17 | want UserStore 18 | wantErr bool 19 | }{ 20 | { 21 | name: "normal user file", 22 | pathToFile: "userfile.json", 23 | content: `{ 24 | "id1": { 25 | "ID": "id1", 26 | "EmailVerified": true, 27 | "PreferredLanguage": "DE" 28 | } 29 | }`, 30 | want: userStore{map[string]*User{ 31 | "id1": { 32 | ID: "id1", 33 | EmailVerified: true, 34 | PreferredLanguage: language.German, 35 | }, 36 | }}, 37 | }, 38 | { 39 | name: "malformed file", 40 | pathToFile: "whatever", 41 | content: "not a json just a text", 42 | wantErr: true, 43 | }, 44 | { 45 | name: "not existing file", 46 | pathToFile: "what/ever/file", 47 | wantErr: true, 48 | }, 49 | } { 50 | t.Run(tc.name, func(t *testing.T) { 51 | actualPath := path.Join(t.TempDir(), tc.pathToFile) 52 | 53 | if tc.content != "" && tc.pathToFile != "" { 54 | if err := os.WriteFile(actualPath, []byte(tc.content), 0666); err != nil { 55 | t.Fatalf("cannot create file with test content: %q", tc.content) 56 | } 57 | } 58 | result, err := StoreFromFile(actualPath) 59 | if err != nil && !tc.wantErr { 60 | t.Errorf("StoreFromFile(%q) returned unexpected error %q", tc.pathToFile, err) 61 | } else if err == nil && tc.wantErr { 62 | t.Errorf("StoreFromFile(%q) did not return an expected error", tc.pathToFile) 63 | } 64 | if !tc.wantErr && !reflect.DeepEqual(tc.want, result.(userStore)) { 65 | t.Errorf("expected StoreFromFile(%q) = %v, but got %v", 66 | tc.pathToFile, tc.want, result) 67 | } 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zitadel/oidc/v3 2 | 3 | go 1.23.7 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/bmatcuk/doublestar/v4 v4.8.1 9 | github.com/go-chi/chi/v5 v5.2.1 10 | github.com/go-jose/go-jose/v4 v4.0.5 11 | github.com/golang/mock v1.6.0 12 | github.com/google/go-github/v31 v31.0.0 13 | github.com/google/uuid v1.6.0 14 | github.com/gorilla/securecookie v1.1.2 15 | github.com/jeremija/gosubmit v0.2.8 16 | github.com/muhlemmer/gu v0.3.1 17 | github.com/muhlemmer/httpforwarded v0.1.0 18 | github.com/rs/cors v1.11.1 19 | github.com/sirupsen/logrus v1.9.3 20 | github.com/stretchr/testify v1.10.0 21 | github.com/zitadel/logging v0.6.2 22 | github.com/zitadel/schema v1.3.1 23 | go.opentelemetry.io/otel v1.29.0 24 | golang.org/x/oauth2 v0.30.0 25 | golang.org/x/text v0.25.0 26 | ) 27 | 28 | require ( 29 | github.com/davecgh/go-spew v1.1.1 // indirect 30 | github.com/go-logr/logr v1.4.2 // indirect 31 | github.com/go-logr/stdr v1.2.2 // indirect 32 | github.com/google/go-querystring v1.1.0 // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | go.opentelemetry.io/otel/metric v1.29.0 // indirect 35 | go.opentelemetry.io/otel/trace v1.29.0 // indirect 36 | golang.org/x/crypto v0.36.0 // indirect 37 | golang.org/x/net v0.38.0 // indirect 38 | golang.org/x/sys v0.31.0 // indirect 39 | gopkg.in/yaml.v3 v3.0.1 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /internal/testutil/gen/gen.go: -------------------------------------------------------------------------------- 1 | // Package gen allows generating of example tokens and claims. 2 | // 3 | // go run ./internal/testutil/gen 4 | package main 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "os" 10 | 11 | tu "github.com/zitadel/oidc/v3/internal/testutil" 12 | "github.com/zitadel/oidc/v3/pkg/oidc" 13 | ) 14 | 15 | var custom = map[string]any{ 16 | "foo": "Hello, World!", 17 | "bar": struct { 18 | Count int `json:"count,omitempty"` 19 | Tags []string `json:"tags,omitempty"` 20 | }{ 21 | Count: 22, 22 | Tags: []string{"some", "tags"}, 23 | }, 24 | } 25 | 26 | func main() { 27 | enc := json.NewEncoder(os.Stdout) 28 | enc.SetIndent("", " ") 29 | 30 | accessToken, atClaims := tu.NewAccessTokenCustom( 31 | tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, 32 | tu.ValidExpiration.AddDate(99, 0, 0), tu.ValidJWTID, 33 | tu.ValidClientID, tu.ValidSkew, custom, 34 | ) 35 | atHash, err := oidc.ClaimHash(accessToken, tu.SignatureAlgorithm) 36 | if err != nil { 37 | panic(err) 38 | } 39 | 40 | idToken, idClaims := tu.NewIDTokenCustom( 41 | tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, 42 | tu.ValidExpiration.AddDate(99, 0, 0), tu.ValidAuthTime, 43 | tu.ValidNonce, tu.ValidACR, tu.ValidAMR, tu.ValidClientID, 44 | tu.ValidSkew, atHash, custom, 45 | ) 46 | 47 | fmt.Println("access token claims:") 48 | if err := enc.Encode(atClaims); err != nil { 49 | panic(err) 50 | } 51 | fmt.Printf("access token:\n%s\n", accessToken) 52 | 53 | fmt.Println("ID token claims:") 54 | if err := enc.Encode(idClaims); err != nil { 55 | panic(err) 56 | } 57 | fmt.Printf("ID token:\n%s\n", idToken) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/zitadel/oidc/v3/pkg/oidc" 11 | ) 12 | 13 | func TestDiscover(t *testing.T) { 14 | type wantFields struct { 15 | UILocalesSupported bool 16 | } 17 | 18 | type args struct { 19 | issuer string 20 | wellKnownUrl []string 21 | } 22 | tests := []struct { 23 | name string 24 | args args 25 | wantFields *wantFields 26 | wantErr error 27 | }{ 28 | { 29 | name: "spotify", // https://github.com/zitadel/oidc/issues/406 30 | args: args{ 31 | issuer: "https://accounts.spotify.com", 32 | }, 33 | wantFields: &wantFields{ 34 | UILocalesSupported: true, 35 | }, 36 | wantErr: nil, 37 | }, 38 | { 39 | name: "discovery failed", 40 | args: args{ 41 | issuer: "https://example.com", 42 | }, 43 | wantErr: oidc.ErrDiscoveryFailed, 44 | }, 45 | } 46 | for _, tt := range tests { 47 | t.Run(tt.name, func(t *testing.T) { 48 | got, err := Discover(context.Background(), tt.args.issuer, http.DefaultClient, tt.args.wellKnownUrl...) 49 | require.ErrorIs(t, err, tt.wantErr) 50 | if tt.wantFields == nil { 51 | return 52 | } 53 | assert.Equal(t, tt.args.issuer, got.Issuer) 54 | if tt.wantFields.UILocalesSupported { 55 | assert.NotEmpty(t, got.UILocalesSupported) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/client/errors.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "errors" 4 | 5 | var ErrEndpointNotSet = errors.New("endpoint not set") 6 | -------------------------------------------------------------------------------- /pkg/client/jwt_profile.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | 7 | "golang.org/x/oauth2" 8 | 9 | "github.com/zitadel/oidc/v3/pkg/http" 10 | "github.com/zitadel/oidc/v3/pkg/oidc" 11 | ) 12 | 13 | // JWTProfileExchange handles the oauth2 jwt profile exchange 14 | func JWTProfileExchange(ctx context.Context, jwtProfileGrantRequest *oidc.JWTProfileGrantRequest, caller TokenEndpointCaller) (*oauth2.Token, error) { 15 | return CallTokenEndpoint(ctx, jwtProfileGrantRequest, caller) 16 | } 17 | 18 | func ClientAssertionCodeOptions(assertion string) []oauth2.AuthCodeOption { 19 | return []oauth2.AuthCodeOption{ 20 | oauth2.SetAuthURLParam("client_assertion", assertion), 21 | oauth2.SetAuthURLParam("client_assertion_type", oidc.ClientAssertionTypeJWTAssertion), 22 | } 23 | } 24 | 25 | func ClientAssertionFormAuthorization(assertion string) http.FormAuthorization { 26 | return func(values url.Values) { 27 | values.Set("client_assertion", assertion) 28 | values.Set("client_assertion_type", oidc.ClientAssertionTypeJWTAssertion) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/client/key.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | ) 7 | 8 | const ( 9 | serviceAccountKey = "serviceaccount" 10 | applicationKey = "application" 11 | ) 12 | 13 | type KeyFile struct { 14 | Type string `json:"type"` // serviceaccount or application 15 | KeyID string `json:"keyId"` 16 | Key string `json:"key"` 17 | Issuer string `json:"issuer"` // not yet in file 18 | 19 | // serviceaccount 20 | UserID string `json:"userId"` 21 | 22 | // application 23 | ClientID string `json:"clientId"` 24 | } 25 | 26 | func ConfigFromKeyFile(path string) (*KeyFile, error) { 27 | data, err := os.ReadFile(path) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return ConfigFromKeyFileData(data) 32 | } 33 | 34 | func ConfigFromKeyFileData(data []byte) (*KeyFile, error) { 35 | var f KeyFile 36 | if err := json.Unmarshal(data, &f); err != nil { 37 | return nil, err 38 | } 39 | return &f, nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/client/profile/jwt_profile.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | jose "github.com/go-jose/go-jose/v4" 9 | "golang.org/x/oauth2" 10 | 11 | "github.com/zitadel/oidc/v3/pkg/client" 12 | "github.com/zitadel/oidc/v3/pkg/oidc" 13 | ) 14 | 15 | type TokenSource interface { 16 | oauth2.TokenSource 17 | TokenCtx(context.Context) (*oauth2.Token, error) 18 | } 19 | 20 | // jwtProfileTokenSource implement the oauth2.TokenSource 21 | // it will request a token using the OAuth2 JWT Profile Grant 22 | // therefore sending an `assertion` by signing a JWT with the provided private key 23 | type jwtProfileTokenSource struct { 24 | clientID string 25 | audience []string 26 | signer jose.Signer 27 | scopes []string 28 | httpClient *http.Client 29 | tokenEndpoint string 30 | } 31 | 32 | // NewJWTProfileTokenSourceFromKeyFile returns an implementation of TokenSource 33 | // It will request a token using the OAuth2 JWT Profile Grant, 34 | // therefore sending an `assertion` by singing a JWT with the provided private key from jsonFile. 35 | // 36 | // The passed context is only used for the call to the Discover endpoint. 37 | func NewJWTProfileTokenSourceFromKeyFile(ctx context.Context, issuer, jsonFile string, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) { 38 | keyData, err := client.ConfigFromKeyFile(jsonFile) 39 | if err != nil { 40 | return nil, err 41 | } 42 | return NewJWTProfileTokenSource(ctx, issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...) 43 | } 44 | 45 | // NewJWTProfileTokenSourceFromKeyFileData returns an implementation of oauth2.TokenSource 46 | // It will request a token using the OAuth2 JWT Profile Grant, 47 | // therefore sending an `assertion` by singing a JWT with the provided private key in jsonData. 48 | // 49 | // The passed context is only used for the call to the Discover endpoint. 50 | func NewJWTProfileTokenSourceFromKeyFileData(ctx context.Context, issuer string, jsonData []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) { 51 | keyData, err := client.ConfigFromKeyFileData(jsonData) 52 | if err != nil { 53 | return nil, err 54 | } 55 | return NewJWTProfileTokenSource(ctx, issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...) 56 | } 57 | 58 | // NewJWTProfileSource returns an implementation of oauth2.TokenSource 59 | // It will request a token using the OAuth2 JWT Profile Grant, 60 | // therefore sending an `assertion` by singing a JWT with the provided private key. 61 | // 62 | // The passed context is only used for the call to the Discover endpoint. 63 | func NewJWTProfileTokenSource(ctx context.Context, issuer, clientID, keyID string, key []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) { 64 | signer, err := client.NewSignerFromPrivateKeyByte(key, keyID) 65 | if err != nil { 66 | return nil, err 67 | } 68 | source := &jwtProfileTokenSource{ 69 | clientID: clientID, 70 | audience: []string{issuer}, 71 | signer: signer, 72 | scopes: scopes, 73 | httpClient: http.DefaultClient, 74 | } 75 | for _, opt := range options { 76 | opt(source) 77 | } 78 | if source.tokenEndpoint == "" { 79 | config, err := client.Discover(ctx, issuer, source.httpClient) 80 | if err != nil { 81 | return nil, err 82 | } 83 | source.tokenEndpoint = config.TokenEndpoint 84 | } 85 | return source, nil 86 | } 87 | 88 | func WithHTTPClient(client *http.Client) func(source *jwtProfileTokenSource) { 89 | return func(source *jwtProfileTokenSource) { 90 | source.httpClient = client 91 | } 92 | } 93 | 94 | func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(source *jwtProfileTokenSource) { 95 | return func(source *jwtProfileTokenSource) { 96 | source.tokenEndpoint = tokenEndpoint 97 | } 98 | } 99 | 100 | func (j *jwtProfileTokenSource) TokenEndpoint() string { 101 | return j.tokenEndpoint 102 | } 103 | 104 | func (j *jwtProfileTokenSource) HttpClient() *http.Client { 105 | return j.httpClient 106 | } 107 | 108 | func (j *jwtProfileTokenSource) Token() (*oauth2.Token, error) { 109 | return j.TokenCtx(context.Background()) 110 | } 111 | 112 | func (j *jwtProfileTokenSource) TokenCtx(ctx context.Context) (*oauth2.Token, error) { 113 | assertion, err := client.SignedJWTProfileAssertion(j.clientID, j.audience, time.Hour, j.signer) 114 | if err != nil { 115 | return nil, err 116 | } 117 | return client.JWTProfileExchange(ctx, oidc.NewJWTProfileGrantRequest(assertion, j.scopes...), j) 118 | } 119 | -------------------------------------------------------------------------------- /pkg/client/rp/cli/browser.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os/exec" 7 | "runtime" 8 | ) 9 | 10 | func OpenBrowser(url string) { 11 | var err error 12 | 13 | switch runtime.GOOS { 14 | case "linux": 15 | err = exec.Command("xdg-open", url).Start() 16 | case "windows": 17 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() 18 | case "darwin": 19 | err = exec.Command("open", url).Start() 20 | default: 21 | err = fmt.Errorf("unsupported platform") 22 | } 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/client/rp/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/zitadel/oidc/v3/pkg/client/rp" 8 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 9 | "github.com/zitadel/oidc/v3/pkg/oidc" 10 | ) 11 | 12 | const ( 13 | loginPath = "/login" 14 | ) 15 | 16 | func CodeFlow[C oidc.IDClaims](ctx context.Context, relyingParty rp.RelyingParty, callbackPath, port string, stateProvider func() string) *oidc.Tokens[C] { 17 | codeflowCtx, codeflowCancel := context.WithCancel(ctx) 18 | defer codeflowCancel() 19 | 20 | tokenChan := make(chan *oidc.Tokens[C], 1) 21 | 22 | callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, rp rp.RelyingParty) { 23 | tokenChan <- tokens 24 | msg := "

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/.json 20 | func jsonFilename(obj any) string { 21 | name := fmt.Sprintf("%T.json", obj) 22 | return path.Join( 23 | dataDir, 24 | strings.TrimPrefix(name, "*"), 25 | ) 26 | } 27 | 28 | func encodeJSON(t *testing.T, w io.Writer, obj any) { 29 | enc := json.NewEncoder(w) 30 | enc.SetIndent("", "\t") 31 | require.NoError(t, enc.Encode(obj)) 32 | } 33 | 34 | var regressionData = []any{ 35 | accessTokenData, 36 | idTokenData, 37 | introspectionResponseData, 38 | userInfoData, 39 | jwtProfileAssertionData, 40 | } 41 | -------------------------------------------------------------------------------- /pkg/oidc/revocation.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | type RevocationRequest struct { 4 | Token string `schema:"token"` 5 | TokenTypeHint string `schema:"token_type_hint"` 6 | } 7 | -------------------------------------------------------------------------------- /pkg/oidc/session.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | // EndSessionRequest for the RP-Initiated Logout according to: 4 | // https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout 5 | type EndSessionRequest struct { 6 | IdTokenHint string `schema:"id_token_hint"` 7 | LogoutHint string `schema:"logout_hint"` 8 | ClientID string `schema:"client_id"` 9 | PostLogoutRedirectURI string `schema:"post_logout_redirect_uri"` 10 | State string `schema:"state"` 11 | UILocales Locales `schema:"ui_locales"` 12 | } 13 | -------------------------------------------------------------------------------- /pkg/oidc/userinfo.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | // UserInfo implements OpenID Connect Core 1.0, section 5.1. 4 | // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims. 5 | type UserInfo struct { 6 | Subject string `json:"sub,omitempty"` 7 | UserInfoProfile 8 | UserInfoEmail 9 | UserInfoPhone 10 | Address *UserInfoAddress `json:"address,omitempty"` 11 | 12 | Claims map[string]any `json:"-"` 13 | } 14 | 15 | func (u *UserInfo) AppendClaims(k string, v any) { 16 | if u.Claims == nil { 17 | u.Claims = make(map[string]any) 18 | } 19 | 20 | u.Claims[k] = v 21 | } 22 | 23 | // GetAddress is a safe getter that takes 24 | // care of a possible nil value. 25 | func (u *UserInfo) GetAddress() *UserInfoAddress { 26 | if u.Address == nil { 27 | return new(UserInfoAddress) 28 | } 29 | return u.Address 30 | } 31 | 32 | // GetSubject implements [rp.SubjectGetter] 33 | func (u *UserInfo) GetSubject() string { 34 | return u.Subject 35 | } 36 | 37 | type uiAlias UserInfo 38 | 39 | func (u *UserInfo) MarshalJSON() ([]byte, error) { 40 | return mergeAndMarshalClaims((*uiAlias)(u), u.Claims) 41 | } 42 | 43 | func (u *UserInfo) UnmarshalJSON(data []byte) error { 44 | return unmarshalJSONMulti(data, (*uiAlias)(u), &u.Claims) 45 | } 46 | 47 | type UserInfoProfile struct { 48 | Name string `json:"name,omitempty"` 49 | GivenName string `json:"given_name,omitempty"` 50 | FamilyName string `json:"family_name,omitempty"` 51 | MiddleName string `json:"middle_name,omitempty"` 52 | Nickname string `json:"nickname,omitempty"` 53 | Profile string `json:"profile,omitempty"` 54 | Picture string `json:"picture,omitempty"` 55 | Website string `json:"website,omitempty"` 56 | Gender Gender `json:"gender,omitempty"` 57 | Birthdate string `json:"birthdate,omitempty"` 58 | Zoneinfo string `json:"zoneinfo,omitempty"` 59 | Locale *Locale `json:"locale,omitempty"` 60 | UpdatedAt Time `json:"updated_at,omitempty"` 61 | PreferredUsername string `json:"preferred_username,omitempty"` 62 | } 63 | 64 | type UserInfoEmail struct { 65 | Email string `json:"email,omitempty"` 66 | 67 | // Handle providers that return email_verified as a string 68 | // https://forums.aws.amazon.com/thread.jspa?messageID=949441󧳁 69 | // https://discuss.elastic.co/t/openid-error-after-authenticating-against-aws-cognito/206018/11 70 | EmailVerified Bool `json:"email_verified,omitempty"` 71 | } 72 | 73 | type Bool bool 74 | 75 | func (bs *Bool) UnmarshalJSON(data []byte) error { 76 | if string(data) == "true" || string(data) == `"true"` { 77 | *bs = true 78 | } 79 | 80 | return nil 81 | } 82 | 83 | type UserInfoPhone struct { 84 | PhoneNumber string `json:"phone_number,omitempty"` 85 | PhoneNumberVerified bool `json:"phone_number_verified,omitempty"` 86 | } 87 | 88 | type UserInfoAddress struct { 89 | Formatted string `json:"formatted,omitempty"` 90 | StreetAddress string `json:"street_address,omitempty"` 91 | Locality string `json:"locality,omitempty"` 92 | Region string `json:"region,omitempty"` 93 | PostalCode string `json:"postal_code,omitempty"` 94 | Country string `json:"country,omitempty"` 95 | } 96 | 97 | type UserInfoRequest struct { 98 | AccessToken string `schema:"access_token"` 99 | } 100 | -------------------------------------------------------------------------------- /pkg/oidc/userinfo_test.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestUserInfo_AppendClaims(t *testing.T) { 11 | u := new(UserInfo) 12 | u.AppendClaims("a", "b") 13 | want := map[string]any{"a": "b"} 14 | assert.Equal(t, want, u.Claims) 15 | 16 | u.AppendClaims("d", "e") 17 | want["d"] = "e" 18 | assert.Equal(t, want, u.Claims) 19 | } 20 | 21 | func TestUserInfo_GetAddress(t *testing.T) { 22 | // nil address 23 | u := new(UserInfo) 24 | assert.Equal(t, &UserInfoAddress{}, u.GetAddress()) 25 | 26 | u.Address = &UserInfoAddress{PostalCode: "1234"} 27 | assert.Equal(t, u.Address, u.GetAddress()) 28 | } 29 | 30 | func TestUserInfoMarshal(t *testing.T) { 31 | userinfo := &UserInfo{ 32 | Subject: "test", 33 | Address: &UserInfoAddress{ 34 | StreetAddress: "Test 789\nPostfach 2", 35 | }, 36 | UserInfoEmail: UserInfoEmail{ 37 | Email: "test", 38 | EmailVerified: true, 39 | }, 40 | UserInfoPhone: UserInfoPhone{ 41 | PhoneNumber: "0791234567", 42 | PhoneNumberVerified: true, 43 | }, 44 | UserInfoProfile: UserInfoProfile{ 45 | Name: "Test", 46 | }, 47 | Claims: map[string]any{"private_claim": "test"}, 48 | } 49 | 50 | marshal, err := json.Marshal(userinfo) 51 | assert.NoError(t, err) 52 | 53 | out := new(UserInfo) 54 | assert.NoError(t, json.Unmarshal(marshal, out)) 55 | expected, err := json.Marshal(out) 56 | 57 | assert.NoError(t, err) 58 | assert.Equal(t, expected, marshal) 59 | 60 | out2 := new(UserInfo) 61 | assert.NoError(t, json.Unmarshal(expected, out2)) 62 | assert.Equal(t, out, out2) 63 | } 64 | 65 | func TestUserInfoEmailVerifiedUnmarshal(t *testing.T) { 66 | t.Parallel() 67 | 68 | t.Run("unmarshal email_verified from json bool true", func(t *testing.T) { 69 | jsonBool := []byte(`{"email": "my@email.com", "email_verified": true}`) 70 | 71 | var uie UserInfoEmail 72 | 73 | err := json.Unmarshal(jsonBool, &uie) 74 | assert.NoError(t, err) 75 | assert.Equal(t, UserInfoEmail{ 76 | Email: "my@email.com", 77 | EmailVerified: true, 78 | }, uie) 79 | }) 80 | 81 | t.Run("unmarshal email_verified from json string true", func(t *testing.T) { 82 | jsonBool := []byte(`{"email": "my@email.com", "email_verified": "true"}`) 83 | 84 | var uie UserInfoEmail 85 | 86 | err := json.Unmarshal(jsonBool, &uie) 87 | assert.NoError(t, err) 88 | assert.Equal(t, UserInfoEmail{ 89 | Email: "my@email.com", 90 | EmailVerified: true, 91 | }, uie) 92 | }) 93 | 94 | t.Run("unmarshal email_verified from json bool false", func(t *testing.T) { 95 | jsonBool := []byte(`{"email": "my@email.com", "email_verified": false}`) 96 | 97 | var uie UserInfoEmail 98 | 99 | err := json.Unmarshal(jsonBool, &uie) 100 | assert.NoError(t, err) 101 | assert.Equal(t, UserInfoEmail{ 102 | Email: "my@email.com", 103 | EmailVerified: false, 104 | }, uie) 105 | }) 106 | 107 | t.Run("unmarshal email_verified from json string false", func(t *testing.T) { 108 | jsonBool := []byte(`{"email": "my@email.com", "email_verified": "false"}`) 109 | 110 | var uie UserInfoEmail 111 | 112 | err := json.Unmarshal(jsonBool, &uie) 113 | assert.NoError(t, err) 114 | assert.Equal(t, UserInfoEmail{ 115 | Email: "my@email.com", 116 | EmailVerified: false, 117 | }, uie) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /pkg/oidc/util.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | // mergeAndMarshalClaims merges registered and the custom 10 | // claims map into a single JSON object. 11 | // Registered fields overwrite custom claims. 12 | func mergeAndMarshalClaims(registered any, extraClaims map[string]any) ([]byte, error) { 13 | // Use a buffer for memory re-use, instead off letting 14 | // json allocate a new []byte for every step. 15 | buf := new(bytes.Buffer) 16 | 17 | // Marshal the registered claims into JSON 18 | if err := json.NewEncoder(buf).Encode(registered); err != nil { 19 | return nil, fmt.Errorf("oidc registered claims: %w", err) 20 | } 21 | 22 | if len(extraClaims) > 0 { 23 | merged := make(map[string]any) 24 | for k, v := range extraClaims { 25 | merged[k] = v 26 | } 27 | 28 | // Merge JSON data into custom claims. 29 | // The full-read action by the decoder resets the buffer 30 | // to zero len, while retaining underlaying cap. 31 | if err := json.NewDecoder(buf).Decode(&merged); err != nil { 32 | return nil, fmt.Errorf("oidc registered claims: %w", err) 33 | } 34 | 35 | // Marshal the final result. 36 | if err := json.NewEncoder(buf).Encode(merged); err != nil { 37 | return nil, fmt.Errorf("oidc custom claims: %w", err) 38 | } 39 | } 40 | 41 | return buf.Bytes(), nil 42 | } 43 | 44 | // unmarshalJSONMulti unmarshals the same JSON data into multiple destinations. 45 | // Each destination must be a pointer, as per json.Unmarshal rules. 46 | // Returns on the first error and destinations may be partly filled with data. 47 | func unmarshalJSONMulti(data []byte, destinations ...any) error { 48 | for _, dst := range destinations { 49 | if err := json.Unmarshal(data, dst); err != nil { 50 | return fmt.Errorf("oidc: %w into %T", err, dst) 51 | } 52 | } 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/oidc/util_test.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | type jsonErrorTest struct{} 12 | 13 | func (jsonErrorTest) MarshalJSON() ([]byte, error) { 14 | return nil, errors.New("test") 15 | } 16 | 17 | func Test_mergeAndMarshalClaims(t *testing.T) { 18 | type args struct { 19 | registered any 20 | claims map[string]any 21 | } 22 | tests := []struct { 23 | name string 24 | args args 25 | want string 26 | wantErr bool 27 | }{ 28 | { 29 | name: "encoder error", 30 | args: args{ 31 | registered: jsonErrorTest{}, 32 | }, 33 | wantErr: true, 34 | }, 35 | { 36 | name: "no claims", 37 | args: args{ 38 | registered: struct { 39 | Foo string `json:"foo,omitempty"` 40 | }{ 41 | Foo: "bar", 42 | }, 43 | }, 44 | want: "{\"foo\":\"bar\"}\n", 45 | }, 46 | { 47 | name: "with claims", 48 | args: args{ 49 | registered: struct { 50 | Foo string `json:"foo,omitempty"` 51 | }{ 52 | Foo: "bar", 53 | }, 54 | claims: map[string]any{ 55 | "bar": "foo", 56 | }, 57 | }, 58 | want: "{\"bar\":\"foo\",\"foo\":\"bar\"}\n", 59 | }, 60 | { 61 | name: "registered overwrites custom", 62 | args: args{ 63 | registered: struct { 64 | Foo string `json:"foo,omitempty"` 65 | }{ 66 | Foo: "bar", 67 | }, 68 | claims: map[string]any{ 69 | "foo": "Hello, World!", 70 | }, 71 | }, 72 | want: "{\"foo\":\"bar\"}\n", 73 | }, 74 | } 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | got, err := mergeAndMarshalClaims(tt.args.registered, tt.args.claims) 78 | if tt.wantErr { 79 | require.Error(t, err) 80 | } else { 81 | require.NoError(t, err) 82 | } 83 | assert.Equal(t, tt.want, string(got)) 84 | }) 85 | } 86 | } 87 | 88 | func Test_unmarshalJSONMulti(t *testing.T) { 89 | type dst struct { 90 | Foo string `json:"foo,omitempty"` 91 | } 92 | 93 | type args struct { 94 | data string 95 | destinations []any 96 | } 97 | tests := []struct { 98 | name string 99 | args args 100 | want []any 101 | wantErr bool 102 | }{ 103 | { 104 | name: "error", 105 | args: args{ 106 | data: "~!~~", 107 | destinations: []any{ 108 | &dst{}, 109 | &map[string]any{}, 110 | }, 111 | }, 112 | want: []any{ 113 | &dst{}, 114 | &map[string]any{}, 115 | }, 116 | wantErr: true, 117 | }, 118 | { 119 | name: "success", 120 | args: args{ 121 | data: "{\"bar\":\"foo\",\"foo\":\"bar\"}\n", 122 | destinations: []any{ 123 | &dst{}, 124 | &map[string]any{}, 125 | }, 126 | }, 127 | want: []any{ 128 | &dst{Foo: "bar"}, 129 | &map[string]any{ 130 | "foo": "bar", 131 | "bar": "foo", 132 | }, 133 | }, 134 | }, 135 | } 136 | for _, tt := range tests { 137 | t.Run(tt.name, func(t *testing.T) { 138 | err := unmarshalJSONMulti([]byte(tt.args.data), tt.args.destinations...) 139 | if tt.wantErr { 140 | require.Error(t, err) 141 | } else { 142 | require.NoError(t, err) 143 | } 144 | assert.Equal(t, tt.want, tt.args.destinations) 145 | }) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /pkg/oidc/verifier_parse_test.go: -------------------------------------------------------------------------------- 1 | package oidc_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 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 | ) 13 | 14 | func TestParseToken(t *testing.T) { 15 | token, wantClaims := tu.ValidIDToken() 16 | wantClaims.SignatureAlg = "" // unset, because is not part of the JSON payload 17 | 18 | wantPayload, err := json.Marshal(wantClaims) 19 | require.NoError(t, err) 20 | 21 | tests := []struct { 22 | name string 23 | tokenString string 24 | wantErr bool 25 | }{ 26 | { 27 | name: "split error", 28 | tokenString: "nope", 29 | wantErr: true, 30 | }, 31 | { 32 | name: "base64 error", 33 | tokenString: "foo.~.bar", 34 | wantErr: true, 35 | }, 36 | { 37 | name: "success", 38 | tokenString: token, 39 | }, 40 | } 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | gotClaims := new(oidc.IDTokenClaims) 44 | gotPayload, err := oidc.ParseToken(tt.tokenString, gotClaims) 45 | if tt.wantErr { 46 | assert.Error(t, err) 47 | return 48 | } 49 | require.NoError(t, err) 50 | assert.Equal(t, wantClaims, gotClaims) 51 | assert.JSONEq(t, string(wantPayload), string(gotPayload)) 52 | }) 53 | } 54 | } 55 | 56 | func TestCheckSignature(t *testing.T) { 57 | errCtx, cancel := context.WithCancel(context.Background()) 58 | cancel() 59 | 60 | token, _ := tu.ValidIDToken() 61 | payload, err := oidc.ParseToken(token, &oidc.IDTokenClaims{}) 62 | require.NoError(t, err) 63 | 64 | type args struct { 65 | ctx context.Context 66 | token string 67 | payload []byte 68 | supportedSigAlgs []string 69 | } 70 | tests := []struct { 71 | name string 72 | args args 73 | wantErr error 74 | }{ 75 | { 76 | name: "parse error", 77 | args: args{ 78 | ctx: context.Background(), 79 | token: "~", 80 | payload: payload, 81 | }, 82 | wantErr: oidc.ErrParse, 83 | }, 84 | { 85 | name: "default sigAlg", 86 | args: args{ 87 | ctx: context.Background(), 88 | token: token, 89 | payload: payload, 90 | }, 91 | }, 92 | { 93 | name: "unsupported sigAlg", 94 | args: args{ 95 | ctx: context.Background(), 96 | token: token, 97 | payload: payload, 98 | supportedSigAlgs: []string{"foo", "bar"}, 99 | }, 100 | wantErr: oidc.ErrSignatureUnsupportedAlg, 101 | }, 102 | { 103 | name: "verify error", 104 | args: args{ 105 | ctx: errCtx, 106 | token: token, 107 | payload: payload, 108 | }, 109 | wantErr: oidc.ErrSignatureInvalid, 110 | }, 111 | { 112 | name: "inequal payloads", 113 | args: args{ 114 | ctx: context.Background(), 115 | token: token, 116 | payload: []byte{0, 1, 2}, 117 | }, 118 | wantErr: oidc.ErrSignatureInvalidPayload, 119 | }, 120 | } 121 | for _, tt := range tests { 122 | t.Run(tt.name, func(t *testing.T) { 123 | claims := new(oidc.TokenClaims) 124 | err := oidc.CheckSignature(tt.args.ctx, tt.args.token, tt.args.payload, claims, tt.args.supportedSigAlgs, tu.KeySet{}) 125 | assert.ErrorIs(t, err, tt.wantErr) 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /pkg/op/context.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type key int 9 | 10 | const ( 11 | issuerKey key = 0 12 | ) 13 | 14 | type IssuerInterceptor struct { 15 | issuerFromRequest IssuerFromRequest 16 | } 17 | 18 | // NewIssuerInterceptor will set the issuer into the context 19 | // by the provided IssuerFromRequest (e.g. returned from StaticIssuer or IssuerFromHost) 20 | func NewIssuerInterceptor(issuerFromRequest IssuerFromRequest) *IssuerInterceptor { 21 | return &IssuerInterceptor{ 22 | issuerFromRequest: issuerFromRequest, 23 | } 24 | } 25 | 26 | func (i *IssuerInterceptor) Handler(next http.Handler) http.Handler { 27 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | i.setIssuerCtx(w, r, next) 29 | }) 30 | } 31 | 32 | func (i *IssuerInterceptor) HandlerFunc(next http.HandlerFunc) http.HandlerFunc { 33 | return func(w http.ResponseWriter, r *http.Request) { 34 | i.setIssuerCtx(w, r, next) 35 | } 36 | } 37 | 38 | // IssuerFromContext reads the issuer from the context (set by an IssuerInterceptor) 39 | // it will return an empty string if not found 40 | func IssuerFromContext(ctx context.Context) string { 41 | ctxIssuer, _ := ctx.Value(issuerKey).(string) 42 | return ctxIssuer 43 | } 44 | 45 | // ContextWithIssuer returns a new context with issuer set to it. 46 | func ContextWithIssuer(ctx context.Context, issuer string) context.Context { 47 | return context.WithValue(ctx, issuerKey, issuer) 48 | } 49 | 50 | func (i *IssuerInterceptor) setIssuerCtx(w http.ResponseWriter, r *http.Request, next http.Handler) { 51 | r = r.WithContext(ContextWithIssuer(r.Context(), i.issuerFromRequest(r))) 52 | next.ServeHTTP(w, r) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/op/context_test.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestIssuerInterceptor(t *testing.T) { 12 | type fields struct { 13 | issuerFromRequest IssuerFromRequest 14 | } 15 | type args struct { 16 | r *http.Request 17 | next http.Handler 18 | } 19 | type res struct { 20 | issuer string 21 | } 22 | tests := []struct { 23 | name string 24 | fields fields 25 | args args 26 | res res 27 | }{ 28 | { 29 | "empty", 30 | fields{ 31 | func(r *http.Request) string { 32 | return "" 33 | }, 34 | }, 35 | args{}, 36 | res{ 37 | issuer: "", 38 | }, 39 | }, 40 | { 41 | "static", 42 | fields{ 43 | func(r *http.Request) string { 44 | return "static" 45 | }, 46 | }, 47 | args{}, 48 | res{ 49 | issuer: "static", 50 | }, 51 | }, 52 | { 53 | "host", 54 | fields{ 55 | func(r *http.Request) string { 56 | return r.Host 57 | }, 58 | }, 59 | args{}, 60 | res{ 61 | issuer: "issuer.com", 62 | }, 63 | }, 64 | } 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | i := NewIssuerInterceptor(tt.fields.issuerFromRequest) 68 | next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { 69 | assert.Equal(t, tt.res.issuer, IssuerFromContext(r.Context())) 70 | }) 71 | req := httptest.NewRequest("", "https://issuer.com", nil) 72 | i.Handler(next).ServeHTTP(nil, req) 73 | i.HandlerFunc(next).ServeHTTP(nil, req) 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/op/crypto.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "github.com/zitadel/oidc/v3/pkg/crypto" 5 | ) 6 | 7 | type Crypto interface { 8 | Encrypt(string) (string, error) 9 | Decrypt(string) (string, error) 10 | } 11 | 12 | type aesCrypto struct { 13 | key string 14 | } 15 | 16 | func NewAESCrypto(key [32]byte) Crypto { 17 | return &aesCrypto{key: string(key[:32])} 18 | } 19 | 20 | func (c *aesCrypto) Encrypt(s string) (string, error) { 21 | return crypto.EncryptAES(s, c.key) 22 | } 23 | 24 | func (c *aesCrypto) Decrypt(s string) (string, error) { 25 | return crypto.DecryptAES(s, c.key) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/op/endpoint.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | type Endpoint struct { 9 | path string 10 | url string 11 | } 12 | 13 | func NewEndpoint(path string) *Endpoint { 14 | return &Endpoint{path: path} 15 | } 16 | 17 | func NewEndpointWithURL(path, url string) *Endpoint { 18 | return &Endpoint{path: path, url: url} 19 | } 20 | 21 | func (e *Endpoint) Relative() string { 22 | if e == nil { 23 | return "" 24 | } 25 | return relativeEndpoint(e.path) 26 | } 27 | 28 | func (e *Endpoint) Absolute(host string) string { 29 | if e == nil { 30 | return "" 31 | } 32 | if e.url != "" { 33 | return e.url 34 | } 35 | return absoluteEndpoint(host, e.path) 36 | } 37 | 38 | var ErrNilEndpoint = errors.New("nil endpoint") 39 | 40 | func (e *Endpoint) Validate() error { 41 | if e == nil { 42 | return ErrNilEndpoint 43 | } 44 | return nil // TODO: 45 | } 46 | 47 | func absoluteEndpoint(host, endpoint string) string { 48 | return strings.TrimSuffix(host, "/") + relativeEndpoint(endpoint) 49 | } 50 | 51 | func relativeEndpoint(endpoint string) string { 52 | return "/" + strings.TrimPrefix(endpoint, "/") 53 | } 54 | -------------------------------------------------------------------------------- /pkg/op/endpoint_test.go: -------------------------------------------------------------------------------- 1 | package op_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/zitadel/oidc/v3/pkg/op" 8 | ) 9 | 10 | func TestEndpoint_Path(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | e *op.Endpoint 14 | want string 15 | }{ 16 | { 17 | "without starting /", 18 | op.NewEndpoint("test"), 19 | "/test", 20 | }, 21 | { 22 | "with starting /", 23 | op.NewEndpoint("/test"), 24 | "/test", 25 | }, 26 | { 27 | "with url", 28 | op.NewEndpointWithURL("/test", "http://test.com/test"), 29 | "/test", 30 | }, 31 | { 32 | "nil", 33 | nil, 34 | "", 35 | }, 36 | } 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | if got := tt.e.Relative(); got != tt.want { 40 | t.Errorf("Endpoint.Relative() = %v, want %v", got, tt.want) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func TestEndpoint_Absolute(t *testing.T) { 47 | type args struct { 48 | host string 49 | } 50 | tests := []struct { 51 | name string 52 | e *op.Endpoint 53 | args args 54 | want string 55 | }{ 56 | { 57 | "no /", 58 | op.NewEndpoint("test"), 59 | args{"https://host"}, 60 | "https://host/test", 61 | }, 62 | { 63 | "endpoint without /", 64 | op.NewEndpoint("test"), 65 | args{"https://host/"}, 66 | "https://host/test", 67 | }, 68 | { 69 | "host without /", 70 | op.NewEndpoint("/test"), 71 | args{"https://host"}, 72 | "https://host/test", 73 | }, 74 | { 75 | "both /", 76 | op.NewEndpoint("/test"), 77 | args{"https://host/"}, 78 | "https://host/test", 79 | }, 80 | { 81 | "with url", 82 | op.NewEndpointWithURL("test", "https://test.com/test"), 83 | args{"https://host"}, 84 | "https://test.com/test", 85 | }, 86 | { 87 | "nil", 88 | nil, 89 | args{"https://host"}, 90 | "", 91 | }, 92 | } 93 | for _, tt := range tests { 94 | t.Run(tt.name, func(t *testing.T) { 95 | if got := tt.e.Absolute(tt.args.host); got != tt.want { 96 | t.Errorf("Endpoint.Absolute() = %v, want %v", got, tt.want) 97 | } 98 | }) 99 | } 100 | } 101 | 102 | // TODO: impl test 103 | func TestEndpoint_Validate(t *testing.T) { 104 | tests := []struct { 105 | name string 106 | e *op.Endpoint 107 | wantErr error 108 | }{ 109 | { 110 | "nil", 111 | nil, 112 | op.ErrNilEndpoint, 113 | }, 114 | } 115 | for _, tt := range tests { 116 | t.Run(tt.name, func(t *testing.T) { 117 | err := tt.e.Validate() 118 | require.ErrorIs(t, err, tt.wantErr) 119 | }) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /pkg/op/form_post.html.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | {{with .Params.state}}{{end}} 7 | {{with .Params.code}}{{end}} 8 | {{with .Params.id_token}}{{end}} 9 | {{with .Params.access_token}}{{end}} 10 | {{with .Params.token_type}}{{end}} 11 | {{with .Params.expires_in}}{{end}} 12 |
13 | 14 | -------------------------------------------------------------------------------- /pkg/op/keys.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | jose "github.com/go-jose/go-jose/v4" 8 | 9 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 10 | ) 11 | 12 | type KeyProvider interface { 13 | KeySet(context.Context) ([]Key, error) 14 | } 15 | 16 | func keysHandler(k KeyProvider) func(http.ResponseWriter, *http.Request) { 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | Keys(w, r, k) 19 | } 20 | } 21 | 22 | func Keys(w http.ResponseWriter, r *http.Request, k KeyProvider) { 23 | ctx, span := tracer.Start(r.Context(), "Keys") 24 | r = r.WithContext(ctx) 25 | defer span.End() 26 | 27 | keySet, err := k.KeySet(r.Context()) 28 | if err != nil { 29 | httphelper.MarshalJSONWithStatus(w, err, http.StatusInternalServerError) 30 | return 31 | } 32 | httphelper.MarshalJSON(w, jsonWebKeySet(keySet)) 33 | } 34 | 35 | func jsonWebKeySet(keys []Key) *jose.JSONWebKeySet { 36 | webKeys := make([]jose.JSONWebKey, len(keys)) 37 | for i, key := range keys { 38 | webKeys[i] = jose.JSONWebKey{ 39 | KeyID: key.ID(), 40 | Algorithm: string(key.Algorithm()), 41 | Use: key.Use(), 42 | Key: key.Key(), 43 | } 44 | } 45 | return &jose.JSONWebKeySet{Keys: webKeys} 46 | } 47 | -------------------------------------------------------------------------------- /pkg/op/keys_test.go: -------------------------------------------------------------------------------- 1 | package op_test 2 | 3 | import ( 4 | "crypto/rsa" 5 | "math/big" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | jose "github.com/go-jose/go-jose/v4" 11 | "github.com/golang/mock/gomock" 12 | "github.com/stretchr/testify/assert" 13 | 14 | "github.com/zitadel/oidc/v3/pkg/oidc" 15 | "github.com/zitadel/oidc/v3/pkg/op" 16 | "github.com/zitadel/oidc/v3/pkg/op/mock" 17 | ) 18 | 19 | func TestKeys(t *testing.T) { 20 | type args struct { 21 | k op.KeyProvider 22 | } 23 | type res struct { 24 | statusCode int 25 | contentType string 26 | body string 27 | } 28 | tests := []struct { 29 | name string 30 | args args 31 | res res 32 | }{ 33 | { 34 | name: "error", 35 | args: args{ 36 | k: func() op.KeyProvider { 37 | m := mock.NewMockKeyProvider(gomock.NewController(t)) 38 | m.EXPECT().KeySet(gomock.Any()).Return(nil, oidc.ErrServerError()) 39 | return m 40 | }(), 41 | }, 42 | res: res{ 43 | statusCode: http.StatusInternalServerError, 44 | contentType: "application/json", 45 | body: `{"error":"server_error"} 46 | `, 47 | }, 48 | }, 49 | { 50 | name: "empty list", 51 | args: args{ 52 | k: func() op.KeyProvider { 53 | m := mock.NewMockKeyProvider(gomock.NewController(t)) 54 | m.EXPECT().KeySet(gomock.Any()).Return(nil, nil) 55 | return m 56 | }(), 57 | }, 58 | res: res{ 59 | statusCode: http.StatusOK, 60 | contentType: "application/json", 61 | body: `{"keys":[]} 62 | `, 63 | }, 64 | }, 65 | { 66 | name: "list", 67 | args: args{ 68 | k: func() op.KeyProvider { 69 | ctrl := gomock.NewController(t) 70 | m := mock.NewMockKeyProvider(ctrl) 71 | k := mock.NewMockKey(ctrl) 72 | k.EXPECT().Key().Return(&rsa.PublicKey{ 73 | N: big.NewInt(1), 74 | E: 1, 75 | }) 76 | k.EXPECT().ID().Return("id") 77 | k.EXPECT().Algorithm().Return(jose.RS256) 78 | k.EXPECT().Use().Return("sig") 79 | m.EXPECT().KeySet(gomock.Any()).Return([]op.Key{k}, nil) 80 | return m 81 | }(), 82 | }, 83 | res: res{ 84 | statusCode: http.StatusOK, 85 | contentType: "application/json", 86 | body: `{"keys":[{"use":"sig","kty":"RSA","kid":"id","alg":"RS256","n":"AQ","e":"AQ"}]} 87 | `, 88 | }, 89 | }, 90 | } 91 | for _, tt := range tests { 92 | t.Run(tt.name, func(t *testing.T) { 93 | w := httptest.NewRecorder() 94 | op.Keys(w, httptest.NewRequest("GET", "/keys", nil), tt.args.k) 95 | assert.Equal(t, tt.res.statusCode, w.Result().StatusCode) 96 | assert.Equal(t, tt.res.contentType, w.Header().Get("content-type")) 97 | assert.Equal(t, tt.res.body, w.Body.String()) 98 | }) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pkg/op/mock/authorizer.mock.impl.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | jose "github.com/go-jose/go-jose/v4" 8 | "github.com/golang/mock/gomock" 9 | "github.com/zitadel/schema" 10 | 11 | "github.com/zitadel/oidc/v3/pkg/oidc" 12 | "github.com/zitadel/oidc/v3/pkg/op" 13 | ) 14 | 15 | func NewAuthorizer(t *testing.T) op.Authorizer { 16 | return NewMockAuthorizer(gomock.NewController(t)) 17 | } 18 | 19 | func NewAuthorizerExpectValid(t *testing.T, wantErr bool) op.Authorizer { 20 | m := NewAuthorizer(t) 21 | ExpectDecoder(m) 22 | ExpectEncoder(m) 23 | //ExpectSigner(m, t) 24 | ExpectStorage(m, t) 25 | ExpectVerifier(m, t) 26 | // ExpectErrorHandler(m, t, wantErr) 27 | return m 28 | } 29 | 30 | func ExpectDecoder(a op.Authorizer) { 31 | mockA := a.(*MockAuthorizer) 32 | mockA.EXPECT().Decoder().AnyTimes().Return(schema.NewDecoder()) 33 | } 34 | 35 | func ExpectEncoder(a op.Authorizer) { 36 | mockA := a.(*MockAuthorizer) 37 | mockA.EXPECT().Encoder().AnyTimes().Return(schema.NewEncoder()) 38 | } 39 | 40 | // 41 | //func ExpectSigner(a op.Authorizer, t *testing.T) { 42 | // mockA := a.(*MockAuthorizer) 43 | // mockA.EXPECT().Signer().DoAndReturn( 44 | // func() op.Signer { 45 | // return &Sig{} 46 | // }) 47 | //} 48 | 49 | func ExpectVerifier(a op.Authorizer, t *testing.T) { 50 | mockA := a.(*MockAuthorizer) 51 | mockA.EXPECT().IDTokenHintVerifier(gomock.Any()).DoAndReturn( 52 | func() *op.IDTokenHintVerifier { 53 | return op.NewIDTokenHintVerifier("", nil) 54 | }) 55 | } 56 | 57 | type Verifier struct{} 58 | 59 | func (v *Verifier) Verify(ctx context.Context, accessToken, idToken string) (*oidc.IDTokenClaims, error) { 60 | return nil, nil 61 | } 62 | 63 | func (v *Verifier) VerifyIDToken(ctx context.Context, idToken string) (*oidc.IDTokenClaims, error) { 64 | return nil, nil 65 | } 66 | 67 | type Sig struct { 68 | signer jose.Signer 69 | } 70 | 71 | func (s *Sig) Signer() jose.Signer { 72 | return s.signer 73 | } 74 | 75 | func (s *Sig) Health(ctx context.Context) error { 76 | return nil 77 | } 78 | 79 | func (s *Sig) SignatureAlgorithm() jose.SignatureAlgorithm { 80 | return jose.HS256 81 | } 82 | 83 | func ExpectStorage(a op.Authorizer, t *testing.T) { 84 | mockA := a.(*MockAuthorizer) 85 | mockA.EXPECT().Storage().AnyTimes().Return(NewMockStorageAny(t)) 86 | } 87 | -------------------------------------------------------------------------------- /pkg/op/mock/client.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/golang/mock/gomock" 7 | 8 | "github.com/zitadel/oidc/v3/pkg/oidc" 9 | "github.com/zitadel/oidc/v3/pkg/op" 10 | ) 11 | 12 | func NewClient(t *testing.T) op.Client { 13 | return NewMockClient(gomock.NewController(t)) 14 | } 15 | 16 | func NewClientExpectAny(t *testing.T, appType op.ApplicationType) op.Client { 17 | c := NewClient(t) 18 | m := c.(*MockClient) 19 | m.EXPECT().RedirectURIs().AnyTimes().Return([]string{ 20 | "https://registered.com/callback", 21 | "http://registered.com/callback", 22 | "http://localhost:9999/callback", 23 | "custom://callback", 24 | }) 25 | m.EXPECT().ApplicationType().AnyTimes().Return(appType) 26 | m.EXPECT().LoginURL(gomock.Any()).AnyTimes().DoAndReturn( 27 | func(id string) string { 28 | return "login?id=" + id 29 | }) 30 | m.EXPECT().IsScopeAllowed(gomock.Any()).AnyTimes().Return(false) 31 | return c 32 | } 33 | 34 | func NewClientWithConfig(t *testing.T, uri []string, appType op.ApplicationType, responseTypes []oidc.ResponseType, devMode bool) op.Client { 35 | c := NewClient(t) 36 | m := c.(*MockClient) 37 | m.EXPECT().RedirectURIs().AnyTimes().Return(uri) 38 | m.EXPECT().ApplicationType().AnyTimes().Return(appType) 39 | m.EXPECT().ResponseTypes().AnyTimes().Return(responseTypes) 40 | m.EXPECT().DevMode().AnyTimes().Return(devMode) 41 | return c 42 | } 43 | -------------------------------------------------------------------------------- /pkg/op/mock/discovery.mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/zitadel/oidc/v3/pkg/op (interfaces: DiscoverStorage) 3 | 4 | // Package mock is a generated GoMock package. 5 | package mock 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | jose "github.com/go-jose/go-jose/v4" 12 | gomock "github.com/golang/mock/gomock" 13 | ) 14 | 15 | // MockDiscoverStorage is a mock of DiscoverStorage interface. 16 | type MockDiscoverStorage struct { 17 | ctrl *gomock.Controller 18 | recorder *MockDiscoverStorageMockRecorder 19 | } 20 | 21 | // MockDiscoverStorageMockRecorder is the mock recorder for MockDiscoverStorage. 22 | type MockDiscoverStorageMockRecorder struct { 23 | mock *MockDiscoverStorage 24 | } 25 | 26 | // NewMockDiscoverStorage creates a new mock instance. 27 | func NewMockDiscoverStorage(ctrl *gomock.Controller) *MockDiscoverStorage { 28 | mock := &MockDiscoverStorage{ctrl: ctrl} 29 | mock.recorder = &MockDiscoverStorageMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockDiscoverStorage) EXPECT() *MockDiscoverStorageMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // SignatureAlgorithms mocks base method. 39 | func (m *MockDiscoverStorage) SignatureAlgorithms(arg0 context.Context) ([]jose.SignatureAlgorithm, error) { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "SignatureAlgorithms", arg0) 42 | ret0, _ := ret[0].([]jose.SignatureAlgorithm) 43 | ret1, _ := ret[1].(error) 44 | return ret0, ret1 45 | } 46 | 47 | // SignatureAlgorithms indicates an expected call of SignatureAlgorithms. 48 | func (mr *MockDiscoverStorageMockRecorder) SignatureAlgorithms(arg0 interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignatureAlgorithms", reflect.TypeOf((*MockDiscoverStorage)(nil).SignatureAlgorithms), arg0) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/op/mock/generate.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | //go:generate go install github.com/golang/mock/mockgen@v1.6.0 4 | //go:generate mockgen -package mock -destination ./storage.mock.go github.com/zitadel/oidc/v3/pkg/op Storage 5 | //go:generate mockgen -package mock -destination ./authorizer.mock.go github.com/zitadel/oidc/v3/pkg/op Authorizer 6 | //go:generate mockgen -package mock -destination ./client.mock.go github.com/zitadel/oidc/v3/pkg/op Client 7 | //go:generate mockgen -package mock -destination ./glob.mock.go github.com/zitadel/oidc/v3/pkg/op HasRedirectGlobs 8 | //go:generate mockgen -package mock -destination ./configuration.mock.go github.com/zitadel/oidc/v3/pkg/op Configuration 9 | //go:generate mockgen -package mock -destination ./discovery.mock.go github.com/zitadel/oidc/v3/pkg/op DiscoverStorage 10 | //go:generate mockgen -package mock -destination ./signer.mock.go github.com/zitadel/oidc/v3/pkg/op SigningKey,Key 11 | //go:generate mockgen -package mock -destination ./key.mock.go github.com/zitadel/oidc/v3/pkg/op KeyProvider 12 | -------------------------------------------------------------------------------- /pkg/op/mock/glob.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "testing" 5 | 6 | gomock "github.com/golang/mock/gomock" 7 | "github.com/zitadel/oidc/v3/pkg/oidc" 8 | op "github.com/zitadel/oidc/v3/pkg/op" 9 | ) 10 | 11 | func NewHasRedirectGlobs(t *testing.T) op.HasRedirectGlobs { 12 | return NewMockHasRedirectGlobs(gomock.NewController(t)) 13 | } 14 | 15 | func NewHasRedirectGlobsWithConfig(t *testing.T, uri []string, appType op.ApplicationType, responseTypes []oidc.ResponseType, devMode bool) op.HasRedirectGlobs { 16 | c := NewHasRedirectGlobs(t) 17 | m := c.(*MockHasRedirectGlobs) 18 | m.EXPECT().RedirectURIs().AnyTimes().Return(uri) 19 | m.EXPECT().RedirectURIGlobs().AnyTimes().Return(uri) 20 | m.EXPECT().ApplicationType().AnyTimes().Return(appType) 21 | m.EXPECT().ResponseTypes().AnyTimes().Return(responseTypes) 22 | m.EXPECT().DevMode().AnyTimes().Return(devMode) 23 | return c 24 | } 25 | -------------------------------------------------------------------------------- /pkg/op/mock/key.mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/zitadel/oidc/v3/pkg/op (interfaces: KeyProvider) 3 | 4 | // Package mock is a generated GoMock package. 5 | package mock 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | op "github.com/zitadel/oidc/v3/pkg/op" 13 | ) 14 | 15 | // MockKeyProvider is a mock of KeyProvider interface. 16 | type MockKeyProvider struct { 17 | ctrl *gomock.Controller 18 | recorder *MockKeyProviderMockRecorder 19 | } 20 | 21 | // MockKeyProviderMockRecorder is the mock recorder for MockKeyProvider. 22 | type MockKeyProviderMockRecorder struct { 23 | mock *MockKeyProvider 24 | } 25 | 26 | // NewMockKeyProvider creates a new mock instance. 27 | func NewMockKeyProvider(ctrl *gomock.Controller) *MockKeyProvider { 28 | mock := &MockKeyProvider{ctrl: ctrl} 29 | mock.recorder = &MockKeyProviderMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockKeyProvider) EXPECT() *MockKeyProviderMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // KeySet mocks base method. 39 | func (m *MockKeyProvider) KeySet(arg0 context.Context) ([]op.Key, error) { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "KeySet", arg0) 42 | ret0, _ := ret[0].([]op.Key) 43 | ret1, _ := ret[1].(error) 44 | return ret0, ret1 45 | } 46 | 47 | // KeySet indicates an expected call of KeySet. 48 | func (mr *MockKeyProviderMockRecorder) KeySet(arg0 interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KeySet", reflect.TypeOf((*MockKeyProvider)(nil).KeySet), arg0) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/op/probes.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | 8 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 9 | ) 10 | 11 | type ProbesFn func(context.Context) error 12 | 13 | func healthHandler(w http.ResponseWriter, r *http.Request) { 14 | ok(w) 15 | } 16 | 17 | func readyHandler(probes []ProbesFn) func(w http.ResponseWriter, r *http.Request) { 18 | return func(w http.ResponseWriter, r *http.Request) { 19 | Readiness(w, r, probes...) 20 | } 21 | } 22 | 23 | func Readiness(w http.ResponseWriter, r *http.Request, probes ...ProbesFn) { 24 | ctx := r.Context() 25 | for _, probe := range probes { 26 | if err := probe(ctx); err != nil { 27 | http.Error(w, "not ready", http.StatusInternalServerError) 28 | return 29 | } 30 | } 31 | ok(w) 32 | } 33 | 34 | func ReadyStorage(s Storage) ProbesFn { 35 | return func(ctx context.Context) error { 36 | if s == nil { 37 | return errors.New("no storage") 38 | } 39 | return s.Health(ctx) 40 | } 41 | } 42 | 43 | func ok(w http.ResponseWriter) { 44 | httphelper.MarshalJSON(w, Status{"ok"}) 45 | } 46 | 47 | type Status struct { 48 | Status string `json:"status,omitempty"` 49 | } 50 | -------------------------------------------------------------------------------- /pkg/op/server_test.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | // implementation check 4 | var _ Server = &UnimplementedServer{} 5 | var _ Server = &LegacyServer{} 6 | -------------------------------------------------------------------------------- /pkg/op/session.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log/slog" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | 11 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 12 | "github.com/zitadel/oidc/v3/pkg/oidc" 13 | ) 14 | 15 | type SessionEnder interface { 16 | Decoder() httphelper.Decoder 17 | Storage() Storage 18 | IDTokenHintVerifier(context.Context) *IDTokenHintVerifier 19 | DefaultLogoutRedirectURI() string 20 | Logger() *slog.Logger 21 | } 22 | 23 | func endSessionHandler(ender SessionEnder) func(http.ResponseWriter, *http.Request) { 24 | return func(w http.ResponseWriter, r *http.Request) { 25 | EndSession(w, r, ender) 26 | } 27 | } 28 | 29 | func EndSession(w http.ResponseWriter, r *http.Request, ender SessionEnder) { 30 | ctx, span := tracer.Start(r.Context(), "EndSession") 31 | defer span.End() 32 | r = r.WithContext(ctx) 33 | 34 | req, err := ParseEndSessionRequest(r, ender.Decoder()) 35 | if err != nil { 36 | http.Error(w, err.Error(), http.StatusInternalServerError) 37 | return 38 | } 39 | session, err := ValidateEndSessionRequest(r.Context(), req, ender) 40 | if err != nil { 41 | RequestError(w, r, err, ender.Logger()) 42 | return 43 | } 44 | redirect := session.RedirectURI 45 | if fromRequest, ok := ender.Storage().(CanTerminateSessionFromRequest); ok { 46 | redirect, err = fromRequest.TerminateSessionFromRequest(r.Context(), session) 47 | } else { 48 | err = ender.Storage().TerminateSession(r.Context(), session.UserID, session.ClientID) 49 | } 50 | if err != nil { 51 | RequestError(w, r, oidc.DefaultToServerError(err, "error terminating session"), ender.Logger()) 52 | return 53 | } 54 | http.Redirect(w, r, redirect, http.StatusFound) 55 | } 56 | 57 | func ParseEndSessionRequest(r *http.Request, decoder httphelper.Decoder) (*oidc.EndSessionRequest, error) { 58 | err := r.ParseForm() 59 | if err != nil { 60 | return nil, oidc.ErrInvalidRequest().WithDescription("error parsing form").WithParent(err) 61 | } 62 | req := new(oidc.EndSessionRequest) 63 | err = decoder.Decode(req, r.Form) 64 | if err != nil { 65 | return nil, oidc.ErrInvalidRequest().WithDescription("error decoding form").WithParent(err) 66 | } 67 | return req, nil 68 | } 69 | 70 | func ValidateEndSessionRequest(ctx context.Context, req *oidc.EndSessionRequest, ender SessionEnder) (*EndSessionRequest, error) { 71 | ctx, span := tracer.Start(ctx, "ValidateEndSessionRequest") 72 | defer span.End() 73 | 74 | session := &EndSessionRequest{ 75 | RedirectURI: ender.DefaultLogoutRedirectURI(), 76 | LogoutHint: req.LogoutHint, 77 | UILocales: req.UILocales, 78 | } 79 | if req.IdTokenHint != "" { 80 | claims, err := VerifyIDTokenHint[*oidc.IDTokenClaims](ctx, req.IdTokenHint, ender.IDTokenHintVerifier(ctx)) 81 | if err != nil && !errors.As(err, &IDTokenHintExpiredError{}) { 82 | return nil, oidc.ErrInvalidRequest().WithDescription("id_token_hint invalid").WithParent(err) 83 | } 84 | session.UserID = claims.GetSubject() 85 | session.IDTokenHintClaims = claims 86 | if req.ClientID != "" && req.ClientID != claims.GetAuthorizedParty() { 87 | return nil, oidc.ErrInvalidRequest().WithDescription("client_id does not match azp of id_token_hint") 88 | } 89 | req.ClientID = claims.GetAuthorizedParty() 90 | } 91 | if req.ClientID != "" { 92 | client, err := ender.Storage().GetClientByClientID(ctx, req.ClientID) 93 | if err != nil { 94 | return nil, oidc.DefaultToServerError(err, "") 95 | } 96 | session.ClientID = client.GetID() 97 | if req.PostLogoutRedirectURI != "" { 98 | if err := ValidateEndSessionPostLogoutRedirectURI(req.PostLogoutRedirectURI, client); err != nil { 99 | return nil, err 100 | } 101 | session.RedirectURI = req.PostLogoutRedirectURI 102 | } 103 | } 104 | if req.State != "" { 105 | redirect, err := url.Parse(session.RedirectURI) 106 | if err != nil { 107 | return nil, oidc.DefaultToServerError(err, "") 108 | } 109 | session.RedirectURI = mergeQueryParams(redirect, url.Values{"state": {req.State}}) 110 | } 111 | return session, nil 112 | } 113 | 114 | func ValidateEndSessionPostLogoutRedirectURI(postLogoutRedirectURI string, client Client) error { 115 | for _, uri := range client.PostLogoutRedirectURIs() { 116 | if uri == postLogoutRedirectURI { 117 | return nil 118 | } 119 | } 120 | if globClient, ok := client.(HasRedirectGlobs); ok { 121 | for _, uriGlob := range globClient.PostLogoutRedirectURIGlobs() { 122 | isMatch, err := path.Match(uriGlob, postLogoutRedirectURI) 123 | if err != nil { 124 | return oidc.ErrServerError().WithParent(err) 125 | } 126 | if isMatch { 127 | return nil 128 | } 129 | } 130 | } 131 | return oidc.ErrInvalidRequest().WithDescription("post_logout_redirect_uri invalid") 132 | } 133 | -------------------------------------------------------------------------------- /pkg/op/signer.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "errors" 5 | 6 | jose "github.com/go-jose/go-jose/v4" 7 | ) 8 | 9 | var ErrSignerCreationFailed = errors.New("signer creation failed") 10 | 11 | type SigningKey interface { 12 | SignatureAlgorithm() jose.SignatureAlgorithm 13 | Key() any 14 | ID() string 15 | } 16 | 17 | func SignerFromKey(key SigningKey) (jose.Signer, error) { 18 | signer, err := jose.NewSigner(jose.SigningKey{ 19 | Algorithm: key.SignatureAlgorithm(), 20 | Key: &jose.JSONWebKey{ 21 | Key: key.Key(), 22 | KeyID: key.ID(), 23 | }, 24 | }, (&jose.SignerOptions{}).WithType("JWT")) 25 | if err != nil { 26 | return nil, ErrSignerCreationFailed // TODO: log / wrap error? 27 | } 28 | return signer, nil 29 | } 30 | 31 | type Key interface { 32 | ID() string 33 | Algorithm() jose.SignatureAlgorithm 34 | Use() string 35 | Key() any 36 | } 37 | -------------------------------------------------------------------------------- /pkg/op/token_client_credentials.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | 8 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 9 | "github.com/zitadel/oidc/v3/pkg/oidc" 10 | ) 11 | 12 | // ClientCredentialsExchange handles the OAuth 2.0 client_credentials grant, including 13 | // parsing, validating, authorizing the client and finally returning a token 14 | func ClientCredentialsExchange(w http.ResponseWriter, r *http.Request, exchanger Exchanger) { 15 | ctx, span := tracer.Start(r.Context(), "ClientCredentialsExchange") 16 | defer span.End() 17 | r = r.WithContext(ctx) 18 | 19 | request, err := ParseClientCredentialsRequest(r, exchanger.Decoder()) 20 | if err != nil { 21 | RequestError(w, r, err, exchanger.Logger()) 22 | } 23 | 24 | validatedRequest, client, err := ValidateClientCredentialsRequest(r.Context(), request, exchanger) 25 | if err != nil { 26 | RequestError(w, r, err, exchanger.Logger()) 27 | return 28 | } 29 | 30 | resp, err := CreateClientCredentialsTokenResponse(r.Context(), validatedRequest, exchanger, client) 31 | if err != nil { 32 | RequestError(w, r, err, exchanger.Logger()) 33 | return 34 | } 35 | 36 | httphelper.MarshalJSON(w, resp) 37 | } 38 | 39 | // ParseClientCredentialsRequest parsed the http request into a oidc.ClientCredentialsRequest 40 | func ParseClientCredentialsRequest(r *http.Request, decoder httphelper.Decoder) (*oidc.ClientCredentialsRequest, error) { 41 | err := r.ParseForm() 42 | if err != nil { 43 | return nil, oidc.ErrInvalidRequest().WithDescription("error parsing form").WithParent(err) 44 | } 45 | 46 | request := new(oidc.ClientCredentialsRequest) 47 | err = decoder.Decode(request, r.Form) 48 | if err != nil { 49 | return nil, oidc.ErrInvalidRequest().WithDescription("error decoding form").WithParent(err) 50 | } 51 | 52 | if clientID, clientSecret, ok := r.BasicAuth(); ok { 53 | clientID, err = url.QueryUnescape(clientID) 54 | if err != nil { 55 | return nil, oidc.ErrInvalidClient().WithDescription("invalid basic auth header").WithParent(err) 56 | } 57 | 58 | clientSecret, err = url.QueryUnescape(clientSecret) 59 | if err != nil { 60 | return nil, oidc.ErrInvalidClient().WithDescription("invalid basic auth header").WithParent(err) 61 | } 62 | 63 | request.ClientID = clientID 64 | request.ClientSecret = clientSecret 65 | } 66 | 67 | return request, nil 68 | } 69 | 70 | // ValidateClientCredentialsRequest validates the client_credentials request parameters including authorization check of the client 71 | // and returns a TokenRequest and Client implementation to be used in the client_credentials response, resp. creation of the corresponding access_token. 72 | func ValidateClientCredentialsRequest(ctx context.Context, request *oidc.ClientCredentialsRequest, exchanger Exchanger) (TokenRequest, Client, error) { 73 | ctx, span := tracer.Start(ctx, "ValidateClientCredentialsRequest") 74 | defer span.End() 75 | 76 | storage, ok := exchanger.Storage().(ClientCredentialsStorage) 77 | if !ok { 78 | return nil, nil, oidc.ErrUnsupportedGrantType().WithDescription("client_credentials grant not supported") 79 | } 80 | 81 | client, err := AuthorizeClientCredentialsClient(ctx, request, storage) 82 | if err != nil { 83 | return nil, nil, err 84 | } 85 | 86 | tokenRequest, err := storage.ClientCredentialsTokenRequest(ctx, request.ClientID, request.Scope) 87 | if err != nil { 88 | return nil, nil, err 89 | } 90 | 91 | return tokenRequest, client, nil 92 | } 93 | 94 | func AuthorizeClientCredentialsClient(ctx context.Context, request *oidc.ClientCredentialsRequest, storage ClientCredentialsStorage) (Client, error) { 95 | ctx, span := tracer.Start(ctx, "AuthorizeClientCredentialsClient") 96 | defer span.End() 97 | 98 | client, err := storage.ClientCredentials(ctx, request.ClientID, request.ClientSecret) 99 | if err != nil { 100 | return nil, oidc.ErrInvalidClient().WithParent(err) 101 | } 102 | 103 | if !ValidateGrantType(client, oidc.GrantTypeClientCredentials) { 104 | return nil, oidc.ErrUnauthorizedClient() 105 | } 106 | 107 | return client, nil 108 | } 109 | 110 | func CreateClientCredentialsTokenResponse(ctx context.Context, tokenRequest TokenRequest, creator TokenCreator, client Client) (*oidc.AccessTokenResponse, error) { 111 | ctx, span := tracer.Start(ctx, "CreateClientCredentialsTokenResponse") 112 | defer span.End() 113 | 114 | accessToken, _, validity, err := CreateAccessToken(ctx, tokenRequest, client.AccessTokenType(), creator, client, "") 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | return &oidc.AccessTokenResponse{ 120 | AccessToken: accessToken, 121 | TokenType: oidc.BearerToken, 122 | ExpiresIn: uint64(validity.Seconds()), 123 | Scope: tokenRequest.GetScopes(), 124 | }, nil 125 | } 126 | -------------------------------------------------------------------------------- /pkg/op/token_intospection.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | 8 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 9 | "github.com/zitadel/oidc/v3/pkg/oidc" 10 | ) 11 | 12 | type Introspector interface { 13 | Decoder() httphelper.Decoder 14 | Crypto() Crypto 15 | Storage() Storage 16 | AccessTokenVerifier(context.Context) *AccessTokenVerifier 17 | } 18 | 19 | type IntrospectorJWTProfile interface { 20 | Introspector 21 | JWTProfileVerifier(context.Context) JWTProfileVerifier 22 | } 23 | 24 | func introspectionHandler(introspector Introspector) func(http.ResponseWriter, *http.Request) { 25 | return func(w http.ResponseWriter, r *http.Request) { 26 | Introspect(w, r, introspector) 27 | } 28 | } 29 | 30 | func Introspect(w http.ResponseWriter, r *http.Request, introspector Introspector) { 31 | ctx, span := tracer.Start(r.Context(), "Introspect") 32 | defer span.End() 33 | r = r.WithContext(ctx) 34 | 35 | response := new(oidc.IntrospectionResponse) 36 | token, clientID, err := ParseTokenIntrospectionRequest(r, introspector) 37 | if err != nil { 38 | http.Error(w, err.Error(), http.StatusUnauthorized) 39 | return 40 | } 41 | tokenID, subject, ok := getTokenIDAndSubject(r.Context(), introspector, token) 42 | if !ok { 43 | httphelper.MarshalJSON(w, response) 44 | return 45 | } 46 | err = introspector.Storage().SetIntrospectionFromToken(r.Context(), response, tokenID, subject, clientID) 47 | if err != nil { 48 | httphelper.MarshalJSON(w, response) 49 | return 50 | } 51 | response.Active = true 52 | httphelper.MarshalJSON(w, response) 53 | } 54 | 55 | func ParseTokenIntrospectionRequest(r *http.Request, introspector Introspector) (token, clientID string, err error) { 56 | clientID, authenticated, err := ClientIDFromRequest(r, introspector) 57 | if err != nil { 58 | return "", "", err 59 | } 60 | if !authenticated { 61 | return "", "", oidc.ErrInvalidClient().WithParent(ErrNoClientCredentials) 62 | } 63 | 64 | req := new(oidc.IntrospectionRequest) 65 | err = introspector.Decoder().Decode(req, r.Form) 66 | if err != nil { 67 | return "", "", errors.New("unable to parse request") 68 | } 69 | 70 | return req.Token, clientID, nil 71 | } 72 | 73 | type IntrospectionRequest struct { 74 | *ClientCredentials 75 | *oidc.IntrospectionRequest 76 | } 77 | -------------------------------------------------------------------------------- /pkg/op/token_jwt_profile.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 9 | "github.com/zitadel/oidc/v3/pkg/oidc" 10 | ) 11 | 12 | type JWTAuthorizationGrantExchanger interface { 13 | Exchanger 14 | JWTProfileVerifier(context.Context) *JWTProfileVerifier 15 | } 16 | 17 | // JWTProfile handles the OAuth 2.0 JWT Profile Authorization Grant https://tools.ietf.org/html/rfc7523#section-2.1 18 | func JWTProfile(w http.ResponseWriter, r *http.Request, exchanger JWTAuthorizationGrantExchanger) { 19 | ctx, span := tracer.Start(r.Context(), "JWTProfile") 20 | defer span.End() 21 | r = r.WithContext(ctx) 22 | 23 | profileRequest, err := ParseJWTProfileGrantRequest(r, exchanger.Decoder()) 24 | if err != nil { 25 | RequestError(w, r, err, exchanger.Logger()) 26 | } 27 | 28 | tokenRequest, err := VerifyJWTAssertion(r.Context(), profileRequest.Assertion, exchanger.JWTProfileVerifier(r.Context())) 29 | if err != nil { 30 | RequestError(w, r, err, exchanger.Logger()) 31 | return 32 | } 33 | 34 | tokenRequest.Scopes, err = exchanger.Storage().ValidateJWTProfileScopes(r.Context(), tokenRequest.Issuer, profileRequest.Scope) 35 | if err != nil { 36 | RequestError(w, r, err, exchanger.Logger()) 37 | return 38 | } 39 | resp, err := CreateJWTTokenResponse(r.Context(), tokenRequest, exchanger) 40 | if err != nil { 41 | RequestError(w, r, err, exchanger.Logger()) 42 | return 43 | } 44 | httphelper.MarshalJSON(w, resp) 45 | } 46 | 47 | func ParseJWTProfileGrantRequest(r *http.Request, decoder httphelper.Decoder) (*oidc.JWTProfileGrantRequest, error) { 48 | err := r.ParseForm() 49 | if err != nil { 50 | return nil, oidc.ErrInvalidRequest().WithDescription("error parsing form").WithParent(err) 51 | } 52 | tokenReq := new(oidc.JWTProfileGrantRequest) 53 | err = decoder.Decode(tokenReq, r.Form) 54 | if err != nil { 55 | return nil, oidc.ErrInvalidRequest().WithDescription("error decoding form").WithParent(err) 56 | } 57 | return tokenReq, nil 58 | } 59 | 60 | // CreateJWTTokenResponse creates an access_token response for a JWT Profile Grant request 61 | // by default the access_token is an opaque string, but can be specified by implementing the JWTProfileTokenStorage interface 62 | func CreateJWTTokenResponse(ctx context.Context, tokenRequest TokenRequest, creator TokenCreator) (*oidc.AccessTokenResponse, error) { 63 | ctx, span := tracer.Start(ctx, "CreateJWTTokenResponse") 64 | defer span.End() 65 | 66 | // return an opaque token as default to not break current implementations 67 | tokenType := AccessTokenTypeBearer 68 | 69 | // the current CreateAccessToken function, esp. CreateJWT requires an implementation of an AccessTokenClient 70 | client := &jwtProfileClient{ 71 | id: tokenRequest.GetSubject(), 72 | } 73 | 74 | // by implementing the JWTProfileTokenStorage the storage can specify the AccessTokenType to be returned 75 | tokenStorage, ok := creator.Storage().(JWTProfileTokenStorage) 76 | if ok { 77 | var err error 78 | tokenType, err = tokenStorage.JWTProfileTokenType(ctx, tokenRequest) 79 | if err != nil { 80 | return nil, err 81 | } 82 | } 83 | 84 | accessToken, _, validity, err := CreateAccessToken(ctx, tokenRequest, tokenType, creator, client, "") 85 | if err != nil { 86 | return nil, err 87 | } 88 | return &oidc.AccessTokenResponse{ 89 | AccessToken: accessToken, 90 | TokenType: oidc.BearerToken, 91 | ExpiresIn: uint64(validity.Seconds()), 92 | Scope: tokenRequest.GetScopes(), 93 | }, nil 94 | } 95 | 96 | // ParseJWTProfileRequest has been renamed to ParseJWTProfileGrantRequest 97 | // 98 | // deprecated: use ParseJWTProfileGrantRequest 99 | func ParseJWTProfileRequest(r *http.Request, decoder httphelper.Decoder) (*oidc.JWTProfileGrantRequest, error) { 100 | return ParseJWTProfileGrantRequest(r, decoder) 101 | } 102 | 103 | type jwtProfileClient struct { 104 | id string 105 | } 106 | 107 | func (j *jwtProfileClient) GetID() string { 108 | return j.id 109 | } 110 | 111 | func (j *jwtProfileClient) ClockSkew() time.Duration { 112 | return 0 113 | } 114 | 115 | func (j *jwtProfileClient) RestrictAdditionalAccessTokenScopes() func(scopes []string) []string { 116 | return func(scopes []string) []string { 117 | return scopes 118 | } 119 | } 120 | 121 | func (j *jwtProfileClient) GrantTypes() []oidc.GrantType { 122 | return []oidc.GrantType{ 123 | oidc.GrantTypeBearer, 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pkg/op/token_request_test.go: -------------------------------------------------------------------------------- 1 | package op_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/zitadel/oidc/v3/pkg/oidc" 8 | "github.com/zitadel/oidc/v3/pkg/op" 9 | ) 10 | 11 | func TestAuthorizeCodeChallenge(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | codeVerifier string 15 | codeChallenge *oidc.CodeChallenge 16 | want func(t *testing.T, err error) 17 | }{ 18 | { 19 | name: "missing both code_verifier and code_challenge", 20 | codeVerifier: "", 21 | codeChallenge: nil, 22 | want: func(t *testing.T, err error) { 23 | assert.Nil(t, err) 24 | }, 25 | }, 26 | { 27 | name: "valid code_verifier", 28 | codeVerifier: "Hello World!", 29 | codeChallenge: &oidc.CodeChallenge{ 30 | Challenge: "f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", 31 | Method: oidc.CodeChallengeMethodS256, 32 | }, 33 | want: func(t *testing.T, err error) { 34 | assert.Nil(t, err) 35 | }, 36 | }, 37 | { 38 | name: "invalid code_verifier", 39 | codeVerifier: "Hi World!", 40 | codeChallenge: &oidc.CodeChallenge{ 41 | Challenge: "f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", 42 | Method: oidc.CodeChallengeMethodS256, 43 | }, 44 | want: func(t *testing.T, err error) { 45 | assert.ErrorContains(t, err, "invalid code_verifier") 46 | }, 47 | }, 48 | { 49 | name: "code_verifier provided without code_challenge", 50 | codeVerifier: "code_verifier", 51 | codeChallenge: nil, 52 | want: func(t *testing.T, err error) { 53 | assert.ErrorContains(t, err, "code_verifier unexpectedly provided") 54 | }, 55 | }, 56 | { 57 | name: "empty code_verifier", 58 | codeVerifier: "", 59 | codeChallenge: &oidc.CodeChallenge{ 60 | Challenge: "f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", 61 | Method: oidc.CodeChallengeMethodS256, 62 | }, 63 | want: func(t *testing.T, err error) { 64 | assert.ErrorContains(t, err, "code_verifier required") 65 | }, 66 | }, 67 | } 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | err := op.AuthorizeCodeChallenge(tt.codeVerifier, tt.codeChallenge) 71 | 72 | tt.want(t, err) 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /pkg/op/userinfo.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "strings" 8 | 9 | httphelper "github.com/zitadel/oidc/v3/pkg/http" 10 | "github.com/zitadel/oidc/v3/pkg/oidc" 11 | ) 12 | 13 | type UserinfoProvider interface { 14 | Decoder() httphelper.Decoder 15 | Crypto() Crypto 16 | Storage() Storage 17 | AccessTokenVerifier(context.Context) *AccessTokenVerifier 18 | } 19 | 20 | func userinfoHandler(userinfoProvider UserinfoProvider) func(http.ResponseWriter, *http.Request) { 21 | return func(w http.ResponseWriter, r *http.Request) { 22 | Userinfo(w, r, userinfoProvider) 23 | } 24 | } 25 | 26 | func Userinfo(w http.ResponseWriter, r *http.Request, userinfoProvider UserinfoProvider) { 27 | ctx, span := tracer.Start(r.Context(), "Userinfo") 28 | r = r.WithContext(ctx) 29 | defer span.End() 30 | 31 | accessToken, err := ParseUserinfoRequest(r, userinfoProvider.Decoder()) 32 | if err != nil { 33 | http.Error(w, "access token missing", http.StatusUnauthorized) 34 | return 35 | } 36 | tokenID, subject, ok := getTokenIDAndSubject(r.Context(), userinfoProvider, accessToken) 37 | if !ok { 38 | http.Error(w, "access token invalid", http.StatusUnauthorized) 39 | return 40 | } 41 | info := new(oidc.UserInfo) 42 | err = userinfoProvider.Storage().SetUserinfoFromToken(r.Context(), info, tokenID, subject, r.Header.Get("origin")) 43 | if err != nil { 44 | httphelper.MarshalJSONWithStatus(w, err, http.StatusForbidden) 45 | return 46 | } 47 | httphelper.MarshalJSON(w, info) 48 | } 49 | 50 | func ParseUserinfoRequest(r *http.Request, decoder httphelper.Decoder) (string, error) { 51 | ctx, span := tracer.Start(r.Context(), "ParseUserinfoRequest") 52 | r = r.WithContext(ctx) 53 | defer span.End() 54 | 55 | accessToken, err := getAccessToken(r) 56 | if err == nil { 57 | return accessToken, nil 58 | } 59 | err = r.ParseForm() 60 | if err != nil { 61 | return "", errors.New("unable to parse request") 62 | } 63 | req := new(oidc.UserInfoRequest) 64 | err = decoder.Decode(req, r.Form) 65 | if err != nil { 66 | return "", errors.New("unable to parse request") 67 | } 68 | return req.AccessToken, nil 69 | } 70 | 71 | func getAccessToken(r *http.Request) (string, error) { 72 | ctx, span := tracer.Start(r.Context(), "getAccessToken") 73 | r = r.WithContext(ctx) 74 | defer span.End() 75 | 76 | authHeader := r.Header.Get("authorization") 77 | if authHeader == "" { 78 | return "", errors.New("no auth header") 79 | } 80 | parts := strings.Split(authHeader, "Bearer ") 81 | if len(parts) != 2 { 82 | return "", errors.New("invalid auth header") 83 | } 84 | return parts[1], nil 85 | } 86 | 87 | func getTokenIDAndSubject(ctx context.Context, userinfoProvider UserinfoProvider, accessToken string) (string, string, bool) { 88 | ctx, span := tracer.Start(ctx, "getTokenIDAndSubject") 89 | defer span.End() 90 | 91 | tokenIDSubject, err := userinfoProvider.Crypto().Decrypt(accessToken) 92 | if err == nil { 93 | splitToken := strings.Split(tokenIDSubject, ":") 94 | if len(splitToken) != 2 { 95 | return "", "", false 96 | } 97 | return splitToken[0], splitToken[1], true 98 | } 99 | accessTokenClaims, err := VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) 100 | if err != nil { 101 | return "", "", false 102 | } 103 | return accessTokenClaims.JWTID, accessTokenClaims.Subject, true 104 | } 105 | -------------------------------------------------------------------------------- /pkg/op/verifier_access_token.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zitadel/oidc/v3/pkg/oidc" 7 | ) 8 | 9 | type AccessTokenVerifier oidc.Verifier 10 | 11 | type AccessTokenVerifierOpt func(*AccessTokenVerifier) 12 | 13 | func WithSupportedAccessTokenSigningAlgorithms(algs ...string) AccessTokenVerifierOpt { 14 | return func(verifier *AccessTokenVerifier) { 15 | verifier.SupportedSignAlgs = algs 16 | } 17 | } 18 | 19 | // NewAccessTokenVerifier returns a AccessTokenVerifier suitable for access token verification. 20 | func NewAccessTokenVerifier(issuer string, keySet oidc.KeySet, opts ...AccessTokenVerifierOpt) *AccessTokenVerifier { 21 | verifier := &AccessTokenVerifier{ 22 | Issuer: issuer, 23 | KeySet: keySet, 24 | } 25 | for _, opt := range opts { 26 | opt(verifier) 27 | } 28 | return verifier 29 | } 30 | 31 | // VerifyAccessToken validates the access token (issuer, signature and expiration). 32 | func VerifyAccessToken[C oidc.Claims](ctx context.Context, token string, v *AccessTokenVerifier) (claims C, err error) { 33 | ctx, span := tracer.Start(ctx, "VerifyAccessToken") 34 | defer span.End() 35 | 36 | var nilClaims C 37 | 38 | decrypted, err := oidc.DecryptToken(token) 39 | if err != nil { 40 | return nilClaims, err 41 | } 42 | payload, err := oidc.ParseToken(decrypted, &claims) 43 | if err != nil { 44 | return nilClaims, err 45 | } 46 | 47 | if err := oidc.CheckIssuer(claims, v.Issuer); err != nil { 48 | return nilClaims, err 49 | } 50 | 51 | if err = oidc.CheckSignature(ctx, decrypted, payload, claims, v.SupportedSignAlgs, v.KeySet); err != nil { 52 | return nilClaims, err 53 | } 54 | 55 | if err = oidc.CheckExpiration(claims, v.Offset); err != nil { 56 | return nilClaims, err 57 | } 58 | 59 | return claims, nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/op/verifier_access_token_example_test.go: -------------------------------------------------------------------------------- 1 | package op_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/oidc" 9 | "github.com/zitadel/oidc/v3/pkg/op" 10 | ) 11 | 12 | // MyCustomClaims extends the TokenClaims base, 13 | // so it implements 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 | CodeHash string `json:"c_hash,omitempty"` 19 | SessionID string `json:"sid,omitempty"` 20 | Scopes []string `json:"scope,omitempty"` 21 | AccessTokenUseNumber int `json:"at_use_nbr,omitempty"` 22 | Foo string `json:"foo,omitempty"` 23 | Bar *Nested `json:"bar,omitempty"` 24 | } 25 | 26 | // Nested struct types are also possible. 27 | type Nested struct { 28 | Count int `json:"count,omitempty"` 29 | Tags []string `json:"tags,omitempty"` 30 | } 31 | 32 | /* 33 | accessToken carries the following claims. foo and bar are custom claims 34 | 35 | { 36 | "aud": [ 37 | "unit", 38 | "test" 39 | ], 40 | "bar": { 41 | "count": 22, 42 | "tags": [ 43 | "some", 44 | "tags" 45 | ] 46 | }, 47 | "exp": 4802234675, 48 | "foo": "Hello, World!", 49 | "iat": 1678097014, 50 | "iss": "local.com", 51 | "jti": "9876", 52 | "nbf": 1678097014, 53 | "sub": "tim@local.com" 54 | } 55 | */ 56 | const accessToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOlsidW5pdCIsInRlc3QiXSwiYmFyIjp7ImNvdW50IjoyMiwidGFncyI6WyJzb21lIiwidGFncyJdfSwiZXhwIjo0ODAyMjM0Njc1LCJmb28iOiJIZWxsbywgV29ybGQhIiwiaWF0IjoxNjc4MDk3MDE0LCJpc3MiOiJsb2NhbC5jb20iLCJqdGkiOiI5ODc2IiwibmJmIjoxNjc4MDk3MDE0LCJzdWIiOiJ0aW1AbG9jYWwuY29tIn0.OUgk-B7OXjYlYFj-nogqSDJiQE19tPrbzqUHEAjcEiJkaWo6-IpGVfDiGKm-TxjXQsNScxpaY0Pg3XIh1xK6TgtfYtoLQm-5RYw_mXgb9xqZB2VgPs6nNEYFUDM513MOU0EBc0QMyqAEGzW-HiSPAb4ugCvkLtM1yo11Xyy6vksAdZNs_mJDT4X3vFXnr0jk0ugnAW6fTN3_voC0F_9HQUAkmd750OIxkAHxAMvEPQcpbLHenVvX_Q0QMrzClVrxehn5TVMfmkYYg7ocr876Bq9xQGPNHAcrwvVIJqdg5uMUA38L3HC2BEueG6furZGvc7-qDWAT1VR9liM5ieKpPg` 57 | 58 | func ExampleVerifyAccessToken_customClaims() { 59 | v := op.NewAccessTokenVerifier("local.com", tu.KeySet{}) 60 | 61 | // VerifyAccessToken can be called with the *MyCustomClaims. 62 | claims, err := op.VerifyAccessToken[*MyCustomClaims](context.TODO(), accessToken, v) 63 | if err != nil { 64 | panic(err) 65 | } 66 | 67 | // Here we have typesafe access to the custom claims 68 | fmt.Println(claims.Foo, claims.Bar.Count, claims.Bar.Tags) 69 | // Output: Hello, World! 22 [some tags] 70 | } 71 | -------------------------------------------------------------------------------- /pkg/op/verifier_access_token_test.go: -------------------------------------------------------------------------------- 1 | package op 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 | ) 13 | 14 | func TestNewAccessTokenVerifier(t *testing.T) { 15 | type args struct { 16 | issuer string 17 | keySet oidc.KeySet 18 | opts []AccessTokenVerifierOpt 19 | } 20 | tests := []struct { 21 | name string 22 | args args 23 | want *AccessTokenVerifier 24 | }{ 25 | { 26 | name: "simple", 27 | args: args{ 28 | issuer: tu.ValidIssuer, 29 | keySet: tu.KeySet{}, 30 | }, 31 | want: &AccessTokenVerifier{ 32 | Issuer: tu.ValidIssuer, 33 | KeySet: tu.KeySet{}, 34 | }, 35 | }, 36 | { 37 | name: "with signature algorithm", 38 | args: args{ 39 | issuer: tu.ValidIssuer, 40 | keySet: tu.KeySet{}, 41 | opts: []AccessTokenVerifierOpt{ 42 | WithSupportedAccessTokenSigningAlgorithms("ABC", "DEF"), 43 | }, 44 | }, 45 | want: &AccessTokenVerifier{ 46 | Issuer: tu.ValidIssuer, 47 | KeySet: tu.KeySet{}, 48 | SupportedSignAlgs: []string{"ABC", "DEF"}, 49 | }, 50 | }, 51 | } 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | got := NewAccessTokenVerifier(tt.args.issuer, tt.args.keySet, tt.args.opts...) 55 | assert.Equal(t, tt.want, got) 56 | }) 57 | } 58 | } 59 | 60 | func TestVerifyAccessToken(t *testing.T) { 61 | verifier := &AccessTokenVerifier{ 62 | Issuer: tu.ValidIssuer, 63 | MaxAgeIAT: 2 * time.Minute, 64 | Offset: time.Second, 65 | SupportedSignAlgs: []string{string(tu.SignatureAlgorithm)}, 66 | KeySet: tu.KeySet{}, 67 | } 68 | 69 | tests := []struct { 70 | name string 71 | tokenClaims func() (string, *oidc.AccessTokenClaims) 72 | wantErr bool 73 | }{ 74 | { 75 | name: "success", 76 | tokenClaims: tu.ValidAccessToken, 77 | }, 78 | { 79 | name: "parse err", 80 | tokenClaims: func() (string, *oidc.AccessTokenClaims) { return "~~~~", nil }, 81 | wantErr: true, 82 | }, 83 | { 84 | name: "invalid signature", 85 | tokenClaims: func() (string, *oidc.AccessTokenClaims) { return tu.InvalidSignatureToken, nil }, 86 | wantErr: true, 87 | }, 88 | { 89 | name: "wrong issuer", 90 | tokenClaims: func() (string, *oidc.AccessTokenClaims) { 91 | return tu.NewAccessToken( 92 | "foo", tu.ValidSubject, tu.ValidAudience, 93 | tu.ValidExpiration, tu.ValidJWTID, tu.ValidClientID, 94 | tu.ValidSkew, 95 | ) 96 | }, 97 | wantErr: true, 98 | }, 99 | { 100 | name: "expired", 101 | tokenClaims: func() (string, *oidc.AccessTokenClaims) { 102 | return tu.NewAccessToken( 103 | tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, 104 | tu.ValidExpiration.Add(-time.Hour), tu.ValidJWTID, tu.ValidClientID, 105 | tu.ValidSkew, 106 | ) 107 | }, 108 | wantErr: true, 109 | }, 110 | } 111 | for _, tt := range tests { 112 | t.Run(tt.name, func(t *testing.T) { 113 | token, want := tt.tokenClaims() 114 | 115 | got, err := VerifyAccessToken[*oidc.AccessTokenClaims](context.Background(), token, verifier) 116 | if tt.wantErr { 117 | assert.Error(t, err) 118 | assert.Nil(t, got) 119 | return 120 | } 121 | require.NoError(t, err) 122 | require.NotNil(t, got) 123 | assert.Equal(t, got, want) 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /pkg/op/verifier_id_token_hint.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/zitadel/oidc/v3/pkg/oidc" 8 | ) 9 | 10 | type IDTokenHintVerifier oidc.Verifier 11 | 12 | type IDTokenHintVerifierOpt func(*IDTokenHintVerifier) 13 | 14 | func WithSupportedIDTokenHintSigningAlgorithms(algs ...string) IDTokenHintVerifierOpt { 15 | return func(verifier *IDTokenHintVerifier) { 16 | verifier.SupportedSignAlgs = algs 17 | } 18 | } 19 | 20 | func NewIDTokenHintVerifier(issuer string, keySet oidc.KeySet, opts ...IDTokenHintVerifierOpt) *IDTokenHintVerifier { 21 | verifier := &IDTokenHintVerifier{ 22 | Issuer: issuer, 23 | KeySet: keySet, 24 | } 25 | for _, opt := range opts { 26 | opt(verifier) 27 | } 28 | return verifier 29 | } 30 | 31 | type IDTokenHintExpiredError struct { 32 | error 33 | } 34 | 35 | func (e IDTokenHintExpiredError) Unwrap() error { 36 | return e.error 37 | } 38 | 39 | func (e IDTokenHintExpiredError) Is(err error) bool { 40 | return errors.Is(err, e.error) 41 | } 42 | 43 | // VerifyIDTokenHint validates the id token according to 44 | // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation. 45 | // In case of an expired token both the Claims and first encountered expiry related error 46 | // is returned of type [IDTokenHintExpiredError]. In that case the caller can choose to still 47 | // trust the token for cases like logout, as signature and other verifications succeeded. 48 | func VerifyIDTokenHint[C oidc.Claims](ctx context.Context, token string, v *IDTokenHintVerifier) (claims C, err error) { 49 | ctx, span := tracer.Start(ctx, "VerifyIDTokenHint") 50 | defer span.End() 51 | 52 | var nilClaims C 53 | 54 | decrypted, err := oidc.DecryptToken(token) 55 | if err != nil { 56 | return nilClaims, err 57 | } 58 | payload, err := oidc.ParseToken(decrypted, &claims) 59 | if err != nil { 60 | return nilClaims, err 61 | } 62 | 63 | if err := oidc.CheckIssuer(claims, v.Issuer); err != nil { 64 | return nilClaims, err 65 | } 66 | 67 | if err = oidc.CheckSignature(ctx, decrypted, payload, claims, v.SupportedSignAlgs, v.KeySet); err != nil { 68 | return nilClaims, err 69 | } 70 | 71 | if err = oidc.CheckAuthorizationContextClassReference(claims, v.ACR); err != nil { 72 | return nilClaims, err 73 | } 74 | 75 | if err = oidc.CheckExpiration(claims, v.Offset); err != nil { 76 | return claims, IDTokenHintExpiredError{err} 77 | } 78 | 79 | if err = oidc.CheckIssuedAt(claims, v.MaxAgeIAT, v.Offset); err != nil { 80 | return claims, IDTokenHintExpiredError{err} 81 | } 82 | 83 | if err = oidc.CheckAuthTime(claims, v.MaxAge); err != nil { 84 | return claims, IDTokenHintExpiredError{err} 85 | } 86 | return claims, nil 87 | } 88 | -------------------------------------------------------------------------------- /pkg/op/verifier_jwt_profile.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | jose "github.com/go-jose/go-jose/v4" 10 | 11 | "github.com/zitadel/oidc/v3/pkg/oidc" 12 | ) 13 | 14 | // JWTProfileVerfiier extends oidc.Verifier with 15 | // a jwtProfileKeyStorage and a function to check 16 | // the subject in a token. 17 | type JWTProfileVerifier struct { 18 | oidc.Verifier 19 | Storage JWTProfileKeyStorage 20 | keySet oidc.KeySet 21 | CheckSubject func(request *oidc.JWTTokenRequest) error 22 | } 23 | 24 | // NewJWTProfileVerifier creates a oidc.Verifier for JWT Profile assertions (authorization grant and client authentication) 25 | func NewJWTProfileVerifier(storage JWTProfileKeyStorage, issuer string, maxAgeIAT, offset time.Duration, opts ...JWTProfileVerifierOption) *JWTProfileVerifier { 26 | return newJWTProfileVerifier(storage, nil, issuer, maxAgeIAT, offset, opts...) 27 | } 28 | 29 | // NewJWTProfileVerifierKeySet creates a oidc.Verifier for JWT Profile assertions (authorization grant and client authentication) 30 | func NewJWTProfileVerifierKeySet(keySet oidc.KeySet, issuer string, maxAgeIAT, offset time.Duration, opts ...JWTProfileVerifierOption) *JWTProfileVerifier { 31 | return newJWTProfileVerifier(nil, keySet, issuer, maxAgeIAT, offset, opts...) 32 | } 33 | 34 | func newJWTProfileVerifier(storage JWTProfileKeyStorage, keySet oidc.KeySet, issuer string, maxAgeIAT, offset time.Duration, opts ...JWTProfileVerifierOption) *JWTProfileVerifier { 35 | j := &JWTProfileVerifier{ 36 | Verifier: oidc.Verifier{ 37 | Issuer: issuer, 38 | MaxAgeIAT: maxAgeIAT, 39 | Offset: offset, 40 | }, 41 | Storage: storage, 42 | keySet: keySet, 43 | CheckSubject: SubjectIsIssuer, 44 | } 45 | 46 | for _, opt := range opts { 47 | opt(j) 48 | } 49 | 50 | return j 51 | } 52 | 53 | type JWTProfileVerifierOption func(*JWTProfileVerifier) 54 | 55 | // SubjectCheck sets a custom function to check the subject. 56 | // Defaults to SubjectIsIssuer() 57 | func SubjectCheck(check func(request *oidc.JWTTokenRequest) error) JWTProfileVerifierOption { 58 | return func(verifier *JWTProfileVerifier) { 59 | verifier.CheckSubject = check 60 | } 61 | } 62 | 63 | // VerifyJWTAssertion verifies the assertion string from JWT Profile (authorization grant and client authentication) 64 | // 65 | // checks audience, exp, iat, signature and that issuer and sub are the same 66 | func VerifyJWTAssertion(ctx context.Context, assertion string, v *JWTProfileVerifier) (*oidc.JWTTokenRequest, error) { 67 | ctx, span := tracer.Start(ctx, "VerifyJWTAssertion") 68 | defer span.End() 69 | 70 | request := new(oidc.JWTTokenRequest) 71 | payload, err := oidc.ParseToken(assertion, request) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | if err = oidc.CheckAudience(request, v.Issuer); err != nil { 77 | return nil, err 78 | } 79 | 80 | if err = oidc.CheckExpiration(request, v.Offset); err != nil { 81 | return nil, err 82 | } 83 | 84 | if err = oidc.CheckIssuedAt(request, v.MaxAgeIAT, v.Offset); err != nil { 85 | return nil, err 86 | } 87 | 88 | if err = v.CheckSubject(request); err != nil { 89 | return nil, err 90 | } 91 | 92 | keySet := v.keySet 93 | if keySet == nil { 94 | keySet = &jwtProfileKeySet{storage: v.Storage, clientID: request.Issuer} 95 | } 96 | if err = oidc.CheckSignature(ctx, assertion, payload, request, nil, keySet); err != nil { 97 | return nil, err 98 | } 99 | return request, nil 100 | } 101 | 102 | type JWTProfileKeyStorage interface { 103 | GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) 104 | } 105 | 106 | // SubjectIsIssuer 107 | func SubjectIsIssuer(request *oidc.JWTTokenRequest) error { 108 | if request.Issuer != request.Subject { 109 | return errors.New("delegation not allowed, issuer and sub must be identical") 110 | } 111 | return nil 112 | } 113 | 114 | type jwtProfileKeySet struct { 115 | storage JWTProfileKeyStorage 116 | clientID string 117 | } 118 | 119 | // VerifySignature implements oidc.KeySet by getting the public key from Storage implementation 120 | func (k *jwtProfileKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) { 121 | ctx, span := tracer.Start(ctx, "VerifySignature") 122 | defer span.End() 123 | 124 | keyID, _ := oidc.GetKeyIDAndAlg(jws) 125 | key, err := k.storage.GetKeyByIDAndClientID(ctx, keyID, k.clientID) 126 | if err != nil { 127 | return nil, fmt.Errorf("error fetching keys: %w", err) 128 | } 129 | return jws.Verify(key) 130 | } 131 | -------------------------------------------------------------------------------- /pkg/op/verifier_jwt_profile_test.go: -------------------------------------------------------------------------------- 1 | package op_test 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 | "github.com/zitadel/oidc/v3/pkg/op" 13 | ) 14 | 15 | func TestNewJWTProfileVerifier(t *testing.T) { 16 | want := &op.JWTProfileVerifier{ 17 | Verifier: oidc.Verifier{ 18 | Issuer: tu.ValidIssuer, 19 | MaxAgeIAT: time.Minute, 20 | Offset: time.Second, 21 | }, 22 | Storage: tu.JWTProfileKeyStorage{}, 23 | } 24 | got := op.NewJWTProfileVerifier(tu.JWTProfileKeyStorage{}, tu.ValidIssuer, time.Minute, time.Second, op.SubjectCheck(func(request *oidc.JWTTokenRequest) error { 25 | return oidc.ErrSubjectMissing 26 | })) 27 | assert.Equal(t, want.Verifier, got.Verifier) 28 | assert.Equal(t, want.Storage, got.Storage) 29 | assert.ErrorIs(t, got.CheckSubject(nil), oidc.ErrSubjectMissing) 30 | } 31 | 32 | func TestVerifyJWTAssertion(t *testing.T) { 33 | errCtx, cancel := context.WithCancel(context.Background()) 34 | cancel() 35 | 36 | verifier := op.NewJWTProfileVerifier(tu.JWTProfileKeyStorage{}, tu.ValidIssuer, time.Minute, 0) 37 | tests := []struct { 38 | name string 39 | ctx context.Context 40 | newToken func() (string, *oidc.JWTTokenRequest) 41 | wantErr bool 42 | }{ 43 | { 44 | name: "parse error", 45 | ctx: context.Background(), 46 | newToken: func() (string, *oidc.JWTTokenRequest) { return "!", nil }, 47 | wantErr: true, 48 | }, 49 | { 50 | name: "wrong audience", 51 | ctx: context.Background(), 52 | newToken: func() (string, *oidc.JWTTokenRequest) { 53 | return tu.NewJWTProfileAssertion( 54 | tu.ValidClientID, tu.ValidClientID, []string{"wrong"}, 55 | time.Now(), tu.ValidExpiration, 56 | ) 57 | }, 58 | wantErr: true, 59 | }, 60 | { 61 | name: "expired", 62 | ctx: context.Background(), 63 | newToken: func() (string, *oidc.JWTTokenRequest) { 64 | return tu.NewJWTProfileAssertion( 65 | tu.ValidClientID, tu.ValidClientID, []string{tu.ValidIssuer}, 66 | time.Now(), time.Now().Add(-time.Hour), 67 | ) 68 | }, 69 | wantErr: true, 70 | }, 71 | { 72 | name: "invalid iat", 73 | ctx: context.Background(), 74 | newToken: func() (string, *oidc.JWTTokenRequest) { 75 | return tu.NewJWTProfileAssertion( 76 | tu.ValidClientID, tu.ValidClientID, []string{tu.ValidIssuer}, 77 | time.Now().Add(time.Hour), tu.ValidExpiration, 78 | ) 79 | }, 80 | wantErr: true, 81 | }, 82 | { 83 | name: "invalid subject", 84 | ctx: context.Background(), 85 | newToken: func() (string, *oidc.JWTTokenRequest) { 86 | return tu.NewJWTProfileAssertion( 87 | tu.ValidClientID, "wrong", []string{tu.ValidIssuer}, 88 | time.Now(), tu.ValidExpiration, 89 | ) 90 | }, 91 | wantErr: true, 92 | }, 93 | { 94 | name: "check signature fail", 95 | ctx: errCtx, 96 | newToken: tu.ValidJWTProfileAssertion, 97 | wantErr: true, 98 | }, 99 | { 100 | name: "ok", 101 | ctx: context.Background(), 102 | newToken: tu.ValidJWTProfileAssertion, 103 | }, 104 | } 105 | for _, tt := range tests { 106 | t.Run(tt.name, func(t *testing.T) { 107 | assertion, want := tt.newToken() 108 | got, err := op.VerifyJWTAssertion(tt.ctx, assertion, verifier) 109 | if tt.wantErr { 110 | assert.Error(t, err) 111 | return 112 | } 113 | require.NoError(t, err) 114 | assert.Equal(t, want, got) 115 | }) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pkg/strings/strings.go: -------------------------------------------------------------------------------- 1 | package strings 2 | 3 | import "slices" 4 | 5 | // Deprecated: Use standard library [slices.Contains] instead. 6 | func Contains(list []string, needle string) bool { 7 | // TODO(v4): remove package. 8 | return slices.Contains(list, needle) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/strings/strings_test.go: -------------------------------------------------------------------------------- 1 | package strings 2 | 3 | import "testing" 4 | 5 | func TestContains(t *testing.T) { 6 | type args struct { 7 | list []string 8 | needle string 9 | } 10 | tests := []struct { 11 | name string 12 | args args 13 | want bool 14 | }{ 15 | { 16 | "empty list false", 17 | args{[]string{}, "needle"}, 18 | false, 19 | }, 20 | { 21 | "list not containing false", 22 | args{[]string{"list"}, "needle"}, 23 | false, 24 | }, 25 | { 26 | "list not containing empty needle false", 27 | args{[]string{"list", "needle"}, ""}, 28 | false, 29 | }, 30 | { 31 | "list containing true", 32 | args{[]string{"list", "needle"}, "needle"}, 33 | true, 34 | }, 35 | { 36 | "list containing empty needle true", 37 | args{[]string{"list", "needle", ""}, ""}, 38 | true, 39 | }, 40 | } 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | if got := Contains(tt.args.list, tt.args.needle); got != tt.want { 44 | t.Errorf("Contains() = %v, want %v", got, tt.want) 45 | } 46 | }) 47 | } 48 | } 49 | --------------------------------------------------------------------------------