diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 60f116c..7958e8b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @auth0/dx-sdks-engineer +* @auth0/project-dx-sdks-engineer-codeowner diff --git a/.github/actions/get-prerelease/action.yml b/.github/actions/get-prerelease/action.yml new file mode 100644 index 0000000..131f93d --- /dev/null +++ b/.github/actions/get-prerelease/action.yml @@ -0,0 +1,30 @@ +name: Return a boolean indicating if the version contains prerelease identifiers + +# +# Returns a simple true/false boolean indicating whether the version indicates it's a prerelease or not. +# +# TODO: Remove once the common repo is public. +# + +inputs: + version: + required: true + +outputs: + prerelease: + value: ${{ steps.get_prerelease.outputs.PRERELEASE }} + +runs: + using: composite + + steps: + - id: get_prerelease + shell: bash + run: | + if [[ "${VERSION}" == *"beta"* || "${VERSION}" == *"alpha"* ]]; then + echo "PRERELEASE=true" >> $GITHUB_OUTPUT + else + echo "PRERELEASE=false" >> $GITHUB_OUTPUT + fi + env: + VERSION: ${{ inputs.version }} \ No newline at end of file diff --git a/.github/actions/get-release-notes/action.yml b/.github/actions/get-release-notes/action.yml new file mode 100644 index 0000000..5ce3f92 --- /dev/null +++ b/.github/actions/get-release-notes/action.yml @@ -0,0 +1,42 @@ +name: Return the release notes extracted from the PR body + +# +# Returns the release notes from the content of a pull request linked to a release branch. It expects the branch name to be in the format release/vX.Y.Z, release/X.Y.Z, release/vX.Y.Z-beta.N. etc. +# +# TODO: Remove once the common repo is public. +# +inputs: + version: + required: true + repo_name: + required: false + repo_owner: + required: true + token: + required: true + +outputs: + release-notes: + value: ${{ steps.get_release_notes.outputs.RELEASE_NOTES }} + +runs: + using: composite + + steps: + - uses: actions/github-script@v7 + id: get_release_notes + with: + result-encoding: string + script: | + const { data: pulls } = await github.rest.pulls.list({ + owner: process.env.REPO_OWNER, + repo: process.env.REPO_NAME, + state: 'all', + head: `${process.env.REPO_OWNER}:release/${process.env.VERSION}`, + }); + core.setOutput('RELEASE_NOTES', pulls[0].body); + env: + GITHUB_TOKEN: ${{ inputs.token }} + REPO_OWNER: ${{ inputs.repo_owner }} + REPO_NAME: ${{ inputs.repo_name }} + VERSION: ${{ inputs.version }} \ No newline at end of file diff --git a/.github/actions/get-version/action.yml b/.github/actions/get-version/action.yml new file mode 100644 index 0000000..84814a3 --- /dev/null +++ b/.github/actions/get-version/action.yml @@ -0,0 +1,21 @@ +name: Return the version extracted from the branch name + +# +# Returns the version from the .version file. +# +# TODO: Remove once the common repo is public. +# + +outputs: + version: + value: ${{ steps.get_version.outputs.VERSION }} + +runs: + using: composite + + steps: + - id: get_version + shell: bash + run: | + VERSION=$(head -1 .version) + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT \ No newline at end of file diff --git a/.github/actions/release-create/action.yml b/.github/actions/release-create/action.yml new file mode 100644 index 0000000..a0db443 --- /dev/null +++ b/.github/actions/release-create/action.yml @@ -0,0 +1,47 @@ +name: Create a GitHub release + +# +# Creates a GitHub release with the given version. +# +# TODO: Remove once the common repo is public. +# + +inputs: + token: + required: true + files: + required: false + name: + required: true + body: + required: true + tag: + required: true + commit: + required: true + draft: + default: false + required: false + prerelease: + default: false + required: false + fail_on_unmatched_files: + default: true + required: false + +runs: + using: composite + + steps: + - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 + with: + body: ${{ inputs.body }} + name: ${{ inputs.name }} + tag_name: ${{ inputs.tag }} + target_commitish: ${{ inputs.commit }} + draft: ${{ inputs.draft }} + prerelease: ${{ inputs.prerelease }} + fail_on_unmatched_files: ${{ inputs.fail_on_unmatched_files }} + files: ${{ inputs.files }} + env: + GITHUB_TOKEN: ${{ inputs.token }} \ No newline at end of file diff --git a/.github/actions/rl-scanner/action.yml b/.github/actions/rl-scanner/action.yml new file mode 100644 index 0000000..b3df2d9 --- /dev/null +++ b/.github/actions/rl-scanner/action.yml @@ -0,0 +1,71 @@ +name: 'Reversing Labs Scanner' +description: 'Runs the Reversing Labs scanner on a specified artifact.' +inputs: + artifact-path: + description: 'Path to the artifact to be scanned.' + required: true + version: + description: 'Version of the artifact.' + required: true + +runs: + using: 'composite' + steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install Python dependencies + shell: bash + run: | + pip install boto3 requests + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + role-to-assume: ${{ env.PRODSEC_TOOLS_ARN }} + aws-region: us-east-1 + mask-aws-account-id: true + + - name: Install RL Wrapper + shell: bash + run: | + pip install rl-wrapper>=1.0.0 --index-url "https://${{ env.PRODSEC_TOOLS_USER }}:${{ env.PRODSEC_TOOLS_TOKEN }}@a0us.jfrog.io/artifactory/api/pypi/python-local/simple" + + - name: Run RL Scanner + shell: bash + env: + RLSECURE_LICENSE: ${{ env.RLSECURE_LICENSE }} + RLSECURE_SITE_KEY: ${{ env.RLSECURE_SITE_KEY }} + SIGNAL_HANDLER_TOKEN: ${{ env.SIGNAL_HANDLER_TOKEN }} + PYTHONUNBUFFERED: 1 + run: | + if [ ! -f "${{ inputs.artifact-path }}" ]; then + echo "Artifact not found: ${{ inputs.artifact-path }}" + exit 1 + fi + + rl-wrapper \ + --artifact "${{ inputs.artifact-path }}" \ + --name "${{ github.event.repository.name }}" \ + --version "${{ inputs.version }}" \ + --repository "${{ github.repository }}" \ + --commit "${{ github.sha }}" \ + --build-env "github_actions" \ + --suppress_output + + # Check the outcome of the scanner + if [ $? -ne 0 ]; then + echo "RL Scanner failed." + echo "scan-status=failed" >> $GITHUB_ENV + exit 1 + else + echo "RL Scanner passed." + echo "scan-status=success" >> $GITHUB_ENV + fi + +outputs: + scan-status: + description: 'The outcome of the scan process.' + value: ${{ env.scan-status }} \ No newline at end of file diff --git a/.github/actions/rubygems-publish/action.yml b/.github/actions/rubygems-publish/action.yml new file mode 100644 index 0000000..e7ea970 --- /dev/null +++ b/.github/actions/rubygems-publish/action.yml @@ -0,0 +1,30 @@ +name: Publishes to RubyGems + +# +# Publishes to RubyGems +# +# TODO: Remove once the common repo is public. +# + +inputs: + rubygems-token: + required: true + ruby-version: + required: true + +runs: + using: composite + + steps: + - name: Configure Ruby + uses: ./.github/actions/setup + with: + ruby: ${{ inputs.ruby-version }} + + - name: Publish to RubyGems + shell: bash + run: | + gem build *.gemspec + gem push *.gem + env: + GEM_HOST_API_KEY: ${{ inputs.rubygems-token }} \ No newline at end of file diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 5c45715..8209684 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -10,10 +10,6 @@ inputs: description: The path to the bundle cache required: false default: vendor/bundle - bundler-cache: - description: Whether to use the bundler cache - required: false - default: true runs: using: composite @@ -23,10 +19,10 @@ runs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ inputs.ruby }} - bundler-cache: ${{ inputs.bundle-cache }} + bundler-cache: false - name: Install dependencies - run: bundle check || bundle install + run: bundle update || bundle install shell: bash env: BUNDLE_PATH: ${{ inputs.bundle-path }} diff --git a/.github/actions/tag-exists/action.yml b/.github/actions/tag-exists/action.yml new file mode 100644 index 0000000..b8f33f6 --- /dev/null +++ b/.github/actions/tag-exists/action.yml @@ -0,0 +1,36 @@ +name: Return a boolean indicating if a tag already exists for the repository + +# +# Returns a simple true/false boolean indicating whether the tag exists or not. +# +# TODO: Remove once the common repo is public. +# + +inputs: + token: + required: true + tag: + required: true + +outputs: + exists: + description: 'Whether the tag exists or not' + value: ${{ steps.tag-exists.outputs.EXISTS }} + +runs: + using: composite + + steps: + - id: tag-exists + shell: bash + run: | + GET_API_URL="https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG_NAME}" + http_status_code=$(curl -LI $GET_API_URL -o /dev/null -w '%{http_code}\n' -s -H "Authorization: token ${GITHUB_TOKEN}") + if [ "$http_status_code" -ne "404" ] ; then + echo "EXISTS=true" >> $GITHUB_OUTPUT + else + echo "EXISTS=false" >> $GITHUB_OUTPUT + fi + env: + TAG_NAME: ${{ inputs.tag }} + GITHUB_TOKEN: ${{ inputs.token }} \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index da2a4ce..0433c61 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,37 +1,33 @@ -name: Publish Release +name: Create Release on: + pull_request: + types: + - closed workflow_dispatch: - inputs: - branch: - description: The branch to release from. - required: true - default: master permissions: - contents: read + contents: write + id-token: write # This is required for requesting the JWT jobs: - publish: - name: Publish to RubyGems - runs-on: ubuntu-latest - environment: release + rl-scanner: + uses: ./.github/workflows/rl-scanner.yml + with: + ruby-version: 3.2 + secrets: + RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} + RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} + SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} + PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} + PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} + PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.inputs.branch }} - - - name: Configure Ruby - uses: ./.github/actions/setup - with: - ruby: 3.2 - - - name: Publish to RubyGems - run: | - gem build *.gemspec - gem push *.gem - env: - GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} + release: + uses: ./.github/workflows/ruby-release.yml + needs: rl-scanner + with: + ruby-version: 3.2 + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + rubygems-token: ${{ secrets.RUBYGEMS_AUTH_TOKEN }} diff --git a/.github/workflows/rl-scanner.yml b/.github/workflows/rl-scanner.yml new file mode 100644 index 0000000..714d933 --- /dev/null +++ b/.github/workflows/rl-scanner.yml @@ -0,0 +1,65 @@ +name: RL-Secure Workflow + +on: + workflow_call: + inputs: + ruby-version: + required: true + type: string + secrets: + RLSECURE_LICENSE: + required: true + RLSECURE_SITE_KEY: + required: true + SIGNAL_HANDLER_TOKEN: + required: true + PRODSEC_TOOLS_USER: + required: true + PRODSEC_TOOLS_TOKEN: + required: true + PRODSEC_TOOLS_ARN: + required: true + +jobs: + rl-scanner: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) + runs-on: ubuntu-latest + outputs: + scan-status: ${{ steps.rl-scan-conclusion.outcome }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Configure Ruby + uses: ./.github/actions/setup + with: + ruby-version: ${{ inputs.ruby-version }} + + - name: Build RubyGems + shell: bash + run: | + gem build *.gemspec + export GEM_FILE=$(ls *.gem) + echo "gem_file=$GEM_FILE" >> $GITHUB_ENV + + - name: Get Artifact Version + id: get_version + uses: ./.github/actions/get-version + + - name: Run RL Scanner + id: rl-scan-conclusion + uses: ./.github/actions/rl-scanner + with: + artifact-path: "$(pwd)/${{ env.gem_file }}" + version: "${{ steps.get_version.outputs.version }}" + env: + RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} + RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} + SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} + PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} + PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} + PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} + + - name: Output scan result + run: echo "scan-status=${{ steps.rl-scan-conclusion.outcome }}" >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/workflows/ruby-release.yml b/.github/workflows/ruby-release.yml new file mode 100644 index 0000000..70438e9 --- /dev/null +++ b/.github/workflows/ruby-release.yml @@ -0,0 +1,72 @@ +name: Create Release + +on: + workflow_call: + inputs: + ruby-version: + required: true + type: string + secrets: + github-token: + required: true + rubygems-token: + required: true + +jobs: + release: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) + runs-on: ubuntu-latest + environment: release + + steps: + # Checkout the code + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # Get the version from the branch name + - id: get_version + uses: ./.github/actions/get-version + + # Get the prerelease flag from the branch name + - id: get_prerelease + uses: ./.github/actions/get-prerelease + with: + version: ${{ steps.get_version.outputs.version }} + + # Get the release notes + # This will expose the release notes as env.RELEASE_NOTES + - id: get_release_notes + uses: ./.github/actions/get-release-notes + with: + token: ${{ secrets.github-token }} + version: ${{ steps.get_version.outputs.version }} + repo_owner: ${{ github.repository_owner }} + repo_name: ${{ github.event.repository.name }} + + # Check if the tag already exists + - id: tag_exists + uses: ./.github/actions/tag-exists + with: + tag: ${{ steps.get_version.outputs.version }} + token: ${{ secrets.github-token }} + + # If the tag already exists, exit with an error + - if: steps.tag_exists.outputs.exists == 'true' + run: exit 1 + + # Publish the release to our package manager + - uses: ./.github/actions/rubygems-publish + with: + ruby-version: ${{ inputs.ruby-version }} + rubygems-token: ${{ secrets.rubygems-token }} + + # Create a release for the tag + - uses: ./.github/actions/release-create + with: + token: ${{ secrets.github-token }} + name: ${{ steps.get_version.outputs.version }} + body: ${{ steps.get_release_notes.outputs.release-notes }} + tag: ${{ steps.get_version.outputs.version }} + commit: ${{ github.sha }} + prerelease: ${{ steps.get_prerelease.outputs.prerelease }} diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml deleted file mode 100644 index 36c687d..0000000 --- a/.github/workflows/semgrep.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Semgrep - -on: - merge_group: - pull_request_target: - types: - - opened - - synchronize - push: - branches: - - master - schedule: - - cron: "30 0 1,15 * *" - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} - -jobs: - authorize: - name: Authorize - environment: ${{ github.actor != 'dependabot[bot]' && github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && 'external' || 'internal' }} - runs-on: ubuntu-latest - steps: - - run: true - - run: - needs: authorize # Require approval before running on forked pull requests - - name: Check for Vulnerabilities - runs-on: ubuntu-latest - - container: - image: returntocorp/semgrep - - steps: - - if: github.actor == 'dependabot[bot]' || github.event_name == 'merge_group' - run: exit 0 # Skip unnecessary test runs for dependabot and merge queues. Artifically flag as successful, as this is a required check for branch protection. - - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.ref }} - - - run: semgrep ci - env: - SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml index de751b8..da1f868 100644 --- a/.github/workflows/snyk.yml +++ b/.github/workflows/snyk.yml @@ -3,7 +3,7 @@ name: Snyk on: merge_group: workflow_dispatch: - pull_request_target: + pull_request: types: - opened - synchronize @@ -21,16 +21,7 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} jobs: - authorize: - name: Authorize - environment: ${{ github.actor != 'dependabot[bot]' && github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && 'external' || 'internal' }} - runs-on: ubuntu-latest - steps: - - run: true - check: - needs: authorize - name: Check for Vulnerabilities runs-on: ubuntu-latest diff --git a/.shiprc b/.shiprc index e531e09..c1c10f9 100644 --- a/.shiprc +++ b/.shiprc @@ -1,6 +1,7 @@ { "files": { - "lib/omniauth-auth0/version.rb": [] + "lib/omniauth-auth0/version.rb": [], + ".version": [] }, "prebump": "bundle install && bundle exec rake test", "postbump": "bundle update" diff --git a/.version b/.version new file mode 100644 index 0000000..aa6c896 --- /dev/null +++ b/.version @@ -0,0 +1 @@ +v3.2.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fd800f0..49a2464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,22 @@ # Change Log -## [v3.2.0](https://github.com/auth0/omniauth-auth0/tree/v3.2.0) (2023-07-14) -[Full Changelog](https://github.com/auth0/omniauth-auth0/compare/v3.1.0...v3.2.0) +## [v3.2.0](https://github.com/auth0/omniauth-auth0/tree/v3.2.0) (2026-05-27) +[Full Changelog](https://github.com/auth0/omniauth-auth0/compare/v3.1.1...v3.2.0) **Added** -- [SDK-4410] Support Organization Name in JWT validation [\#184](https://github.com/auth0/omniauth-auth0/pull/184) ([stevehobbsdev](https://github.com/stevehobbsdev)) +- Add support for client assertion signing key authentication [\#203](https://github.com/auth0/omniauth-auth0/pull/203) ([kaczowkad](https://github.com/kaczowkad)) -**Fixed** -- fix: upgrade to Sinatra 3 and use Rack::Session::Cookie in tests [\#165](https://github.com/auth0/omniauth-auth0/pull/165) ([stevehobbsdev](https://github.com/stevehobbsdev)) +**Dependency Bumps** +- Bump faraday from 2.7.10 to 2.14.1 [\#215](https://github.com/auth0/omniauth-auth0/pull/215) ([dependabot[bot]](https://github.com/apps/dependabot)) +- Bump rack from 2.2.7 to 2.2.23 [\#217](https://github.com/auth0/omniauth-auth0/pull/217) ([dependabot[bot]](https://github.com/apps/dependabot)) +- Bump rexml from 3.2.5 to 3.3.9 [\#206](https://github.com/auth0/omniauth-auth0/pull/206) ([arpit-jn](https://github.com/arpit-jn)) ## [v3.1.1](https://github.com/auth0/omniauth-auth0/tree/v3.1.1) (2023-03-01) [Full Changelog](https://github.com/auth0/omniauth-auth0/compare/v3.1.0...v3.1.1) +**Added** +- [SDK-4410] Support Organization Name in JWT validation [\#184](https://github.com/auth0/omniauth-auth0/pull/184) ([stevehobbsdev](https://github.com/stevehobbsdev)) + **Fixed** - fix: upgrade to Sinatra 3 and use Rack::Session::Cookie in tests [\#165](https://github.com/auth0/omniauth-auth0/pull/165) ([stevehobbsdev](https://github.com/stevehobbsdev)) diff --git a/Gemfile b/Gemfile index 78f32a4..92041bb 100644 --- a/Gemfile +++ b/Gemfile @@ -19,7 +19,7 @@ group :test do gem 'listen', '~> 3' gem 'rack-test', '~> 2', '>= 2.0.2' gem 'rspec', '~> 3' - gem 'simplecov-cobertura', '~> 2' + gem 'simplecov-cobertura', '~> 3.0' gem 'webmock', '~> 3' gem 'multi_json', '~> 1' end diff --git a/Gemfile.lock b/Gemfile.lock index e5bcdd3..47100a4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,33 +1,44 @@ PATH remote: . specs: - omniauth-auth0 (3.1.1) + omniauth-auth0 (3.2.0) + jwt (~> 2) omniauth (~> 2) omniauth-oauth2 (~> 1) GEM remote: https://rubygems.org/ specs: - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) - ast (2.4.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.2) coderay (1.1.3) - crack (0.4.5) + crack (1.0.1) + bigdecimal rexml daemons (1.4.1) - diff-lcs (1.5.0) - docile (1.4.0) + diff-lcs (1.6.2) + docile (1.4.1) dotenv (2.8.1) eventmachine (1.2.7) - faraday (2.7.10) - faraday-net_http (>= 2.0, < 3.1) - ruby2_keywords (>= 0.0.4) - faraday-net_http (3.0.2) - ffi (1.15.5) - formatador (1.1.0) - guard (2.18.0) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + formatador (1.2.3) + reline + guard (2.20.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) + logger (~> 1.6) lumberjack (>= 1.0.12, < 2.0) nenv (~> 0.1) notiffany (~> 0.0) @@ -39,88 +50,102 @@ GEM guard (~> 2.1) guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) - hashdiff (1.0.1) - hashie (5.0.0) - json (2.6.3) - jwt (2.7.1) - language_server-protocol (3.17.0.3) - listen (3.8.0) + hashdiff (1.2.1) + hashie (5.1.0) + logger + io-console (0.8.2) + json (2.19.5) + jwt (2.10.2) + base64 + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + listen (3.10.0) + logger rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - lumberjack (1.2.8) - method_source (1.0.0) - multi_json (1.15.0) - multi_xml (0.6.0) - mustermann (3.0.0) - ruby2_keywords (~> 0.0.1) + logger (1.7.0) + lumberjack (1.4.2) + method_source (1.1.0) + multi_json (1.21.1) + multi_xml (0.9.1) + bigdecimal (>= 3.1, < 5) + mustermann (3.1.1) nenv (0.3.0) + net-http (0.9.1) + uri (>= 0.11.1) notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) - oauth2 (2.0.9) - faraday (>= 0.17.3, < 3.0) - jwt (>= 1.0, < 3.0) + oauth2 (2.0.18) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) multi_xml (~> 0.5) rack (>= 1.2, < 4) - snaky_hash (~> 2.0) - version_gem (~> 1.1) - omniauth (2.1.1) + snaky_hash (~> 2.0, >= 2.0.3) + version_gem (~> 1.1, >= 1.1.9) + omniauth (2.1.4) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection - omniauth-oauth2 (1.8.0) - oauth2 (>= 1.4, < 3) + omniauth-oauth2 (1.9.0) + oauth2 (>= 2.0.2, < 3) omniauth (~> 2.0) - parallel (1.23.0) - parser (3.2.2.3) + parallel (2.1.0) + parser (3.3.11.1) ast (~> 2.4.1) racc - pry (0.14.2) + prism (1.9.0) + pry (0.16.0) coderay (~> 1.1) method_source (~> 1.0) - public_suffix (5.0.3) - racc (1.7.1) - rack (2.2.9) - rack-protection (3.0.6) - rack - rack-test (2.1.0) + reline (>= 0.6.0) + public_suffix (7.0.5) + racc (1.8.1) + rack (2.2.23) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-test (2.2.0) rack (>= 1.3) rainbow (3.1.1) - rake (13.0.6) + rake (13.4.2) rb-fsevent (0.11.2) - rb-inotify (0.10.1) + rb-inotify (0.11.1) ffi (~> 1.0) - regexp_parser (2.8.1) - rexml (3.2.8) - strscan (>= 3.0.9) - rspec (3.12.0) - rspec-core (~> 3.12.0) - rspec-expectations (~> 3.12.0) - rspec-mocks (~> 3.12.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.6) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-support (3.12.1) - rubocop (1.54.2) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.86.1) json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) - parser (>= 3.2.2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.29.0) - parser (>= 3.2.1.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) ruby-progressbar (1.13.0) - ruby2_keywords (0.0.5) shellany (0.0.1) shotgun (0.9.2) rack (>= 1.0) @@ -128,29 +153,31 @@ GEM docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-cobertura (2.1.0) + simplecov-cobertura (3.1.0) rexml simplecov (~> 0.19) - simplecov-html (0.12.3) + simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - sinatra (3.0.6) + sinatra (3.2.0) mustermann (~> 3.0) rack (~> 2.2, >= 2.2.4) - rack-protection (= 3.0.6) + rack-protection (= 3.2.0) tilt (~> 2.0) - snaky_hash (2.0.1) - hashie - version_gem (~> 1.1, >= 1.1.1) - strscan (3.1.0) + snaky_hash (2.0.3) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) thin (1.8.2) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) - thor (1.2.2) - tilt (2.2.0) - unicode-display_width (2.4.2) - version_gem (1.1.3) - webmock (3.18.1) + thor (1.5.0) + tilt (2.7.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + version_gem (1.1.9) + webmock (3.26.2) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -159,6 +186,8 @@ PLATFORMS aarch64-linux arm64-darwin-21 arm64-darwin-22 + arm64-darwin-23 + arm64-darwin-25 x86_64-darwin-22 x86_64-linux @@ -176,7 +205,7 @@ DEPENDENCIES rspec (~> 3) rubocop (~> 1) shotgun (~> 0, >= 0.9.2) - simplecov-cobertura (~> 2) + simplecov-cobertura (~> 3.0) sinatra (~> 3) thin (~> 1) webmock (~> 3) diff --git a/README.md b/README.md index 88d4104..b286c22 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![codecov](https://codecov.io/gh/auth0/omniauth-auth0/branch/master/graph/badge.svg)](https://codecov.io/gh/auth0/omniauth-auth0) [![Gem Version](https://badge.fury.io/rb/omniauth-auth0.svg)](https://badge.fury.io/rb/omniauth-auth0) [![MIT licensed](https://img.shields.io/dub/l/vibe-d.svg?style=flat)](https://github.com/auth0/omniauth-auth0/blob/master/LICENSE) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/auth0/omniauth-auth0)
📚 Documentation - 🚀 Getting started - 💻 API reference - 💬 Feedback @@ -53,6 +54,8 @@ Adding the SDK to your Rails app requires a few steps: Create the file `./config/auth0.yml` within your application directory with the following content: +### For client secret authentication + ```yml development: auth0_domain: @@ -60,10 +63,25 @@ development: auth0_client_secret: ``` +#### For client assertion signing key authentication + +```yml +development: + auth0_domain: + auth0_client_id: + auth0_client_assertion_signing_key: + auth0_client_assertion_signing_algorithm: +``` +**Note**: you must upload the corresponding public key to your Auth0 tenant, so that Auth0 is able to verify the JWT signature. + +client_assertion_signing_algorithm is optional and defaults to RS256. + ### Create the initializer Create a new Ruby file in `./config/initializers/auth0.rb` to configure the OmniAuth middleware: +### For client secret authentication + ```ruby AUTH0_CONFIG = Rails.application.config_for(:auth0) @@ -81,6 +99,29 @@ Rails.application.config.middleware.use OmniAuth::Builder do end ``` +#### For client assertion signing key authentication + +```ruby +AUTH0_CONFIG = Rails.application.config_for(:auth0) + +Rails.application.config.middleware.use OmniAuth::Builder do + provider( + :auth0, + AUTH0_CONFIG['auth0_client_id'], + nil, + AUTH0_CONFIG['auth0_domain'], + callback_path: '/auth/auth0/callback', + authorize_params: { + scope: 'openid profile' + }, + client_assertion_signing_key: OpenSSL::PKey::RSA.new(AUTH0_CONFIG[:auth0_client_assertion_signing_key]), + client_assertion_signing_algorithm: AUTH0_CONFIG[:auth0_client_assertion_signing_algorithm] + ) +end +``` + +**Note**: The client_assertion_signing_key must be provided as a PKey object. + ### Create the callback controller Create a new controller `./app/controllers/auth0_controller.rb` to handle the callback from Auth0. @@ -165,4 +206,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker

