├── .circleci └── config.yml ├── .dependencies.yml ├── .editorconfig ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── .vscode └── launch.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.md ├── adaptors ├── adaptor.go └── lestrratGoJwx │ └── lestrratGoJwx.go ├── discovery ├── discovery.go └── oidc │ └── oidc.go ├── errors └── JwtEmptyString.go ├── go.mod ├── go.sum ├── jwtverifier.go ├── jwtverifier_test.go └── utils ├── cache.go ├── cache_example_test.go ├── cache_test.go ├── nonce.go ├── parseEnv.go ├── pkce_code_verifier.go └── pkce_code_verifier_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Use the latest 2.1 version of CircleCI pipeline process engine. 2 | # See: https://circleci.com/docs/2.0/configuration-reference 3 | version: 2.1 4 | 5 | orbs: 6 | general-platform-helpers: okta/general-platform-helpers@1.9.4 7 | python: circleci/python@2.0.3 8 | aws-cli: circleci/aws-cli@5.1 9 | 10 | # Define a job to be invoked later in a workflow. 11 | # See: https://circleci.com/docs/2.0/configuration-reference/#jobs 12 | jobs: 13 | test: 14 | docker: 15 | - image: cimg/go:1.19.4 16 | steps: 17 | - checkout 18 | - run: go version 19 | - general-platform-helpers/step-load-dependencies 20 | - run: 21 | name: "test stage" 22 | command: make test 23 | 24 | snyk-scan: 25 | docker: 26 | - image: cimg/go:1.19.4 27 | steps: 28 | - checkout 29 | - general-platform-helpers/step-load-dependencies 30 | - general-platform-helpers/step-run-snyk-monitor: 31 | scan-all-projects: false 32 | skip-unresolved: false 33 | run-on-non-main: true 34 | 35 | reversing-labs: 36 | docker: 37 | - image: cimg/go:1.21.13 38 | steps: 39 | - checkout 40 | - run: 41 | name: Install Python 42 | command: | 43 | sudo apt-get update 44 | sudo apt-get install -y python3 python3-pip 45 | sudo pip install --upgrade pip 46 | - run: 47 | name: Download Reverse Labs Scanner 48 | command: | 49 | curl https://dso-resources.oktasecurity.com/scanner \ 50 | -H "x-api-key: $DSO_RLSECURE_TOKEN" \ 51 | --output rl_wrapper-0.0.2+35ababa-py3-none-any.whl 52 | - run: 53 | name: Install RL Wrapper 54 | command: | 55 | pip install ./rl_wrapper-0.0.2+35ababa-py3-none-any.whl 56 | - aws-cli/setup: 57 | profile_name: default 58 | role_arn: $AWS_ARN 59 | region: us-east-1 60 | - run: >- 61 | eval "$(aws configure export-credentials --profile default --format env)" 2> /dev/null 62 | - run: 63 | name: Build binary to scan 64 | command: | 65 | go mod vendor 66 | go build 67 | - run: 68 | name: Run Reversing Labs Wrapper Scanner 69 | command: | 70 | rl-wrapper \ 71 | --artifact ${CIRCLE_WORKING_DIRECTORY/#\~/$HOME} \ 72 | --name $CIRCLE_PROJECT_REPONAME\ 73 | --version $CIRCLE_SHA1\ 74 | --repository $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME \ 75 | --commit $CIRCLE_SHA1 \ 76 | --build-env "circleci" \ 77 | --suppress_output 78 | 79 | # Invoke jobs via workflows 80 | # See: https://circleci.com/docs/2.0/configuration-reference/#workflows 81 | workflows: 82 | "Circle CI Tests": 83 | jobs: 84 | - test 85 | - snyk-scan: 86 | name: execute-snyk 87 | context: 88 | - static-analysis 89 | 90 | semgrep: 91 | jobs: 92 | - general-platform-helpers/job-semgrep-scan: 93 | name: "Scan with Semgrep" 94 | resource-class: "medium" 95 | context: 96 | - static-analysis 97 | 98 | "Malware Scanner": 99 | jobs: 100 | - reversing-labs: 101 | context: 102 | - static-analysis 103 | # VS Code Extension Version: 1.4.0 104 | -------------------------------------------------------------------------------- /.dependencies.yml: -------------------------------------------------------------------------------- 1 | vault: 2 | - USERNAME: 3 | devex/okta-jwt-verifier-golang: USERNAME 4 | - PASSWORD: 5 | devex/okta-jwt-verifier-golang: PASSWORD 6 | - CLIENT_ID: 7 | devex/okta-jwt-verifier-golang: CLIENT_ID 8 | - ISSUER: 9 | devex/okta-jwt-verifier-golang: ISSUER 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | 11 | [*.go] 12 | indent_style = tab 13 | indent_size = 8 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This GitHub action can publish assets for release when a tag is created. 2 | # Currently its setup to run on any tag that matches the pattern "v*" (ie. v0.1.0). 3 | # 4 | # This uses an action (hashicorp/ghaction-import-gpg) that assumes you set your 5 | # private key in the `GPG_PRIVATE_KEY` secret and passphrase in the `PASSPHRASE` 6 | # secret. If you would rather own your own GPG handling, please fork this action 7 | # or use an alternative one for key handling. 8 | # 9 | # You will need to pass the `--batch` flag to `gpg` in your signing step 10 | # in `goreleaser` to indicate this is being used in a non-interactive mode. 11 | # 12 | name: release 13 | on: 14 | push: 15 | branches: [ master ] 16 | tags: 17 | - 'v*' 18 | jobs: 19 | goreleaser: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - 23 | name: Checkout 24 | uses: actions/checkout@v3.0.0 25 | - 26 | name: Unshallow 27 | run: git fetch --prune --unshallow 28 | - 29 | name: Set up Go 30 | uses: actions/setup-go@v3.0.0 31 | with: 32 | go-version: 1.17 33 | - 34 | name: Run GoReleaser 35 | uses: goreleaser/goreleaser-action@v2.9.1 36 | with: 37 | version: latest 38 | args: release --clean 39 | env: 40 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 41 | # GitHub sets this automatically 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .vscode 3 | .idea 4 | /vendor 5 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - 4 | skip: true 5 | release: 6 | github: 7 | owner: okta 8 | name: okta-jwt-verifier-golang 9 | draft: true 10 | changelog: 11 | disable: true 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}", 13 | "env": {}, 14 | "args": [] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.1.0 (September 5th, 2024) 4 | 5 | ### Updates: 6 | 7 | Make Audience validation optional [#116](https://github.com/okta/okta-jwt-verifier-golang/pull/116) 8 | 9 | ## v2.0.5 (September 3rd, 2024) 10 | 11 | ### Updates: 12 | 13 | Make audience optional [#117](https://github.com/okta/okta-jwt-verifier-golang/pull/117) 14 | Update jwx library [#114](https://github.com/okta/okta-jwt-verifier-golang/pull/114) 15 | 16 | ## v2.0.4 (February 26th, 2024) 17 | 18 | ### Updates: 19 | 20 | Update staticcheck [#110](https://github.com/okta/okta-jwt-verifier-golang/pull/110) 21 | 22 | ## v2.0.3 (July 28th, 2023) 23 | 24 | ### Updates: 25 | 26 | * Fixing the race condition [#102](https://github.com/okta/okta-jwt-verifier-golang/pull/102) 27 | 28 | ## v2.0.2 (May 18th, 2023) 29 | 30 | ### Updates: 31 | 32 | * Correct okta-jwt-verifier-golang version reference to v2 [#101](https://github.com/okta/okta-jwt-verifier-golang/pull/101) 33 | 34 | ## v2.0.1 (May 15th, 2023) 35 | 36 | ### Enhancements: 37 | 38 | * Customizable HTTP client [#99](https://github.com/okta/okta-jwt-verifier-golang/pull/99) 39 | 40 | ### Updates: 41 | 42 | * Project maintenance for CI [#95](https://github.com/okta/okta-jwt-verifier-golang/pull/95) 43 | * Correct logging typo [#91](https://github.com/okta/okta-jwt-verifier-golang/pull/91) 44 | * Replace `math/rand` with `crypto/rand` [#89](https://github.com/okta/okta-jwt-verifier-golang/pull/89) 45 | 46 | ## v2.0.0 (January 4th, 2023) 47 | 48 | ### Enhancements: 49 | 50 | * Customizable cache timeout and change to the cache method. [#92](https://github.com/okta/okta-jwt-verifier-golang/pull/92) 51 | 52 | ## v1.3.1 (April 6th, 2022) 53 | 54 | ### Updates: 55 | 56 | * Correctly error if metadata from issuer is not 200. [#85](https://github.com/okta/okta-jwt-verifier-golang/pull/85). Thanks, [@monde](https://github.com/monde)! 57 | 58 | ## v1.3.0 (March 17th, 2022) 59 | 60 | ### Enhancements: 61 | 62 | * New PCKE code verifier utility. [#81](https://github.com/okta/okta-jwt-verifier-golang/pull/81). Thanks, [@deepu105](https://github.com/deepu105)! 63 | 64 | ## v1.2.1 (February 16, 2022) 65 | 66 | ### Updates 67 | 68 | * Update JWX package. Thanks, [@thomassampson](https://github.com/thomassampson)! 69 | 70 | ## v1.2.0 (February 16, 2022) 71 | 72 | ### Updates 73 | 74 | * Customizable resource cache. Thanks, [@tschaub](https://github.com/tschaub)! 75 | 76 | ## v1.1.3 77 | 78 | ### Updates 79 | 80 | - Fixed edge cause with `aud` claim that would not find Auth0 being JWTs valid. Thanks [@awrenn](https://github.com/awrenn)! 81 | - Updated readme with testing notes. 82 | - Ran `gofumpt` on code for clean up. 83 | 84 | ## v1.1.2 85 | 86 | ### Updates 87 | 88 | - Only `alg` and `kid` claims in a JWT header are considered during verification. 89 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to Okta Open Source Repos 2 | ====================================== 3 | 4 | Sign the CLA 5 | ------------ 6 | 7 | If you haven't already, [sign the CLA](https://developer.okta.com/cla/). Common questions/answers are also listed on the CLA page. 8 | 9 | Summary 10 | ------- 11 | This document covers how to contribute to an Okta Open Source project. These instructions assume you have a GitHub 12 | .com account, so if you don't have one you will have to create one. Your proposed code changes will be published to 13 | your own fork of the Okta JWT verifier for Golang project and you will submit a Pull Request for your changes to be added. 14 | 15 | _Lets get started!!!_ 16 | 17 | 18 | Fork the code 19 | ------------- 20 | 21 | In your browser, navigate to: [https://github.com/okta/okta-jwt-verifier-golang](https://github.com/okta/okta-jwt-verifier-golang) 22 | 23 | Fork the repository by clicking on the 'Fork' button on the top right hand side. The fork will happen and you will be taken to your own fork of the repository. Copy the Git repository URL by clicking on the clipboard next to the URL on the right hand side of the page under '**HTTPS** clone URL'. You will paste this URL when doing the following `git clone` command. 24 | 25 | On your computer, follow these steps to setup a local repository for working on the Okta JWT verifier for Golang: 26 | 27 | ``` bash 28 | $ git clone https://github.com/YOUR_ACCOUNT/okta-jwt-verifier-golang.git 29 | $ cd okta-jwt-verifier-golang 30 | $ git remote add upstream https://github.com/okta/okta-jwt-verifier-golang.git 31 | $ git checkout develop 32 | $ git fetch upstream 33 | $ git rebase upstream/develop 34 | ``` 35 | 36 | 37 | Making changes 38 | -------------- 39 | 40 | It is important that you create a new branch to make changes on and that you do not change the `develop` 41 | branch (other than to rebase in changes from `upstream/develop`). In this example I will assume you will be making 42 | your changes to a branch called `feature/myFeature`. This `feature/myFeature` branch will be created on your local repository and will be pushed to your forked repository on GitHub. Once this branch is on your fork you will create a Pull Request for the changes to be added to the Okta JWT verifier for Golang project. 43 | 44 | It is best practice to create a new branch each time you want to contribute to the project and only track the changes for that pull request in this branch. 45 | 46 | ``` bash 47 | $ git checkout develop 48 | $ git checkout -b feature/myFeature 49 | (make your changes) 50 | $ git status 51 | $ git add 52 | $ git commit -m "descriptive commit message for your changes" 53 | ``` 54 | 55 | > The `-b` specifies that you want to create a new branch called `feature/myFeature`. You only specify `-b` the first time you checkout because you are creating a new branch. Once the `feature/myFeature` branch exists, you can later switch to it with only `git checkout feature/myFeature`. 56 | 57 | 58 | Rebase `feature/myFeature` to include updates from `upstream/develop` 59 | ------------------------------------------------------------ 60 | 61 | It is important that you maintain an up-to-date `develop` branch in your local repository. This is done by rebasing in 62 | the code changes from `upstream/develop` (the official Okta JWT verifier for Golang repository) into your local repository. 63 | You will want to do this before you start working on a feature as well as right before you submit your changes as a pull request. I recommend you do this process periodically while you work to make sure you are working off the most recent project code. 64 | 65 | This process will do the following: 66 | 67 | 1. Checkout your local `develop` branch 68 | 2. Synchronize your local `develop` branch with the `upstream/develop` so you have all the latest changes from the 69 | project 70 | 3. Rebase the latest project code into your `feature/myFeature` branch so it is up-to-date with the upstream code 71 | 72 | ``` bash 73 | $ git checkout develop 74 | $ git fetch upstream 75 | $ git rebase upstream/develop 76 | $ git checkout feature/myFeature 77 | $ git rebase develop 78 | ``` 79 | 80 | > Now your `feature/myFeature` branch is up-to-date with all the code in `upstream/develop`. 81 | 82 | 83 | Make a GitHub Pull Request to contribute your changes 84 | ----------------------------------------------------- 85 | 86 | When you are happy with your changes and you are ready to contribute them, you will create a Pull Request on GitHub to do so. This is done by pushing your local changes to your forked repository (default remote name is `origin`) and then initiating a pull request on GitHub. 87 | 88 | > **IMPORTANT:** Make sure you have rebased your `feature/myFeature` branch to include the latest code from `upstream/develop` 89 | _before_ you do this. 90 | 91 | ``` bash 92 | $ git push origin feature/myFeature 93 | ``` 94 | 95 | Now that the `feature/myFeature` branch has been pushed to your GitHub repository, you can initiate the pull request. 96 | 97 | To initiate the pull request, do the following: 98 | 99 | 1. In your browser, navigate to your forked repository: [https://github.com/YOUR_ACCOUNT/okta-jwt-verifier-golang](https://github 100 | .com/YOUR_ACCOUNT/okta-jwt-verifier-golang) 101 | 2. Click the new button called '**Compare & pull request**' that showed up just above the main area in your forked repository 102 | 3. Validate the pull request will be into the upstream `develop` and will be from your `feature/myFeature` branch 103 | 4. Enter a detailed description of the work you have done and then click '**Send pull request**' 104 | 105 | If you are requested to make modifications to your proposed changes, make the changes locally on your `feature/myFeature` branch, re-push the `feature/myFeature` branch to your fork. The existing pull request should automatically pick up the change and update accordingly. 106 | 107 | 108 | Cleaning up after a successful pull request 109 | ------------------------------------------- 110 | 111 | Once the `feature/myFeature` branch has been committed into the `upstream/develop` branch, your local `feature/myFeature` branch and 112 | the `origin/feature/myFeature` branch are no longer needed. If you want to make additional changes, restart the process with a new branch. 113 | 114 | > **IMPORTANT:** Make sure that your changes are in `upstream/develop` before you delete your `feature/myFeature` and 115 | `origin/feature/myFeature` branches! 116 | 117 | You can delete these deprecated branches with the following: 118 | 119 | ``` bash 120 | $ git checkout master 121 | $ git branch -D feature/myFeature 122 | $ git push origin :feature/myFeature 123 | ``` -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-Present Okta Inc. 2 | 3 | Apache License 4 | Version 2.0, January 2004 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, and 12 | distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 15 | owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all other entities 18 | that control, are controlled by, or are under common control with that entity. 19 | For the purposes of this definition, "control" means (i) the power, direct or 20 | indirect, to cause the direction or management of such entity, whether by 21 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | You" (or "Your") shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, including 28 | but not limited to software source code, documentation source, and 29 | configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical transformation or 32 | translation of a Source form, including but not limited to compiled object 33 | code, generated documentation, and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or Object form, 36 | made available under the License, as indicated by a copyright notice that is 37 | included in or attached to the work (an example is provided in the Appendix 38 | below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object form, that 41 | is based on (or derived from) the Work and for which the editorial revisions, 42 | annotations, elaborations, or other modifications represent, as a whole, an 43 | original work of authorship. For the purposes of this License, Derivative Works 44 | shall not include works that remain separable from, or merely link (or bind by 45 | name) to the interfaces of, the Work and Derivative Works thereof. 46 | 47 | "Contribution" shall mean any work of authorship, including the original 48 | version of the Work and any modifications or additions to that Work or 49 | Derivative Works thereof, that is intentionally submitted to Licensor for 50 | inclusion in the Work by the copyright owner or by an individual or Legal 51 | Entity authorized to submit on behalf of the copyright owner. For the purposes 52 | of this definition, "submitted" means any form of electronic, verbal, or 53 | written communication sent to the Licensor or its representatives, including 54 | but not limited to communication on electronic mailing lists, source code 55 | control systems, and issue tracking systems that are managed by, or on behalf 56 | of, the Licensor for the purpose of discussing and improving the Work, but 57 | excluding communication that is conspicuously marked or otherwise designated in 58 | writing by the copyright owner as "Not a Contribution." 59 | 60 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 61 | of whom a Contribution has been received by Licensor and subsequently 62 | incorporated within the Work. 63 | 2. Grant of Copyright License. Subject to the terms and conditions of this 64 | License, each Contributor hereby grants to You a perpetual, worldwide, 65 | non-exclusive, no-charge, royalty-free, irrevocable copyright license to 66 | reproduce, prepare Derivative Works of, publicly display, publicly perform, 67 | sublicense, and distribute the Work and such Derivative Works in Source or 68 | Object form. 69 | 70 | 3. Grant of Patent License. Subject to the terms and conditions of this 71 | License, each Contributor hereby grants to You a perpetual, worldwide, 72 | non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this 73 | section) patent license to make, have made, use, offer to sell, sell, import, 74 | and otherwise transfer the Work, where such license applies only to those 75 | patent claims licensable by such Contributor that are necessarily infringed by 76 | their Contribution(s) alone or by combination of their Contribution(s) with the 77 | Work to which such Contribution(s) was submitted. If You institute patent 78 | litigation against any entity (including a cross-claim or counterclaim in a 79 | lawsuit) alleging that the Work or a Contribution incorporated within the Work 80 | constitutes direct or contributory patent infringement, then any patent 81 | licenses granted to You under this License for that Work shall terminate as of 82 | the date such litigation is filed. 83 | 84 | 4. Redistribution. You may reproduce and distribute copies of the Work or 85 | Derivative Works thereof in any medium, with or without modifications, and in 86 | Source or Object form, provided that You meet the following conditions: 87 | 88 | (a) You must give any other recipients of the Work or Derivative Works a copy 89 | of this License; and 90 | 91 | (b) You must cause any modified files to carry prominent notices stating that 92 | You changed the files; and 93 | 94 | (c) You must retain, in the Source form of any Derivative Works that You 95 | distribute, all copyright, patent, trademark, and attribution notices from the 96 | Source form of the Work, excluding those notices that do not pertain to any 97 | part of the Derivative Works; and 98 | 99 | (d) If the Work includes a "NOTICE" text file as part of its distribution, then 100 | any Derivative Works that You distribute must include a readable copy of the 101 | attribution notices contained within such NOTICE file, excluding those notices 102 | that do not pertain to any part of the Derivative Works, in at least one of the 103 | following places: within a NOTICE text file distributed as part of the 104 | Derivative Works; within the Source form or documentation, if provided along 105 | with the Derivative Works; or, within a display generated by the Derivative 106 | Works, if and wherever such third-party notices normally appear. The contents 107 | of the NOTICE file are for informational purposes only and do not modify the 108 | License. You may add Your own attribution notices within Derivative Works that 109 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 110 | provided that such additional attribution notices cannot be construed as 111 | modifying the License. 112 | 113 | You may add Your own copyright statement to Your modifications and may provide 114 | additional or different license terms and conditions for use, reproduction, or 115 | distribution of Your modifications, or for any such Derivative Works as a 116 | whole, provided Your use, reproduction, and distribution of the Work otherwise 117 | complies with the conditions stated in this License. 118 | 119 | 5. Submission of Contributions. Unless You explicitly state otherwise, any 120 | Contribution intentionally submitted for inclusion in the Work by You to the 121 | Licensor shall be under the terms and conditions of this License, without any 122 | additional terms or conditions. Notwithstanding the above, nothing herein shall 123 | supersede or modify the terms of any separate license agreement you may have 124 | executed with Licensor regarding such Contributions. 125 | 126 | 6. Trademarks. This License does not grant permission to use the trade names, 127 | trademarks, service marks, or product names of the Licensor, except as required 128 | for reasonable and customary use in describing the origin of the Work and 129 | reproducing the content of the NOTICE file. 130 | 131 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in 132 | writing, Licensor provides the Work (and each Contributor provides its 133 | Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 134 | KIND, either express or implied, including, without limitation, any warranties 135 | or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 136 | PARTICULAR PURPOSE. You are solely responsible for determining the 137 | appropriateness of using or redistributing the Work and assume any risks 138 | associated with Your exercise of permissions under this License. 139 | 140 | 8. Limitation of Liability. In no event and under no legal theory, whether in 141 | tort (including negligence), contract, or otherwise, unless required by 142 | applicable law (such as deliberate and grossly negligent acts) or agreed to in 143 | writing, shall any Contributor be liable to You for damages, including any 144 | direct, indirect, special, incidental, or consequential damages of any 145 | character arising as a result of this License or out of the use or inability to 146 | use the Work (including but not limited to damages for loss of goodwill, work 147 | stoppage, computer failure or malfunction, or any and all other commercial 148 | damages or losses), even if such Contributor has been advised of the 149 | possibility of such damages. 150 | 151 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or 152 | Derivative Works thereof, You may choose to offer, and charge a fee for, 153 | acceptance of support, warranty, indemnity, or other liability obligations 154 | and/or rights consistent with this License. However, in accepting such 155 | obligations, You may act only on Your own behalf and on Your sole 156 | responsibility, not on behalf of any other Contributor, and only if You agree 157 | to indemnify, defend, and hold each Contributor harmless for any liability 158 | incurred by, or claims asserted against, such Contributor by reason of your 159 | accepting any such warranty or additional liability. 160 | 161 | END OF TERMS AND CONDITIONS 162 | 163 | APPENDIX: How to apply the Apache License to your work. 164 | 165 | To apply the Apache License to your work, attach the following boilerplate 166 | notice, with the fields enclosed by brackets "[]" replaced with your own 167 | identifying information. (Don't include the brackets!) The text should be 168 | enclosed in the appropriate comment syntax for the file format. We also 169 | recommend that a file or class name and description of purpose be included on 170 | the same "printed page" as the copyright notice for easier identification 171 | within third-party archives. 172 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOFMT:=gofumpt 2 | STATICCHECK=staticcheck 3 | TEST?=$$(go list ./... |grep -v 'vendor') 4 | 5 | default: build 6 | 7 | ifdef TEST_FILTER 8 | TEST_FILTER := -run $(TEST_FILTER) 9 | endif 10 | 11 | build: fmtcheck 12 | go install 13 | 14 | clean: 15 | go clean -cache -testcache ./... 16 | 17 | clean-all: 18 | go clean -cache -testcache -modcache ./... 19 | 20 | dep: 21 | go mod tidy 22 | 23 | fmt: tools 24 | @$(GOFMT) -l -w . 25 | 26 | fmtcheck: 27 | @gofumpt -d -l . 28 | 29 | test: 30 | echo $(TEST) | \ 31 | xargs -t -n4 go test -test.v $(TESTARGS) $(TEST_FILTER) -timeout=30s -parallel=4 32 | 33 | tools: 34 | @which $(GOFMT) || go install mvdan.cc/gofumpt@v0.2.1 35 | @which $(STATICCHECK) || go install honnef.co/go/tools/cmd/staticcheck@2023.1.7 36 | 37 | tools-update: 38 | @go install mvdan.cc/gofumpt@v0.2.1 39 | @go install honnef.co/go/tools/cmd/staticcheck@2023.1.7 40 | 41 | vet: 42 | @go vet ./... 43 | @staticcheck ./... 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Okta JWT Verifier for Golang 2 | 3 | This library helps you verify tokens that have been issued by Okta. To learn more about verification cases and Okta's tokens please read [Working With OAuth 2.0 Tokens](https://developer.okta.com/authentication-guide/tokens/) 4 | 5 | ## Release status 6 | 7 | This library uses semantic versioning and follows Okta's [library version policy](https://developer.okta.com/code/library-versions/). 8 | 9 | | Version | Status | 10 | | ------- | -------------------------------- | 11 | | 0.x | :warning: Beta Release (Retired) | 12 | | 1.x | :heavy_check_mark: Release | 13 | 14 | ## Installation 15 | 16 | ```sh 17 | go get -u github.com/okta/okta-jwt-verifier-golang 18 | ``` 19 | 20 | ## Usage 21 | 22 | This library was built to keep configuration to a minimum. To get it running at its most basic form, all you need to provide is the the following information: 23 | 24 | - **Issuer** - This is the URL of the authorization server that will perform authentication. All Developer Accounts have a "default" authorization server. The issuer is a combination of your Org URL (found in the upper right of the console home page) and `/oauth2/default`. For example, `https://dev-1234.oktapreview.com/oauth2/default`. 25 | - **Client ID**- These can be found on the "General" tab of the Web application that you created earlier in the Okta Developer Console. 26 | 27 | #### Access Token Validation 28 | 29 | ```go 30 | import "github.com/okta/okta-jwt-verifier-golang/v2" 31 | 32 | toValidate := map[string]string{} 33 | toValidate["aud"] = "api://default" 34 | toValidate["cid"] = "{CLIENT_ID}" 35 | 36 | jwtVerifierSetup := jwtverifier.JwtVerifier{ 37 | Issuer: "{ISSUER}", 38 | ClaimsToValidate: toValidate, 39 | } 40 | 41 | verifier := jwtVerifierSetup.New() 42 | 43 | token, err := verifier.VerifyAccessToken("{JWT}") 44 | ``` 45 | 46 | #### Id Token Validation 47 | 48 | ```go 49 | import "github.com/okta/okta-jwt-verifier-golang/v2" 50 | 51 | toValidate := map[string]string{} 52 | toValidate["nonce"] = "{NONCE}" 53 | toValidate["aud"] = "{CLIENT_ID}" 54 | 55 | 56 | jwtVerifierSetup := jwtverifier.JwtVerifier{ 57 | Issuer: "{ISSUER}", 58 | ClaimsToValidate: toValidate, 59 | } 60 | 61 | verifier := jwtVerifierSetup.New() 62 | 63 | token, err := verifier.VerifyIdToken("{JWT}") 64 | ``` 65 | 66 | This will either provide you with the token which gives you access to all the claims, or an error. The token struct contains a `Claims` property that will give you a `map[string]interface{}` of all the claims in the token. 67 | 68 | ```go 69 | // Getting the sub from the token 70 | sub := token.Claims["sub"] 71 | ``` 72 | 73 | #### Dealing with clock skew 74 | 75 | We default to a two minute clock skew adjustment in our validation. If you need to change this, you can use the `SetLeeway` method: 76 | 77 | ```go 78 | jwtVerifierSetup := JwtVerifier{ 79 | Issuer: "{ISSUER}", 80 | } 81 | 82 | verifier := jwtVerifierSetup.New() 83 | verifier.SetLeeway("2m") //String instance of time that will be parsed by `time.ParseDuration` 84 | ``` 85 | 86 | #### Customizable Resource Cache 87 | 88 | The verifier setup has a default cache based on 89 | [`patrickmn/go-cache`](https://github.com/patrickmn/go-cache) with a 5 minute 90 | expiry and 10 minute purge default setting that is used to store resources fetched over 91 | HTTP. The expiry and purge setting is configurable through SetCleanUp and SetTimeOut method. 92 | It also defines a `Cacher` interface with a `Get` method allowing 93 | customization of that caching. If you want to establish your own caching 94 | strategy then provide your own `Cacher` object that implements that interface. 95 | Your custom cache is set in the verifier via the `Cache` attribute. See the 96 | example in the [cache example test](utils/cache_example_test.go) that shows a 97 | "forever" cache (that one would never use in production ...) 98 | 99 | ```go 100 | jwtVerifierSetup := jwtverifier.JwtVerifier{ 101 | Cache: NewForeverCache, 102 | // other fields here 103 | } 104 | 105 | verifier := jwtVerifierSetup.New() 106 | ``` 107 | 108 | #### Utilities 109 | 110 | The below utilities are available in this package that can be used for Authentication flows 111 | 112 | **Nonce Generator** 113 | 114 | ```go 115 | import jwtUtils "github.com/okta/okta-jwt-verifier-golang/v2/utils" 116 | 117 | nonce, err := jwtUtils.GenerateNonce() 118 | ``` 119 | 120 | **PKCE Code Verifier and Challenge Generator** 121 | 122 | ```go 123 | import jwtUtils "github.com/okta/okta-jwt-verifier-golang/v2/utils" 124 | 125 | codeVerifier, err := jwtUtils.GenerateCodeVerifier() 126 | // or 127 | codeVerifier, err := jwtUtils.GenerateCodeVerifierWithLength(50) 128 | 129 | // get string value for oauth2 code verifier 130 | codeVerifierValue := codeVerifier.String() 131 | 132 | // get plain text code challenge from verifier 133 | codeChallengePlain := codeVerifier.CodeChallengePlain() 134 | codeChallengeMethod := "plain" 135 | // get sha256 code challenge from verifier 136 | codeChallengeS256 := codeVerifier.CodeChallengeS256() 137 | codeChallengeMethod := "S256" 138 | ``` 139 | 140 | ## Testing 141 | 142 | If you create a PR from a fork of okta/okta-jwt-verifier-golang the build for 143 | the PR will fail. Don't worry, we'll bring your commits into a review branch in 144 | okta/okta-jwt-verifier-golang and get a green build. 145 | 146 | jwtverifier_test.go expects environment variables for `ISSUER`, `CLIENT_ID`, 147 | `USERNAME`, and `PASSWORD` to be present. Take note if you use zshell as 148 | `USERSNAME` is a special environment variable and is not settable. Therefore 149 | tests shouldn't be run in zshell. 150 | 151 | `USERNAME` and `PASSWORD` are for a user with access to the test app associated 152 | with `CLIENT_ID`. The test app should not have 2FA enabled and allow password 153 | login. The General Settings for the test app should have Application Grant type 154 | with Implicit (hybrid) enabled. 155 | 156 | ``` 157 | go test -test.v 158 | ``` 159 | -------------------------------------------------------------------------------- /adaptors/adaptor.go: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2018 - Present Okta, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ******************************************************************************/ 16 | 17 | package adaptors 18 | 19 | type Adaptor interface { 20 | New() (Adaptor, error) 21 | Decode(jwt string, jwkUri string) (interface{}, error) 22 | } 23 | -------------------------------------------------------------------------------- /adaptors/lestrratGoJwx/lestrratGoJwx.go: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2018 - Present Okta, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ******************************************************************************/ 16 | 17 | package lestrratGoJwx 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "fmt" 23 | "net/http" 24 | "time" 25 | 26 | "github.com/lestrrat-go/jwx/v2/jwk" 27 | "github.com/lestrrat-go/jwx/v2/jws" 28 | "github.com/okta/okta-jwt-verifier-golang/v2/adaptors" 29 | "github.com/okta/okta-jwt-verifier-golang/v2/utils" 30 | ) 31 | 32 | func (lgj *LestrratGoJwx) fetchJwkSet(jwkUri string) (interface{}, error) { 33 | return jwk.Fetch(context.Background(), jwkUri, jwk.WithHTTPClient(lgj.Client)) 34 | } 35 | 36 | type LestrratGoJwx struct { 37 | JWKSet jwk.Set 38 | Cache func(func(string) (interface{}, error), time.Duration, time.Duration) (utils.Cacher, error) 39 | jwkSetCache utils.Cacher 40 | Timeout time.Duration 41 | Cleanup time.Duration 42 | Client *http.Client 43 | } 44 | 45 | func (lgj *LestrratGoJwx) New() (adaptors.Adaptor, error) { 46 | var err error 47 | if lgj.Cache == nil { 48 | lgj.Cache = utils.NewDefaultCache 49 | } 50 | lgj.jwkSetCache, err = lgj.Cache(lgj.fetchJwkSet, lgj.Timeout, lgj.Cleanup) 51 | if err != nil { 52 | return nil, err 53 | } 54 | return lgj, nil 55 | } 56 | 57 | func (lgj *LestrratGoJwx) Decode(jwt string, jwkUri string) (interface{}, error) { 58 | value, err := lgj.jwkSetCache.Get(jwkUri) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | jwkSet, ok := value.(jwk.Set) 64 | if !ok { 65 | return nil, fmt.Errorf("could not cast %v to jwk.Set", value) 66 | } 67 | 68 | token, err := jws.Verify([]byte(jwt), jws.WithKeySet(jwkSet)) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | var claims interface{} 74 | if err := json.Unmarshal(token, &claims); err != nil { 75 | return nil, fmt.Errorf("could not unmarshal claims: %w", err) 76 | } 77 | 78 | return claims, nil 79 | } 80 | -------------------------------------------------------------------------------- /discovery/discovery.go: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2018 - Present Okta, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ******************************************************************************/ 16 | 17 | package discovery 18 | 19 | type Discovery interface { 20 | New() Discovery 21 | GetWellKnownUrl() string 22 | } 23 | -------------------------------------------------------------------------------- /discovery/oidc/oidc.go: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2018 - Present Okta, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ******************************************************************************/ 16 | 17 | package oidc 18 | 19 | import "github.com/okta/okta-jwt-verifier-golang/v2/discovery" 20 | 21 | type Oidc struct { 22 | wellKnownUrl string 23 | } 24 | 25 | func (d Oidc) New() discovery.Discovery { 26 | d.wellKnownUrl = "/.well-known/openid-configuration" 27 | return d 28 | } 29 | 30 | func (d Oidc) GetWellKnownUrl() string { 31 | return d.wellKnownUrl 32 | } 33 | -------------------------------------------------------------------------------- /errors/JwtEmptyString.go: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2018 - Present Okta, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ******************************************************************************/ 16 | 17 | package errors 18 | 19 | type JwtEmptyString struct { 20 | message string 21 | } 22 | 23 | func JwtEmptyStringError() *JwtEmptyString { 24 | return &JwtEmptyString{ 25 | message: "you must provide a jwt to verify", 26 | } 27 | } 28 | 29 | func (e *JwtEmptyString) Error() string { 30 | return e.message 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/okta/okta-jwt-verifier-golang/v2 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/jarcoal/httpmock v1.1.0 7 | github.com/lestrrat-go/jwx/v2 v2.0.21 8 | github.com/patrickmn/go-cache v0.0.0-20180815053127-5633e0862627 9 | github.com/stretchr/testify v1.9.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 15 | github.com/goccy/go-json v0.10.2 // indirect 16 | github.com/lestrrat-go/blackmagic v1.0.2 // indirect 17 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 18 | github.com/lestrrat-go/httprc v1.0.5 // indirect 19 | github.com/lestrrat-go/iter v1.0.2 // indirect 20 | github.com/lestrrat-go/option v1.0.1 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | github.com/segmentio/asm v1.2.0 // indirect 23 | golang.org/x/crypto v0.21.0 // indirect 24 | golang.org/x/sys v0.18.0 // indirect 25 | gopkg.in/yaml.v3 v3.0.1 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 5 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 6 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 7 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 8 | github.com/jarcoal/httpmock v1.1.0 h1:F47ChZj1Y2zFsCXxNkBPwNNKnAyOATcdQibk0qEdVCE= 9 | github.com/jarcoal/httpmock v1.1.0/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= 10 | github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 11 | github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 12 | github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 13 | github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 14 | github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk= 15 | github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 16 | github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 17 | github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 18 | github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0= 19 | github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM= 20 | github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 21 | github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 22 | github.com/patrickmn/go-cache v0.0.0-20180815053127-5633e0862627 h1:pSCLCl6joCFRnjpeojzOpEYs4q7Vditq8fySFG5ap3Y= 23 | github.com/patrickmn/go-cache v0.0.0-20180815053127-5633e0862627/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 27 | github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 28 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 29 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 30 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 31 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 32 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 33 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 34 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 35 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 36 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 37 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 38 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 39 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 40 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 41 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 42 | -------------------------------------------------------------------------------- /jwtverifier.go: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2018 - Present Okta, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ******************************************************************************/ 16 | 17 | package jwtverifier 18 | 19 | import ( 20 | "encoding/base64" 21 | "encoding/json" 22 | "fmt" 23 | "net/http" 24 | "regexp" 25 | "strings" 26 | "time" 27 | 28 | "github.com/okta/okta-jwt-verifier-golang/v2/adaptors" 29 | "github.com/okta/okta-jwt-verifier-golang/v2/adaptors/lestrratGoJwx" 30 | "github.com/okta/okta-jwt-verifier-golang/v2/discovery" 31 | "github.com/okta/okta-jwt-verifier-golang/v2/discovery/oidc" 32 | "github.com/okta/okta-jwt-verifier-golang/v2/errors" 33 | "github.com/okta/okta-jwt-verifier-golang/v2/utils" 34 | ) 35 | 36 | var ( 37 | regx = regexp.MustCompile(`[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.?([a-zA-Z0-9-_]+)[/a-zA-Z0-9-_]+?$`) 38 | ) 39 | 40 | type JwtVerifier struct { 41 | Issuer string 42 | 43 | ClaimsToValidate map[string]string 44 | 45 | Discovery discovery.Discovery 46 | 47 | Adaptor adaptors.Adaptor 48 | 49 | Client *http.Client 50 | 51 | // Cache allows customization of the cache used to store resources 52 | Cache func(func(string) (interface{}, error), time.Duration, time.Duration) (utils.Cacher, error) 53 | 54 | metadataCache utils.Cacher 55 | 56 | leeway int64 57 | Timeout time.Duration 58 | Cleanup time.Duration 59 | } 60 | 61 | type Jwt struct { 62 | Claims map[string]interface{} 63 | } 64 | 65 | func (j *JwtVerifier) fetchMetaData(url string) (interface{}, error) { 66 | resp, err := j.Client.Get(url) 67 | if err != nil { 68 | return nil, fmt.Errorf("request for metadata was not successful: %w", err) 69 | } 70 | defer resp.Body.Close() 71 | 72 | ok := resp.StatusCode >= 200 && resp.StatusCode < 300 73 | if !ok { 74 | return nil, fmt.Errorf("request for metadata %q was not HTTP 2xx OK, it was: %d", url, resp.StatusCode) 75 | } 76 | 77 | metadata := make(map[string]interface{}) 78 | if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { 79 | return nil, err 80 | } 81 | return metadata, nil 82 | } 83 | 84 | func (j *JwtVerifier) New() (*JwtVerifier, error) { 85 | // Default to OIDC discovery if none is defined 86 | if j.Discovery == nil { 87 | disc := oidc.Oidc{} 88 | j.Discovery = disc.New() 89 | } 90 | 91 | if j.Timeout == 0 { 92 | j.Timeout = 5 * time.Minute 93 | } 94 | 95 | if j.Cleanup == 0 { 96 | j.Cleanup = 10 * time.Minute 97 | } 98 | 99 | if j.Client == nil { 100 | j.Client = http.DefaultClient 101 | } 102 | 103 | if j.Cache == nil { 104 | j.Cache = utils.NewDefaultCache 105 | } 106 | 107 | // Default to LestrratGoJwx Adaptor if none is defined 108 | if j.Adaptor == nil { 109 | adaptor := &lestrratGoJwx.LestrratGoJwx{Cache: j.Cache, Timeout: j.Timeout, Cleanup: j.Cleanup, Client: j.Client} 110 | adp, err := adaptor.New() 111 | if err != nil { 112 | return nil, err 113 | } 114 | j.Adaptor = adp 115 | } 116 | 117 | // Default to PT2M Leeway 118 | j.leeway = 120 119 | var err error 120 | metadataCache, err := j.Cache(j.fetchMetaData, j.Timeout, j.Cleanup) 121 | if err != nil { 122 | return nil, err 123 | } 124 | j.metadataCache = metadataCache 125 | return j, nil 126 | } 127 | 128 | func (j *JwtVerifier) SetLeeway(duration string) { 129 | dur, _ := time.ParseDuration(duration) 130 | j.leeway = int64(dur.Seconds()) 131 | } 132 | 133 | func (j *JwtVerifier) SetTimeOut(duration time.Duration) { 134 | j.Timeout = duration 135 | } 136 | 137 | func (j *JwtVerifier) SetCleanUp(duration time.Duration) { 138 | j.Cleanup = duration 139 | } 140 | 141 | func (j *JwtVerifier) VerifyAccessToken(jwt string) (*Jwt, error) { 142 | validJwt, err := j.isValidJwt(jwt) 143 | if !validJwt { 144 | return nil, fmt.Errorf("token is not valid: %w", err) 145 | } 146 | 147 | resp, err := j.decodeJwt(jwt) 148 | if err != nil { 149 | return nil, err 150 | } 151 | 152 | token := resp.(map[string]interface{}) 153 | 154 | myJwt := Jwt{ 155 | Claims: token, 156 | } 157 | 158 | err = j.validateIss(token["iss"]) 159 | if err != nil { 160 | return &myJwt, fmt.Errorf("the `Issuer` was not able to be validated. %w", err) 161 | } 162 | 163 | err = j.validateAudience(token["aud"]) 164 | if err != nil { 165 | return &myJwt, fmt.Errorf("the `Audience` was not able to be validated. %w", err) 166 | } 167 | 168 | err = j.validateClientId(token["cid"]) 169 | if err != nil { 170 | return &myJwt, fmt.Errorf("the `Client Id` was not able to be validated. %w", err) 171 | } 172 | 173 | err = j.validateExp(token["exp"]) 174 | if err != nil { 175 | return &myJwt, fmt.Errorf("the `Expiration` was not able to be validated. %w", err) 176 | } 177 | 178 | err = j.validateIat(token["iat"]) 179 | if err != nil { 180 | return &myJwt, fmt.Errorf("the `Issued At` was not able to be validated. %w", err) 181 | } 182 | 183 | return &myJwt, nil 184 | } 185 | 186 | func (j *JwtVerifier) decodeJwt(jwt string) (interface{}, error) { 187 | metaData, err := j.getMetaData() 188 | if err != nil { 189 | return nil, err 190 | } 191 | jwksURI, ok := metaData["jwks_uri"].(string) 192 | if !ok { 193 | return nil, fmt.Errorf("failed to decode JWT: missing 'jwks_uri' from metadata") 194 | } 195 | resp, err := j.Adaptor.Decode(jwt, jwksURI) 196 | if err != nil { 197 | return nil, fmt.Errorf("could not decode token: %w", err) 198 | } 199 | 200 | return resp, nil 201 | } 202 | 203 | func (j *JwtVerifier) VerifyIdToken(jwt string) (*Jwt, error) { 204 | validJwt, err := j.isValidJwt(jwt) 205 | if !validJwt { 206 | return nil, fmt.Errorf("token is not valid: %w", err) 207 | } 208 | 209 | resp, err := j.decodeJwt(jwt) 210 | if err != nil { 211 | return nil, err 212 | } 213 | 214 | token := resp.(map[string]interface{}) 215 | 216 | myJwt := Jwt{ 217 | Claims: token, 218 | } 219 | 220 | err = j.validateIss(token["iss"]) 221 | if err != nil { 222 | return &myJwt, fmt.Errorf("the `Issuer` was not able to be validated. %w", err) 223 | } 224 | 225 | err = j.validateAudience(token["aud"]) 226 | if err != nil { 227 | return &myJwt, fmt.Errorf("the `Audience` was not able to be validated. %w", err) 228 | } 229 | 230 | err = j.validateExp(token["exp"]) 231 | if err != nil { 232 | return &myJwt, fmt.Errorf("the `Expiration` was not able to be validated. %w", err) 233 | } 234 | 235 | err = j.validateIat(token["iat"]) 236 | if err != nil { 237 | return &myJwt, fmt.Errorf("the `Issued At` was not able to be validated. %w", err) 238 | } 239 | 240 | err = j.validateNonce(token["nonce"]) 241 | if err != nil { 242 | return &myJwt, fmt.Errorf("the `Nonce` was not able to be validated. %w", err) 243 | } 244 | 245 | return &myJwt, nil 246 | } 247 | 248 | func (j *JwtVerifier) GetDiscovery() discovery.Discovery { 249 | return j.Discovery 250 | } 251 | 252 | func (j *JwtVerifier) GetAdaptor() adaptors.Adaptor { 253 | return j.Adaptor 254 | } 255 | 256 | func (j *JwtVerifier) validateNonce(nonce interface{}) error { 257 | if nonce == nil { 258 | nonce = "" 259 | } 260 | 261 | if nonce != j.ClaimsToValidate["nonce"] { 262 | return fmt.Errorf("nonce: %s does not match %s", nonce, j.ClaimsToValidate["nonce"]) 263 | } 264 | return nil 265 | } 266 | 267 | func (j *JwtVerifier) validateAudience(audience interface{}) error { 268 | // Audience is optional, it will be validated if it is present in the ClaimsToValidate array 269 | if expectedAudience, exists := j.ClaimsToValidate["aud"]; !exists || audience == expectedAudience { 270 | return nil 271 | } 272 | 273 | switch v := audience.(type) { 274 | case string: 275 | if v != j.ClaimsToValidate["aud"] { 276 | return fmt.Errorf("aud: %s does not match %s", v, j.ClaimsToValidate["aud"]) 277 | } 278 | case []string: 279 | for _, element := range v { 280 | if element == j.ClaimsToValidate["aud"] { 281 | return nil 282 | } 283 | } 284 | return fmt.Errorf("aud: %s does not match %s", v, j.ClaimsToValidate["aud"]) 285 | case []interface{}: 286 | for _, e := range v { 287 | element, ok := e.(string) 288 | if !ok { 289 | return fmt.Errorf("unknown type for audience validation") 290 | } 291 | if element == j.ClaimsToValidate["aud"] { 292 | return nil 293 | } 294 | } 295 | return fmt.Errorf("aud: %s does not match %s", v, j.ClaimsToValidate["aud"]) 296 | default: 297 | return fmt.Errorf("unknown type for audience validation") 298 | } 299 | 300 | return nil 301 | } 302 | 303 | func (j *JwtVerifier) validateClientId(clientId interface{}) error { 304 | // Client Id can be optional, it will be validated if it is present in the ClaimsToValidate array 305 | if cid, exists := j.ClaimsToValidate["cid"]; exists && clientId != cid { 306 | switch v := clientId.(type) { 307 | case string: 308 | if v != cid { 309 | return fmt.Errorf("cid: %s does not match %s", v, cid) 310 | } 311 | case []string: 312 | for _, element := range v { 313 | if element == cid { 314 | return nil 315 | } 316 | } 317 | return fmt.Errorf("cid: %s does not match %s", v, cid) 318 | default: 319 | return fmt.Errorf("unknown type for clientId validation") 320 | } 321 | } 322 | return nil 323 | } 324 | 325 | func (j *JwtVerifier) validateExp(exp interface{}) error { 326 | expf, ok := exp.(float64) 327 | if !ok { 328 | return fmt.Errorf("exp: missing") 329 | } 330 | if float64(time.Now().Unix()-j.leeway) > expf { 331 | return fmt.Errorf("the token is expired") 332 | } 333 | return nil 334 | } 335 | 336 | func (j *JwtVerifier) validateIat(iat interface{}) error { 337 | iatf, ok := iat.(float64) 338 | if !ok { 339 | return fmt.Errorf("iat: missing") 340 | } 341 | if float64(time.Now().Unix()+j.leeway) < iatf { 342 | return fmt.Errorf("the token was issued in the future") 343 | } 344 | return nil 345 | } 346 | 347 | func (j *JwtVerifier) validateIss(issuer interface{}) error { 348 | if issuer != j.Issuer { 349 | return fmt.Errorf("iss: %s does not match %s", issuer, j.Issuer) 350 | } 351 | return nil 352 | } 353 | 354 | func (j *JwtVerifier) getMetaData() (map[string]interface{}, error) { 355 | metaDataUrl := j.Issuer + j.Discovery.GetWellKnownUrl() 356 | 357 | value, err := j.metadataCache.Get(metaDataUrl) 358 | if err != nil { 359 | return nil, err 360 | } 361 | 362 | metadata, ok := value.(map[string]interface{}) 363 | if !ok { 364 | return nil, fmt.Errorf("unable to cast %v to metadata", value) 365 | } 366 | return metadata, nil 367 | } 368 | 369 | func (j *JwtVerifier) isValidJwt(jwt string) (bool, error) { 370 | if jwt == "" { 371 | return false, errors.JwtEmptyStringError() 372 | } 373 | 374 | // Verify that the JWT Follows correct JWT encoding. 375 | jwtRegex := regx.MatchString 376 | if !jwtRegex(jwt) { 377 | return false, fmt.Errorf("token must contain at least 1 period ('.') and only characters 'a-Z 0-9 _'") 378 | } 379 | 380 | parts := strings.Split(jwt, ".") 381 | header := parts[0] 382 | header = padHeader(header) 383 | headerDecoded, err := base64.StdEncoding.DecodeString(header) 384 | if err != nil { 385 | return false, fmt.Errorf("the tokens header does not appear to be a base64 encoded string") 386 | } 387 | 388 | var jsonObject map[string]interface{} 389 | isHeaderJson := json.Unmarshal([]byte(headerDecoded), &jsonObject) == nil 390 | if !isHeaderJson { 391 | return false, fmt.Errorf("the tokens header is not a json object") 392 | } 393 | 394 | _, algExists := jsonObject["alg"] 395 | _, kidExists := jsonObject["kid"] 396 | 397 | if !algExists { 398 | return false, fmt.Errorf("the tokens header must contain an 'alg'") 399 | } 400 | 401 | if !kidExists { 402 | return false, fmt.Errorf("the tokens header must contain a 'kid'") 403 | } 404 | 405 | if jsonObject["alg"] != "RS256" { 406 | return false, fmt.Errorf("the only supported alg is RS256") 407 | } 408 | 409 | return true, nil 410 | } 411 | 412 | func padHeader(header string) string { 413 | if i := len(header) % 4; i != 0 { 414 | header += strings.Repeat("=", 4-i) 415 | } 416 | return header 417 | } 418 | -------------------------------------------------------------------------------- /jwtverifier_test.go: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2018 - Present Okta, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ******************************************************************************/ 16 | 17 | package jwtverifier 18 | 19 | import ( 20 | "bytes" 21 | "encoding/json" 22 | "fmt" 23 | "io" 24 | "log" 25 | "net/http" 26 | "net/url" 27 | "os" 28 | "reflect" 29 | "strings" 30 | "testing" 31 | "time" 32 | 33 | "github.com/jarcoal/httpmock" 34 | "github.com/okta/okta-jwt-verifier-golang/v2/adaptors/lestrratGoJwx" 35 | "github.com/okta/okta-jwt-verifier-golang/v2/discovery/oidc" 36 | "github.com/okta/okta-jwt-verifier-golang/v2/utils" 37 | "github.com/stretchr/testify/require" 38 | ) 39 | 40 | func Test_the_verifier_defaults_to_oidc_if_nothing_is_provided_for_discovery(t *testing.T) { 41 | jvs := JwtVerifier{ 42 | Issuer: "issuer", 43 | } 44 | 45 | jv, _ := jvs.New() 46 | 47 | if reflect.TypeOf(jv.GetDiscovery()) != reflect.TypeOf(oidc.Oidc{}) { 48 | t.Errorf("discovery did not set to oidc by default. Was set to: %s", 49 | reflect.TypeOf(jv.GetDiscovery())) 50 | } 51 | } 52 | 53 | func Test_the_verifier_defaults_to_lestrratGoJwx_if_nothing_is_provided_for_adaptor(t *testing.T) { 54 | jvs := JwtVerifier{ 55 | Issuer: "issuer", 56 | } 57 | 58 | jv, _ := jvs.New() 59 | 60 | if reflect.TypeOf(jv.GetAdaptor()) != reflect.TypeOf(&lestrratGoJwx.LestrratGoJwx{}) { 61 | t.Errorf("adaptor did not set to lestrratGoJwx by default. Was set to: %s", 62 | reflect.TypeOf(jv.GetAdaptor())) 63 | } 64 | } 65 | 66 | func Test_can_validate_iss_from_issuer_provided(t *testing.T) { 67 | jvs := JwtVerifier{ 68 | Issuer: "https://golang.oktapreview.com", 69 | } 70 | 71 | jv, _ := jvs.New() 72 | 73 | err := jv.validateIss("test") 74 | if err == nil { 75 | t.Errorf("the issuer validation did not trigger an error") 76 | } 77 | } 78 | 79 | func Test_can_validate_nonce(t *testing.T) { 80 | tv := map[string]string{} 81 | tv["nonce"] = "abc123" 82 | 83 | jvs := JwtVerifier{ 84 | Issuer: "https://golang.oktapreview.com", 85 | ClaimsToValidate: tv, 86 | } 87 | 88 | jv, _ := jvs.New() 89 | 90 | err := jv.validateNonce("test") 91 | if err == nil { 92 | t.Errorf("the nonce validation did not trigger an error") 93 | } 94 | } 95 | 96 | func Test_can_validate_aud(t *testing.T) { 97 | tv := map[string]string{} 98 | tv["aud"] = "abc123" 99 | 100 | jvs := JwtVerifier{ 101 | Issuer: "https://golang.oktapreview.com", 102 | ClaimsToValidate: tv, 103 | } 104 | 105 | jv, _ := jvs.New() 106 | 107 | err := jv.validateAudience("test") 108 | if err == nil { 109 | t.Errorf("the audience validation did not trigger an error") 110 | } 111 | } 112 | 113 | func Test_can_validate_cid(t *testing.T) { 114 | tv := map[string]string{} 115 | tv["cid"] = "abc123" 116 | 117 | jvs := JwtVerifier{ 118 | Issuer: "https://golang.oktapreview.com", 119 | ClaimsToValidate: tv, 120 | } 121 | 122 | jv, _ := jvs.New() 123 | 124 | err := jv.validateClientId("test") 125 | if err == nil { 126 | t.Errorf("the cid validation did not trigger an error") 127 | } 128 | } 129 | 130 | func Test_can_validate_iat(t *testing.T) { 131 | jvs := JwtVerifier{ 132 | Issuer: "https://golang.oktapreview.com", 133 | } 134 | 135 | jv, _ := jvs.New() 136 | 137 | // token issued in future triggers error 138 | err := jv.validateIat(float64(time.Now().Unix() + 300)) 139 | if err == nil { 140 | t.Errorf("the iat validation did not trigger an error") 141 | } 142 | 143 | // token within leeway does not trigger error 144 | err = jv.validateIat(float64(time.Now().Unix())) 145 | if err != nil { 146 | t.Errorf("the iat validation triggered an error") 147 | } 148 | } 149 | 150 | func Test_can_validate_exp(t *testing.T) { 151 | jvs := JwtVerifier{ 152 | Issuer: "https://golang.oktapreview.com", 153 | } 154 | 155 | jv, _ := jvs.New() 156 | 157 | // expired token triggers error 158 | err := jv.validateExp(float64(time.Now().Unix() - 300)) 159 | if err == nil { 160 | t.Errorf("the exp validation did not trigger an error for expired token") 161 | } 162 | 163 | // token within leeway does not trigger error 164 | err = jv.validateExp(float64(time.Now().Unix())) 165 | if err != nil { 166 | t.Errorf("the exp validation triggered an error for valid token") 167 | } 168 | } 169 | 170 | // ID TOKEN TESTS 171 | func Test_invalid_formatting_of_id_token_throws_an_error(t *testing.T) { 172 | jvs := JwtVerifier{ 173 | Issuer: "https://golang.oktapreview.com", 174 | } 175 | 176 | jv, _ := jvs.New() 177 | 178 | _, err := jv.VerifyIdToken("aa") 179 | 180 | if err == nil { 181 | t.Errorf("an error was not thrown when an id token does not contain at least 1 period ('.')") 182 | } 183 | 184 | if !strings.Contains(err.Error(), "token must contain at least 1 period ('.')") { 185 | t.Errorf("the error for id token with no periods did not trigger") 186 | } 187 | } 188 | 189 | func Test_an_id_token_header_that_is_improperly_formatted_throws_an_error(t *testing.T) { 190 | jvs := JwtVerifier{ 191 | Issuer: "https://golang.oktapreview.com", 192 | } 193 | 194 | jv, _ := jvs.New() 195 | 196 | _, err := jv.VerifyIdToken("123456789.aa.aa") 197 | 198 | if !strings.Contains(err.Error(), "does not appear to be a base64 encoded string") { 199 | t.Errorf("the error for id token with header that is not base64 encoded did not trigger") 200 | } 201 | } 202 | 203 | func Test_an_id_token_header_that_is_not_decoded_into_json_throws_an_error(t *testing.T) { 204 | jvs := JwtVerifier{ 205 | Issuer: "https://golang.oktapreview.com", 206 | } 207 | 208 | jv, _ := jvs.New() 209 | 210 | _, err := jv.VerifyIdToken("aa.aa.aa") 211 | 212 | if !strings.Contains(err.Error(), "not a json object") { 213 | t.Errorf("the error for id token with header that is not a json object did not trigger") 214 | } 215 | } 216 | 217 | func Test_an_id_token_header_that_is_not_contain_the_correct_parts_throws_an_error(t *testing.T) { 218 | jvs := JwtVerifier{ 219 | Issuer: "https://golang.oktapreview.com", 220 | } 221 | 222 | jv, _ := jvs.New() 223 | 224 | _, err := jv.VerifyIdToken("ew0KICAia2lkIjogImFiYzEyMyIsDQogICJhbmQiOiAidGhpcyINCn0.aa.aa") 225 | 226 | if !strings.Contains(err.Error(), "header must contain an 'alg'") { 227 | t.Errorf("the error for id token with header that did not contain alg did not trigger") 228 | } 229 | 230 | _, err = jv.VerifyIdToken("ew0KICAiYWxnIjogIlJTMjU2IiwNCiAgImFuZCI6ICJ0aGlzIg0KfQ.aa.aa") 231 | 232 | if !strings.Contains(err.Error(), "header must contain a 'kid'") { 233 | t.Errorf("the error for id token with header that did not contain kid did not trigger") 234 | } 235 | } 236 | 237 | func Test_an_id_token_header_that_is_not_rs256_throws_an_error(t *testing.T) { 238 | jvs := JwtVerifier{ 239 | Issuer: "https://golang.oktapreview.com", 240 | } 241 | 242 | jv, _ := jvs.New() 243 | 244 | _, err := jv.VerifyIdToken("ew0KICAia2lkIjogImFiYzEyMyIsDQogICJhbGciOiAiSFMyNTYiDQp9.aa.aa") 245 | 246 | if !strings.Contains(err.Error(), "only supported alg is RS256") { 247 | t.Errorf("the error for id token with with wrong alg did not trigger") 248 | } 249 | } 250 | 251 | // ACCESS TOKEN TESTS 252 | func Test_invalid_formatting_of_access_token_throws_an_error(t *testing.T) { 253 | jvs := JwtVerifier{ 254 | Issuer: "https://golang.oktapreview.com", 255 | } 256 | 257 | jv, _ := jvs.New() 258 | 259 | _, err := jv.VerifyAccessToken("aa") 260 | 261 | if err == nil { 262 | t.Errorf("an error was not thrown when an access token does not contain at least 1 period ('.')") 263 | } 264 | 265 | if !strings.Contains(err.Error(), "token must contain at least 1 period ('.')") { 266 | t.Errorf("the error for access token with no periods did not trigger") 267 | } 268 | } 269 | 270 | func Test_an_access_token_header_that_is_improperly_formatted_throws_an_error(t *testing.T) { 271 | jvs := JwtVerifier{ 272 | Issuer: "https://golang.oktapreview.com", 273 | } 274 | 275 | jv, _ := jvs.New() 276 | 277 | _, err := jv.VerifyAccessToken("123456789.aa.aa") 278 | 279 | if !strings.Contains(err.Error(), "does not appear to be a base64 encoded string") { 280 | t.Errorf("the error for access token with header that is not base64 encoded did not trigger") 281 | } 282 | } 283 | 284 | func Test_an_access_token_header_that_is_not_decoded_into_json_throws_an_error(t *testing.T) { 285 | jvs := JwtVerifier{ 286 | Issuer: "https://golang.oktapreview.com", 287 | } 288 | 289 | jv, _ := jvs.New() 290 | 291 | _, err := jv.VerifyAccessToken("aa.aa.aa") 292 | 293 | if !strings.Contains(err.Error(), "not a json object") { 294 | t.Errorf("the error for access token with header that is not a json object did not trigger") 295 | } 296 | } 297 | 298 | func Test_an_access_token_header_that_is_not_contain_the_correct_parts_throws_an_error(t *testing.T) { 299 | jvs := JwtVerifier{ 300 | Issuer: "https://golang.oktapreview.com", 301 | } 302 | 303 | jv, _ := jvs.New() 304 | 305 | _, err := jv.VerifyAccessToken("ew0KICAia2lkIjogImFiYzEyMyIsDQogICJhbmQiOiAidGhpcyINCn0.aa.aa") 306 | 307 | if !strings.Contains(err.Error(), "header must contain an 'alg'") { 308 | t.Errorf("the error for access token with header that did not contain alg did not trigger") 309 | } 310 | 311 | _, err = jv.VerifyAccessToken("ew0KICAiYWxnIjogIlJTMjU2IiwNCiAgImFuZCI6ICJ0aGlzIg0KfQ.aa.aa") 312 | 313 | if !strings.Contains(err.Error(), "header must contain a 'kid'") { 314 | t.Errorf("the error for access token with header that did not contain kid did not trigger") 315 | } 316 | } 317 | 318 | func Test_an_access_token_header_that_is_not_rs256_throws_an_error(t *testing.T) { 319 | jvs := JwtVerifier{ 320 | Issuer: "https://golang.oktapreview.com", 321 | } 322 | 323 | jv, _ := jvs.New() 324 | 325 | _, err := jv.VerifyAccessToken("ew0KICAia2lkIjogImFiYzEyMyIsDQogICJhbGciOiAiSFMyNTYiDQp9.aa.aa") 326 | 327 | if !strings.Contains(err.Error(), "only supported alg is RS256") { 328 | t.Errorf("the error for access token with with wrong alg did not trigger") 329 | } 330 | } 331 | 332 | func Test_a_successful_authentication_can_have_its_tokens_parsed(t *testing.T) { 333 | utils.ParseEnvironment() 334 | 335 | if os.Getenv("ISSUER") == "" || os.Getenv("CLIENT_ID") == "" { 336 | log.Printf("Skipping integration tests") 337 | t.Skip("appears that environment variables are not set, skipping the integration test for now") 338 | } 339 | 340 | type AuthnResponse struct { 341 | SessionToken string `json:"sessionToken"` 342 | } 343 | 344 | nonce, err := utils.GenerateNonce() 345 | if err != nil { 346 | t.Errorf("could not generate nonce") 347 | } 348 | 349 | // Get Session Token 350 | issuerParts, _ := url.Parse(os.Getenv("ISSUER")) 351 | baseUrl := issuerParts.Scheme + "://" + issuerParts.Hostname() 352 | requestUri := baseUrl + "/api/v1/authn" 353 | postValues := map[string]string{"username": os.Getenv("USERNAME"), "password": os.Getenv("PASSWORD")} 354 | postJsonValues, _ := json.Marshal(postValues) 355 | resp, err := http.Post(requestUri, "application/json", bytes.NewReader(postJsonValues)) 356 | if err != nil { 357 | t.Errorf("could not submit authentication endpoint") 358 | } 359 | defer resp.Body.Close() 360 | var buf bytes.Buffer 361 | _, _ = io.Copy(&buf, resp.Body) 362 | 363 | var authn AuthnResponse 364 | err = json.Unmarshal(buf.Bytes(), &authn) 365 | if err != nil { 366 | t.Errorf("could not unmarshal authn response") 367 | } 368 | 369 | // Issue get request with session token to get id/access tokens 370 | authzUri := os.Getenv("ISSUER") + "/v1/authorize?client_id=" + os.Getenv( 371 | "CLIENT_ID") + "&nonce=" + nonce + "&redirect_uri=http://localhost:8080/implicit/callback" + 372 | "&response_type=token%20id_token&scope=openid&state" + 373 | "=ApplicationState&sessionToken=" + authn.SessionToken 374 | 375 | client := &http.Client{ 376 | CheckRedirect: func(req *http.Request, with []*http.Request) error { 377 | return http.ErrUseLastResponse 378 | }, 379 | } 380 | 381 | resp, err = client.Get(authzUri) 382 | 383 | if err != nil { 384 | t.Errorf("could not submit authorization endpoint: %s", err.Error()) 385 | } 386 | 387 | defer resp.Body.Close() 388 | location := resp.Header.Get("Location") 389 | locParts, _ := url.Parse(location) 390 | fragmentParts, _ := url.ParseQuery(locParts.Fragment) 391 | 392 | if fragmentParts["access_token"] == nil { 393 | t.Errorf("could not extract access_token") 394 | } 395 | 396 | if fragmentParts["id_token"] == nil { 397 | t.Errorf("could not extract id_token") 398 | } 399 | 400 | accessToken := fragmentParts["access_token"][0] 401 | idToken := fragmentParts["id_token"][0] 402 | 403 | tv := map[string]string{} 404 | tv["aud"] = os.Getenv("CLIENT_ID") 405 | tv["nonce"] = nonce 406 | jv := JwtVerifier{ 407 | Issuer: os.Getenv("ISSUER"), 408 | ClaimsToValidate: tv, 409 | } 410 | 411 | jwtv1, err := jv.New() 412 | if err != nil { 413 | fmt.Println(err) 414 | } 415 | 416 | claims, err := jwtv1.VerifyIdToken(idToken) 417 | if err != nil { 418 | t.Errorf("could not verify id_token: %s", err.Error()) 419 | } 420 | 421 | issuer := claims.Claims["iss"] 422 | 423 | if issuer == nil { 424 | t.Errorf("issuer claim could not be pulled from access_token") 425 | } 426 | 427 | tv = map[string]string{} 428 | tv["aud"] = "api://default" 429 | tv["cid"] = os.Getenv("CLIENT_ID") 430 | jv = JwtVerifier{ 431 | Issuer: os.Getenv("ISSUER"), 432 | ClaimsToValidate: tv, 433 | } 434 | 435 | jwtv2, err := jv.New() 436 | if err != nil { 437 | fmt.Println(err) 438 | } 439 | claims, err = jwtv2.VerifyAccessToken(accessToken) 440 | 441 | if err != nil { 442 | t.Errorf("could not verify access_token: %s", err.Error()) 443 | } 444 | 445 | issuer = claims.Claims["iss"] 446 | 447 | if issuer == nil { 448 | t.Errorf("issuer claim could not be pulled from access_token") 449 | } 450 | 451 | // Should validate without CID 452 | tv = map[string]string{} 453 | tv["aud"] = "api://default" 454 | jv = JwtVerifier{ 455 | Issuer: "https://golang-sdk-oie.oktapreview.com/oauth2/default", 456 | ClaimsToValidate: tv, 457 | } 458 | 459 | jwtv3, err := jv.New() 460 | if err != nil { 461 | fmt.Println(err) 462 | } 463 | claims, err = jwtv3.VerifyAccessToken(accessToken) 464 | 465 | if err != nil { 466 | t.Errorf("could not verify access_token: %s", err.Error()) 467 | } 468 | 469 | issuer = claims.Claims["iss"] 470 | 471 | if issuer == nil { 472 | t.Errorf("issuer claim could not be pulled from access_token") 473 | } 474 | } 475 | 476 | func TestWhenFetchMetaDataHas404(t *testing.T) { 477 | httpmock.Activate() 478 | defer httpmock.DeactivateAndReset() 479 | 480 | errJson := `{"errorCode":"E0000022","errorSummary":"The endpoint does not support the provided HTTP method","errorLink":"E0000022","errorId":"oaebpimEDg8TSuQwXXT-wjzwA","errorCauses":[]}` 481 | responder := httpmock.NewStringResponder(404, errJson) 482 | issuer := `https://example.com/.well-known/openid-configuration` 483 | httpmock.RegisterResponder("GET", issuer, responder) 484 | 485 | jvs := JwtVerifier{ 486 | Issuer: "https://example.com", 487 | } 488 | jv, _ := jvs.New() 489 | token := `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im15b3JnIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.ORhY_syF7eW3e4-h2Lt0i2-7yWSr3GFu4XdHtsNQTquvnrVLN2VhM6gDhoaVtZutuVpDQD-Srd6haKtQTEffrUl2IM6erWVPKNlG_ljdm2hDQ4cw58hs9CJkTkPte4RAtFwsq-zLebdk_eF__rMYqwfgkgKK_13FoG0u8nEVtSoK_2gYBPrdFONC08Uwwre_iUz1MTHugWNcITT3u866UHeNHnRARAIn5L-rKMiEH6sQyhDoGqLyfL5xpn6d1xkxtEgqvoj7F-L4Cw87i4Jzmxl8Eo3xseBe0EGU0s-zMOzqWWVBrcG_pxA9IakgNPHGiRmoQk_rc3796FuwAkYZOA` 490 | _, err := jv.VerifyIdToken(token) 491 | 492 | require.ErrorContains(t, err, "request for metadata \"https://example.com/.well-known/openid-configuration\" was not HTTP 2xx OK, it was: 404") 493 | } 494 | 495 | func validate(verifier *JwtVerifier, token string) { 496 | _, err := verifier.VerifyAccessToken(token) 497 | if err != nil { 498 | log.Printf("token not valid: %v", err) 499 | } else { 500 | log.Println("valid") 501 | } 502 | } 503 | 504 | func TestRaceCondition(t *testing.T) { 505 | t.Skip("Run locally to test for race condition") 506 | type AuthnResponse struct { 507 | SessionToken string `json:"sessionToken"` 508 | } 509 | 510 | nonce, err := utils.GenerateNonce() 511 | if err != nil { 512 | t.Errorf("could not generate nonce") 513 | } 514 | 515 | // Get Session Token 516 | issuerParts, _ := url.Parse(os.Getenv("ISSUER")) 517 | baseUrl := issuerParts.Scheme + "://" + issuerParts.Hostname() 518 | requestUri := baseUrl + "/api/v1/authn" 519 | postValues := map[string]string{"username": os.Getenv("USERNAME"), "password": os.Getenv("PASSWORD")} 520 | postJsonValues, _ := json.Marshal(postValues) 521 | resp, err := http.Post(requestUri, "application/json", bytes.NewReader(postJsonValues)) 522 | if err != nil { 523 | t.Errorf("could not submit authentication endpoint") 524 | } 525 | defer resp.Body.Close() 526 | var buf bytes.Buffer 527 | _, _ = io.Copy(&buf, resp.Body) 528 | 529 | var authn AuthnResponse 530 | err = json.Unmarshal(buf.Bytes(), &authn) 531 | if err != nil { 532 | t.Errorf("could not unmarshal authn response") 533 | } 534 | 535 | // Issue get request with session token to get id/access tokens 536 | authzUri := os.Getenv("ISSUER") + "/v1/authorize?client_id=" + os.Getenv( 537 | "CLIENT_ID") + "&nonce=" + nonce + "&redirect_uri=http://localhost:8080/implicit/callback" + 538 | "&response_type=token%20id_token&scope=openid&state" + 539 | "=ApplicationState&sessionToken=" + authn.SessionToken 540 | 541 | client := &http.Client{ 542 | CheckRedirect: func(req *http.Request, with []*http.Request) error { 543 | return http.ErrUseLastResponse 544 | }, 545 | } 546 | 547 | resp, err = client.Get(authzUri) 548 | 549 | if err != nil { 550 | t.Errorf("could not submit authorization endpoint: %s", err.Error()) 551 | } 552 | 553 | defer resp.Body.Close() 554 | location := resp.Header.Get("Location") 555 | locParts, _ := url.Parse(location) 556 | fragmentParts, _ := url.ParseQuery(locParts.Fragment) 557 | 558 | if fragmentParts["access_token"] == nil { 559 | t.Errorf("could not extract access_token") 560 | } 561 | 562 | if fragmentParts["id_token"] == nil { 563 | t.Errorf("could not extract id_token") 564 | } 565 | 566 | accessToken := fragmentParts["access_token"][0] 567 | idToken := fragmentParts["id_token"][0] 568 | 569 | tv := map[string]string{} 570 | tv["aud"] = os.Getenv("CLIENT_ID") 571 | tv["nonce"] = nonce 572 | jv := JwtVerifier{ 573 | Issuer: os.Getenv("ISSUER"), 574 | ClaimsToValidate: tv, 575 | } 576 | 577 | jwtv1, err := jv.New() 578 | if err != nil { 579 | fmt.Println(err) 580 | } 581 | 582 | claims, err := jwtv1.VerifyIdToken(idToken) 583 | if err != nil { 584 | t.Errorf("could not verify id_token: %s", err.Error()) 585 | } 586 | 587 | issuer := claims.Claims["iss"] 588 | 589 | if issuer == nil { 590 | t.Errorf("issuer claim could not be pulled from access_token") 591 | } 592 | 593 | tv = map[string]string{} 594 | tv["aud"] = "api://default" 595 | tv["cid"] = os.Getenv("CLIENT_ID") 596 | jv = JwtVerifier{ 597 | Issuer: os.Getenv("ISSUER"), 598 | ClaimsToValidate: tv, 599 | } 600 | 601 | verifier, err := jv.New() 602 | if err != nil { 603 | fmt.Println(err) 604 | } 605 | 606 | go validate(verifier, accessToken) 607 | validate(verifier, accessToken) 608 | time.Sleep(2 * time.Second) 609 | } 610 | -------------------------------------------------------------------------------- /utils/cache.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/patrickmn/go-cache" 8 | ) 9 | 10 | // Cacher is a read-only cache interface. 11 | // 12 | // Get returns the value associated with the given key. 13 | type Cacher interface { 14 | Get(string) (interface{}, error) 15 | } 16 | 17 | type defaultCache struct { 18 | cache *cache.Cache 19 | lookup func(string) (interface{}, error) 20 | mutex *sync.Mutex 21 | } 22 | 23 | func (c *defaultCache) Get(key string) (interface{}, error) { 24 | if value, found := c.cache.Get(key); found { 25 | return value, nil 26 | } 27 | c.mutex.Lock() 28 | defer c.mutex.Unlock() 29 | // once lock, check the cache again because there could be 30 | // another thread that has update the keys during the last check 31 | if value, found := c.cache.Get(key); found { 32 | return value, nil 33 | } 34 | 35 | value, err := c.lookup(key) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | c.cache.SetDefault(key, value) 41 | return value, nil 42 | } 43 | 44 | // defaultCache implements the Cacher interface 45 | var _ Cacher = (*defaultCache)(nil) 46 | 47 | func NewDefaultCache(lookup func(string) (interface{}, error), timeout, cleanup time.Duration) (Cacher, error) { 48 | return &defaultCache{ 49 | cache: cache.New(timeout, cleanup), 50 | lookup: lookup, 51 | mutex: &sync.Mutex{}, 52 | }, nil 53 | } 54 | -------------------------------------------------------------------------------- /utils/cache_example_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | jwtverifier "github.com/okta/okta-jwt-verifier-golang/v2" 8 | "github.com/okta/okta-jwt-verifier-golang/v2/utils" 9 | ) 10 | 11 | // ForeverCache caches values forever 12 | type ForeverCache struct { 13 | values map[string]interface{} 14 | lookup func(string) (interface{}, error) 15 | } 16 | 17 | // Get returns the value for the given key 18 | func (c *ForeverCache) Get(key string) (interface{}, error) { 19 | value, ok := c.values[key] 20 | if ok { 21 | return value, nil 22 | } 23 | value, err := c.lookup(key) 24 | if err != nil { 25 | return nil, err 26 | } 27 | c.values[key] = value 28 | return value, nil 29 | } 30 | 31 | // ForeverCache implements the read-only Cacher interface 32 | var _ utils.Cacher = (*ForeverCache)(nil) 33 | 34 | // NewForeverCache takes a lookup function and returns a cache 35 | func NewForeverCache(lookup func(string) (interface{}, error), t, c time.Duration) (utils.Cacher, error) { 36 | return &ForeverCache{ 37 | values: map[string]interface{}{}, 38 | lookup: lookup, 39 | }, nil 40 | } 41 | 42 | // Example demonstrating how the JwtVerifier can be configured with a custom Cache function. 43 | func Example() { 44 | jwtVerifierSetup := jwtverifier.JwtVerifier{ 45 | Cache: NewForeverCache, 46 | // other fields here 47 | } 48 | 49 | verifier, _ := jwtVerifierSetup.New() 50 | fmt.Println(verifier) 51 | } 52 | -------------------------------------------------------------------------------- /utils/cache_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/okta/okta-jwt-verifier-golang/v2/utils" 8 | ) 9 | 10 | type Value struct { 11 | key string 12 | } 13 | 14 | func TestNewDefaultCache(t *testing.T) { 15 | lookup := func(key string) (interface{}, error) { 16 | return &Value{key: key}, nil 17 | } 18 | cache, err := utils.NewDefaultCache(lookup, 5*time.Minute, 10*time.Minute) 19 | if err != nil { 20 | t.Fatalf("unexpected error: %v", err) 21 | } 22 | 23 | first, firstErr := cache.Get("first") 24 | if firstErr != nil { 25 | t.Fatalf("Expected no error, got %v", firstErr) 26 | } 27 | if _, ok := first.(*Value); !ok { 28 | t.Error("Expected first to be a *Value") 29 | } 30 | 31 | second, secondErr := cache.Get("second") 32 | if secondErr != nil { 33 | t.Fatalf("Expected no error, got %v", secondErr) 34 | } 35 | if _, ok := second.(*Value); !ok { 36 | t.Error("Expected second to be a *Value") 37 | } 38 | 39 | if first == second { 40 | t.Error("Expected first and second to be different") 41 | } 42 | 43 | firstAgain, firstAgainErr := cache.Get("first") 44 | if firstAgainErr != nil { 45 | t.Fatalf("Expected no error, got %v", firstAgainErr) 46 | } 47 | if first != firstAgain { 48 | t.Error("Expected cached value to be the same") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /utils/nonce.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "fmt" 7 | ) 8 | 9 | // GenerateNonce generates a random base64 encoded string suitable for OpenID nonce 10 | func GenerateNonce() (string, error) { 11 | nonceBytes := make([]byte, 32) 12 | _, err := rand.Read(nonceBytes) 13 | if err != nil { 14 | return "", fmt.Errorf("could not generate nonce") 15 | } 16 | 17 | return base64.URLEncoding.EncodeToString(nonceBytes), nil 18 | } 19 | -------------------------------------------------------------------------------- /utils/parseEnv.go: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2018 - Present Okta, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ******************************************************************************/ 16 | 17 | package utils 18 | 19 | import ( 20 | "bufio" 21 | "log" 22 | "os" 23 | "strings" 24 | ) 25 | 26 | func ParseEnvironment() { 27 | if _, err := os.Stat(".env"); os.IsNotExist(err) { 28 | log.Printf("Environment Variable file (.env) is not present. Relying on Global Environment Variables") 29 | } 30 | 31 | setEnvVariable("CLIENT_ID", os.Getenv("CLIENT_ID")) 32 | setEnvVariable("ISSUER", os.Getenv("ISSUER")) 33 | setEnvVariable("USERNAME", os.Getenv("USERNAME")) 34 | setEnvVariable("PASSWORD", os.Getenv("PASSWORD")) 35 | if os.Getenv("CLIENT_ID") == "" { 36 | log.Printf("Could not resolve a CLIENT_ID environment variable.") 37 | os.Exit(1) 38 | } 39 | 40 | if os.Getenv("ISSUER") == "" { 41 | log.Printf("Could not resolve a ISSUER environment variable.") 42 | os.Exit(1) 43 | } 44 | } 45 | 46 | func setEnvVariable(env string, current string) { 47 | if current != "" { 48 | return 49 | } 50 | 51 | file, _ := os.Open(".env") 52 | defer file.Close() 53 | 54 | lookInFile := bufio.NewScanner(file) 55 | lookInFile.Split(bufio.ScanLines) 56 | 57 | for lookInFile.Scan() { 58 | parts := strings.Split(lookInFile.Text(), "=") 59 | key, value := parts[0], parts[1] 60 | if key == env { 61 | os.Setenv(key, value) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /utils/pkce_code_verifier.go: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2022 - Present Okta, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ******************************************************************************/ 16 | 17 | // based on https://datatracker.ietf.org/doc/html/rfc7636 18 | package utils 19 | 20 | import ( 21 | "crypto/rand" 22 | "crypto/sha256" 23 | "encoding/base64" 24 | "fmt" 25 | "strings" 26 | ) 27 | 28 | const ( 29 | MinLength = 32 30 | MaxLength = 96 31 | ) 32 | 33 | type PKCECodeVerifier struct { 34 | CodeVerifier string 35 | } 36 | 37 | func (v *PKCECodeVerifier) String() string { 38 | return v.CodeVerifier 39 | } 40 | 41 | // CodeChallengePlain generates a plain code challenge from a code verifier 42 | func (v *PKCECodeVerifier) CodeChallengePlain() string { 43 | return v.CodeVerifier 44 | } 45 | 46 | // CodeChallengeS256 generates a Sha256 code challenge from a code verifier 47 | func (v *PKCECodeVerifier) CodeChallengeS256() string { 48 | h := sha256.New() 49 | h.Write([]byte(v.CodeVerifier)) 50 | return encode(h.Sum(nil)) 51 | } 52 | 53 | // GenerateCodeVerifier generates a code verifier with the minimum length 54 | func GenerateCodeVerifier() (*PKCECodeVerifier, error) { 55 | return GenerateCodeVerifierWithLength(MinLength) 56 | } 57 | 58 | // GenerateCodeVerifierWithLength generates a code verifier with the specified length 59 | func GenerateCodeVerifierWithLength(length int) (*PKCECodeVerifier, error) { 60 | if length < MinLength || length > MaxLength { 61 | return nil, fmt.Errorf("invalid length: %v", length) 62 | } 63 | // create random bytes 64 | b, err := bytes(length) 65 | if err != nil { 66 | return nil, err 67 | } 68 | return &PKCECodeVerifier{ 69 | CodeVerifier: encode(b), 70 | }, nil 71 | } 72 | 73 | // bytes generates n random bytes 74 | func bytes(n int) ([]byte, error) { 75 | b := make([]byte, n) 76 | _, err := rand.Read(b) 77 | return b, err 78 | } 79 | 80 | // encode encodes a byte array to a base64 string with no padding 81 | func encode(b []byte) string { 82 | encoded := base64.StdEncoding.EncodeToString(b) 83 | encoded = strings.Replace(encoded, "+", "-", -1) 84 | encoded = strings.Replace(encoded, "/", "_", -1) 85 | encoded = strings.Replace(encoded, "=", "", -1) 86 | return encoded 87 | } 88 | -------------------------------------------------------------------------------- /utils/pkce_code_verifier_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | ) 7 | 8 | func TestGenerateCodeVerifierWithLength(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | length int 12 | wantErr bool 13 | }{ 14 | { 15 | name: "invalid length min", 16 | length: 10, 17 | wantErr: true, 18 | }, 19 | { 20 | name: "invalid length max", 21 | length: 100, 22 | wantErr: true, 23 | }, 24 | { 25 | name: "valid min length", 26 | length: 32, 27 | wantErr: false, 28 | }, 29 | { 30 | name: "valid max length", 31 | length: 96, 32 | wantErr: false, 33 | }, 34 | { 35 | name: "valid length", 36 | length: 50, 37 | wantErr: false, 38 | }, 39 | } 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | got, err := GenerateCodeVerifierWithLength(tt.length) 43 | if (err != nil) != tt.wantErr { 44 | t.Errorf("GenerateCodeVerifierWithLength() error = %v, wantErr %v", err, tt.wantErr) 45 | return 46 | } 47 | if err == nil { 48 | if got == nil { 49 | t.Errorf("GenerateCodeVerifierWithLength() = nil, value is needed") 50 | } else { 51 | verifyLengthAndPattern(got.CodeVerifier, t) 52 | } 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func TestPKCECodeVerifier_CodeChallengePlain(t *testing.T) { 59 | tests := []struct { 60 | name string 61 | CodeVerifier string 62 | want string 63 | }{ 64 | { 65 | name: "should be same as verifier", 66 | CodeVerifier: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~", 67 | want: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~", 68 | }, 69 | } 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | v := &PKCECodeVerifier{ 73 | CodeVerifier: tt.CodeVerifier, 74 | } 75 | 76 | if got := v.CodeChallengePlain(); got != tt.want { 77 | t.Errorf("PKCECodeVerifier.CodeChallengePlain() = %v, want %v", got, tt.want) 78 | } 79 | }) 80 | } 81 | } 82 | 83 | // via https://tools.ietf.org/html/rfc7636#appendix-B 84 | func TestPKCECodeVerifier_CodeChallengeS256(t *testing.T) { 85 | cv, _ := GenerateCodeVerifierWithLength(50) 86 | 87 | tests := []struct { 88 | name string 89 | CodeVerifier string 90 | want string 91 | }{ 92 | { 93 | name: "should be sha256 of verifier", 94 | CodeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", 95 | want: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", 96 | }, 97 | { 98 | name: "should be same as verifier", 99 | CodeVerifier: cv.CodeVerifier, 100 | want: "", // since we are only verifying pattern 101 | }, 102 | } 103 | for _, tt := range tests { 104 | t.Run(tt.name, func(t *testing.T) { 105 | v := &PKCECodeVerifier{ 106 | CodeVerifier: tt.CodeVerifier, 107 | } 108 | got := v.CodeChallengeS256() 109 | if tt.want != "" && got != tt.want { 110 | t.Errorf("PKCECodeVerifier.CodeChallengeS256() = %v, want %v", got, tt.want) 111 | } 112 | verifyLengthAndPattern(got, t) 113 | }) 114 | } 115 | } 116 | 117 | func verifyLengthAndPattern(val string, t *testing.T) { 118 | if len(val) < 43 || len(val) > 128 { 119 | t.Errorf("Invalid length: %v", val) 120 | } 121 | if _, e := regexp.Match(`[a-zA-Z0-9-_.~]+`, []byte(val)); e != nil { 122 | t.Errorf("Invalid pattern: %v", val) 123 | } 124 | } 125 | --------------------------------------------------------------------------------