This project is licensed under the MIT license. See the LICENSE file for more info. -

+

\ No newline at end of file diff --git a/lib/omniauth-auth0/version.rb b/lib/omniauth-auth0/version.rb index afc7afb..50e694c 100644 --- a/lib/omniauth-auth0/version.rb +++ b/lib/omniauth-auth0/version.rb @@ -1,5 +1,5 @@ module OmniAuth module Auth0 - VERSION = '3.1.1'.freeze + VERSION = '3.2.0'.freeze end end diff --git a/lib/omniauth/auth0/jwt_token.rb b/lib/omniauth/auth0/jwt_token.rb new file mode 100644 index 0000000..62a1e16 --- /dev/null +++ b/lib/omniauth/auth0/jwt_token.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'jwt' +require 'securerandom' + +module OmniAuth + module Auth0 + # JWTToken class to generate a JWT token for client assertion + # as per the OAuth 2.0 Client Credentials Grant specification. + class JWTToken + attr_reader :client_id, :domain_url, :client_assertion_signing_key, :client_assertion_signing_algorithm + + def initialize(client_id, domain_url, client_assertion_signing_key, client_assertion_signing_algorithm = nil) + @client_id = client_id + @domain_url = domain_url + @client_assertion_signing_key = client_assertion_signing_key + @client_assertion_signing_algorithm = client_assertion_signing_algorithm || 'RS256' + end + + def jwt_token + JWT.encode(jwt_payload, client_assertion_signing_key, client_assertion_signing_algorithm) + end + + private + + def jwt_payload + { + iss: client_id, + sub: client_id, + aud: File.join(domain_url, '/oauth/token'), + iat: Time.now.utc.to_i, + exp: Time.now.utc.to_i + 60, + jti: SecureRandom.uuid + } + end + end + end +end diff --git a/lib/omniauth/auth0/jwt_validator.rb b/lib/omniauth/auth0/jwt_validator.rb index d7f0ecf..cb99bfe 100644 --- a/lib/omniauth/auth0/jwt_validator.rb +++ b/lib/omniauth/auth0/jwt_validator.rb @@ -272,10 +272,10 @@ def verify_org(id_token, organization) if validate_as_id org_id = id_token['org_id'] if !org_id || !org_id.is_a?(String) - raise OmniAuth::Auth0::TokenValidationError, + raise OmniAuth::Auth0::TokenValidationError, 'Organization Id (org_id) claim must be a string present in the ID token' elsif org_id != organization - raise OmniAuth::Auth0::TokenValidationError, + raise OmniAuth::Auth0::TokenValidationError, "Organization Id (org_id) claim value mismatch in the ID token; expected '#{organization}', found '#{org_id}'" end else diff --git a/lib/omniauth/strategies/auth0.rb b/lib/omniauth/strategies/auth0.rb index e37df53..f694364 100644 --- a/lib/omniauth/strategies/auth0.rb +++ b/lib/omniauth/strategies/auth0.rb @@ -4,6 +4,7 @@ require 'uri' require 'securerandom' require 'omniauth-oauth2' +require 'omniauth/auth0/jwt_token' require 'omniauth/auth0/jwt_validator' require 'omniauth/auth0/telemetry' require 'omniauth/auth0/errors' @@ -13,6 +14,8 @@ module Strategies # Auth0 OmniAuth strategy class Auth0 < OmniAuth::Strategies::OAuth2 include OmniAuth::Auth0::Telemetry + AUTHORIZATION_CODE_GRANT_TYPE = 'authorization_code' + CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' option :name, 'auth0' @@ -28,6 +31,8 @@ def client options.client_options.authorize_url = '/authorize' options.client_options.token_url = '/oauth/token' options.client_options.userinfo_url = '/userinfo' + setup_client_options_auth_scheme + super end @@ -99,25 +104,20 @@ def authorize_params end def build_access_token + options.token_params.merge!(client_assertion_signing_key_token_params) if client_assertion_signing_key_auth? options.token_params[:headers] = { 'Auth0-Client' => telemetry_encoded } super end # Declarative override for the request phase of authentication def request_phase - if no_client_id? - # Do we have a client_id for this Application? - fail!(:missing_client_id) - elsif no_client_secret? - # Do we have a client_secret for this Application? - fail!(:missing_client_secret) - elsif no_domain? - # Do we have a domain for this Application? - fail!(:missing_domain) - else - # All checks pass, run the Oauth2 request_phase method. - super - end + return fail!(:missing_client_id) if no_client_id? + return fail!(:missing_client_secret) if no_client_secret? + return fail!(:missing_domain) if no_domain? + return fail!(:missing_client_assertion_signing_key) if no_client_assertion_signing_key? + + # All checks pass, run the Oauth2 request_phase method. + super end def callback_phase @@ -127,16 +127,38 @@ def callback_phase end private + def is_authorized_param?(param_key) authorized_keys = %w[connection connection_scope prompt screen_hint login_hint organization invitation ui_locales] param_key.start_with?("ext-") || authorized_keys.include?(param_key) end + def client_assertion_signing_key_auth? + options['client_assertion_signing_key'] + end + + def client_assertion_signing_key_token_params + { + grant_type: AUTHORIZATION_CODE_GRANT_TYPE, + client_id: options.client_id, + client_assertion_type: CLIENT_ASSERTION_TYPE, + client_assertion: jwt_token + } + end + def jwt_validator @jwt_validator ||= OmniAuth::Auth0::JWTValidator.new(options) end + def jwt_token + OmniAuth::Auth0::JWTToken.new(options.client_id, + domain_url, + options.client_assertion_signing_key, + options.client_assertion_signing_algorithm) + .jwt_token + end + # Parse the raw user info. def raw_info return @raw_info if @raw_info @@ -159,7 +181,7 @@ def no_client_id? # Check if the options include a client_secret def no_client_secret? - ['', nil].include?(options.client_secret) + ['', nil].include?(options.client_secret) && !options.key?('client_assertion_signing_key') end # Check if the options include a domain @@ -167,12 +189,24 @@ def no_domain? ['', nil].include?(options.domain) end + # Check if the options include a client_assertion_signing_key + def no_client_assertion_signing_key? + options.key?('client_assertion_signing_key') && ['', nil].include?(options.client_assertion_signing_key) + end + # Normalize a domain to a URL. def domain_url domain_url = URI(options.domain) domain_url = URI("https://#{domain_url}") if domain_url.scheme.nil? domain_url.to_s end + + # Setup the auth_scheme for the client options if using client assertion signing key + def setup_client_options_auth_scheme + return unless client_assertion_signing_key_auth? + + options.client_options.auth_scheme = :request_body + end end end end diff --git a/omniauth-auth0.gemspec b/omniauth-auth0.gemspec index 68b8102..f4fc6fb 100644 --- a/omniauth-auth0.gemspec +++ b/omniauth-auth0.gemspec @@ -21,6 +21,7 @@ omniauth-auth0 is the OmniAuth strategy for Auth0. s.executables = `git ls-files -- bin/*`.split('\n').map{ |f| File.basename(f) } s.require_paths = ['lib'] + s.add_runtime_dependency 'jwt', '~> 2' s.add_runtime_dependency 'omniauth', '~> 2' s.add_runtime_dependency 'omniauth-oauth2', '~> 1' diff --git a/spec/omniauth/auth0/jwt_token_spec.rb b/spec/omniauth/auth0/jwt_token_spec.rb new file mode 100644 index 0000000..ada4d32 --- /dev/null +++ b/spec/omniauth/auth0/jwt_token_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'jwt' + +describe OmniAuth::Auth0::JWTToken do + let(:client_id) { 'CLIENT_ID' } + let(:domain_url) { 'https://samples.auth0.com' } + let(:client_assertion_signing_key) { OpenSSL::PKey::RSA.generate(2048) } + + describe '#jwt_token' do + it 'generates a valid JWT token' do + uuid = '12345678-1234-5678-1234-567812345678' + allow(SecureRandom).to receive(:uuid).and_return(uuid) + + jwt_token = described_class.new(client_id, + domain_url, + client_assertion_signing_key, + 'RS256') + .jwt_token + decoded_token = JWT.decode(jwt_token, client_assertion_signing_key, true, { algorithm: 'RS256' }) + + expect(decoded_token[0]['iss']).to eq(client_id) + expect(decoded_token[0]['sub']).to eq(client_id) + expect(decoded_token[0]['aud']).to eq("#{domain_url}/oauth/token") + expect(decoded_token[0]['iat']).to be_within(5).of(Time.now.utc.to_i) + expect(decoded_token[0]['exp']).to eq(decoded_token[0]['iat'] + 60) + expect(decoded_token[0]['jti']).to eq(uuid) + end + + it 'defaults to RS256 algorithm if not specified' do + uuid = '12345678-1234-5678-1234-567812345678' + allow(SecureRandom).to receive(:uuid).and_return(uuid) + + jwt_token = described_class.new(client_id, domain_url, client_assertion_signing_key).jwt_token + decoded_token = JWT.decode(jwt_token, client_assertion_signing_key, true, { algorithm: 'RS256' }) + + expect(decoded_token[0]['iss']).to eq(client_id) + expect(decoded_token[0]['sub']).to eq(client_id) + expect(decoded_token[0]['aud']).to eq("#{domain_url}/oauth/token") + expect(decoded_token[0]['iat']).to be_within(5).of(Time.now.utc.to_i) + expect(decoded_token[0]['exp']).to eq(decoded_token[0]['iat'] + 60) + expect(decoded_token[0]['jti']).to eq(uuid) + end + + context 'when using ES256 algorithm' do + let(:client_assertion_signing_key) { OpenSSL::PKey::EC.generate('prime256v1') } + + it 'generates a valid JWT token' do + uuid = '12345678-1234-5678-1234-567812345678' + allow(SecureRandom).to receive(:uuid).and_return(uuid) + jwt_token = described_class.new(client_id, + domain_url, + client_assertion_signing_key, + 'ES256') + .jwt_token + decoded_token = JWT.decode(jwt_token, client_assertion_signing_key, true, { algorithm: 'ES256' }) + + expect(decoded_token[0]['iss']).to eq(client_id) + expect(decoded_token[0]['sub']).to eq(client_id) + expect(decoded_token[0]['aud']).to eq("#{domain_url}/oauth/token") + expect(decoded_token[0]['iat']).to be_within(5).of(Time.now.utc.to_i) + expect(decoded_token[0]['exp']).to eq(decoded_token[0]['iat'] + 60) + expect(decoded_token[0]['jti']).to eq(uuid) + end + + it 'accepts client_assertion_signing_key_token_params as a string' do + uuid = '12345678-1234-5678-1234-567812345678' + allow(SecureRandom).to receive(:uuid).and_return(uuid) + jwt_token = described_class.new(client_id, + domain_url, + client_assertion_signing_key, + 'ES256') + .jwt_token + decoded_token = JWT.decode(jwt_token, client_assertion_signing_key, true, { algorithm: 'ES256' }) + + expect(decoded_token[0]['iss']).to eq(client_id) + expect(decoded_token[0]['sub']).to eq(client_id) + expect(decoded_token[0]['aud']).to eq("#{domain_url}/oauth/token") + expect(decoded_token[0]['iat']).to be_within(5).of(Time.now.utc.to_i) + expect(decoded_token[0]['exp']).to eq(decoded_token[0]['iat'] + 60) + expect(decoded_token[0]['jti']).to eq(uuid) + end + end + end +end diff --git a/spec/omniauth/strategies/auth0_spec.rb b/spec/omniauth/strategies/auth0_spec.rb index 2bc3cdb..9ee0c8a 100644 --- a/spec/omniauth/strategies/auth0_spec.rb +++ b/spec/omniauth/strategies/auth0_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' require 'jwt' require 'multi_json' +require 'cgi' OmniAuth.config.allowed_request_methods = [:get, :post] @@ -10,10 +11,257 @@ it { expect(subject.site).to eq(url) } end +RSpec.shared_examples 'client_options with valid configuration' do + context 'domain with https' do + let(:domain_url) { 'https://samples.auth0.com' } + it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' + end + + context 'domain with http' do + let(:domain_url) { 'http://mydomain.com' } + it_behaves_like 'site has valid domain url', 'http://mydomain.com' + end + + context 'domain with host only' do + let(:domain_url) { 'samples.auth0.com' } + it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' + end + + it 'should have correct authorize path' do + expect(subject.options[:authorize_url]).to eq('/authorize') + end + + it 'should have the correct userinfo path' do + expect(subject.options[:userinfo_url]).to eq('/userinfo') + end + + it 'should have the correct token path' do + expect(subject.options[:token_url]).to eq('/oauth/token') + end +end + +RSpec.shared_examples 'oauth redirects with various parameters' do + it 'redirects to hosted login page' do + get 'auth/auth0' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('ext-test') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it 'redirects to hosted login page' do + get 'auth/auth0?connection=abcd' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('connection', 'abcd') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('ext-test') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it 'redirects to the hosted login page with connection_scope' do + get 'auth/auth0?connection_scope=identity_provider_scope' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url) + .to have_query('connection_scope', 'identity_provider_scope') + end + + it 'redirects to hosted login page with prompt=login' do + get 'auth/auth0?prompt=login' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('prompt', 'login') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('ext-test') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it 'redirects to hosted login page with screen_hint=signup' do + get 'auth/auth0?screen_hint=signup' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('screen_hint', 'signup') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('ext-test') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it 'redirects to hosted login page with organization=TestOrg and invitation=TestInvite' do + get 'auth/auth0?organization=TestOrg&invitation=TestInvite' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('organization', 'TestOrg') + expect(redirect_url).to have_query('invitation', 'TestInvite') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('ext-test') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('login_hint') + end + + it 'redirects to hosted login page with login_hint=example@mail.com' do + get 'auth/auth0?login_hint=example@mail.com' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('login_hint', 'example@mail.com') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('ext-test') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it 'redirects to hosted login page with ext-test=testval' do + get 'auth/auth0?ext-test=testval' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('ext-test', 'testval') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it "stores session['authorize_params'] as a plain Ruby Hash" do + get '/auth/auth0' + expect(session['authorize_params'].class).to eq(::Hash) + end +end + +RSpec.shared_examples 'basic oauth callback assertions' do + it 'to succeed' do + expect(last_response.status).to eq(200) + end + + it 'has credentials' do + expect(subject['credentials']['token']).to eq(access_token) + expect(subject['credentials']['expires']).to be true + expect(subject['credentials']['expires_at']).to_not be_nil + end + + it 'has basic values' do + expect(subject['provider']).to eq('auth0') + expect(subject['uid']).to eq(user_id) + expect(subject['info']['name']).to eq(name) + end + + it 'should use the user info endpoint' do + expect(subject['extra']['raw_info']).to eq(basic_user_info) + end +end + +RSpec.shared_examples 'basic oauth refresh token callback assertions' do + it 'to succeed' do + expect(last_response.status).to eq(200) + end + + it 'has credentials' do + expect(subject['credentials']['token']).to eq(access_token) + expect(subject['credentials']['refresh_token']).to eq(refresh_token) + expect(subject['credentials']['expires']).to be true + expect(subject['credentials']['expires_at']).to_not be_nil + end +end + +RSpec.shared_examples 'oidc callback assertions' do + it 'to succeed' do + expect(last_response.status).to eq(200) + end + + it 'has credentials' do + expect(subject['credentials']['token']).to eq(access_token) + expect(subject['credentials']['expires']).to be true + expect(subject['credentials']['expires_at']).to_not be_nil + expect(subject['credentials']['id_token']).to eq(id_token) + end + + it 'has basic values' do + expect(subject['provider']).to eq('auth0') + expect(subject['uid']).to eq(user_id) + end + + it 'has info' do + expect(subject['info']['name']).to eq(name) + expect(subject['info']['nickname']).to eq(nickname) + expect(subject['info']['image']).to eq(picture) + expect(subject['info']['email']).to eq(email) + end + + it 'has extra' do + expect(subject['extra']['raw_info']['email_verified']).to be true + end +end + describe OmniAuth::Strategies::Auth0 do let(:client_id) { 'CLIENT_ID' } let(:client_secret) { 'CLIENT_SECRET' } let(:domain_url) { 'https://samples.auth0.com' } + let(:client_assertion_signing_algorithm) { 'RS256' } + let(:client_assertion_signing_key) { OpenSSL::PKey::RSA.generate(2048) } let(:application) do lambda do [200, {}, ['Hello.']] @@ -27,202 +275,97 @@ domain_url ) end - - describe 'client_options' do - let(:subject) { OmniAuth::Strategies::Auth0.new( + let(:auth0_client_assertion_signing_key) do + OmniAuth::Strategies::Auth0.new( application, client_id, - client_secret, - domain_url - ).client } - - context 'domain with https' do - let(:domain_url) { 'https://samples.auth0.com' } - it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' - end - - context 'domain with http' do - let(:domain_url) { 'http://mydomain.com' } - it_behaves_like 'site has valid domain url', 'http://mydomain.com' - end - - context 'domain with host only' do - let(:domain_url) { 'samples.auth0.com' } - it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' + nil, + domain_url, + { client_assertion_signing_key: client_assertion_signing_key, + client_assertion_signing_algorithm: client_assertion_signing_algorithm} + ) + end + describe 'client_options' do + context 'when using client_secret authentication' do + let(:subject) { OmniAuth::Strategies::Auth0.new( + application, + client_id, + client_secret, + domain_url + ).client } + + it_behaves_like 'client_options with valid configuration' end - it 'should have correct authorize path' do - expect(subject.options[:authorize_url]).to eq('/authorize') - end + context 'when using client assertion signing key authentication' do + let(:subject) do + OmniAuth::Strategies::Auth0.new( + application, + client_id, + nil, + domain_url, + { client_assertion_signing_key: client_assertion_signing_key, + client_assertion_signing_algorithm: client_assertion_signing_algorithm } + ).client + end - it 'should have the correct userinfo path' do - expect(subject.options[:userinfo_url]).to eq('/userinfo') - end + it_behaves_like 'client_options with valid configuration' - it 'should have the correct token path' do - expect(subject.options[:token_url]).to eq('/oauth/token') + it 'should have the correct auth_scheme' do + expect(subject.options[:auth_scheme]).to eq(:request_body) + end end end describe 'options' do - let(:subject) { auth0.options } - - it 'should have the correct client_id' do - expect(subject[:client_id]).to eq(client_id) - end + context 'when using client_secret authentication' do + let(:subject) { auth0.options } - it 'should have the correct client secret' do - expect(subject[:client_secret]).to eq(client_secret) - end - it 'should have correct domain' do - expect(subject[:domain]).to eq(domain_url) - end - end + it 'should have the correct client_id' do + expect(subject[:client_id]).to eq(client_id) + end - describe 'oauth' do - it 'redirects to hosted login page' do - get 'auth/auth0' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('connection_scope') - expect(redirect_url).not_to have_query('ext-test') - expect(redirect_url).not_to have_query('prompt') - expect(redirect_url).not_to have_query('screen_hint') - expect(redirect_url).not_to have_query('login_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') + it 'should have the correct client secret' do + expect(subject[:client_secret]).to eq(client_secret) + end + it 'should have correct domain' do + expect(subject[:domain]).to eq(domain_url) + end end - it 'redirects to hosted login page' do - get 'auth/auth0?connection=abcd' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('connection', 'abcd') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection_scope') - expect(redirect_url).not_to have_query('ext-test') - expect(redirect_url).not_to have_query('prompt') - expect(redirect_url).not_to have_query('screen_hint') - expect(redirect_url).not_to have_query('login_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') - end + context 'when using client assertion signing key authentication' do + let(:subject) { auth0_client_assertion_signing_key.options } - it 'redirects to the hosted login page with connection_scope' do - get 'auth/auth0?connection_scope=identity_provider_scope' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url) - .to have_query('connection_scope', 'identity_provider_scope') - end + it 'should have the correct client_id' do + expect(subject[:client_id]).to eq(client_id) + end - it 'redirects to hosted login page with prompt=login' do - get 'auth/auth0?prompt=login' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('prompt', 'login') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('ext-test') - expect(redirect_url).not_to have_query('login_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') - end + it 'should have the correct client secret' do + expect(subject[:client_secret]).to eq(nil) + end + it 'should have correct domain' do + expect(subject[:domain]).to eq(domain_url) + end - it 'redirects to hosted login page with screen_hint=signup' do - get 'auth/auth0?screen_hint=signup' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('screen_hint', 'signup') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('ext-test') - expect(redirect_url).not_to have_query('login_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') + it 'should have the correct client_assertion_signing_key' do + expect(subject[:client_assertion_signing_key]).to eq(client_assertion_signing_key) + end end + end - it 'redirects to hosted login page with organization=TestOrg and invitation=TestInvite' do - get 'auth/auth0?organization=TestOrg&invitation=TestInvite' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('organization', 'TestOrg') - expect(redirect_url).to have_query('invitation', 'TestInvite') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('connection_scope') - expect(redirect_url).not_to have_query('ext-test') - expect(redirect_url).not_to have_query('prompt') - expect(redirect_url).not_to have_query('screen_hint') - expect(redirect_url).not_to have_query('login_hint') + describe 'oauth' do + context 'when using client_secret authentication' do + it_behaves_like 'oauth redirects with various parameters' end - it 'redirects to hosted login page with login_hint=example@mail.com' do - get 'auth/auth0?login_hint=example@mail.com' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('login_hint', 'example@mail.com') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('connection_scope') - expect(redirect_url).not_to have_query('ext-test') - expect(redirect_url).not_to have_query('prompt') - expect(redirect_url).not_to have_query('screen_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') - end + context 'when using client assertion signing key authentication' do + before do + @app = make_application(client_secret: nil, + client_assertion_signing_key: client_assertion_signing_key, + client_assertion_signing_algorithm: client_assertion_signing_algorithm) + end - it 'redirects to hosted login page with ext-test=testval' do - get 'auth/auth0?ext-test=testval' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('ext-test', 'testval') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('connection_scope') - expect(redirect_url).not_to have_query('login_hint') - expect(redirect_url).not_to have_query('prompt') - expect(redirect_url).not_to have_query('screen_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') + it_behaves_like 'oauth redirects with various parameters' end def session @@ -232,12 +375,6 @@ def session Marshal.load(decoded_session_data) end - it "stores session['authorize_params'] as a plain Ruby Hash" do - get '/auth/auth0' - - expect(session['authorize_params'].class).to eq(::Hash) - end - describe 'callback' do let(:access_token) { 'access token' } let(:expires_in) { 2000 } @@ -253,20 +390,6 @@ def session let(:email) { 'mail@mail.com' } let(:email_verified) { true } - let(:id_token) do - payload = {} - payload['sub'] = user_id - payload['iss'] = "#{domain_url}/" - payload['aud'] = client_id - payload['name'] = name - payload['nickname'] = nickname - payload['picture'] = picture - payload['email'] = email - payload['email_verified'] = email_verified - - JWT.encode payload, client_secret, 'HS256' - end - let(:oauth_response) do { access_token: access_token, @@ -286,15 +409,6 @@ def session let(:basic_user_info) { { "sub" => user_id, "name" => name } } - def stub_auth(body) - stub_request(:post, 'https://samples.auth0.com/oauth/token') - .with(headers: { 'Auth0-Client' => telemetry_value }) - .to_return( - headers: { 'Content-Type' => 'application/json' }, - body: MultiJson.encode(body) - ) - end - def stub_userinfo(body) stub_request(:get, 'https://samples.auth0.com/userinfo') .to_return( @@ -316,91 +430,217 @@ def trigger_callback MultiJson.decode(last_response.body) end - context 'basic oauth' do - before do - stub_auth(oauth_response) - stub_userinfo(basic_user_info) - trigger_callback + context 'when using client_secret authentication' do + let(:id_token) do + payload = {} + payload['sub'] = user_id + payload['iss'] = "#{domain_url}/" + payload['aud'] = client_id + payload['name'] = name + payload['nickname'] = nickname + payload['picture'] = picture + payload['email'] = email + payload['email_verified'] = email_verified + + JWT.encode payload, client_secret, 'HS256' end - it 'to succeed' do - expect(last_response.status).to eq(200) + def stub_auth(body) + stub_request(:post, 'https://samples.auth0.com/oauth/token') + .with(headers: { 'Auth0-Client' => telemetry_value }) + .to_return( + headers: { 'Content-Type' => 'application/json' }, + body: MultiJson.encode(body) + ) end - it 'has credentials' do - expect(subject['credentials']['token']).to eq(access_token) - expect(subject['credentials']['expires']).to be true - expect(subject['credentials']['expires_at']).to_not be_nil + context 'basic oauth' do + before do + stub_auth(oauth_response) + stub_userinfo(basic_user_info) + trigger_callback + end + + it_behaves_like 'basic oauth callback assertions' end - it 'has basic values' do - expect(subject['provider']).to eq('auth0') - expect(subject['uid']).to eq(user_id) - expect(subject['info']['name']).to eq(name) + context 'basic oauth w/refresh token' do + before do + stub_auth(oauth_response.merge(refresh_token: refresh_token)) + stub_userinfo(basic_user_info) + trigger_callback + end + + it_behaves_like 'basic oauth refresh token callback assertions' end - it 'should use the user info endpoint' do - expect(subject['extra']['raw_info']).to eq(basic_user_info) + context 'oidc' do + before do + stub_auth(oidc_response) + trigger_callback + end + + it_behaves_like 'oidc callback assertions' end end - context 'basic oauth w/refresh token' do - before do - stub_auth(oauth_response.merge(refresh_token: refresh_token)) - stub_userinfo(basic_user_info) - trigger_callback + context 'when using client assertion signing key authentication' do + let(:jwt_token) { JWT.encode({ sub: client_id }, client_assertion_signing_key, 'RS256') } + let(:valid_jwks_kid) { 'NkJCQzIyQzRBMEU4NjhGNUU4MzU4RkY0M0ZDQzkwOUQ0Q0VGNUMwQg' } + + let(:rsa_private_key) do + OpenSSL::PKey::RSA.generate 2048 end - it 'to succeed' do - expect(last_response.status).to eq(200) + let(:valid_jwks) do + { + keys: [ + { + kid: valid_jwks_kid, + x5c: [Base64.encode64(make_cert(rsa_private_key).to_der)] + } + ] + }.to_json end - it 'has credentials' do - expect(subject['credentials']['token']).to eq(access_token) - expect(subject['credentials']['refresh_token']).to eq(refresh_token) - expect(subject['credentials']['expires']).to be true - expect(subject['credentials']['expires_at']).to_not be_nil + let(:id_token) do + payload = {} + payload['sub'] = user_id + payload['iss'] = "#{domain_url}/" + payload['aud'] = client_id + payload['name'] = name + payload['nickname'] = nickname + payload['picture'] = picture + payload['email'] = email + payload['email_verified'] = email_verified + + JWT.encode payload, rsa_private_key, 'RS256', kid: valid_jwks_kid end - end - context 'oidc' do - before do - stub_auth(oidc_response) - trigger_callback + def jwt_token?(token) + JWT.decode(token, nil, false) + true + rescue JWT::DecodeError, ArgumentError + false end - it 'to succeed' do - expect(last_response.status).to eq(200) + def make_cert(private_key) + cert = OpenSSL::X509::Certificate.new + cert.issuer = OpenSSL::X509::Name.parse('/C=BE/O=Auth0/OU=Auth0/CN=Auth0') + cert.subject = cert.issuer + cert.not_before = Time.now + cert.not_after = Time.now + 365 * 24 * 60 * 60 + cert.public_key = private_key.public_key + cert.serial = 0x0 + cert.version = 2 + + ef = OpenSSL::X509::ExtensionFactory.new + ef.subject_certificate = cert + ef.issuer_certificate = cert + cert.extensions = [ + ef.create_extension('basicConstraints', 'CA:TRUE', true), + ef.create_extension('subjectKeyIdentifier', 'hash') + ] + cert.add_extension ef.create_extension( + 'authorityKeyIdentifier', + 'keyid:always,issuer:always' + ) + + cert.sign private_key, OpenSSL::Digest.new('SHA1') end - it 'has credentials' do - expect(subject['credentials']['token']).to eq(access_token) - expect(subject['credentials']['expires']).to be true - expect(subject['credentials']['expires_at']).to_not be_nil - expect(subject['credentials']['id_token']).to eq(id_token) + def stub_auth(body, stubbed_jwt_token: true) + stub_request(:post, "#{domain_url}/oauth/token") + .with do |request| + params = URI.decode_www_form(request.body).to_h + token = params['client_assertion'] + + request.headers['Auth0-Client'] == telemetry_value && + params['grant_type'] == described_class::AUTHORIZATION_CODE_GRANT_TYPE && + params['client_id'] == client_id && + params['client_assertion_type'] == described_class::CLIENT_ASSERTION_TYPE && + (stubbed_jwt_token ? token == jwt_token : jwt_token?(token)) + end + .to_return( + headers: { 'Content-Type' => 'application/json' }, + body: MultiJson.encode(body) + ) end - it 'has basic values' do - expect(subject['provider']).to eq('auth0') - expect(subject['uid']).to eq(user_id) + def stub_expected_jwks + stub_request(:get, 'https://samples.auth0.com/.well-known/jwks.json') + .to_return( + headers: { 'Content-Type' => 'application/json' }, + body: valid_jwks, + status: 200 + ) end - it 'has info' do - expect(subject['info']['name']).to eq(name) - expect(subject['info']['nickname']).to eq(nickname) - expect(subject['info']['image']).to eq(picture) - expect(subject['info']['email']).to eq(email) + def stub_jwt_token(algorithm: client_assertion_signing_algorithm) + allow(OmniAuth::Auth0::JWTToken).to receive(:new) + .with(client_id, + domain_url, + client_assertion_signing_key, + algorithm) + .and_return(instance_double(OmniAuth::Auth0::JWTToken, jwt_token: jwt_token)) end - it 'has extra' do - expect(subject['extra']['raw_info']['email_verified']).to be true + context 'basic oauth' do + before do + @app = make_application(client_secret: nil, client_assertion_signing_key: client_assertion_signing_key) + stub_jwt_token(algorithm: nil) + stub_auth(oauth_response) + stub_userinfo(basic_user_info) + trigger_callback + end + + it_behaves_like 'basic oauth callback assertions' + end + + context 'basic oath without stubbing jwt token' do + before do + @app = make_application(client_secret: nil, client_assertion_signing_key: client_assertion_signing_key) + stub_auth(oauth_response, stubbed_jwt_token: false) + stub_userinfo(basic_user_info) + trigger_callback + end + + it_behaves_like 'basic oauth callback assertions' + end + + context 'basic oauth w/refresh token' do + before do + @app = make_application(client_secret: nil, + client_assertion_signing_key: client_assertion_signing_key, + client_assertion_signing_algorithm: client_assertion_signing_algorithm) + stub_jwt_token + stub_auth(oauth_response.merge(refresh_token: refresh_token)) + stub_userinfo(basic_user_info) + trigger_callback + end + + it_behaves_like 'basic oauth refresh token callback assertions' + end + + context 'oidc' do + before do + @app = make_application(client_secret: nil, + client_assertion_signing_key: client_assertion_signing_key, + client_assertion_signing_algorithm: client_assertion_signing_algorithm) + stub_jwt_token + stub_auth(oidc_response) + stub_expected_jwks + trigger_callback + end + + it_behaves_like 'oidc callback assertions' end end end end describe 'error_handling' do - it 'fails when missing client_id' do + it 'fails when missing client_id and client_assertion_signing_key' do @app = make_application(client_id: nil) get 'auth/auth0' expect(last_response.status).to eq(302) @@ -408,7 +648,7 @@ def trigger_callback expect(redirect_url).to fail_auth_with('missing_client_id') end - it 'fails when missing client_secret' do + it 'fails when missing client_secret and client_assertion_signing_key' do @app = make_application(client_secret: nil) get 'auth/auth0' expect(last_response.status).to eq(302) @@ -423,6 +663,14 @@ def trigger_callback redirect_url = last_response.headers['Location'] expect(redirect_url).to fail_auth_with('missing_domain') end + + it 'fails when missing client_assertion_signing_key' do + @app = make_application(client_secret: nil, client_assertion_signing_key: nil) + get 'auth/auth0' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to fail_auth_with('missing_client_assertion_signing_key') + end end end