├── .eslintrc.security ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── Bug Report.yml │ ├── Feature Request.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── actions │ ├── build │ │ └── action.yml │ ├── framework │ │ └── action.yml │ ├── get-prerelease │ │ └── action.yml │ ├── get-release-notes │ │ └── action.yml │ ├── get-version │ │ └── action.yml │ ├── npm-publish │ │ └── action.yml │ ├── release-create │ │ └── action.yml │ ├── rl-scanner │ │ └── action.yml │ └── tag-exists │ │ └── action.yml ├── dependabot.yml ├── stale.yml └── workflows │ ├── browserstack.yml │ ├── codeql.yml │ ├── npm-release.yml │ ├── release.yml │ ├── rl-secure.yml │ ├── semgrep.yml │ ├── snyk.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .semgrepignore ├── .shiprc ├── .version ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── EXAMPLES.md ├── FAQ.md ├── Jenkinsfile ├── LICENSE ├── MIGRATION_GUIDE.md ├── README.md ├── __mocks__ ├── browser-tabs-lock │ └── index.ts └── promise-polyfill │ └── src │ └── polyfill.js ├── __tests__ ├── Auth0Client │ ├── checkSession.test.ts │ ├── constructor.test.ts │ ├── exchangeToken.test.ts │ ├── getIdTokenClaims.test.ts │ ├── getTokenSilently.test.ts │ ├── getTokenWithPopup.test.ts │ ├── getUser.test.ts │ ├── handleRedirectCallback.test.ts │ ├── helpers.ts │ ├── isAuthenticated.test.ts │ ├── loginWithPopup.test.ts │ ├── loginWithRedirect.test.ts │ └── logout.test.ts ├── api.test.ts ├── cache │ ├── cache-manager.test.ts │ ├── cache.test.ts │ ├── key-manifest.test.ts │ └── shared.ts ├── constants.ts ├── helpers.ts ├── http.test.ts ├── index.test.ts ├── jwt.test.ts ├── promise-utils.test.ts ├── scope.test.ts ├── ssr.test.ts ├── storage.test.ts ├── token.worker.test.ts ├── transaction-manager.test.ts └── utils.test.ts ├── browserstack.json ├── codecov.yml ├── cypress.config.js ├── cypress ├── e2e │ ├── getTokenSilently.cy.js │ ├── handleRedirectCallback.cy.js │ ├── initialisation.cy.js │ ├── loginWithRedirect.cy.js │ ├── logout.cy.js │ └── multiple_clients.cy.js ├── fixtures │ ├── example.json │ ├── profile.json │ └── users.json ├── plugins │ └── index.js ├── support │ ├── commands.js │ ├── e2e.js │ └── utils.js └── tsconfig.json ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ ├── main.js │ ├── navigation.js │ ├── search.js │ └── style.css ├── classes │ ├── Auth0Client.html │ ├── AuthenticationError.html │ ├── CacheKey.html │ ├── GenericError.html │ ├── InMemoryCache.html │ ├── LocalStorageCache.html │ ├── MfaRequiredError.html │ ├── MissingRefreshTokenError.html │ ├── PopupCancelledError.html │ ├── PopupTimeoutError.html │ ├── TimeoutError.html │ └── User.html ├── functions │ └── createAuth0Client.html ├── hierarchy.html ├── index.html ├── interfaces │ ├── Auth0ClientOptions.html │ ├── AuthorizationParams.html │ ├── DecodedToken.html │ ├── GetTokenSilentlyOptions.html │ ├── GetTokenWithPopupOptions.html │ ├── ICache.html │ ├── IdToken.html │ ├── LogoutOptions.html │ ├── LogoutUrlOptions.html │ ├── PopupConfigOptions.html │ ├── PopupLoginOptions.html │ ├── RedirectLoginOptions.html │ └── RedirectLoginResult.html ├── modules.html └── types │ ├── CacheEntry.html │ ├── CacheKeyData.html │ ├── CacheLocation.html │ ├── Cacheable.html │ ├── GetTokenSilentlyVerboseResponse.html │ ├── KeyManifestEntry.html │ ├── MaybePromise.html │ ├── TokenEndpointResponse.html │ └── WrappedCacheEntry.html ├── jest.config.js ├── jest.environment.js ├── jest.setup.js ├── opslevel.yml ├── package-lock.json ├── package.json ├── rollup.config.js ├── scripts ├── exec.js ├── oidc-provider.js ├── prepack.js └── print-bundle-size.mjs ├── src ├── Auth0Client.ts ├── Auth0Client.utils.ts ├── TokenExchange.ts ├── api.ts ├── cache │ ├── cache-localstorage.ts │ ├── cache-manager.ts │ ├── cache-memory.ts │ ├── index.ts │ ├── key-manifest.ts │ └── shared.ts ├── constants.ts ├── errors.ts ├── global.ts ├── http.ts ├── index.ts ├── jwt.ts ├── promise-utils.ts ├── scope.ts ├── storage.ts ├── transaction-manager.ts ├── utils.ts ├── version.ts └── worker │ ├── __mocks__ │ └── token.worker.ts │ ├── token.worker.ts │ ├── worker.types.ts │ └── worker.utils.ts ├── static ├── auth0-spa-js.development_old.js ├── index.html ├── multiple_clients.html ├── perf.html ├── v1.html └── v2.html ├── tsconfig.json ├── tsconfig.test.json └── typedoc.js /.eslintrc.security: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true, 6 | "jest": true, 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint/tslint" 10 | ], 11 | "ignorePatterns": ["**/__mocks__/**"], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "project": "tsconfig.json", 15 | "ecmaVersion": 6, 16 | "sourceType": "module" 17 | }, 18 | "extends": [ 19 | "plugin:security/recommended" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @auth0/project-dx-sdks-engineer-codeowner 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug Report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Report a bug 2 | description: Have you found a bug or issue? Create a bug report for this library 3 | labels: ['bug'] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | **Please do not report security vulnerabilities here**. The [Responsible Disclosure Program](https://auth0.com/responsible-disclosure-policy) details the procedure for disclosing security issues. 10 | 11 | - type: checkboxes 12 | id: checklist 13 | attributes: 14 | label: Checklist 15 | options: 16 | - label: The issue can be reproduced in the [auth0-spa-js sample app](https://github.com/auth0-samples/auth0-javascript-samples/tree/master/01-Login) (or N/A). 17 | required: true 18 | - label: I have looked into the [Readme](https://github.com/auth0/auth0-spa-js#readme), [Examples](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md), and [FAQ](https://github.com/auth0/auth0-spa-js/blob/main/FAQ.md) and have not found a suitable solution or answer. 19 | required: true 20 | - label: I have looked into the [documentation](https://auth0.com/docs/libraries/auth0-single-page-app-sdk) and [API documentation](https://auth0.github.io/auth0-spa-js/), and have not found a suitable solution or answer. 21 | required: true 22 | - label: I have searched the [issues](https://github.com/auth0/auth0-spa-js/issues) and have not found a suitable solution or answer. 23 | required: true 24 | - label: I have searched the [Auth0 Community](https://community.auth0.com) forums and have not found a suitable solution or answer. 25 | required: true 26 | - label: I agree to the terms within the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md). 27 | required: true 28 | 29 | - type: textarea 30 | id: description 31 | attributes: 32 | label: Description 33 | description: Provide a clear and concise description of the issue, including what you expected to happen. 34 | validations: 35 | required: true 36 | 37 | - type: textarea 38 | id: reproduction 39 | attributes: 40 | label: Reproduction 41 | description: Detail the steps taken to reproduce this error, and whether this issue can be reproduced consistently or if it is intermittent. 42 | placeholder: | 43 | 1. Step 1... 44 | 2. Step 2... 45 | 3. ... 46 | validations: 47 | required: true 48 | 49 | - type: textarea 50 | id: additional-context 51 | attributes: 52 | label: Additional context 53 | description: Other libraries that might be involved, or any other relevant information you think would be useful. 54 | validations: 55 | required: false 56 | 57 | - type: input 58 | id: environment-version 59 | attributes: 60 | label: auth0-spa-js version 61 | validations: 62 | required: true 63 | 64 | - type: input 65 | id: environment-framework 66 | attributes: 67 | label: Which framework are you using (React, Angular, Vue...)? 68 | validations: 69 | required: false 70 | 71 | - type: input 72 | id: environment-framework-version 73 | attributes: 74 | label: Framework version 75 | validations: 76 | required: false 77 | 78 | - type: dropdown 79 | id: environment-browser 80 | attributes: 81 | label: Which browsers have you tested in? 82 | multiple: true 83 | options: 84 | - Chrome 85 | - Edge 86 | - Safari 87 | - Firefox 88 | - Opera 89 | - Other 90 | validations: 91 | required: true 92 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature Request.yml: -------------------------------------------------------------------------------- 1 | name: 🧩 Feature request 2 | description: Suggest an idea or a feature for this library 3 | labels: ['feature request'] 4 | 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: Checklist 10 | options: 11 | - label: I have looked into the [Readme](https://github.com/auth0/auth0-spa-js#readme), [Examples](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md), and [FAQ](https://github.com/auth0/auth0-spa-js/blob/main/FAQ.md) and have not found a suitable solution or answer. 12 | required: true 13 | - label: I have looked into the [documentation](https://auth0.com/docs/libraries/auth0-single-page-app-sdk) and [API documentation](https://auth0.github.io/auth0-spa-js/), and have not found a suitable solution or answer. 14 | required: true 15 | - label: I have searched the [issues](https://github.com/auth0/auth0-spa-js/issues) and have not found a suitable solution or answer. 16 | required: true 17 | - label: I have searched the [Auth0 Community](https://community.auth0.com) forums and have not found a suitable solution or answer. 18 | required: true 19 | - label: I agree to the terms within the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md). 20 | required: true 21 | 22 | - type: textarea 23 | id: description 24 | attributes: 25 | label: Describe the problem you'd like to have solved 26 | description: A clear and concise description of what the problem is. 27 | placeholder: I'm always frustrated when... 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: ideal-solution 33 | attributes: 34 | label: Describe the ideal solution 35 | description: A clear and concise description of what you want to happen. 36 | validations: 37 | required: true 38 | 39 | - type: textarea 40 | id: alternatives-and-workarounds 41 | attributes: 42 | label: Alternatives and current workarounds 43 | description: A clear and concise description of any alternatives you've considered or any workarounds that are currently in place. 44 | validations: 45 | required: false 46 | 47 | - type: textarea 48 | id: additional-context 49 | attributes: 50 | label: Additional context 51 | description: Add any other context or screenshots about the feature request here. 52 | validations: 53 | required: false 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Auth0 Community 4 | url: https://community.auth0.com 5 | about: Discuss this SDK in the Auth0 Community forums 6 | - name: FAQ 7 | url: https://github.com/auth0/auth0-spa-js/blob/main/FAQ.md 8 | about: Read the FAQ to get answers to common issues 9 | - name: SDK API Documentation 10 | url: https://auth0.github.io/auth0-spa-js/ 11 | about: Read the API documentation for this SDK 12 | - name: Library Documentation 13 | url: https://auth0.com/docs/libraries/auth0-spa-js 14 | about: Read the library docs on Auth0.com 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Changes 4 | 5 | 11 | 12 | ### References 13 | 14 | 24 | 25 | ### Testing 26 | 27 | 30 | 31 | - [ ] This change adds unit test coverage 32 | - [ ] This change adds integration test coverage 33 | - [ ] This change has been tested on the latest version of the platform/language 34 | 35 | ### Checklist 36 | 37 | - [ ] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md) 38 | - [ ] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) 39 | - [ ] All code quality tools/guidelines have been run/followed 40 | -------------------------------------------------------------------------------- /.github/actions/build/action.yml: -------------------------------------------------------------------------------- 1 | name: Build package 2 | description: Build the SDK package 3 | 4 | inputs: 5 | node: 6 | description: The Node version to use 7 | required: false 8 | default: 18 9 | 10 | runs: 11 | using: composite 12 | 13 | steps: 14 | - name: Setup Node 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: ${{ inputs.node }} 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | shell: bash 22 | run: npm ci 23 | 24 | - name: Build package 25 | shell: bash 26 | run: npm run build 27 | env: 28 | WITH_STATS: true 29 | 30 | - name: Get bundle size 31 | shell: bash 32 | run: npm run print-bundle-size 33 | 34 | - name: Run `es-check` 35 | shell: bash 36 | run: npm run test:es-check 37 | -------------------------------------------------------------------------------- /.github/actions/framework/action.yml: -------------------------------------------------------------------------------- 1 | name: Run framework test 2 | description: Run tests for a given framework 3 | 4 | inputs: 5 | node: 6 | description: The Node version to use 7 | required: false 8 | default: 18 9 | cache: 10 | description: Cache key to restore for build artifacts. 11 | required: true 12 | install: 13 | description: The installation command to run 14 | required: true 15 | content: 16 | description: The SDK entrypoint code to inject. 17 | required: true 18 | import: 19 | description: The SDK's import code to inject. 20 | required: true 21 | 22 | runs: 23 | using: composite 24 | 25 | steps: 26 | - name: Setup Node 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: ${{ inputs.node }} 30 | cache: 'npm' 31 | 32 | - name: Restore build artifacts 33 | uses: actions/cache/restore@v3 34 | with: 35 | path: . 36 | key: ${{ inputs.cache }} 37 | 38 | - name: Install dependencies 39 | shell: bash 40 | run: npm ci 41 | 42 | - name: Create application 43 | shell: bash 44 | run: ${{ inputs.install }} 45 | 46 | - name: Install SDK 47 | shell: bash 48 | run: | 49 | npm link '../' 50 | ${{ inputs.content }} 51 | ${{ inputs.import }} 52 | working-directory: my-app 53 | 54 | - name: Build application 55 | shell: bash 56 | run: npm run build 57 | working-directory: my-app 58 | -------------------------------------------------------------------------------- /.github/actions/get-prerelease/action.yml: -------------------------------------------------------------------------------- 1 | name: Return a boolean indicating if the version contains prerelease identifiers 2 | 3 | # 4 | # Returns a simple true/false boolean indicating whether the version indicates it's a prerelease or not. 5 | # 6 | # TODO: Remove once the common repo is public. 7 | # 8 | 9 | inputs: 10 | version: 11 | required: true 12 | 13 | outputs: 14 | prerelease: 15 | value: ${{ steps.get_prerelease.outputs.PRERELEASE }} 16 | 17 | runs: 18 | using: composite 19 | 20 | steps: 21 | - id: get_prerelease 22 | shell: bash 23 | run: | 24 | if [[ "${VERSION}" == *"beta"* || "${VERSION}" == *"alpha"* ]]; then 25 | echo "PRERELEASE=true" >> $GITHUB_OUTPUT 26 | else 27 | echo "PRERELEASE=false" >> $GITHUB_OUTPUT 28 | fi 29 | env: 30 | VERSION: ${{ inputs.version }} 31 | -------------------------------------------------------------------------------- /.github/actions/get-release-notes/action.yml: -------------------------------------------------------------------------------- 1 | name: Return the release notes extracted from the body of the PR associated with the release. 2 | 3 | # 4 | # 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. 5 | # 6 | # TODO: Remove once the common repo is public. 7 | # 8 | inputs: 9 | version: 10 | required: true 11 | repo_name: 12 | required: false 13 | repo_owner: 14 | required: true 15 | token: 16 | required: true 17 | 18 | outputs: 19 | release-notes: 20 | value: ${{ steps.get_release_notes.outputs.RELEASE_NOTES }} 21 | 22 | runs: 23 | using: composite 24 | 25 | steps: 26 | - uses: actions/github-script@v7 27 | id: get_release_notes 28 | with: 29 | result-encoding: string 30 | script: | 31 | const { data: pulls } = await github.rest.pulls.list({ 32 | owner: process.env.REPO_OWNER, 33 | repo: process.env.REPO_NAME, 34 | state: 'all', 35 | head: `${process.env.REPO_OWNER}:release/${process.env.VERSION}`, 36 | }); 37 | core.setOutput('RELEASE_NOTES', pulls[0].body); 38 | env: 39 | GITHUB_TOKEN: ${{ inputs.token }} 40 | REPO_OWNER: ${{ inputs.repo_owner }} 41 | REPO_NAME: ${{ inputs.repo_name }} 42 | VERSION: ${{ inputs.version }} -------------------------------------------------------------------------------- /.github/actions/get-version/action.yml: -------------------------------------------------------------------------------- 1 | name: Return the version extracted from the branch name 2 | 3 | # 4 | # Returns the version from the .version file. 5 | # 6 | # TODO: Remove once the common repo is public. 7 | # 8 | 9 | outputs: 10 | version: 11 | value: ${{ steps.get_version.outputs.VERSION }} 12 | 13 | runs: 14 | using: composite 15 | 16 | steps: 17 | - id: get_version 18 | shell: bash 19 | run: | 20 | VERSION=$(head -1 .version) 21 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 22 | -------------------------------------------------------------------------------- /.github/actions/npm-publish/action.yml: -------------------------------------------------------------------------------- 1 | name: Publish release to npm 2 | 3 | inputs: 4 | node-version: 5 | required: true 6 | npm-token: 7 | required: true 8 | version: 9 | required: true 10 | require-build: 11 | default: true 12 | release-directory: 13 | default: './' 14 | 15 | runs: 16 | using: composite 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ inputs.node-version }} 26 | cache: 'npm' 27 | registry-url: 'https://registry.npmjs.org' 28 | 29 | - name: Install dependencies 30 | shell: bash 31 | run: npm ci --include=dev 32 | 33 | - name: Build package 34 | if: inputs.require-build == 'true' 35 | shell: bash 36 | run: npm run build 37 | 38 | - name: Publish release to NPM 39 | shell: bash 40 | working-directory: ${{ inputs.release-directory }} 41 | run: | 42 | if [[ "${VERSION}" == *"beta"* ]]; then 43 | TAG="beta" 44 | elif [[ "${VERSION}" == *"alpha"* ]]; then 45 | TAG="alpha" 46 | else 47 | TAG="latest" 48 | fi 49 | npm publish --provenance --tag $TAG 50 | env: 51 | NODE_AUTH_TOKEN: ${{ inputs.npm-token }} 52 | VERSION: ${{ inputs.version }} -------------------------------------------------------------------------------- /.github/actions/release-create/action.yml: -------------------------------------------------------------------------------- 1 | name: Create a GitHub release 2 | 3 | # 4 | # Creates a GitHub release with the given version. 5 | # 6 | # TODO: Remove once the common repo is public. 7 | # 8 | 9 | inputs: 10 | token: 11 | required: true 12 | files: 13 | required: false 14 | name: 15 | required: true 16 | body: 17 | required: true 18 | tag: 19 | required: true 20 | commit: 21 | required: true 22 | draft: 23 | default: false 24 | required: false 25 | prerelease: 26 | default: false 27 | required: false 28 | fail_on_unmatched_files: 29 | default: true 30 | required: false 31 | 32 | runs: 33 | using: composite 34 | 35 | steps: 36 | - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 37 | with: 38 | body: ${{ inputs.body }} 39 | name: ${{ inputs.name }} 40 | tag_name: ${{ inputs.tag }} 41 | target_commitish: ${{ inputs.commit }} 42 | draft: ${{ inputs.draft }} 43 | prerelease: ${{ inputs.prerelease }} 44 | fail_on_unmatched_files: ${{ inputs.fail_on_unmatched_files }} 45 | files: ${{ inputs.files }} 46 | env: 47 | GITHUB_TOKEN: ${{ inputs.token }} 48 | -------------------------------------------------------------------------------- /.github/actions/rl-scanner/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Reversing Labs Scanner' 2 | description: 'Runs the Reversing Labs scanner on a specified artifact.' 3 | inputs: 4 | artifact-path: 5 | description: 'Path to the artifact to be scanned.' 6 | required: true 7 | version: 8 | description: 'Version of the artifact.' 9 | required: true 10 | 11 | runs: 12 | using: 'composite' 13 | steps: 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.10' 18 | 19 | - name: Install Python dependencies 20 | shell: bash 21 | run: | 22 | pip install boto3 requests 23 | 24 | - name: Configure AWS credentials 25 | uses: aws-actions/configure-aws-credentials@v1 26 | with: 27 | role-to-assume: ${{ env.PRODSEC_TOOLS_ARN }} 28 | aws-region: us-east-1 29 | mask-aws-account-id: true 30 | 31 | - name: Install RL Wrapper 32 | shell: bash 33 | run: | 34 | 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" 35 | 36 | - name: Run RL Scanner 37 | shell: bash 38 | env: 39 | RLSECURE_LICENSE: ${{ env.RLSECURE_LICENSE }} 40 | RLSECURE_SITE_KEY: ${{ env.RLSECURE_SITE_KEY }} 41 | SIGNAL_HANDLER_TOKEN: ${{ env.SIGNAL_HANDLER_TOKEN }} 42 | PYTHONUNBUFFERED: 1 43 | run: | 44 | if [ ! -f "${{ inputs.artifact-path }}" ]; then 45 | echo "Artifact not found: ${{ inputs.artifact-path }}" 46 | exit 1 47 | fi 48 | 49 | rl-wrapper \ 50 | --artifact "${{ inputs.artifact-path }}" \ 51 | --name "${{ github.event.repository.name }}" \ 52 | --version "${{ inputs.version }}" \ 53 | --repository "${{ github.repository }}" \ 54 | --commit "${{ github.sha }}" \ 55 | --build-env "github_actions" \ 56 | --suppress_output 57 | 58 | # Check the outcome of the scanner 59 | if [ $? -ne 0 ]; then 60 | echo "RL Scanner failed." 61 | echo "scan-status=failed" >> $GITHUB_ENV 62 | exit 1 63 | else 64 | echo "RL Scanner passed." 65 | echo "scan-status=success" >> $GITHUB_ENV 66 | fi 67 | 68 | outputs: 69 | scan-status: 70 | description: 'The outcome of the scan process.' 71 | value: ${{ env.scan-status }} 72 | -------------------------------------------------------------------------------- /.github/actions/tag-exists/action.yml: -------------------------------------------------------------------------------- 1 | name: Return a boolean indicating if a tag already exists for the repository 2 | 3 | # 4 | # Returns a simple true/false boolean indicating whether the tag exists or not. 5 | # 6 | # TODO: Remove once the common repo is public. 7 | # 8 | 9 | inputs: 10 | token: 11 | required: true 12 | tag: 13 | required: true 14 | 15 | outputs: 16 | exists: 17 | description: 'Whether the tag exists or not' 18 | value: ${{ steps.tag-exists.outputs.EXISTS }} 19 | 20 | runs: 21 | using: composite 22 | 23 | steps: 24 | - id: tag-exists 25 | shell: bash 26 | run: | 27 | GET_API_URL="https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG_NAME}" 28 | http_status_code=$(curl -LI $GET_API_URL -o /dev/null -w '%{http_code}\n' -s -H "Authorization: token ${GITHUB_TOKEN}") 29 | if [ "$http_status_code" -ne "404" ] ; then 30 | echo "EXISTS=true" >> $GITHUB_OUTPUT 31 | else 32 | echo "EXISTS=false" >> $GITHUB_OUTPUT 33 | fi 34 | env: 35 | TAG_NAME: ${{ inputs.tag }} 36 | GITHUB_TOKEN: ${{ inputs.token }} 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "npm" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | ignore: 12 | - dependency-name: "*" 13 | update-types: ["version-update:semver-major"] 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 30 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | daysUntilClose: 7 8 | 9 | # Only issues or pull requests with all of these labels are considered by StaleBot. Defaults to `[]` (disabled) 10 | onlyLabels: 11 | - 'more info needed' 12 | 13 | # Ignore issues in projects 14 | exemptProjects: true 15 | 16 | # Ignore issues and PRs in milestones 17 | exemptMilestones: true 18 | 19 | # Set to true to ignore issues with an assignee 20 | exemptAssignees: true 21 | 22 | # Label to use when marking as stale 23 | staleLabel: closed:stale 24 | 25 | # Comment to post when marking as stale. Set to `false` to disable 26 | markComment: > 27 | This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If you have not received a response for our team (apologies for the delay) and this is still a blocker, please reply with additional information or just a ping. Thank you for your contribution! 🙇‍♂️ 28 | -------------------------------------------------------------------------------- /.github/workflows/browserstack.yml: -------------------------------------------------------------------------------- 1 | name: Browserstack 2 | 3 | on: 4 | merge_group: 5 | workflow_dispatch: 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | push: 11 | branches: 12 | - main 13 | 14 | permissions: 15 | contents: read 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 19 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 20 | 21 | env: 22 | NODE_VERSION: 18 23 | 24 | jobs: 25 | 26 | browserstack: 27 | 28 | name: BrowserStack Tests 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - name: Checkout code 33 | uses: actions/checkout@v4 34 | with: 35 | ref: ${{ github.event.pull_request.head.sha || github.ref }} 36 | 37 | - name: Build package 38 | uses: ./.github/actions/build 39 | with: 40 | node: ${{ env.NODE_VERSION }} 41 | 42 | - name: Run tests 43 | shell: bash 44 | run: npx concurrently --raw --kill-others --success first "npm:dev" "wait-on http://127.0.0.1:3000/ && browserstack-cypress run --build-name ${{ github.event.pull_request.head.sha || github.ref }}" 45 | env: 46 | BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 47 | BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} 48 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | merge_group: 5 | pull_request: 6 | types: 7 | - opened 8 | - synchronize 9 | push: 10 | branches: 11 | - main 12 | - beta 13 | - v* 14 | schedule: 15 | - cron: '56 12 * * 1' 16 | 17 | permissions: 18 | actions: read 19 | contents: read 20 | security-events: write 21 | 22 | concurrency: 23 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 24 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | language: [javascript] 35 | 36 | steps: 37 | - if: github.actor == 'dependabot[bot]' || github.event_name == 'merge_group' 38 | 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. 39 | 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | 43 | - name: Initialize CodeQL 44 | uses: github/codeql-action/init@v3 45 | with: 46 | languages: ${{ matrix.language }} 47 | queries: +security-and-quality 48 | 49 | - name: Autobuild 50 | uses: github/codeql-action/autobuild@v3 51 | 52 | - name: Perform CodeQL Analysis 53 | uses: github/codeql-action/analyze@v3 54 | with: 55 | category: '/language:${{ matrix.language }}' 56 | -------------------------------------------------------------------------------- /.github/workflows/npm-release.yml: -------------------------------------------------------------------------------- 1 | name: Create npm and GitHub Release 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | node-version: 7 | required: true 8 | type: string 9 | require-build: 10 | default: true 11 | type: string 12 | release-directory: 13 | default: './' 14 | type: string 15 | secrets: 16 | github-token: 17 | required: true 18 | npm-token: 19 | required: true 20 | 21 | jobs: 22 | release: 23 | if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) 24 | runs-on: ubuntu-latest 25 | environment: release 26 | 27 | steps: 28 | # Checkout the code 29 | - uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 0 32 | 33 | # Get the version from the branch name 34 | - id: get_version 35 | uses: ./.github/actions/get-version 36 | 37 | # Get the prerelease flag from the branch name 38 | - id: get_prerelease 39 | uses: ./.github/actions/get-prerelease 40 | with: 41 | version: ${{ steps.get_version.outputs.version }} 42 | 43 | # Get the release notes 44 | - id: get_release_notes 45 | uses: ./.github/actions/get-release-notes 46 | with: 47 | token: ${{ secrets.github-token }} 48 | version: ${{ steps.get_version.outputs.version }} 49 | repo_owner: ${{ github.repository_owner }} 50 | repo_name: ${{ github.event.repository.name }} 51 | 52 | # Check if the tag already exists 53 | - id: tag_exists 54 | uses: ./.github/actions/tag-exists 55 | with: 56 | tag: ${{ steps.get_version.outputs.version }} 57 | token: ${{ secrets.github-token }} 58 | 59 | # If the tag already exists, exit with an error 60 | - if: steps.tag_exists.outputs.exists == 'true' 61 | run: exit 1 62 | 63 | # Publish the release to our package manager 64 | - uses: ./.github/actions/npm-publish 65 | with: 66 | node-version: ${{ inputs.node-version }} 67 | require-build: ${{ inputs.require-build }} 68 | version: ${{ steps.get_version.outputs.version }} 69 | npm-token: ${{ secrets.npm-token }} 70 | release-directory: ${{ inputs.release-directory }} 71 | 72 | # Create a release for the tag 73 | - uses: ./.github/actions/release-create 74 | with: 75 | token: ${{ secrets.github-token }} 76 | name: ${{ steps.get_version.outputs.version }} 77 | body: ${{ steps.get_release_notes.outputs.release-notes }} 78 | tag: ${{ steps.get_version.outputs.version }} 79 | commit: ${{ github.sha }} 80 | prerelease: ${{ steps.get_prerelease.outputs.prerelease }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create npm and GitHub Release 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | id-token: write # For publishing to npm using --provenance 12 | 13 | ### TODO: Replace instances of './.github/workflows/' w/ `auth0/dx-sdk-actions/workflows/` and append `@latest` after the common `dx-sdk-actions` repo is made public. 14 | ### TODO: Also remove `get-prerelease`, `get-release-notes`, `get-version`, `npm-publish`, `release-create`, and `tag-exists` actions from this repo's .github/actions folder once the repo is public. 15 | ### TODO: Also remove `npm-release` workflow from this repo's .github/workflows folder once the repo is public. 16 | 17 | jobs: 18 | rl-scanner: 19 | uses: ./.github/workflows/rl-secure.yml 20 | with: 21 | node-version: 18 22 | artifact-name: 'auth0-spa-js.tgz' 23 | secrets: 24 | RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} 25 | RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} 26 | SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} 27 | PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} 28 | PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} 29 | PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} 30 | release: 31 | uses: ./.github/workflows/npm-release.yml 32 | needs: rl-scanner 33 | with: 34 | node-version: 18 35 | require-build: true 36 | secrets: 37 | npm-token: ${{ secrets.NPM_TOKEN }} 38 | github-token: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/rl-secure.yml: -------------------------------------------------------------------------------- 1 | name: RL-Secure Workflow 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | node-version: 7 | required: true 8 | type: string 9 | artifact-name: 10 | required: true 11 | type: string 12 | secrets: 13 | RLSECURE_LICENSE: 14 | required: true 15 | RLSECURE_SITE_KEY: 16 | required: true 17 | SIGNAL_HANDLER_TOKEN: 18 | required: true 19 | PRODSEC_TOOLS_USER: 20 | required: true 21 | PRODSEC_TOOLS_TOKEN: 22 | required: true 23 | PRODSEC_TOOLS_ARN: 24 | required: true 25 | 26 | jobs: 27 | rl-scanner: 28 | name: Run Reversing Labs Scanner 29 | if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) 30 | runs-on: ubuntu-latest 31 | outputs: 32 | scan-status: ${{ steps.rl-scan-conclusion.outcome }} 33 | 34 | steps: 35 | - name: Checkout code 36 | uses: actions/checkout@v4 37 | with: 38 | fetch-depth: 0 39 | 40 | - name: Build package 41 | uses: ./.github/actions/build 42 | with: 43 | node: ${{ inputs.node-version }} 44 | 45 | - name: Create tgz build artifact 46 | run: | 47 | tar -czvf ${{ inputs.artifact-name }} * 48 | 49 | - id: get_version 50 | uses: ./.github/actions/get-version 51 | 52 | - name: Run RL Scanner 53 | id: rl-scan-conclusion 54 | uses: ./.github/actions/rl-scanner 55 | with: 56 | artifact-path: "$(pwd)/${{ inputs.artifact-name }}" 57 | version: "${{ steps.get_version.outputs.version }}" 58 | env: 59 | RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} 60 | RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} 61 | SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} 62 | PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} 63 | PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} 64 | PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} 65 | 66 | - name: Output scan result 67 | run: echo "scan-status=${{ steps.rl-scan-conclusion.outcome }}" >> $GITHUB_ENV 68 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | name: Semgrep 2 | 3 | on: 4 | merge_group: 5 | pull_request: 6 | types: 7 | - opened 8 | - synchronize 9 | push: 10 | branches: 11 | - main 12 | schedule: 13 | - cron: '30 0 1,15 * *' 14 | 15 | permissions: 16 | contents: read 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 20 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 21 | 22 | jobs: 23 | 24 | run: 25 | 26 | name: Check for Vulnerabilities 27 | runs-on: ubuntu-latest 28 | 29 | container: 30 | image: returntocorp/semgrep 31 | 32 | steps: 33 | - if: github.actor == 'dependabot[bot]' || github.event_name == 'merge_group' 34 | 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. 35 | 36 | - uses: actions/checkout@v4 37 | with: 38 | ref: ${{ github.event.pull_request.head.sha || github.ref }} 39 | 40 | - run: semgrep ci 41 | env: 42 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/snyk.yml: -------------------------------------------------------------------------------- 1 | name: Snyk 2 | 3 | on: 4 | merge_group: 5 | workflow_dispatch: 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | push: 11 | branches: 12 | - main 13 | schedule: 14 | - cron: '30 0 1,15 * *' 15 | 16 | permissions: 17 | contents: read 18 | 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 21 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 22 | 23 | jobs: 24 | 25 | check: 26 | 27 | name: Check for Vulnerabilities 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - if: github.actor == 'dependabot[bot]' || github.event_name == 'merge_group' 32 | 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. 33 | 34 | - uses: actions/checkout@v4 35 | with: 36 | ref: ${{ github.event.pull_request.head.sha || github.ref }} 37 | 38 | - uses: snyk/actions/node@b98d498629f1c368650224d6d212bf7dfa89e4bf # pin@0.4.0 39 | env: 40 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | merge_group: 5 | workflow_dispatch: 6 | pull_request: 7 | branches: 8 | - main 9 | push: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 19 | 20 | env: 21 | NODE_VERSION: 18 22 | CACHE_KEY: '${{ github.ref }}-${{ github.run_id }}-${{ github.run_attempt }}' 23 | IMPORT_STATEMENT: | 24 | import './auth0'; 25 | AUTH0_CONTENT: | 26 | import { Auth0Client } from '@auth0/auth0-spa-js'; 27 | new Auth0Client({ 28 | domain: 'DOMAIN', 29 | clientId: 'CLIENT_ID' 30 | }); 31 | 32 | jobs: 33 | test: 34 | name: Build Package 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - name: Checkout code 39 | uses: actions/checkout@v4 40 | 41 | - name: Build package 42 | uses: ./.github/actions/build 43 | with: 44 | node: ${{ env.NODE_VERSION }} 45 | 46 | - name: Save build artifacts 47 | uses: actions/cache/save@v4 48 | with: 49 | path: . 50 | key: ${{ env.CACHE_KEY }} 51 | 52 | unit: 53 | needs: test 54 | 55 | name: Unit Tests 56 | runs-on: ubuntu-latest 57 | 58 | steps: 59 | - name: Checkout code 60 | uses: actions/checkout@v4 61 | 62 | - name: Setup Node 63 | uses: actions/setup-node@v4 64 | with: 65 | node-version: ${{ env.NODE_VERSION }} 66 | cache: 'npm' 67 | 68 | - name: Restore build artifacts 69 | uses: actions/cache/restore@v4 70 | with: 71 | path: . 72 | key: ${{ env.CACHE_KEY }} 73 | 74 | - name: Run tests 75 | run: npm run test -- --maxWorkers=2 76 | 77 | - name: Upload coverage 78 | uses: codecov/codecov-action@4fe8c5f003fae66aa5ebb77cfd3e7bfbbda0b6b0 # pin@3.1.5 79 | gatsby: 80 | needs: test 81 | 82 | name: Gatsby Tests 83 | runs-on: ubuntu-latest 84 | 85 | env: 86 | IMPORT_STATEMENT: | 87 | import './../auth0'; 88 | 89 | steps: 90 | - name: Checkout code 91 | uses: actions/checkout@v4 92 | 93 | - name: Run framework tests 94 | uses: ./.github/actions/framework 95 | with: 96 | node: ${{ env.NODE_VERSION }} 97 | cache: ${{ env.CACHE_KEY }} 98 | install: | 99 | npx gatsby new my-app < /dev/null 100 | content: | 101 | echo -e "${{ env.AUTH0_CONTENT }}" > src/auth0.js; 102 | import: | 103 | echo -e "${{ env.IMPORT_STATEMENT }}"|cat - src/pages/index.js > /tmp/out && mv /tmp/out src/pages/index.js; 104 | 105 | react: 106 | needs: test 107 | 108 | name: React Tests 109 | runs-on: ubuntu-latest 110 | 111 | steps: 112 | - name: Checkout code 113 | uses: actions/checkout@v4 114 | 115 | - name: Run framework tests 116 | uses: ./.github/actions/framework 117 | with: 118 | node: ${{ env.NODE_VERSION }} 119 | cache: ${{ env.CACHE_KEY }} 120 | install: | 121 | npx create-react-app my-app < /dev/null 122 | content: | 123 | echo -e "${{ env.AUTH0_CONTENT }}" > src/auth0.js; 124 | import: | 125 | echo -e "${{ env.IMPORT_STATEMENT }}"|cat - src/index.js > /tmp/out && mv /tmp/out src/index.js; 126 | 127 | vue: 128 | needs: test 129 | 130 | name: Vue Tests 131 | runs-on: ubuntu-latest 132 | 133 | steps: 134 | - name: Checkout code 135 | uses: actions/checkout@v4 136 | 137 | - name: Run framework tests 138 | uses: ./.github/actions/framework 139 | with: 140 | node: 20 141 | cache: ${{ env.CACHE_KEY }} 142 | install: | 143 | npx -p @vue/cli vue create my-app -d --packageManager npm < /dev/null 144 | content: | 145 | echo -e "${{ env.AUTH0_CONTENT }}" > src/auth0.js; 146 | import: | 147 | echo -e "${{ env.IMPORT_STATEMENT }}"|cat - src/main.js > /tmp/out && mv /tmp/out src/main.js; 148 | 149 | angular: 150 | needs: test 151 | 152 | name: Angular Tests 153 | runs-on: ubuntu-latest 154 | 155 | steps: 156 | - name: Checkout code 157 | uses: actions/checkout@v4 158 | 159 | - name: Run framework tests 160 | uses: ./.github/actions/framework 161 | with: 162 | node: ${{ env.NODE_VERSION }} 163 | cache: ${{ env.CACHE_KEY }} 164 | install: | 165 | npx -p @angular/cli ng new my-app --defaults=true < /dev/null 166 | content: | 167 | echo -e "$AUTH0_CONTENT" > src/auth0.ts; 168 | import: | 169 | echo -e "${{ env.IMPORT_STATEMENT }}"|cat - src/main.ts > /tmp/out && mv /tmp/out src/main.ts; 170 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rpt2_cache 2 | node_modules 3 | dist 4 | coverage 5 | stats.html 6 | cypress/screenshots 7 | cypress/videos 8 | cypress.env.json 9 | .release 10 | .idea 11 | test-results 12 | yarn.lock 13 | .DS_Store 14 | release-tmp* 15 | bundle-stats 16 | 17 | # BrowserStack 18 | log/ 19 | *.log 20 | results/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | test 4 | static 5 | .gitignore 6 | .npmignore 7 | rollup.config.js 8 | tsconfig.json 9 | .rpt2_cache 10 | .vscode 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | docs 2 | static/auth0-spa-js.development_old.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 80, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /.semgrepignore: -------------------------------------------------------------------------------- 1 | __mocks__/ 2 | __tests__/ 3 | cypress/ 4 | docs/ 5 | static/ 6 | scripts/prepack.js 7 | *.md 8 | -------------------------------------------------------------------------------- /.shiprc: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "src/version.ts": [], 4 | ".version": [], 5 | "README.md": [ 6 | "{MAJOR}.{MINOR}" 7 | ], 8 | "FAQ.md": [ 9 | "{MAJOR}.{MINOR}.{PATCH}" 10 | ] 11 | }, 12 | "postbump": "npm run docs" 13 | } -------------------------------------------------------------------------------- /.version: -------------------------------------------------------------------------------- 1 | v2.2.0 -------------------------------------------------------------------------------- /.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 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest (current file)", 11 | "program": "${workspaceFolder}/node_modules/.bin/jest", 12 | "args": ["${fileBasenameNoExtension}"], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "disableOptimisticBPs": true, 16 | "skipFiles": ["/**"] 17 | }, 18 | { 19 | "type": "node", 20 | "request": "launch", 21 | "name": "Jest (all tests)", 22 | "program": "${workspaceFolder}/node_modules/.bin/jest", 23 | "args": ["--runInBand"], 24 | "console": "integratedTerminal", 25 | "internalConsoleOptions": "neverOpen", 26 | "disableOptimisticBPs": true, 27 | "skipFiles": ["/**"] 28 | }, 29 | { 30 | "type": "node", 31 | "request": "launch", 32 | "name": "Build", 33 | "program": "${workspaceFolder}/node_modules/.bin/rollup", 34 | "args": ["-m", "-c"], 35 | "console": "integratedTerminal" 36 | }, 37 | { 38 | "name": "Debug Jest Tests", 39 | "type": "node", 40 | "request": "launch", 41 | "runtimeArgs": [ 42 | "--inspect-brk", 43 | "${workspaceRoot}/node_modules/.bin/jest", 44 | ], 45 | "console": "integratedTerminal", 46 | "internalConsoleOptions": "neverOpen", 47 | "port": 9229 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "search.exclude": { 4 | "**/node_modules": true, 5 | "**/bower_components": true, 6 | "**/dist": true, 7 | "**/.rpt2_cache": true, 8 | "**/docs": true 9 | }, 10 | "typescript.tsdk": "node_modules/typescript/lib" 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | Please read [Auth0's contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md). 4 | 5 | ## Environment setup 6 | 7 | - Make sure you have node and npm installed 8 | - Run `npm install` to install dependencies 9 | - Follow the local development steps below to get started 10 | 11 | ## Local development 12 | 13 | - `npm install`: install dependencies 14 | - `npm start`: starts development http server at [http://localhost:3000](http://localhost:3000) with live reload enabled 15 | - `npm run test`: run unit tests 16 | - `npm run test:watch`: run unit tests continuously 17 | - `npm run test:integration`: run integration tests 18 | - `npm run test:watch:integration`: run integration tests continuously 19 | - `npm run build`: build distribution files 20 | - `npm run test:es-check`: check if distribution files are compatible with browsers 21 | - `npm run print-bundle-size`: print the final bundle size of distribution files 22 | 23 | ## Testing 24 | 25 | ### Adding tests 26 | 27 | - Unit tests go inside [\_\_tests\_\_](https://github.com/auth0/auth0-spa-js/tree/main/__tests__) 28 | - Integration tests go inside [cypress/integration](https://github.com/auth0/auth0-spa-js/tree/main/cypress/integration) 29 | 30 | ### Running tests 31 | 32 | Run unit and integration tests before opening a PR: 33 | 34 | ```bash 35 | npm run test 36 | npm run test:integration 37 | ``` 38 | 39 | Also include any information about essential manual tests. 40 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | ## Environment 2 | 3 | - Node >= 12.x 4 | 5 | ## Building 6 | 7 | The SDK uses [Rollup](https://rollupjs.org/guide/en/) to compile all JavaScript assets into a set of output modules to be consumed by other module builders such as [Webpack](https://webpack.js.org/) and Rollup, or directly into and HTML file via the CDN. 8 | 9 | To perform a build, use the `build` script: 10 | 11 | ``` 12 | npm run build 13 | ``` 14 | 15 | ### Bundle stats 16 | 17 | Bundle size statistics can be generated when `WITH_STATS=true` is present in the environment. This outputs production bundle stats into the terminal when running `npm run build`, but also generates a visualization into the `bundle-stats` folder. 18 | 19 | To build with stats then view the results, do: 20 | 21 | ``` 22 | WITH_STATS=true npm run build 23 | npm run serve:stats 24 | ``` 25 | 26 | Then browse to http://localhost:5000 to view an HTML-based bundle size report. 27 | 28 | ## Running Tests 29 | 30 | ### Unit tests 31 | 32 | Unit tests can be executed using [Jest](https://jestjs.io/) by issuing the following command: 33 | 34 | ``` 35 | npm test 36 | ``` 37 | 38 | To interactively perform tests using Jest's `watch` mode, use: 39 | 40 | ``` 41 | npm run test:watch 42 | ``` 43 | 44 | ### Integration tests 45 | 46 | Integration tests can be run through [Cypress](https://www.cypress.io/) to perform integration testing using the SDK and Auth0. 47 | 48 | To run these, use: 49 | 50 | ``` 51 | npm run test:integration 52 | ``` 53 | 54 | To perform these tests interactively and watch the output, use: 55 | 56 | ``` 57 | npm run test:watch:integration 58 | ``` 59 | 60 | ### Test coverage 61 | 62 | Coverage is automatically generated just by running `npm test`. To view the coverage output, use: 63 | 64 | ``` 65 | npm run serve:coverage 66 | ``` 67 | 68 | Then, browse to http://localhost:5000 to view an HTML-based coverage report. 69 | 70 | ## The SDK Playground 71 | 72 | The SDK provides a simple [Vue JS](https://vuejs.org/) app to test out and experiment with features of the SDK. This Playground is also used by the integration tests to verify behaviors. If you make changes to the Playground that are to be commited, ensure that the integration tests pass. 73 | 74 | To test the SDK manually and play around with the various options and features, you can use the Playground by cloning this repository and using: 75 | 76 | ``` 77 | # Install dependencies 78 | npm i 79 | 80 | # Run the playground app 81 | npm start 82 | ``` 83 | 84 | This will open a web server on `http://localhost:3000` and display a simple web app that allows you to manually perform various features of the SDK. This is preconfigured with an Auth0 tenant and client ID but you may change this to your own for testing. 85 | 86 | You may specify a different port for the development server by specifying the `DEV_PORT` environment variable: 87 | 88 | ``` 89 | DEV_PORT=8080 npm start 90 | ``` 91 | 92 | The Playground may not cover all use cases. In this case, modify the [index.html file](https://github.com/auth0/auth0-spa-js/blob/main/static/index.html) to configure the SDK as desired to invoke different behaviors. 93 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | @Library('k8sAgents') agentLibrary 2 | @Library('auth0') _ 3 | 4 | pipeline { 5 | agent { 6 | kubernetes { 7 | yaml defaultAgent() 8 | } 9 | } 10 | 11 | tools { 12 | nodejs '14.16.1' 13 | } 14 | 15 | options { 16 | timeout(time: 10, unit: 'MINUTES') 17 | } 18 | 19 | stages { 20 | stage('Build') { 21 | steps { 22 | sshagent(['auth0extensions-ssh-key']) { 23 | sh 'npm ci' 24 | sh 'npm run build' 25 | } 26 | } 27 | } 28 | stage('Test') { 29 | steps { 30 | script { 31 | try { 32 | sh 'npm run test' 33 | githubNotify context: 'jenkinsfile/auth0/tests', description: 'Tests passed', status: 'SUCCESS' 34 | } catch (error) { 35 | githubNotify context: 'jenkinsfile/auth0/tests', description: 'Tests failed', status: 'FAILURE' 36 | throw error 37 | } 38 | } 39 | } 40 | } 41 | stage('Publish to CDN') { 42 | when { 43 | anyOf { 44 | branch 'beta' 45 | branch 'main' 46 | } 47 | } 48 | steps { 49 | sshagent(['auth0extensions-ssh-key']) { 50 | sh 'npm run publish:cdn' 51 | } 52 | } 53 | } 54 | } 55 | 56 | post { 57 | cleanup { 58 | deleteDir() 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Auth0, Inc. (http://auth0.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Auth0 SDK for Single Page Applications using Authorization Code Grant Flow with PKCE.](https://cdn.auth0.com/website/sdks/banners/spa-js-banner.png) 2 | 3 | ![Release](https://img.shields.io/npm/v/@auth0/auth0-spa-js) 4 | [![Codecov](https://img.shields.io/codecov/c/github/auth0/auth0-spa-js)](https://codecov.io/gh/auth0/auth0-spa-js) 5 | ![Downloads](https://img.shields.io/npm/dw/@auth0/auth0-spa-js) 6 | [![License](https://img.shields.io/:license-mit-blue.svg?style=flat)](https://opensource.org/licenses/MIT) 7 | ![CircleCI](https://img.shields.io/circleci/build/github/auth0/auth0-spa-js) 8 | 9 | 📚 [Documentation](#documentation) - 🚀 [Getting Started](#getting-started) - 💻 [API Reference](#api-reference) - 💬 [Feedback](#feedback) 10 | 11 | ## Documentation 12 | 13 | - [Quickstart](https://auth0.com/docs/quickstart/spa/vanillajs/interactive) - our interactive guide for quickly adding login, logout and user information to your app using Auth0. 14 | - [Sample app](https://github.com/auth0-samples/auth0-javascript-samples/tree/master/01-Login) - a full-fledged sample app integrated with Auth0. 15 | - [FAQs](https://github.com/auth0/auth0-spa-js/blob/main/FAQ.md) - frequently asked questions about auth0-spa-js SDK. 16 | - [Examples](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md) - code samples for common scenarios. 17 | - [Docs Site](https://auth0.com/docs) - explore our Docs site and learn more about Auth0. 18 | 19 | ## Getting Started 20 | 21 | ### Installation 22 | 23 | Using [npm](https://npmjs.org) in your project directory run the following command: 24 | 25 | ```sh 26 | npm install @auth0/auth0-spa-js 27 | ``` 28 | 29 | From the CDN: 30 | 31 | ```html 32 | 33 | ``` 34 | 35 | ### Configure Auth0 36 | 37 | Create a **Single Page Application** in the [Auth0 Dashboard](https://manage.auth0.com/#/applications). 38 | 39 | > **If you're using an existing application**, verify that you have configured the following settings in your Single Page Application: 40 | > 41 | > - Click on the "Settings" tab of your application's page. 42 | > - Scroll down and click on the "Show Advanced Settings" link. 43 | > - Under "Advanced Settings", click on the "OAuth" tab. 44 | > - Ensure that "JsonWebToken Signature Algorithm" is set to `RS256` and that "OIDC Conformant" is enabled. 45 | 46 | Next, configure the following URLs for your application under the "Application URIs" section of the "Settings" page: 47 | 48 | - **Allowed Callback URLs**: `http://localhost:3000` 49 | - **Allowed Logout URLs**: `http://localhost:3000` 50 | - **Allowed Web Origins**: `http://localhost:3000` 51 | 52 | > These URLs should reflect the origins that your application is running on. **Allowed Callback URLs** may also include a path, depending on where you're handling the callback (see below). 53 | 54 | Take note of the **Client ID** and **Domain** values under the "Basic Information" section. You'll need these values in the next step. 55 | 56 | ### Configure the SDK 57 | 58 | Create an `Auth0Client` instance before rendering or initializing your application. You should only have one instance of the client. 59 | 60 | ```js 61 | import { createAuth0Client } from '@auth0/auth0-spa-js'; 62 | 63 | //with async/await 64 | const auth0 = await createAuth0Client({ 65 | domain: '', 66 | clientId: '', 67 | authorizationParams: { 68 | redirect_uri: '' 69 | } 70 | }); 71 | 72 | //or, you can just instantiate the client on its own 73 | import { Auth0Client } from '@auth0/auth0-spa-js'; 74 | 75 | const auth0 = new Auth0Client({ 76 | domain: '', 77 | clientId: '', 78 | authorizationParams: { 79 | redirect_uri: '' 80 | } 81 | }); 82 | 83 | //if you do this, you'll need to check the session yourself 84 | try { 85 | await auth0.getTokenSilently(); 86 | } catch (error) { 87 | if (error.error !== 'login_required') { 88 | throw error; 89 | } 90 | } 91 | ``` 92 | 93 | ### Logging In 94 | 95 | You can then use login using the `Auth0Client` instance you created: 96 | 97 | ```html 98 | 99 | ``` 100 | 101 | ```js 102 | //redirect to the Universal Login Page 103 | document.getElementById('login').addEventListener('click', async () => { 104 | await auth0.loginWithRedirect(); 105 | }); 106 | 107 | //in your callback route () 108 | window.addEventListener('load', async () => { 109 | const redirectResult = await auth0.handleRedirectCallback(); 110 | //logged in. you can get the user profile like this: 111 | const user = await auth0.getUser(); 112 | console.log(user); 113 | }); 114 | ``` 115 | 116 | For other comprehensive examples, see the [EXAMPLES.md](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md) document. 117 | 118 | ## API Reference 119 | 120 | Explore API Methods available in auth0-spa-js. 121 | 122 | - [Configuration Options](https://auth0.github.io/auth0-spa-js/interfaces/Auth0ClientOptions.html) 123 | 124 | - [Auth0Client](https://auth0.github.io/auth0-spa-js/classes/Auth0Client.html) 125 | - [createAuth0Client](https://auth0.github.io/auth0-spa-js/functions/createAuth0Client.html) 126 | 127 | ## Feedback 128 | 129 | ### Contributing 130 | 131 | We appreciate feedback and contribution to this repo! Before you get started, please see the following: 132 | 133 | - [Auth0's general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md) 134 | - [Auth0's code of conduct guidelines](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) 135 | - [This repo's contribution guide](https://github.com/auth0/auth0-spa-js/blob/main/CONTRIBUTING.md) 136 | 137 | ### Raise an issue 138 | 139 | To provide feedback or report a bug, please [raise an issue on our issue tracker](https://github.com/auth0/auth0-spa-js/issues). 140 | 141 | ### Vulnerability Reporting 142 | 143 | Please do not report security vulnerabilities on the public GitHub issue tracker. The [Responsible Disclosure Program](https://auth0.com/whitehat) details the procedure for disclosing security issues. 144 | 145 | ## What is Auth0? 146 | 147 |

148 | 149 | 150 | 151 | Auth0 Logo 152 | 153 |

154 |

155 | Auth0 is an easy to implement, adaptable authentication and authorization platform. To learn more checkout Why Auth0? 156 |

157 |

158 | This project is licensed under the MIT license. See the LICENSE file for more info. 159 |

160 | -------------------------------------------------------------------------------- /__mocks__/browser-tabs-lock/index.ts: -------------------------------------------------------------------------------- 1 | const Lock = jest.requireActual('browser-tabs-lock').default; 2 | 3 | export const acquireLockSpy = jest.fn().mockResolvedValue(true); 4 | export const releaseLockSpy = jest.fn(); 5 | 6 | export default class extends Lock { 7 | async acquireLock(...args) { 8 | const canProceed = await acquireLockSpy(...args); 9 | if (canProceed) { 10 | return super.acquireLock(...args); 11 | } 12 | } 13 | releaseLock(...args) { 14 | releaseLockSpy(...args); 15 | return super.releaseLock(...args); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /__mocks__/promise-polyfill/src/polyfill.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/checkSession.test.ts: -------------------------------------------------------------------------------- 1 | import * as esCookie from 'es-cookie'; 2 | import { verify } from '../../src/jwt'; 3 | import { MessageChannel } from 'worker_threads'; 4 | import * as utils from '../../src/utils'; 5 | import * as scope from '../../src/scope'; 6 | import { expect } from '@jest/globals'; 7 | 8 | // @ts-ignore 9 | 10 | import { checkSessionFn, fetchResponse, setupFn } from './helpers'; 11 | 12 | import { 13 | TEST_ACCESS_TOKEN, 14 | TEST_CLIENT_ID, 15 | TEST_CODE_CHALLENGE, 16 | TEST_DOMAIN, 17 | TEST_ID_TOKEN, 18 | TEST_ORG_ID, 19 | TEST_REFRESH_TOKEN, 20 | TEST_STATE 21 | } from '../constants'; 22 | 23 | jest.mock('es-cookie'); 24 | jest.mock('../../src/jwt'); 25 | jest.mock('../../src/worker/token.worker'); 26 | 27 | const mockWindow = global; 28 | const mockFetch = mockWindow.fetch; 29 | const mockVerify = verify; 30 | 31 | jest 32 | .spyOn(utils, 'bufferToBase64UrlEncoded') 33 | .mockReturnValue(TEST_CODE_CHALLENGE); 34 | 35 | jest.spyOn(utils, 'runPopup'); 36 | 37 | const setup = setupFn(mockVerify); 38 | const checkSession = checkSessionFn(window.fetch); 39 | 40 | describe('Auth0Client', () => { 41 | const oldWindowLocation = window.location; 42 | 43 | beforeEach(() => { 44 | // https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/ 45 | delete window.location; 46 | window.location = Object.defineProperties( 47 | {}, 48 | { 49 | ...Object.getOwnPropertyDescriptors(oldWindowLocation), 50 | assign: { 51 | configurable: true, 52 | value: jest.fn() 53 | } 54 | } 55 | ) as Location; 56 | // -- 57 | 58 | mockWindow.open = jest.fn(); 59 | mockWindow.addEventListener = jest.fn(); 60 | mockWindow.crypto = { 61 | subtle: { 62 | digest: () => 'foo' 63 | }, 64 | getRandomValues() { 65 | return '123'; 66 | } 67 | }; 68 | mockWindow.MessageChannel = MessageChannel; 69 | mockWindow.Worker = {}; 70 | jest.spyOn(scope, 'getUniqueScopes'); 71 | sessionStorage.clear(); 72 | }); 73 | 74 | afterEach(() => { 75 | mockFetch.mockReset(); 76 | jest.clearAllMocks(); 77 | window.location = oldWindowLocation; 78 | }); 79 | 80 | describe('checkSession', () => { 81 | it("skips checking the auth0 session when there's no auth cookie", async () => { 82 | const auth0 = setup(); 83 | 84 | jest.spyOn(utils, 'runIframe'); 85 | 86 | await auth0.checkSession(); 87 | 88 | expect(utils.runIframe).not.toHaveBeenCalled(); 89 | }); 90 | 91 | it('checks the auth0 session when there is an auth cookie', async () => { 92 | const auth0 = setup(); 93 | 94 | jest.spyOn(utils, 'runIframe').mockResolvedValue({ 95 | access_token: TEST_ACCESS_TOKEN, 96 | state: TEST_STATE 97 | }); 98 | 99 | (esCookie.get).mockReturnValue(true); 100 | 101 | mockFetch.mockResolvedValueOnce( 102 | fetchResponse(true, { 103 | id_token: TEST_ID_TOKEN, 104 | refresh_token: TEST_REFRESH_TOKEN, 105 | access_token: TEST_ACCESS_TOKEN, 106 | expires_in: 86400 107 | }) 108 | ); 109 | await auth0.checkSession(); 110 | 111 | expect(utils.runIframe).toHaveBeenCalled(); 112 | }); 113 | 114 | it('checks the legacy samesite cookie', async () => { 115 | const auth0 = setup(); 116 | 117 | (esCookie.get).mockReturnValueOnce(undefined); 118 | 119 | await checkSession(auth0); 120 | 121 | expect(esCookie.get).toHaveBeenCalledWith( 122 | `auth0.${TEST_CLIENT_ID}.is.authenticated` 123 | ); 124 | 125 | expect(esCookie.get).toHaveBeenCalledWith( 126 | `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated` 127 | ); 128 | }); 129 | 130 | it('skips checking the legacy samesite cookie when configured', async () => { 131 | const auth0 = setup({ 132 | legacySameSiteCookie: false 133 | }); 134 | 135 | await checkSession(auth0); 136 | 137 | expect(esCookie.get).toHaveBeenCalledWith( 138 | `auth0.${TEST_CLIENT_ID}.is.authenticated` 139 | ); 140 | 141 | expect(esCookie.get).not.toHaveBeenCalledWith( 142 | `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated` 143 | ); 144 | }); 145 | 146 | it('migrates the old is.authenticated cookie to the new name', async () => { 147 | const auth0 = setup(); 148 | 149 | (esCookie.get as jest.Mock).mockImplementation(name => { 150 | switch (name) { 151 | case 'auth0.is.authenticated': 152 | return true; 153 | case `auth0.${TEST_CLIENT_ID}.is.authenticated`: 154 | return; 155 | } 156 | }); 157 | 158 | await checkSession(auth0); 159 | 160 | expect(esCookie.get).toHaveBeenCalledWith( 161 | `auth0.${TEST_CLIENT_ID}.is.authenticated` 162 | ); 163 | 164 | expect(esCookie.get).toHaveBeenCalledWith(`auth0.is.authenticated`); 165 | 166 | expect(esCookie.set).toHaveBeenCalledWith( 167 | `auth0.${TEST_CLIENT_ID}.is.authenticated`, 168 | 'true', 169 | { expires: 1 } 170 | ); 171 | 172 | expect(esCookie.remove).toHaveBeenCalledWith('auth0.is.authenticated', {}); 173 | }); 174 | 175 | it('uses the organization hint cookie if available', async () => { 176 | const auth0 = setup(); 177 | 178 | jest.spyOn(utils, 'runIframe').mockResolvedValue({ 179 | access_token: TEST_ACCESS_TOKEN, 180 | state: TEST_STATE 181 | }); 182 | 183 | (esCookie.get) 184 | .mockReturnValueOnce(JSON.stringify(true)) 185 | .mockReturnValueOnce(JSON.stringify(TEST_ORG_ID)); 186 | 187 | await checkSession(auth0); 188 | 189 | expect(utils.runIframe).toHaveBeenCalledWith( 190 | expect.stringContaining(TEST_ORG_ID), 191 | `https://${TEST_DOMAIN}`, 192 | undefined 193 | ); 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/constructor.test.ts: -------------------------------------------------------------------------------- 1 | import { verify } from '../../src/jwt'; 2 | import { MessageChannel } from 'worker_threads'; 3 | import * as utils from '../../src/utils'; 4 | import * as scope from '../../src/scope'; 5 | import { expect } from '@jest/globals'; 6 | 7 | // @ts-ignore 8 | 9 | import { assertUrlEquals, loginWithRedirectFn, setupFn } from './helpers'; 10 | 11 | import { TEST_CLIENT_ID, TEST_CODE_CHALLENGE, TEST_DOMAIN } from '../constants'; 12 | import { ICache } from '../../src/cache'; 13 | 14 | jest.mock('es-cookie'); 15 | jest.mock('../../src/jwt'); 16 | jest.mock('../../src/worker/token.worker'); 17 | 18 | const mockWindow = global; 19 | const mockFetch = mockWindow.fetch; 20 | const mockVerify = verify; 21 | 22 | const mockCache: ICache = { 23 | set: jest.fn().mockResolvedValue(null), 24 | get: jest.fn().mockResolvedValue(null), 25 | remove: jest.fn().mockResolvedValue(null) 26 | }; 27 | 28 | jest 29 | .spyOn(utils, 'bufferToBase64UrlEncoded') 30 | .mockReturnValue(TEST_CODE_CHALLENGE); 31 | 32 | jest.spyOn(utils, 'runPopup'); 33 | 34 | const setup = setupFn(mockVerify); 35 | 36 | describe('Auth0Client', () => { 37 | const oldWindowLocation = window.location; 38 | 39 | beforeEach(() => { 40 | // https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/ 41 | delete window.location; 42 | window.location = Object.defineProperties( 43 | {}, 44 | { 45 | ...Object.getOwnPropertyDescriptors(oldWindowLocation), 46 | assign: { 47 | configurable: true, 48 | value: jest.fn() 49 | } 50 | } 51 | ) as Location; 52 | // -- 53 | 54 | mockWindow.open = jest.fn(); 55 | mockWindow.addEventListener = jest.fn(); 56 | mockWindow.crypto = { 57 | subtle: { 58 | digest: () => 'foo' 59 | }, 60 | getRandomValues() { 61 | return '123'; 62 | } 63 | }; 64 | mockWindow.MessageChannel = MessageChannel; 65 | mockWindow.Worker = {}; 66 | jest.spyOn(scope, 'getUniqueScopes'); 67 | sessionStorage.clear(); 68 | }); 69 | 70 | afterEach(() => { 71 | mockFetch.mockReset(); 72 | jest.clearAllMocks(); 73 | window.location = oldWindowLocation; 74 | }); 75 | 76 | describe('constructor', () => { 77 | it('automatically adds the offline_access scope during construction', () => { 78 | const auth0 = setup({ 79 | useRefreshTokens: true, 80 | authorizationParams: { 81 | scope: 'profile email test-scope' 82 | } 83 | }); 84 | 85 | expect((auth0).scope).toBe( 86 | 'openid profile email test-scope offline_access' 87 | ); 88 | }); 89 | 90 | it('ensures the openid scope is defined when customizing default scopes', () => { 91 | const auth0 = setup({ 92 | authorizationParams: { 93 | scope: 'test-scope' 94 | } 95 | }); 96 | 97 | expect((auth0).scope).toBe('openid test-scope'); 98 | }); 99 | 100 | it('allows an empty custom default scope', () => { 101 | const auth0 = setup({ 102 | authorizationParams: { 103 | scope: null 104 | } 105 | }); 106 | 107 | expect((auth0).scope).toBe('openid'); 108 | }); 109 | 110 | it('should create issuer from domain', () => { 111 | const auth0 = setup({ 112 | domain: 'test.dev' 113 | }); 114 | 115 | expect((auth0).tokenIssuer).toEqual('https://test.dev/'); 116 | }); 117 | 118 | it('should allow issuer as a domain', () => { 119 | const auth0 = setup({ 120 | issuer: 'foo.bar.com' 121 | }); 122 | 123 | expect((auth0).tokenIssuer).toEqual('https://foo.bar.com/'); 124 | }); 125 | 126 | it('should allow issuer as a fully qualified url', () => { 127 | const auth0 = setup({ 128 | issuer: 'https://some.issuer.com/' 129 | }); 130 | 131 | expect((auth0).tokenIssuer).toEqual('https://some.issuer.com/'); 132 | }); 133 | 134 | it('should allow specifying domain with http scheme', () => { 135 | const auth0 = setup({ 136 | domain: 'http://localhost' 137 | }); 138 | 139 | expect((auth0).domainUrl).toEqual('http://localhost'); 140 | }); 141 | 142 | it('should allow specifying domain with https scheme', () => { 143 | const auth0 = setup({ 144 | domain: 'https://localhost' 145 | }); 146 | 147 | expect((auth0).domainUrl).toEqual('https://localhost'); 148 | }); 149 | 150 | it('uses a custom cache if one was given in the configuration', async () => { 151 | const auth0 = setup({ 152 | cache: mockCache 153 | }); 154 | 155 | await loginWithRedirectFn(mockWindow, mockFetch)(auth0); 156 | 157 | expect(mockCache.set).toHaveBeenCalled(); 158 | }); 159 | 160 | it('uses a custom cache if both `cache` and `cacheLocation` were specified', async () => { 161 | const auth0 = setup({ 162 | cache: mockCache, 163 | cacheLocation: 'localstorage' 164 | }); 165 | 166 | await loginWithRedirectFn(mockWindow, mockFetch)(auth0); 167 | 168 | expect(mockCache.set).toHaveBeenCalled(); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/exchangeToken.test.ts: -------------------------------------------------------------------------------- 1 | import { verify } from '../../src/jwt'; 2 | import { MessageChannel } from 'worker_threads'; 3 | import * as utils from '../../src/utils'; 4 | import * as scope from '../../src/scope'; 5 | 6 | // @ts-ignore 7 | 8 | import { fetchResponse, setupFn, setupMessageEventLister } from './helpers'; 9 | 10 | import { 11 | TEST_ACCESS_TOKEN, 12 | TEST_CODE_CHALLENGE, 13 | TEST_ID_TOKEN, 14 | TEST_REFRESH_TOKEN, 15 | TEST_STATE 16 | } from '../constants'; 17 | 18 | import { Auth0ClientOptions } from '../../src'; 19 | import { expect } from '@jest/globals'; 20 | import { CustomTokenExchangeOptions } from '../../src/TokenExchange'; 21 | 22 | jest.mock('es-cookie'); 23 | jest.mock('../../src/jwt'); 24 | jest.mock('../../src/worker/token.worker'); 25 | 26 | const mockWindow = global; 27 | const mockFetch = mockWindow.fetch; 28 | const mockVerify = verify; 29 | 30 | jest 31 | .spyOn(utils, 'bufferToBase64UrlEncoded') 32 | .mockReturnValue(TEST_CODE_CHALLENGE); 33 | 34 | jest.spyOn(utils, 'runPopup'); 35 | 36 | const setup = setupFn(mockVerify); 37 | 38 | describe('Auth0Client', () => { 39 | const oldWindowLocation = window.location; 40 | 41 | beforeEach(() => { 42 | mockWindow.open = jest.fn(); 43 | mockWindow.addEventListener = jest.fn(); 44 | mockWindow.crypto = { 45 | subtle: { 46 | digest: () => 'foo' 47 | }, 48 | getRandomValues() { 49 | return '123'; 50 | } 51 | }; 52 | mockWindow.MessageChannel = MessageChannel; 53 | mockWindow.Worker = {}; 54 | jest.spyOn(scope, 'getUniqueScopes'); 55 | sessionStorage.clear(); 56 | }); 57 | 58 | afterEach(() => { 59 | mockFetch.mockReset(); 60 | jest.clearAllMocks(); 61 | window.location = oldWindowLocation; 62 | }); 63 | 64 | describe('exchangeToken()', () => { 65 | const localSetup = async (clientOptions?: Partial) => { 66 | const auth0 = setup(clientOptions); 67 | 68 | setupMessageEventLister(mockWindow, { state: TEST_STATE }); 69 | 70 | mockFetch.mockResolvedValueOnce( 71 | fetchResponse(true, { 72 | id_token: TEST_ID_TOKEN, 73 | refresh_token: TEST_REFRESH_TOKEN, 74 | access_token: TEST_ACCESS_TOKEN, 75 | expires_in: 86400 76 | }) 77 | ); 78 | 79 | auth0['_requestToken'] = async function (requestOptions: any) { 80 | return { 81 | decodedToken: { 82 | encoded: { 83 | header: 'fake_header', 84 | payload: 'fake_payload', 85 | signature: 'fake_signature' 86 | }, 87 | header: {}, 88 | claims: { __raw: 'fake_raw' }, 89 | user: {} 90 | }, 91 | id_token: 'fake_id_token', 92 | access_token: 'fake_access_token', 93 | expires_in: 3600, 94 | scope: requestOptions.scope 95 | }; 96 | }; 97 | 98 | return auth0; 99 | }; 100 | 101 | it('calls `exchangeToken` with the correct default options', async () => { 102 | const auth0 = await localSetup(); 103 | const cteOptions: CustomTokenExchangeOptions = { 104 | subject_token: 'external_token_value', 105 | subject_token_type: 'urn:acme:legacy-system-token', // valid token type (not reserved) 106 | scope: 'openid profile email', 107 | audience: 'https://api.test.com' 108 | }; 109 | const result = await auth0.exchangeToken(cteOptions); 110 | console.log(result); 111 | expect(result.id_token).toEqual('fake_id_token'); 112 | expect(result.access_token).toEqual('fake_access_token'); 113 | expect(result.expires_in).toEqual(3600); 114 | expect(typeof result.scope).toBe('string'); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/getIdTokenClaims.test.ts: -------------------------------------------------------------------------------- 1 | import { verify } from '../../src/jwt'; 2 | import { MessageChannel } from 'worker_threads'; 3 | import * as utils from '../../src/utils'; 4 | import * as scope from '../../src/scope'; 5 | import { expect } from '@jest/globals'; 6 | 7 | // @ts-ignore 8 | 9 | import { loginWithPopupFn, loginWithRedirectFn, setupFn } from './helpers'; 10 | 11 | import { TEST_CODE_CHALLENGE } from '../constants'; 12 | 13 | jest.mock('es-cookie'); 14 | jest.mock('../../src/jwt'); 15 | jest.mock('../../src/worker/token.worker'); 16 | 17 | const mockWindow = global; 18 | const mockFetch = mockWindow.fetch; 19 | const mockVerify = verify; 20 | 21 | jest 22 | .spyOn(utils, 'bufferToBase64UrlEncoded') 23 | .mockReturnValue(TEST_CODE_CHALLENGE); 24 | 25 | jest.spyOn(utils, 'runPopup'); 26 | 27 | const setup = setupFn(mockVerify); 28 | const loginWithRedirect = loginWithRedirectFn(mockWindow, mockFetch); 29 | const loginWithPopup = loginWithPopupFn(mockWindow, mockFetch); 30 | 31 | describe('Auth0Client', () => { 32 | const oldWindowLocation = window.location; 33 | 34 | beforeEach(() => { 35 | // https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/ 36 | delete window.location; 37 | window.location = Object.defineProperties( 38 | {}, 39 | { 40 | ...Object.getOwnPropertyDescriptors(oldWindowLocation), 41 | assign: { 42 | configurable: true, 43 | value: jest.fn() 44 | } 45 | } 46 | ) as Location; 47 | // -- 48 | 49 | mockWindow.open = jest.fn(); 50 | mockWindow.addEventListener = jest.fn(); 51 | mockWindow.crypto = { 52 | subtle: { 53 | digest: () => 'foo' 54 | }, 55 | getRandomValues() { 56 | return '123'; 57 | } 58 | }; 59 | mockWindow.MessageChannel = MessageChannel; 60 | mockWindow.Worker = {}; 61 | jest.spyOn(scope, 'getUniqueScopes'); 62 | sessionStorage.clear(); 63 | }); 64 | 65 | afterEach(() => { 66 | mockFetch.mockReset(); 67 | jest.clearAllMocks(); 68 | window.location = oldWindowLocation; 69 | }); 70 | 71 | describe('getIdTokenClaims', () => { 72 | it('returns undefined if there is no cache', async () => { 73 | const auth0 = setup(); 74 | const decodedToken = await auth0.getIdTokenClaims(); 75 | 76 | expect(decodedToken).toBeUndefined(); 77 | }); 78 | 79 | // The getIdTokenClaims is dependent on the result of a successful or failed login. 80 | // As the SDK allows for a user to login using a redirect or a popup approach, 81 | // functionality has to be guaranteed to be working in both situations. 82 | 83 | // To avoid excessive test duplication, tests are being generated twice. 84 | // - once for loginWithRedirect 85 | // - once for loginWithPopup 86 | [ 87 | { 88 | name: 'loginWithRedirect', 89 | login: loginWithRedirect 90 | }, 91 | { 92 | name: 'loginWithPopup', 93 | login: loginWithPopup 94 | } 95 | ].forEach( 96 | ({ 97 | name, 98 | login 99 | }: { 100 | name: string; 101 | login: typeof loginWithRedirect | typeof loginWithPopup; 102 | }) => { 103 | describe(`when ${name}`, () => { 104 | it('returns the ID token claims', async () => { 105 | const auth0 = setup({ authorizationParams: { scope: 'foo' } }); 106 | await login(auth0); 107 | 108 | expect(await auth0.getIdTokenClaims()).toHaveProperty('exp'); 109 | expect(await auth0.getIdTokenClaims()).not.toHaveProperty('me'); 110 | }); 111 | 112 | it('returns the ID token claims with custom scope', async () => { 113 | const auth0 = setup({ 114 | authorizationParams: { 115 | scope: 'scope1 scope2' 116 | } 117 | }); 118 | await login(auth0, { authorizationParams: { scope: 'scope3' } }); 119 | 120 | expect(await auth0.getIdTokenClaims()).toHaveProperty('exp'); 121 | }); 122 | 123 | describe('when using refresh tokens', () => { 124 | it('returns the ID token claims with offline_access', async () => { 125 | const auth0 = setup({ 126 | authorizationParams: { scope: 'foo' }, 127 | useRefreshTokens: true 128 | }); 129 | await login(auth0); 130 | 131 | expect(await auth0.getIdTokenClaims()).toHaveProperty('exp'); 132 | }); 133 | 134 | it('returns the ID token claims with custom scope and offline_access', async () => { 135 | const auth0 = setup({ 136 | authorizationParams: { 137 | scope: 'scope1 scope2' 138 | }, 139 | useRefreshTokens: true 140 | }); 141 | await login(auth0, { authorizationParams: { scope: 'scope3' } }); 142 | 143 | expect(await auth0.getIdTokenClaims()).toHaveProperty('exp'); 144 | }); 145 | }); 146 | }); 147 | } 148 | ); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/getTokenWithPopup.test.ts: -------------------------------------------------------------------------------- 1 | import { verify } from '../../src/jwt'; 2 | import { MessageChannel } from 'worker_threads'; 3 | import * as utils from '../../src/utils'; 4 | import * as scope from '../../src/scope'; 5 | 6 | // @ts-ignore 7 | 8 | import { 9 | assertPostFn, 10 | fetchResponse, 11 | setupFn, 12 | setupMessageEventLister 13 | } from './helpers'; 14 | 15 | import { 16 | TEST_ACCESS_TOKEN, 17 | TEST_CLIENT_ID, 18 | TEST_CODE, 19 | TEST_CODE_CHALLENGE, 20 | TEST_CODE_VERIFIER, 21 | TEST_ID_TOKEN, 22 | TEST_REDIRECT_URI, 23 | TEST_REFRESH_TOKEN, 24 | TEST_STATE 25 | } from '../constants'; 26 | 27 | import { Auth0ClientOptions } from '../../src'; 28 | import { DEFAULT_AUTH0_CLIENT } from '../../src/constants'; 29 | import { expect } from '@jest/globals'; 30 | 31 | jest.mock('es-cookie'); 32 | jest.mock('../../src/jwt'); 33 | jest.mock('../../src/worker/token.worker'); 34 | 35 | const mockWindow = global; 36 | const mockFetch = mockWindow.fetch; 37 | const mockVerify = verify; 38 | const assertPost = assertPostFn(mockFetch); 39 | 40 | jest 41 | .spyOn(utils, 'bufferToBase64UrlEncoded') 42 | .mockReturnValue(TEST_CODE_CHALLENGE); 43 | 44 | jest.spyOn(utils, 'runPopup'); 45 | 46 | const setup = setupFn(mockVerify); 47 | 48 | describe('Auth0Client', () => { 49 | const oldWindowLocation = window.location; 50 | 51 | beforeEach(() => { 52 | // https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/ 53 | delete window.location; 54 | window.location = Object.defineProperties( 55 | {}, 56 | { 57 | ...Object.getOwnPropertyDescriptors(oldWindowLocation), 58 | assign: { 59 | configurable: true, 60 | value: jest.fn() 61 | } 62 | } 63 | ) as Location; 64 | // -- 65 | 66 | mockWindow.open = jest.fn(); 67 | mockWindow.addEventListener = jest.fn(); 68 | mockWindow.crypto = { 69 | subtle: { 70 | digest: () => 'foo' 71 | }, 72 | getRandomValues() { 73 | return '123'; 74 | } 75 | }; 76 | mockWindow.MessageChannel = MessageChannel; 77 | mockWindow.Worker = {}; 78 | jest.spyOn(scope, 'getUniqueScopes'); 79 | sessionStorage.clear(); 80 | }); 81 | 82 | afterEach(() => { 83 | mockFetch.mockReset(); 84 | jest.clearAllMocks(); 85 | window.location = oldWindowLocation; 86 | }); 87 | 88 | describe('getTokenWithPopup()', () => { 89 | const localSetup = async (clientOptions?: Partial) => { 90 | const auth0 = setup(clientOptions); 91 | 92 | setupMessageEventLister(mockWindow, { state: TEST_STATE }); 93 | 94 | mockFetch.mockResolvedValueOnce( 95 | fetchResponse(true, { 96 | id_token: TEST_ID_TOKEN, 97 | refresh_token: TEST_REFRESH_TOKEN, 98 | access_token: TEST_ACCESS_TOKEN, 99 | expires_in: 86400 100 | }) 101 | ); 102 | 103 | return auth0; 104 | }; 105 | 106 | it('calls `loginWithPopup` with the correct default options', async () => { 107 | const auth0 = await localSetup(); 108 | expect(await auth0.getTokenWithPopup()).toEqual(TEST_ACCESS_TOKEN); 109 | }); 110 | 111 | it('respects customized scopes', async () => { 112 | const auth0 = await localSetup({ 113 | authorizationParams: { 114 | scope: 'email read:email' 115 | } 116 | }); 117 | 118 | const config = { 119 | popup: { 120 | location: { 121 | href: '' 122 | }, 123 | close: jest.fn() 124 | } 125 | }; 126 | 127 | expect(await auth0.getTokenWithPopup({}, config)).toEqual( 128 | TEST_ACCESS_TOKEN 129 | ); 130 | 131 | expect(config.popup.location.href).toMatch(/openid\+email\+read%3Aemail/); 132 | }); 133 | 134 | it('passes custom login options', async () => { 135 | const auth0 = await localSetup(); 136 | 137 | const loginOptions = { 138 | authorizationParams: { 139 | audience: 'other-audience', 140 | screen_hint: 'signup' 141 | } 142 | }; 143 | 144 | const config = { 145 | popup: { 146 | location: { 147 | href: '' 148 | }, 149 | close: jest.fn() 150 | } 151 | }; 152 | 153 | await auth0.getTokenWithPopup(loginOptions, config); 154 | 155 | expect(config.popup.location.href).toMatch(/other-audience/); 156 | expect(config.popup.location.href).toMatch(/screen_hint/); 157 | }); 158 | 159 | it('should use form data by default', async () => { 160 | const auth0 = await localSetup({}); 161 | 162 | const loginOptions = { 163 | authorizationParams: { 164 | audience: 'other-audience', 165 | screen_hint: 'signup' 166 | } 167 | }; 168 | 169 | const config = { 170 | popup: { 171 | location: { 172 | href: '' 173 | }, 174 | close: jest.fn() 175 | } 176 | }; 177 | 178 | await auth0.getTokenWithPopup(loginOptions, config); 179 | 180 | assertPost( 181 | 'https://auth0_domain/oauth/token', 182 | { 183 | redirect_uri: TEST_REDIRECT_URI, 184 | client_id: TEST_CLIENT_ID, 185 | code_verifier: TEST_CODE_VERIFIER, 186 | grant_type: 'authorization_code' 187 | }, 188 | { 189 | 'Auth0-Client': btoa(JSON.stringify(DEFAULT_AUTH0_CLIENT)), 190 | 'Content-Type': 'application/x-www-form-urlencoded' 191 | }, 192 | 0, 193 | false 194 | ); 195 | }); 196 | 197 | it('can use the global audience', async () => { 198 | const auth0 = await localSetup({ 199 | authorizationParams: { 200 | audience: 'global-audience' 201 | } 202 | }); 203 | 204 | const config = { 205 | popup: { 206 | location: { 207 | href: '' 208 | }, 209 | close: jest.fn() 210 | } 211 | }; 212 | 213 | await auth0.getTokenWithPopup({}, config); 214 | 215 | expect(config.popup.location.href).toMatch(/global-audience/); 216 | }); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/getUser.test.ts: -------------------------------------------------------------------------------- 1 | import { verify } from '../../src/jwt'; 2 | import { MessageChannel } from 'worker_threads'; 3 | import * as utils from '../../src/utils'; 4 | import * as scope from '../../src/scope'; 5 | import { expect } from '@jest/globals'; 6 | 7 | // @ts-ignore 8 | 9 | import { setupFn } from './helpers'; 10 | 11 | import { TEST_CODE_CHALLENGE } from '../constants'; 12 | import { ICache } from '../../src'; 13 | 14 | jest.mock('es-cookie'); 15 | jest.mock('../../src/jwt'); 16 | jest.mock('../../src/worker/token.worker'); 17 | 18 | const mockWindow = global; 19 | const mockFetch = mockWindow.fetch; 20 | const mockVerify = verify; 21 | 22 | jest 23 | .spyOn(utils, 'bufferToBase64UrlEncoded') 24 | .mockReturnValue(TEST_CODE_CHALLENGE); 25 | 26 | jest.spyOn(utils, 'runPopup'); 27 | 28 | const setup = setupFn(mockVerify); 29 | 30 | describe('Auth0Client', () => { 31 | const oldWindowLocation = window.location; 32 | 33 | beforeEach(() => { 34 | // https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/ 35 | delete window.location; 36 | window.location = Object.defineProperties( 37 | {}, 38 | { 39 | ...Object.getOwnPropertyDescriptors(oldWindowLocation), 40 | assign: { 41 | configurable: true, 42 | value: jest.fn() 43 | } 44 | } 45 | ) as Location; 46 | // -- 47 | 48 | mockWindow.open = jest.fn(); 49 | mockWindow.addEventListener = jest.fn(); 50 | mockWindow.crypto = { 51 | subtle: { 52 | digest: () => 'foo' 53 | }, 54 | getRandomValues() { 55 | return '123'; 56 | } 57 | }; 58 | mockWindow.MessageChannel = MessageChannel; 59 | mockWindow.Worker = {}; 60 | jest.spyOn(scope, 'getUniqueScopes'); 61 | sessionStorage.clear(); 62 | }); 63 | 64 | afterEach(() => { 65 | mockFetch.mockReset(); 66 | jest.clearAllMocks(); 67 | window.location = oldWindowLocation; 68 | }); 69 | 70 | describe('getUser', () => { 71 | it('returns undefined if there is no user in the cache', async () => { 72 | const auth0 = setup(); 73 | const decodedToken = await auth0.getUser(); 74 | 75 | expect(decodedToken).toBeUndefined(); 76 | }); 77 | 78 | it('searches the user in the cache', async () => { 79 | const cache: ICache = { 80 | get: jest.fn(), 81 | set: jest.fn(), 82 | remove: jest.fn(), 83 | allKeys: jest.fn() 84 | }; 85 | const auth0 = setup({ cache }); 86 | await auth0.getUser(); 87 | 88 | expect(cache.get).toBeCalledWith( 89 | '@@auth0spajs@@::auth0_client_id::@@user@@' 90 | ); 91 | }); 92 | 93 | it('fallback to searching the user stored with the access token', async () => { 94 | const getMock = jest.fn(); 95 | const cache: ICache = { 96 | get: getMock, 97 | set: jest.fn(), 98 | remove: jest.fn(), 99 | allKeys: jest.fn() 100 | }; 101 | 102 | getMock.mockImplementation((key: string) => { 103 | if ( 104 | key === 105 | '@@auth0spajs@@::auth0_client_id::default::openid profile email' 106 | ) { 107 | return { 108 | body: { id_token: 'abc', decodedToken: { user: { sub: '123' } } } 109 | }; 110 | } 111 | }); 112 | 113 | const auth0 = setup({ cache }); 114 | const user = await auth0.getUser(); 115 | 116 | expect(cache.get).toBeCalledWith( 117 | '@@auth0spajs@@::auth0_client_id::@@user@@' 118 | ); 119 | expect(cache.get).toBeCalledWith( 120 | '@@auth0spajs@@::auth0_client_id::default::openid profile email' 121 | ); 122 | expect(user?.sub).toBe('123'); 123 | }); 124 | 125 | it('does not fallback to searching the user stored with the access token when user found', async () => { 126 | const getMock = jest.fn(); 127 | const cache: ICache = { 128 | get: getMock, 129 | set: jest.fn(), 130 | remove: jest.fn(), 131 | allKeys: jest.fn() 132 | }; 133 | 134 | getMock.mockImplementation((key: string) => { 135 | if (key === '@@auth0spajs@@::auth0_client_id::@@user@@') { 136 | return { id_token: 'abc', decodedToken: { user: { sub: '123' } } }; 137 | } 138 | }); 139 | 140 | const auth0 = setup({ cache }); 141 | const user = await auth0.getUser(); 142 | 143 | expect(cache.get).toBeCalledWith( 144 | '@@auth0spajs@@::auth0_client_id::@@user@@' 145 | ); 146 | expect(cache.get).not.toBeCalledWith( 147 | '@@auth0spajs@@::auth0_client_id::default::openid profile email' 148 | ); 149 | expect(user?.sub).toBe('123'); 150 | }); 151 | 152 | it('should return from the in memory cache if no changes', async () => { 153 | const getMock = jest.fn(); 154 | const cache: ICache = { 155 | get: getMock, 156 | set: jest.fn(), 157 | remove: jest.fn(), 158 | allKeys: jest.fn() 159 | }; 160 | 161 | getMock.mockImplementation((key: string) => { 162 | if (key === '@@auth0spajs@@::auth0_client_id::@@user@@') { 163 | return { id_token: 'abcd', decodedToken: { user: { sub: '123' } } }; 164 | } 165 | }); 166 | 167 | const auth0 = setup({ cache }); 168 | const user = await auth0.getUser(); 169 | const secondUser = await auth0.getUser(); 170 | 171 | expect(user).toBe(secondUser); 172 | }); 173 | 174 | it('should return a new object from the cache when the user object changes', async () => { 175 | const getMock = jest.fn(); 176 | const cache: ICache = { 177 | get: getMock, 178 | set: jest.fn(), 179 | remove: jest.fn(), 180 | allKeys: jest.fn() 181 | }; 182 | 183 | getMock.mockImplementation((key: string) => { 184 | if (key === '@@auth0spajs@@::auth0_client_id::@@user@@') { 185 | return { id_token: 'abcd', decodedToken: { user: { sub: '123' } } }; 186 | } 187 | }); 188 | 189 | const auth0 = setup({ cache }); 190 | const user = await auth0.getUser(); 191 | const secondUser = await auth0.getUser(); 192 | 193 | expect(user).toBe(secondUser); 194 | 195 | getMock.mockImplementation((key: string) => { 196 | if (key === '@@auth0spajs@@::auth0_client_id::@@user@@') { 197 | return { 198 | id_token: 'abcdefg', 199 | decodedToken: { user: { sub: '123' } } 200 | }; 201 | } 202 | }); 203 | 204 | const thirdUser = await auth0.getUser(); 205 | expect(thirdUser).not.toBe(user); 206 | }); 207 | 208 | it('should return undefined if there is no cache entry', async () => { 209 | const cache: ICache = { 210 | get: jest.fn(), 211 | set: jest.fn(), 212 | remove: jest.fn(), 213 | allKeys: jest.fn() 214 | }; 215 | 216 | const auth0 = setup({ cache }); 217 | await expect(auth0.getUser()).resolves.toBe(undefined); 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/isAuthenticated.test.ts: -------------------------------------------------------------------------------- 1 | import { verify } from '../../src/jwt'; 2 | import { MessageChannel } from 'worker_threads'; 3 | import * as utils from '../../src/utils'; 4 | import * as scope from '../../src/scope'; 5 | import { expect } from '@jest/globals'; 6 | 7 | // @ts-ignore 8 | 9 | import { loginWithPopupFn, loginWithRedirectFn, setupFn } from './helpers'; 10 | 11 | import { TEST_CODE_CHALLENGE } from '../constants'; 12 | 13 | jest.mock('es-cookie'); 14 | jest.mock('../../src/jwt'); 15 | jest.mock('../../src/worker/token.worker'); 16 | 17 | const mockWindow = global; 18 | const mockFetch = mockWindow.fetch; 19 | const mockVerify = verify; 20 | 21 | jest 22 | .spyOn(utils, 'bufferToBase64UrlEncoded') 23 | .mockReturnValue(TEST_CODE_CHALLENGE); 24 | 25 | jest.spyOn(utils, 'runPopup'); 26 | 27 | const setup = setupFn(mockVerify); 28 | const loginWithRedirect = loginWithRedirectFn(mockWindow, mockFetch); 29 | const loginWithPopup = loginWithPopupFn(mockWindow, mockFetch); 30 | 31 | describe('Auth0Client', () => { 32 | const oldWindowLocation = window.location; 33 | 34 | beforeEach(() => { 35 | // https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/ 36 | delete window.location; 37 | window.location = Object.defineProperties( 38 | {}, 39 | { 40 | ...Object.getOwnPropertyDescriptors(oldWindowLocation), 41 | assign: { 42 | configurable: true, 43 | value: jest.fn() 44 | } 45 | } 46 | ) as Location; 47 | // -- 48 | 49 | mockWindow.open = jest.fn(); 50 | mockWindow.addEventListener = jest.fn(); 51 | 52 | mockWindow.crypto = { 53 | subtle: { 54 | digest: () => 'foo' 55 | }, 56 | getRandomValues() { 57 | return '123'; 58 | } 59 | }; 60 | 61 | mockWindow.MessageChannel = MessageChannel; 62 | mockWindow.Worker = {}; 63 | 64 | jest.spyOn(scope, 'getUniqueScopes'); 65 | 66 | sessionStorage.clear(); 67 | }); 68 | 69 | afterEach(() => { 70 | mockFetch.mockReset(); 71 | jest.clearAllMocks(); 72 | window.location = oldWindowLocation; 73 | }); 74 | 75 | describe('isAuthenticated', () => { 76 | describe('loginWithRedirect', () => { 77 | it('returns true if there is a user', async () => { 78 | const auth0 = setup(); 79 | await loginWithRedirect(auth0); 80 | 81 | const result = await auth0.isAuthenticated(); 82 | expect(result).toBe(true); 83 | }); 84 | 85 | it('returns false if error was returned', async () => { 86 | const auth0 = setup(); 87 | 88 | try { 89 | await loginWithRedirect(auth0, undefined, { 90 | authorize: { 91 | error: 'some-error' 92 | } 93 | }); 94 | } catch {} 95 | 96 | const result = await auth0.isAuthenticated(); 97 | 98 | expect(result).toBe(false); 99 | }); 100 | 101 | it('returns false if token call fails', async () => { 102 | const auth0 = setup(); 103 | try { 104 | await loginWithRedirect(auth0, undefined, { 105 | token: { success: false } 106 | }); 107 | } catch {} 108 | const result = await auth0.isAuthenticated(); 109 | expect(result).toBe(false); 110 | }); 111 | }); 112 | 113 | describe('loginWithPopup', () => { 114 | it('returns true if there is a user', async () => { 115 | const auth0 = setup(); 116 | await loginWithPopup(auth0); 117 | 118 | const result = await auth0.isAuthenticated(); 119 | expect(result).toBe(true); 120 | }); 121 | }); 122 | 123 | it('returns false if code not part of URL', async () => { 124 | const auth0 = setup(); 125 | 126 | try { 127 | await loginWithPopup(auth0, undefined, undefined, { 128 | authorize: { 129 | response: { 130 | error: 'some error' 131 | } 132 | } 133 | }); 134 | } catch {} 135 | 136 | const result = await auth0.isAuthenticated(); 137 | 138 | expect(result).toBe(false); 139 | }); 140 | 141 | it('returns false if there is no user', async () => { 142 | const auth0 = setup(); 143 | const result = await auth0.isAuthenticated(); 144 | 145 | expect(result).toBe(false); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /__tests__/cache/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CacheKey, 3 | ICache, 4 | InMemoryCache, 5 | LocalStorageCache 6 | } from '../../src/cache'; 7 | import { CacheEntry } from '../../src/cache/shared'; 8 | 9 | import { 10 | TEST_CLIENT_ID, 11 | TEST_SCOPES, 12 | TEST_ID_TOKEN, 13 | TEST_ACCESS_TOKEN, 14 | dayInSeconds, 15 | nowSeconds, 16 | TEST_AUDIENCE 17 | } from '../constants'; 18 | import { InMemoryAsyncCacheNoKeys } from './shared'; 19 | import { expect } from '@jest/globals'; 20 | 21 | const cacheFactories = [ 22 | { new: () => new LocalStorageCache(), name: 'LocalStorage Cache' }, 23 | { new: () => new InMemoryCache().enclosedCache, name: 'In-memory Cache' }, 24 | { 25 | new: () => new InMemoryAsyncCacheNoKeys(), 26 | name: 'In-memory async cache with no allKeys' 27 | } 28 | ]; 29 | 30 | const defaultEntry: CacheEntry = { 31 | client_id: TEST_CLIENT_ID, 32 | audience: TEST_AUDIENCE, 33 | scope: TEST_SCOPES, 34 | id_token: TEST_ID_TOKEN, 35 | access_token: TEST_ACCESS_TOKEN, 36 | expires_in: dayInSeconds, 37 | decodedToken: { 38 | claims: { 39 | __raw: TEST_ID_TOKEN, 40 | exp: nowSeconds() + dayInSeconds + 100, 41 | name: 'Test' 42 | }, 43 | user: { name: 'Test' } 44 | } 45 | }; 46 | 47 | cacheFactories.forEach(cacheFactory => { 48 | describe(cacheFactory.name, () => { 49 | let cache: ICache; 50 | 51 | beforeEach(() => { 52 | cache = cacheFactory.new(); 53 | }); 54 | 55 | it('returns undefined when there is no data', async () => { 56 | expect(await cache.get('some-fictional-key')).toBeFalsy(); 57 | }); 58 | 59 | it('retrieves values from the cache', async () => { 60 | const data = { 61 | ...defaultEntry, 62 | decodedToken: { 63 | claims: { 64 | __raw: TEST_ID_TOKEN, 65 | exp: nowSeconds() + dayInSeconds, 66 | name: 'Test' 67 | }, 68 | user: { name: 'Test' } 69 | } 70 | }; 71 | 72 | const cacheKey = CacheKey.fromCacheEntry(data); 73 | 74 | await cache.set(cacheKey.toKey(), data); 75 | expect(await cache.get(cacheKey.toKey())).toStrictEqual(data); 76 | }); 77 | 78 | it('retrieves values from the cache when scopes do not match', async () => { 79 | const data = { 80 | ...defaultEntry, 81 | scope: 'the_scope the_scope2', 82 | decodedToken: { 83 | claims: { 84 | __raw: TEST_ID_TOKEN, 85 | exp: nowSeconds() + dayInSeconds, 86 | name: 'Test' 87 | }, 88 | user: { name: 'Test' } 89 | } 90 | }; 91 | 92 | const cacheKey = new CacheKey({ 93 | clientId: TEST_CLIENT_ID, 94 | audience: TEST_AUDIENCE, 95 | scope: 'the_scope' 96 | }); 97 | 98 | await cache.set(cacheKey.toKey(), data); 99 | expect(await cache.get(cacheKey.toKey())).toStrictEqual(data); 100 | }); 101 | 102 | it('retrieves values from the cache when scopes do not match and multiple scopes are provided in a different order', async () => { 103 | const data = { 104 | ...defaultEntry, 105 | scope: 'the_scope the_scope2 the_scope3', 106 | decodedToken: { 107 | claims: { 108 | __raw: TEST_ID_TOKEN, 109 | exp: nowSeconds() + dayInSeconds, 110 | name: 'Test' 111 | }, 112 | user: { name: 'Test' } 113 | } 114 | }; 115 | 116 | const cacheKey = new CacheKey({ 117 | clientId: TEST_CLIENT_ID, 118 | audience: TEST_AUDIENCE, 119 | scope: 'the_scope3 the_scope' 120 | }); 121 | 122 | await cache.set(cacheKey.toKey(), data); 123 | expect(await cache.get(cacheKey.toKey())).toStrictEqual(data); 124 | }); 125 | 126 | it('returns undefined when not all scopes match', async () => { 127 | const data = { 128 | client_id: TEST_CLIENT_ID, 129 | audience: TEST_AUDIENCE, 130 | scope: 'the_scope the_scope2 the_scope3', 131 | id_token: TEST_ID_TOKEN, 132 | access_token: TEST_ACCESS_TOKEN, 133 | expires_in: dayInSeconds, 134 | decodedToken: { 135 | claims: { 136 | __raw: TEST_ID_TOKEN, 137 | exp: nowSeconds() + dayInSeconds, 138 | name: 'Test' 139 | }, 140 | user: { name: 'Test' } 141 | } 142 | }; 143 | 144 | const cacheKey = CacheKey.fromCacheEntry(data); 145 | 146 | // Set cache with one set of scopes.. 147 | await cache.set(cacheKey.toKey(), data); 148 | 149 | // Retrieve with another 150 | expect( 151 | await cache.get( 152 | new CacheKey({ 153 | clientId: TEST_CLIENT_ID, 154 | audience: TEST_AUDIENCE, 155 | scope: 'the_scope4 the_scope' 156 | }).toKey() 157 | ) 158 | ).toBeFalsy(); 159 | }); 160 | 161 | it('can remove an item from the cache', async () => { 162 | const cacheKey = CacheKey.fromCacheEntry(defaultEntry).toKey(); 163 | 164 | await cache.set(cacheKey, defaultEntry); 165 | expect(await cache.get(cacheKey)).toStrictEqual(defaultEntry); 166 | await cache.remove(cacheKey); 167 | expect(await cache.get(cacheKey)).toBeFalsy(); 168 | }); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /__tests__/cache/key-manifest.test.ts: -------------------------------------------------------------------------------- 1 | import { CacheKey, ICache, InMemoryCache } from '../../src/cache'; 2 | import { CacheKeyManifest } from '../../src/cache/key-manifest'; 3 | import { TEST_AUDIENCE, TEST_CLIENT_ID, TEST_SCOPES } from '../constants'; 4 | import { expect } from '@jest/globals'; 5 | 6 | describe('CacheKeyManifest', () => { 7 | let manifest: CacheKeyManifest; 8 | 9 | beforeEach(() => { 10 | manifest = new CacheKeyManifest( 11 | new InMemoryCache().enclosedCache, 12 | TEST_CLIENT_ID 13 | ); 14 | }); 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks(); 18 | }); 19 | 20 | it('should create a new item in the manifest if one does not exist', async () => { 21 | const key = new CacheKey({ 22 | clientId: TEST_CLIENT_ID, 23 | audience: TEST_AUDIENCE, 24 | scope: TEST_SCOPES 25 | }); 26 | 27 | expect(await manifest.get()).toBeFalsy(); 28 | await manifest.add(key.toKey()); 29 | 30 | const entry = await manifest.get(); 31 | 32 | expect(entry.keys).toStrictEqual([key.toKey()]); 33 | }); 34 | 35 | it('should add another key to the same list if an entry already exists in the manifest', async () => { 36 | const key = new CacheKey({ 37 | clientId: TEST_CLIENT_ID, 38 | audience: TEST_AUDIENCE, 39 | scope: TEST_SCOPES 40 | }); 41 | 42 | await manifest.add(key.toKey()); 43 | 44 | const key2 = new CacheKey({ 45 | clientId: TEST_CLIENT_ID, 46 | audience: 'http://another-audience', 47 | scope: TEST_SCOPES 48 | }); 49 | 50 | await manifest.add(key2.toKey()); 51 | 52 | const entry = await manifest.get(); 53 | 54 | expect(entry.keys).toHaveLength(2); 55 | expect(entry.keys).toStrictEqual([key.toKey(), key2.toKey()]); 56 | }); 57 | 58 | it('should not add the same key twice', async () => { 59 | const key = new CacheKey({ 60 | clientId: TEST_CLIENT_ID, 61 | audience: TEST_AUDIENCE, 62 | scope: TEST_SCOPES 63 | }); 64 | 65 | await manifest.add(key.toKey()); 66 | 67 | const key2 = new CacheKey({ 68 | clientId: TEST_CLIENT_ID, 69 | audience: 'http://another-audience', 70 | scope: TEST_SCOPES 71 | }); 72 | 73 | await manifest.add(key2.toKey()); 74 | await manifest.add(key2.toKey()); 75 | 76 | const entry = await manifest.get(); 77 | 78 | // Should still only have 2 keys, despite adding key, key2 and key2 again 79 | expect(entry.keys).toHaveLength(2); 80 | expect(entry.keys).toStrictEqual([key.toKey(), key2.toKey()]); 81 | }); 82 | 83 | it('can remove an entry', async () => { 84 | const key = new CacheKey({ 85 | clientId: TEST_CLIENT_ID, 86 | audience: TEST_AUDIENCE, 87 | scope: TEST_SCOPES 88 | }); 89 | 90 | await manifest.add(key.toKey()); 91 | await manifest.remove(key.toKey()); 92 | expect(await manifest.get()).toBeFalsy(); 93 | }); 94 | 95 | it('does nothing if trying to remove an item that does not exist', async () => { 96 | const key = new CacheKey({ 97 | clientId: TEST_CLIENT_ID, 98 | audience: TEST_AUDIENCE, 99 | scope: TEST_SCOPES 100 | }); 101 | 102 | await expect(manifest.remove(key.toKey())).resolves.toBeFalsy(); 103 | }); 104 | 105 | it('can remove a key from an entry and leave others intact', async () => { 106 | const key = new CacheKey({ 107 | clientId: TEST_CLIENT_ID, 108 | audience: TEST_AUDIENCE, 109 | scope: TEST_SCOPES 110 | }); 111 | 112 | const key2 = new CacheKey({ 113 | clientId: TEST_CLIENT_ID, 114 | audience: 'http://another-audience', 115 | scope: TEST_SCOPES 116 | }); 117 | 118 | await manifest.add(key.toKey()); 119 | await manifest.add(key2.toKey()); 120 | await manifest.remove(key.toKey()); 121 | expect((await manifest.get()).keys).toStrictEqual([key2.toKey()]); 122 | }); 123 | 124 | it('does not remove the whole entry if the key was not found', async () => { 125 | const key = new CacheKey({ 126 | clientId: TEST_CLIENT_ID, 127 | audience: TEST_AUDIENCE, 128 | scope: TEST_SCOPES 129 | }); 130 | 131 | const randomKey = new CacheKey({ 132 | clientId: key.clientId, 133 | audience: 'http://some-other-audience', 134 | scope: key.scope 135 | }); 136 | 137 | await manifest.add(key.toKey()); 138 | await manifest.remove(randomKey.toKey()); 139 | expect((await manifest.get()).keys).toStrictEqual([key.toKey()]); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /__tests__/cache/shared.ts: -------------------------------------------------------------------------------- 1 | import { Cacheable, ICache } from '../../src/cache'; 2 | 3 | export class InMemoryAsyncCacheNoKeys implements ICache { 4 | private cache: Record = {}; 5 | 6 | set(key: string, entry: T) { 7 | this.cache[key] = entry; 8 | return Promise.resolve(); 9 | } 10 | 11 | get(key: string) { 12 | const cacheEntry = this.cache[key] as T; 13 | 14 | if (!cacheEntry) { 15 | return Promise.resolve(null); 16 | } 17 | 18 | return Promise.resolve(cacheEntry); 19 | } 20 | 21 | remove(key: string) { 22 | delete this.cache[key]; 23 | return Promise.resolve(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /__tests__/constants.ts: -------------------------------------------------------------------------------- 1 | import version from '../src/version'; 2 | import { DEFAULT_SCOPE } from '../src/constants'; 3 | 4 | export const TEST_AUTH0_CLIENT_QUERY_STRING = `&auth0Client=${encodeURIComponent( 5 | btoa( 6 | JSON.stringify({ 7 | name: 'auth0-spa-js', 8 | version: version 9 | }) 10 | ) 11 | )}`; 12 | 13 | export const TEST_DOMAIN = 'auth0_domain'; 14 | export const TEST_CLIENT_ID = 'auth0_client_id'; 15 | export const TEST_REDIRECT_URI = 'my_callback_url'; 16 | export const TEST_AUDIENCE = 'my_audience'; 17 | export const TEST_ID_TOKEN = 'my_id_token'; 18 | export const TEST_ACCESS_TOKEN = 'my_access_token'; 19 | export const TEST_REFRESH_TOKEN = 'my_refresh_token'; 20 | export const TEST_STATE = 'MTIz'; 21 | export const TEST_NONCE = 'MTIz'; 22 | export const TEST_CODE = 'my_code'; 23 | export const TEST_SCOPES = DEFAULT_SCOPE; 24 | export const TEST_CODE_CHALLENGE = 'TEST_CODE_CHALLENGE'; 25 | export const TEST_CODE_VERIFIER = '123'; 26 | export const GET_TOKEN_SILENTLY_LOCK_KEY = 'auth0.lock.getTokenSilently'; 27 | export const TEST_QUERY_PARAMS = 'query=params'; 28 | export const TEST_ENCODED_STATE = 'encoded-state'; 29 | export const TEST_RANDOM_STRING = 'random-string'; 30 | export const TEST_ARRAY_BUFFER = 'this-is-an-array-buffer'; 31 | export const TEST_BASE64_ENCODED_STRING = 'base64-url-encoded-string'; 32 | export const TEST_USER_ID = 'user-id'; 33 | export const TEST_USER_EMAIL = 'user@email.com'; 34 | export const TEST_APP_STATE = { bestPet: 'dog' }; 35 | export const TEST_ORG_ID = 'org_id_123'; 36 | 37 | export const nowSeconds = () => Math.floor(Date.now() / 1000); 38 | export const dayInSeconds = 86400; 39 | -------------------------------------------------------------------------------- /__tests__/helpers.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@jest/globals'; 2 | 3 | export const expectToHaveBeenCalledWithAuth0ClientParam = (mock, expected) => { 4 | const [[url]] = (mock).mock.calls; 5 | const param = new URL(url).searchParams.get('auth0Client'); 6 | const decodedParam = decodeURIComponent(atob(param)); 7 | const actual = JSON.parse(decodedParam); 8 | expect(actual).toStrictEqual(expected); 9 | }; 10 | 11 | export const expectToHaveBeenCalledWithHash = (mock, expected) => { 12 | const [[url]] = (mock).mock.calls; 13 | const hash = new URL(url).hash; 14 | expect(hash).toEqual(expected); 15 | }; 16 | -------------------------------------------------------------------------------- /__tests__/http.test.ts: -------------------------------------------------------------------------------- 1 | import { MfaRequiredError, MissingRefreshTokenError } from '../src/errors'; 2 | import { switchFetch, getJSON } from '../src/http'; 3 | import { expect } from '@jest/globals'; 4 | 5 | jest.mock('../src/worker/token.worker'); 6 | 7 | const mockUnfetch = fetch; 8 | 9 | describe('switchFetch', () => { 10 | it('clears timeout when successful', async () => { 11 | mockUnfetch.mockImplementation(() => 12 | Promise.resolve({ 13 | ok: true, 14 | json: () => Promise.resolve() 15 | }) 16 | ); 17 | jest.spyOn(window, 'clearTimeout'); 18 | await switchFetch('https://test.com/', null, null, {}, undefined); 19 | expect(clearTimeout).toBeCalledTimes(1); 20 | }); 21 | }); 22 | 23 | describe('getJson', () => { 24 | it('throws MfaRequiredError when mfa_required is returned', async () => { 25 | mockUnfetch.mockImplementation(() => 26 | Promise.resolve({ 27 | ok: false, 28 | json: () => Promise.resolve({ error: 'mfa_required' }) 29 | }) 30 | ); 31 | 32 | await expect( 33 | getJSON('https://test.com/', null, null, null, {}, undefined) 34 | ).rejects.toBeInstanceOf(MfaRequiredError); 35 | }); 36 | 37 | it('reads the mfa_token when mfa_required is returned', async () => { 38 | mockUnfetch.mockImplementation(() => 39 | Promise.resolve({ 40 | ok: false, 41 | json: () => 42 | Promise.resolve({ error: 'mfa_required', mfa_token: '1234' }) 43 | }) 44 | ); 45 | 46 | await expect( 47 | getJSON('https://test.com/', null, null, null, {}, undefined) 48 | ).rejects.toHaveProperty('mfa_token', '1234'); 49 | }); 50 | 51 | it('throws MissingRefreshTokenError when missing_refresh_token is returned', async () => { 52 | mockUnfetch.mockImplementation(() => 53 | Promise.resolve({ 54 | ok: false, 55 | json: () => Promise.resolve({ error: 'missing_refresh_token' }) 56 | }) 57 | ); 58 | 59 | await expect( 60 | getJSON('https://test.com/', null, null, null, {}, undefined) 61 | ).rejects.toBeInstanceOf(MissingRefreshTokenError); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /__tests__/promise-utils.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | import { retryPromise, singlePromise } from '../src/promise-utils'; 5 | import { expect } from '@jest/globals'; 6 | 7 | describe('Promise Utils', () => { 8 | describe('singlePromise', () => { 9 | it('reuses the same promise when the key matches', async () => { 10 | const cb = jest.fn().mockResolvedValue({}); 11 | 12 | await Promise.all([ 13 | singlePromise(cb as any, 'test-key'), 14 | singlePromise(cb as any, 'test-key') 15 | ]); 16 | 17 | expect(cb).toHaveBeenCalledTimes(1); 18 | }); 19 | 20 | it('does not reuse the same promise when the key is different', async () => { 21 | const cb = jest.fn().mockResolvedValue({}); 22 | 23 | await Promise.all([ 24 | singlePromise(cb as any, 'test-key'), 25 | singlePromise(cb as any, 'test-key2') 26 | ]); 27 | 28 | expect(cb).toHaveBeenCalledTimes(2); 29 | }); 30 | 31 | it('does not reuse the same promise when the key matches but the first promise resolves before calling the second', async () => { 32 | const cb = jest.fn().mockResolvedValue({}); 33 | 34 | await singlePromise(cb as any, 'test-key'); 35 | await singlePromise(cb as any, 'test-key'); 36 | 37 | expect(cb).toHaveBeenCalledTimes(2); 38 | }); 39 | }); 40 | 41 | describe('retryPromise', () => { 42 | it('does not retry promise when it resolves to true', async () => { 43 | const cb = jest.fn().mockResolvedValue(true); 44 | 45 | const value = await retryPromise(cb as any); 46 | 47 | expect(value).toBe(true); 48 | expect(cb).toHaveBeenCalledTimes(1); 49 | }); 50 | 51 | it('retries promise until it resolves to true', async () => { 52 | let i = 1; 53 | const cb = jest.fn().mockImplementation(() => { 54 | if (i === 3) { 55 | return Promise.resolve(true); 56 | } 57 | 58 | i++; 59 | return Promise.resolve(false); 60 | }); 61 | 62 | const value = await retryPromise(cb as any); 63 | 64 | expect(value).toBe(true); 65 | expect(cb).toHaveBeenCalledTimes(3); 66 | }); 67 | 68 | it('resolves to false when all retries resolve to false', async () => { 69 | const cb = jest.fn().mockResolvedValue(false); 70 | 71 | const value = await retryPromise(cb as any, 5); 72 | 73 | expect(value).toBe(false); 74 | expect(cb).toHaveBeenCalledTimes(5); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /__tests__/scope.test.ts: -------------------------------------------------------------------------------- 1 | import { getUniqueScopes } from '../src/scope'; 2 | import { expect } from '@jest/globals'; 3 | 4 | describe('getUniqueScopes', () => { 5 | it('removes duplicates', () => { 6 | expect(getUniqueScopes('openid openid', 'email')).toBe('openid email'); 7 | }); 8 | 9 | it('handles whitespace', () => { 10 | expect(getUniqueScopes(' openid profile ', ' ')).toBe('openid profile'); 11 | }); 12 | 13 | it('handles undefined/empty/null/whitespace', () => { 14 | expect( 15 | getUniqueScopes('openid profile', ' ', undefined, 'email', '', null) 16 | ).toBe('openid profile email'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /__tests__/ssr.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | import { Auth0Client } from '../src/Auth0Client'; 5 | import { expect } from '@jest/globals'; 6 | 7 | describe('In a Node SSR environment', () => { 8 | it('can be constructed', () => { 9 | expect( 10 | () => new Auth0Client({ clientId: 'foo', domain: 'bar' }) 11 | ).not.toThrow(); 12 | }); 13 | 14 | it('can check authenticated state', async () => { 15 | const client = new Auth0Client({ clientId: 'foo', domain: 'bar' }); 16 | expect(await client.isAuthenticated()).toBeFalsy(); 17 | expect(await client.getUser()).toBeUndefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /__tests__/transaction-manager.test.ts: -------------------------------------------------------------------------------- 1 | import { TransactionManager } from '../src/transaction-manager'; 2 | import { CookieStorage, SessionStorage } from '../src/storage'; 3 | import { TEST_CLIENT_ID, TEST_STATE } from './constants'; 4 | import { expect } from '@jest/globals'; 5 | 6 | const TRANSACTION_KEY_PREFIX = 'a0.spajs.txs'; 7 | 8 | const transaction = { 9 | nonce: 'nonceIn', 10 | code_verifier: 'code_verifierIn', 11 | appState: 'appStateIn', 12 | scope: 'scopeIn', 13 | audience: ' audienceIn', 14 | redirect_uri: 'http://localhost', 15 | state: TEST_STATE 16 | }; 17 | 18 | const transactionJson = JSON.stringify(transaction); 19 | 20 | const transactionKey = (clientId = TEST_CLIENT_ID) => 21 | `${TRANSACTION_KEY_PREFIX}.${clientId}`; 22 | 23 | describe('transaction manager', () => { 24 | let tm: TransactionManager; 25 | 26 | beforeEach(() => { 27 | jest.resetAllMocks(); 28 | }); 29 | 30 | describe('get', () => { 31 | it('loads transactions from storage (per key)', () => { 32 | tm = new TransactionManager(SessionStorage, TEST_CLIENT_ID); 33 | 34 | tm.get(); 35 | 36 | expect(sessionStorage.getItem).toHaveBeenCalledWith(transactionKey()); 37 | }); 38 | }); 39 | 40 | describe('with empty transactions', () => { 41 | beforeEach(() => { 42 | tm = new TransactionManager(SessionStorage, TEST_CLIENT_ID); 43 | }); 44 | 45 | it('`create` creates the transaction', () => { 46 | jest.mocked(sessionStorage.getItem).mockReturnValue(transactionJson); 47 | tm.create(transaction); 48 | expect(tm.get()).toMatchObject(transaction); 49 | }); 50 | 51 | it('`create` saves the transaction in the storage', () => { 52 | tm.create(transaction); 53 | 54 | expect(sessionStorage.setItem).toHaveBeenCalledWith( 55 | transactionKey(), 56 | transactionJson 57 | ); 58 | }); 59 | 60 | it('`get` without a transaction should return undefined', () => { 61 | expect(tm.get()).toBeUndefined(); 62 | }); 63 | 64 | it('`get` with a transaction should return the transaction', () => { 65 | jest.mocked(sessionStorage.getItem).mockReturnValue(transactionJson); 66 | expect(tm.get()).toMatchObject(transaction); 67 | }); 68 | 69 | it('`remove` removes the transaction', () => { 70 | tm.create(transaction); 71 | tm.remove(); 72 | expect(tm.get()).toBeUndefined(); 73 | }); 74 | 75 | it('`remove` removes transaction from storage', () => { 76 | tm.create(transaction); 77 | 78 | expect(sessionStorage.setItem).toHaveBeenCalledWith( 79 | transactionKey(), 80 | transactionJson 81 | ); 82 | 83 | tm.remove(); 84 | expect(sessionStorage.removeItem).toHaveBeenCalledWith(transactionKey()); 85 | }); 86 | }); 87 | 88 | describe('CookieStorage usage', () => { 89 | it('`create` saves the transaction in the storage with the provided domain', () => { 90 | CookieStorage.save = jest.fn(); 91 | const cookieDomain = 'vanity.auth.com'; 92 | tm = new TransactionManager(CookieStorage, TEST_CLIENT_ID, cookieDomain); 93 | tm.create(transaction); 94 | 95 | expect(CookieStorage.save).toHaveBeenCalledWith( 96 | transactionKey(), 97 | expect.anything(), 98 | { 99 | daysUntilExpire: 1, 100 | cookieDomain: cookieDomain 101 | } 102 | ); 103 | }); 104 | 105 | it('`remove` deletes the transaction in the storage with the provided domain', () => { 106 | CookieStorage.remove = jest.fn(); 107 | const cookieDomain = 'vanity.auth.com'; 108 | tm = new TransactionManager(CookieStorage, TEST_CLIENT_ID, cookieDomain); 109 | tm.remove(); 110 | 111 | expect(CookieStorage.remove).toHaveBeenCalledWith(transactionKey(), { 112 | cookieDomain: cookieDomain 113 | }); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /browserstack.json: -------------------------------------------------------------------------------- 1 | { 2 | "browsers": [ 3 | { 4 | "browser": "chrome", 5 | "os": "Windows 10", 6 | "versions": [ 7 | "latest" 8 | ] 9 | }, 10 | { 11 | "browser": "firefox", 12 | "os": "Windows 10", 13 | "versions": [ 14 | "latest" 15 | ] 16 | }, 17 | { 18 | "browser": "edge", 19 | "os": "Windows 10", 20 | "versions": [ 21 | "latest" 22 | ] 23 | } 24 | ], 25 | "run_settings": { 26 | "cypress_config_file": "./cypress.config.js", 27 | "cypress-version": "13", 28 | "project_name": "Auth0 SPA SDK", 29 | "exclude": [], 30 | "parallels": "5", 31 | "npm_dependencies": { 32 | "qss": "2.0.3" 33 | }, 34 | "package_config_options": {}, 35 | "headless": true 36 | }, 37 | "connection_settings": { 38 | "local": true, 39 | "local_mode": "always-on" 40 | }, 41 | "disable_usage_reporting": false 42 | } -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress'); 2 | 3 | module.exports = defineConfig({ 4 | chromeWebSecurity: false, 5 | viewportWidth: 1000, 6 | viewportHeight: 1000, 7 | e2e: { 8 | // We've imported your old cypress plugins here. 9 | // You may want to clean this up later by importing these. 10 | setupNodeEvents(on, config) { 11 | return require('./cypress/plugins/index.js')(on, config) 12 | }, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /cypress/e2e/handleRedirectCallback.cy.js: -------------------------------------------------------------------------------- 1 | describe('handleRedirectCallback', function () { 2 | beforeEach(cy.resetTests); 3 | afterEach(cy.fixCookies); 4 | 5 | it('caches token and user', function () { 6 | cy.loginNoCallback(); 7 | cy.handleRedirectCallback(); 8 | cy.isAuthenticated().should('contain', 'true'); 9 | cy.getAccessTokens().should('have.length', 1); 10 | cy.getUser().should('be.visible'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /cypress/e2e/initialisation.cy.js: -------------------------------------------------------------------------------- 1 | import { whenReady } from '../support/utils'; 2 | 3 | describe('initialisation', function () { 4 | beforeEach(cy.resetTests); 5 | afterEach(cy.fixCookies); 6 | 7 | it('should expose a factory method and constructor', function () { 8 | whenReady().then(win => { 9 | assert.isFunction( 10 | win.auth0.createAuth0Client, 11 | 'The createAuth0Client function should be declared on window.auth0.' 12 | ); 13 | assert.isFunction( 14 | win.auth0.Auth0Client, 15 | 'The Auth0Client constructor should be declared on window.auth0.' 16 | ); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /cypress/e2e/loginWithRedirect.cy.js: -------------------------------------------------------------------------------- 1 | import { whenReady, shouldInclude, tolerance, get } from '../support/utils'; 2 | 3 | describe('loginWithRedirect', function () { 4 | beforeEach(cy.resetTests); 5 | afterEach(cy.fixCookies); 6 | 7 | it('can perform the login flow', () => { 8 | whenReady().then(() => { 9 | cy.loginNoCallback(); 10 | 11 | cy.url().should(url => shouldInclude(url, 'http://127.0.0.1:3000')); 12 | 13 | whenReady().then(win => { 14 | get('client-id').then($clientIdBox => { 15 | expect( 16 | win.sessionStorage.getItem(`a0.spajs.txs.${$clientIdBox.val()}`) 17 | ).to.exist; 18 | 19 | cy.handleRedirectCallback().then(() => { 20 | expect( 21 | win.sessionStorage.getItem(`a0.spajs.txs.${$clientIdBox.val()}`) 22 | ).to.not.exist; 23 | }); 24 | }); 25 | }); 26 | }); 27 | }); 28 | 29 | it('can perform the login flow with cookie transactions', () => { 30 | whenReady(); 31 | cy.setSwitch('cookie-txns', true); 32 | 33 | const tomorrowInSeconds = Math.floor(Date.now() / 1000) + 86400; 34 | 35 | cy.loginNoCallback(); 36 | cy.url().should(url => shouldInclude(url, 'http://127.0.0.1:3000')); 37 | whenReady(); 38 | 39 | get('client-id').then($clientIdBox => { 40 | cy.getCookie(`a0.spajs.txs.${$clientIdBox.val()}`) 41 | .should('exist') 42 | .should(cookie => { 43 | // Check that the cookie value is at least within a second of what we expect, to make 44 | // the test a little less brittle. 45 | expect(tolerance(cookie.expiry, tomorrowInSeconds, 1)).to.be.true; 46 | }); 47 | 48 | cy.handleRedirectCallback().then(() => { 49 | cy.getCookie(`a0.spajs.txs.${$clientIdBox.val()}`).should('not.exist'); 50 | }); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /cypress/e2e/logout.cy.js: -------------------------------------------------------------------------------- 1 | describe('logout', function () { 2 | beforeEach(cy.resetTests); 3 | afterEach(cy.fixCookies); 4 | 5 | it('works correctly', function () { 6 | cy.login(); 7 | cy.isAuthenticated().should('contain', 'true'); 8 | cy.logout(); 9 | cy.getUser().should('not.exist'); 10 | cy.isAuthenticated().should('contain', 'false'); 11 | cy.getTokenSilently().then(() => cy.getError().should('be.visible')); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /cypress/e2e/multiple_clients.cy.js: -------------------------------------------------------------------------------- 1 | import { whenReady, shouldInclude, tolerance, get } from '../support/utils'; 2 | 3 | const login = instanceId => { 4 | get(`client-login-${instanceId}`).click(); 5 | cy.get('.login-card input[name=login]').clear().type('test'); 6 | cy.get('.login-card input[name=password]').clear().type('test'); 7 | 8 | cy.get('.login-submit').click(); 9 | // Need to click one more time to give consent. 10 | // It is actually a different button with the same class. 11 | cy.get('.login-submit').click(); 12 | }; 13 | 14 | describe('using multiple clients in the app', () => { 15 | beforeEach(() => { 16 | cy.visit('http://127.0.0.1:3000/multiple_clients.html'); 17 | get('client-logout-1').click(); 18 | cy.window().then(win => win.localStorage.clear()); 19 | cy.visit('http://127.0.0.1:3000/multiple_clients.html'); 20 | }); 21 | 22 | afterEach(cy.fixCookies); 23 | 24 | it('can log into just one client', () => { 25 | whenReady(); 26 | 27 | // Get a token for the exact client we log into and no more 28 | login(1); 29 | get('client-access-token-1').should('not.be.empty'); 30 | get('client-access-token-2').should('be.empty'); 31 | get('client-access-token-3').should('be.empty'); 32 | 33 | // Logging into a second client should not work 34 | get('client-token-2').click(); 35 | 36 | shouldInclude(get('client-error-2'), 'requested scopes not granted'); 37 | 38 | // Verify check session 39 | cy.reload(); 40 | whenReady(); 41 | get('client-access-token-1').should('not.be.empty'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/fixtures/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 8739, 3 | "name": "Jane", 4 | "email": "jane@example.com" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Leanne Graham", 5 | "username": "Bret", 6 | "email": "Sincere@april.biz", 7 | "address": { 8 | "street": "Kulas Light", 9 | "suite": "Apt. 556", 10 | "city": "Gwenborough", 11 | "zipcode": "92998-3874", 12 | "geo": { 13 | "lat": "-37.3159", 14 | "lng": "81.1496" 15 | } 16 | }, 17 | "phone": "1-770-736-8031 x56442", 18 | "website": "hildegard.org", 19 | "company": { 20 | "name": "Romaguera-Crona", 21 | "catchPhrase": "Multi-layered client-server neural-net", 22 | "bs": "harness real-time e-markets" 23 | } 24 | }, 25 | { 26 | "id": 2, 27 | "name": "Ervin Howell", 28 | "username": "Antonette", 29 | "email": "Shanna@melissa.tv", 30 | "address": { 31 | "street": "Victor Plains", 32 | "suite": "Suite 879", 33 | "city": "Wisokyburgh", 34 | "zipcode": "90566-7771", 35 | "geo": { 36 | "lat": "-43.9509", 37 | "lng": "-34.4618" 38 | } 39 | }, 40 | "phone": "010-692-6593 x09125", 41 | "website": "anastasia.net", 42 | "company": { 43 | "name": "Deckow-Crist", 44 | "catchPhrase": "Proactive didactic contingency", 45 | "bs": "synergize scalable supply-chains" 46 | } 47 | }, 48 | { 49 | "id": 3, 50 | "name": "Clementine Bauch", 51 | "username": "Samantha", 52 | "email": "Nathan@yesenia.net", 53 | "address": { 54 | "street": "Douglas Extension", 55 | "suite": "Suite 847", 56 | "city": "McKenziehaven", 57 | "zipcode": "59590-4157", 58 | "geo": { 59 | "lat": "-68.6102", 60 | "lng": "-47.0653" 61 | } 62 | }, 63 | "phone": "1-463-123-4447", 64 | "website": "ramiro.info", 65 | "company": { 66 | "name": "Romaguera-Jacobson", 67 | "catchPhrase": "Face to face bifurcated interface", 68 | "bs": "e-enable strategic applications" 69 | } 70 | }, 71 | { 72 | "id": 4, 73 | "name": "Patricia Lebsack", 74 | "username": "Karianne", 75 | "email": "Julianne.OConner@kory.org", 76 | "address": { 77 | "street": "Hoeger Mall", 78 | "suite": "Apt. 692", 79 | "city": "South Elvis", 80 | "zipcode": "53919-4257", 81 | "geo": { 82 | "lat": "29.4572", 83 | "lng": "-164.2990" 84 | } 85 | }, 86 | "phone": "493-170-9623 x156", 87 | "website": "kale.biz", 88 | "company": { 89 | "name": "Robel-Corkery", 90 | "catchPhrase": "Multi-tiered zero tolerance productivity", 91 | "bs": "transition cutting-edge web services" 92 | } 93 | }, 94 | { 95 | "id": 5, 96 | "name": "Chelsey Dietrich", 97 | "username": "Kamren", 98 | "email": "Lucio_Hettinger@annie.ca", 99 | "address": { 100 | "street": "Skiles Walks", 101 | "suite": "Suite 351", 102 | "city": "Roscoeview", 103 | "zipcode": "33263", 104 | "geo": { 105 | "lat": "-31.8129", 106 | "lng": "62.5342" 107 | } 108 | }, 109 | "phone": "(254)954-1289", 110 | "website": "demarco.info", 111 | "company": { 112 | "name": "Keebler LLC", 113 | "catchPhrase": "User-centric fault-tolerant solution", 114 | "bs": "revolutionize end-to-end systems" 115 | } 116 | }, 117 | { 118 | "id": 6, 119 | "name": "Mrs. Dennis Schulist", 120 | "username": "Leopoldo_Corkery", 121 | "email": "Karley_Dach@jasper.info", 122 | "address": { 123 | "street": "Norberto Crossing", 124 | "suite": "Apt. 950", 125 | "city": "South Christy", 126 | "zipcode": "23505-1337", 127 | "geo": { 128 | "lat": "-71.4197", 129 | "lng": "71.7478" 130 | } 131 | }, 132 | "phone": "1-477-935-8478 x6430", 133 | "website": "ola.org", 134 | "company": { 135 | "name": "Considine-Lockman", 136 | "catchPhrase": "Synchronised bottom-line interface", 137 | "bs": "e-enable innovative applications" 138 | } 139 | }, 140 | { 141 | "id": 7, 142 | "name": "Kurtis Weissnat", 143 | "username": "Elwyn.Skiles", 144 | "email": "Telly.Hoeger@billy.biz", 145 | "address": { 146 | "street": "Rex Trail", 147 | "suite": "Suite 280", 148 | "city": "Howemouth", 149 | "zipcode": "58804-1099", 150 | "geo": { 151 | "lat": "24.8918", 152 | "lng": "21.8984" 153 | } 154 | }, 155 | "phone": "210.067.6132", 156 | "website": "elvis.io", 157 | "company": { 158 | "name": "Johns Group", 159 | "catchPhrase": "Configurable multimedia task-force", 160 | "bs": "generate enterprise e-tailers" 161 | } 162 | }, 163 | { 164 | "id": 8, 165 | "name": "Nicholas Runolfsdottir V", 166 | "username": "Maxime_Nienow", 167 | "email": "Sherwood@rosamond.me", 168 | "address": { 169 | "street": "Ellsworth Summit", 170 | "suite": "Suite 729", 171 | "city": "Aliyaview", 172 | "zipcode": "45169", 173 | "geo": { 174 | "lat": "-14.3990", 175 | "lng": "-120.7677" 176 | } 177 | }, 178 | "phone": "586.493.6943 x140", 179 | "website": "jacynthe.com", 180 | "company": { 181 | "name": "Abernathy Group", 182 | "catchPhrase": "Implemented secondary concept", 183 | "bs": "e-enable extensible e-tailers" 184 | } 185 | }, 186 | { 187 | "id": 9, 188 | "name": "Glenna Reichert", 189 | "username": "Delphine", 190 | "email": "Chaim_McDermott@dana.io", 191 | "address": { 192 | "street": "Dayna Park", 193 | "suite": "Suite 449", 194 | "city": "Bartholomebury", 195 | "zipcode": "76495-3109", 196 | "geo": { 197 | "lat": "24.6463", 198 | "lng": "-168.8889" 199 | } 200 | }, 201 | "phone": "(775)976-6794 x41206", 202 | "website": "conrad.com", 203 | "company": { 204 | "name": "Yost and Sons", 205 | "catchPhrase": "Switchable contextually-based project", 206 | "bs": "aggregate real-time technologies" 207 | } 208 | }, 209 | { 210 | "id": 10, 211 | "name": "Clementina DuBuque", 212 | "username": "Moriah.Stanton", 213 | "email": "Rey.Padberg@karina.biz", 214 | "address": { 215 | "street": "Kattie Turnpike", 216 | "suite": "Suite 198", 217 | "city": "Lebsackbury", 218 | "zipcode": "31428-2261", 219 | "geo": { 220 | "lat": "-38.2386", 221 | "lng": "57.2232" 222 | } 223 | }, 224 | "phone": "024-648-3804", 225 | "website": "ambrose.net", 226 | "company": { 227 | "name": "Hoeger LLC", 228 | "catchPhrase": "Centralized empowering task-force", 229 | "bs": "target end-to-end models" 230 | } 231 | } 232 | ] 233 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | }; 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | import { whenReady } from './utils'; 2 | 3 | // *********************************************** 4 | // This example commands.js shows you how to 5 | // create various custom commands and overwrite 6 | // existing commands. 7 | // 8 | // For more comprehensive examples of custom 9 | // commands please read more here: 10 | // https://on.cypress.io/custom-commands 11 | // *********************************************** 12 | // 13 | // 14 | // -- This is a parent command -- 15 | 16 | const login = () => { 17 | cy.get('#login_redirect').click(); 18 | 19 | cy.get('.login-card input[name=login]').clear().type('test'); 20 | 21 | cy.get('.login-card input[name=password]').clear().type('test'); 22 | 23 | cy.get('.login-submit').click(); 24 | // Need to click one more time to give consent. 25 | // It is actually a different button with the same class. 26 | cy.get('.login-submit').click(); 27 | }; 28 | 29 | const handleCallback = () => { 30 | return cy 31 | .get('[data-cy=handle-redirect-callback]') 32 | .click() 33 | .get('[data-cy=profile]'); 34 | }; 35 | 36 | Cypress.Commands.add('login', () => { 37 | login(); 38 | 39 | return whenReady().then(() => handleCallback()); 40 | }); 41 | 42 | Cypress.Commands.add('handleRedirectCallback', () => handleCallback()); 43 | 44 | Cypress.Commands.add('logout', () => { 45 | cy.get('[data-cy=logout]').click(); 46 | // When hitting the Node OIDC v2/logout, we need to confirm logout 47 | cy.url().then(url => { 48 | if (url.indexOf('/v2/logout') > -1) { 49 | cy.get('button[name=logout]').click(); 50 | } 51 | }); 52 | }); 53 | 54 | Cypress.Commands.add('setSwitch', (name, value) => { 55 | // Can only use `check` or `uncheck` on an actual checkbox, but the switch 56 | // value we're given is for the label. Get the `for` attribute to find the actual 57 | // checkbox and return that instead. 58 | const checkbox = () => 59 | cy 60 | .get(`[data-cy=switch-${name}]`) 61 | .then($label => cy.get(`#${$label.attr('for')}`)); 62 | 63 | // These are forced because of the way the checkboxes on the playground are rendered 64 | // (they're covered by some UI to make them look pretty) 65 | !!value === true 66 | ? checkbox().check({ force: true }) 67 | : checkbox().uncheck({ force: true }); 68 | }); 69 | 70 | Cypress.Commands.add('setScope', scope => 71 | cy.get(`[data-cy=scope]`).clear().type(scope) 72 | ); 73 | 74 | Cypress.Commands.add('isAuthenticated', () => 75 | cy.get(`[data-cy=authenticated]`) 76 | ); 77 | 78 | Cypress.Commands.add('getUser', () => cy.get('[data-cy=profile]')); 79 | 80 | Cypress.Commands.add('getError', () => cy.get(`[data-cy=error]`)); 81 | 82 | Cypress.Commands.add('getAccessTokens', index => 83 | cy.get(index ? `[data-cy=access-token-${index}]` : '[data-cy=access-token]') 84 | ); 85 | 86 | Cypress.Commands.add('getTokenSilently', index => 87 | cy.get(index ? `[data-cy=get-token-${index}]` : `[data-cy=get-token]`).click() 88 | ); 89 | 90 | Cypress.Commands.add('loginNoCallback', () => { 91 | login(); 92 | 93 | return whenReady(); 94 | }); 95 | 96 | Cypress.Commands.add('resetTests', () => { 97 | cy.visit('http://127.0.0.1:3000'); 98 | cy.get('#reset-config').click(); 99 | cy.window().then(win => win.localStorage.clear()); 100 | cy.get('[data-cy=use-node-oidc-provider]').click(); 101 | cy.get('#logout').click(); 102 | }); 103 | 104 | Cypress.Commands.add('fixCookies', () => { 105 | // Temporary fix for https://github.com/cypress-io/cypress/issues/6375 106 | if (Cypress.isBrowser('firefox')) { 107 | cy.getCookies({ log: false }).then(cookies => 108 | cookies.forEach(cookie => cy.clearCookie(cookie.name, { log: false })) 109 | ); 110 | cy.log('clearCookies'); 111 | } else { 112 | cy.clearCookies(); 113 | } 114 | }); 115 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/support/utils.js: -------------------------------------------------------------------------------- 1 | module.exports.shouldBeUndefined = e => expect(e).to.be.undefined; 2 | module.exports.shouldNotBeUndefined = e => expect(e).to.not.be.undefined; 3 | module.exports.shouldBe = (expected, actual) => expect(actual).to.eq(expected); 4 | 5 | module.exports.shouldInclude = (expected, actual) => 6 | expect(actual).to.include(actual); 7 | 8 | // Gets an element using its `data-cy` attribute name 9 | module.exports.get = attrId => cy.get(`[data-cy=${attrId}]`); 10 | 11 | module.exports.shouldNotBe = (expected, actual) => 12 | expect(actual).to.not.eq(expected); 13 | 14 | module.exports.whenReady = () => 15 | cy.get('#loaded', { timeout: 10000 }).then(() => cy.window()); 16 | 17 | /** 18 | * Returns true if a is within b +- tolerance 19 | * @param {*} a The value to check 20 | * @param {*} b The value to compare against 21 | * @param {*} tolerance The tolerance value 22 | */ 23 | module.exports.tolerance = (a, b, tolerance) => { 24 | if (a >= b - tolerance && a <= b + tolerance) { 25 | return true; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "target": "es5", 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "moduleResolution": "node", 6 | "compilerOptions": { 7 | "strict": true, 8 | "baseUrl": "../node_modules", 9 | "types": ["cypress", "node"], 10 | "noImplicitAny": false 11 | }, 12 | "include": ["**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #795E26; 3 | --dark-hl-0: #DCDCAA; 4 | --light-hl-1: #000000; 5 | --dark-hl-1: #D4D4D4; 6 | --light-hl-2: #A31515; 7 | --dark-hl-2: #CE9178; 8 | --light-hl-3: #800000; 9 | --dark-hl-3: #808080; 10 | --light-hl-4: #800000; 11 | --dark-hl-4: #569CD6; 12 | --light-hl-5: #000000FF; 13 | --dark-hl-5: #D4D4D4; 14 | --light-hl-6: #E50000; 15 | --dark-hl-6: #9CDCFE; 16 | --light-hl-7: #0000FF; 17 | --dark-hl-7: #CE9178; 18 | --light-hl-8: #AF00DB; 19 | --dark-hl-8: #C586C0; 20 | --light-hl-9: #001080; 21 | --dark-hl-9: #9CDCFE; 22 | --light-hl-10: #008000; 23 | --dark-hl-10: #6A9955; 24 | --light-hl-11: #0000FF; 25 | --dark-hl-11: #569CD6; 26 | --light-hl-12: #0070C1; 27 | --dark-hl-12: #4FC1FF; 28 | --light-hl-13: #267F99; 29 | --dark-hl-13: #4EC9B0; 30 | --light-code-background: #FFFFFF; 31 | --dark-code-background: #1E1E1E; 32 | } 33 | 34 | @media (prefers-color-scheme: light) { :root { 35 | --hl-0: var(--light-hl-0); 36 | --hl-1: var(--light-hl-1); 37 | --hl-2: var(--light-hl-2); 38 | --hl-3: var(--light-hl-3); 39 | --hl-4: var(--light-hl-4); 40 | --hl-5: var(--light-hl-5); 41 | --hl-6: var(--light-hl-6); 42 | --hl-7: var(--light-hl-7); 43 | --hl-8: var(--light-hl-8); 44 | --hl-9: var(--light-hl-9); 45 | --hl-10: var(--light-hl-10); 46 | --hl-11: var(--light-hl-11); 47 | --hl-12: var(--light-hl-12); 48 | --hl-13: var(--light-hl-13); 49 | --code-background: var(--light-code-background); 50 | } } 51 | 52 | @media (prefers-color-scheme: dark) { :root { 53 | --hl-0: var(--dark-hl-0); 54 | --hl-1: var(--dark-hl-1); 55 | --hl-2: var(--dark-hl-2); 56 | --hl-3: var(--dark-hl-3); 57 | --hl-4: var(--dark-hl-4); 58 | --hl-5: var(--dark-hl-5); 59 | --hl-6: var(--dark-hl-6); 60 | --hl-7: var(--dark-hl-7); 61 | --hl-8: var(--dark-hl-8); 62 | --hl-9: var(--dark-hl-9); 63 | --hl-10: var(--dark-hl-10); 64 | --hl-11: var(--dark-hl-11); 65 | --hl-12: var(--dark-hl-12); 66 | --hl-13: var(--dark-hl-13); 67 | --code-background: var(--dark-code-background); 68 | } } 69 | 70 | :root[data-theme='light'] { 71 | --hl-0: var(--light-hl-0); 72 | --hl-1: var(--light-hl-1); 73 | --hl-2: var(--light-hl-2); 74 | --hl-3: var(--light-hl-3); 75 | --hl-4: var(--light-hl-4); 76 | --hl-5: var(--light-hl-5); 77 | --hl-6: var(--light-hl-6); 78 | --hl-7: var(--light-hl-7); 79 | --hl-8: var(--light-hl-8); 80 | --hl-9: var(--light-hl-9); 81 | --hl-10: var(--light-hl-10); 82 | --hl-11: var(--light-hl-11); 83 | --hl-12: var(--light-hl-12); 84 | --hl-13: var(--light-hl-13); 85 | --code-background: var(--light-code-background); 86 | } 87 | 88 | :root[data-theme='dark'] { 89 | --hl-0: var(--dark-hl-0); 90 | --hl-1: var(--dark-hl-1); 91 | --hl-2: var(--dark-hl-2); 92 | --hl-3: var(--dark-hl-3); 93 | --hl-4: var(--dark-hl-4); 94 | --hl-5: var(--dark-hl-5); 95 | --hl-6: var(--dark-hl-6); 96 | --hl-7: var(--dark-hl-7); 97 | --hl-8: var(--dark-hl-8); 98 | --hl-9: var(--dark-hl-9); 99 | --hl-10: var(--dark-hl-10); 100 | --hl-11: var(--dark-hl-11); 101 | --hl-12: var(--dark-hl-12); 102 | --hl-13: var(--dark-hl-13); 103 | --code-background: var(--dark-code-background); 104 | } 105 | 106 | .hl-0 { color: var(--hl-0); } 107 | .hl-1 { color: var(--hl-1); } 108 | .hl-2 { color: var(--hl-2); } 109 | .hl-3 { color: var(--hl-3); } 110 | .hl-4 { color: var(--hl-4); } 111 | .hl-5 { color: var(--hl-5); } 112 | .hl-6 { color: var(--hl-6); } 113 | .hl-7 { color: var(--hl-7); } 114 | .hl-8 { color: var(--hl-8); } 115 | .hl-9 { color: var(--hl-9); } 116 | .hl-10 { color: var(--hl-10); } 117 | .hl-11 { color: var(--hl-11); } 118 | .hl-12 { color: var(--hl-12); } 119 | .hl-13 { color: var(--hl-13); } 120 | pre, code { background: var(--code-background); } 121 | -------------------------------------------------------------------------------- /docs/assets/navigation.js: -------------------------------------------------------------------------------- 1 | window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAE43WUW+bMBAH8O/i52hto7Xb8jalVRU10SLarg/VHhxzgFVjM/uQxqZ+9wmSqGDMmdfc3z/b4MN5/ccQ/iBbse81FpdrJUEjW7CKY8FWTCjuHLiLXvFTgaViC/YmdcpWV8uv74uBARql4CiNvrPW2LDlhShzzUUBD9CMoXOFGn0PGqwUE0vpVyllo3dQGtt0M46ZQZlytkZw9YjG8hwmrFGE8nYZT+B3LS2kEzv0E6QmnZM6TyCz4Ion8wZTr3AqSel7U9XVmmsBSk0uNxCKmk+yBFMjJfYjlEdTc5VnB4HR7a+x3jm12I+q7Qz3YUiNYDMuhp14ig3R5fWNhxor/3attueWl9Oql6PYWxAmhbR78UGvH6Cge8Au8ygVaFQNtfOJ7Bz+RWLRHYQ5vh+mJth4XdzjNoHu9QYTD3ATf3Zbk5uaPCuDRJx6tiqufYQo8NjHRmcyp8hxLIpuTS511OynKDKBVFoQGFVDwdlwAq5WGHePOYrtztSdRtu7DrGpzpdhV/HGX377cnW9DFyotxx5UDnVZjntddU+jCB0Ls6S+EFBUGkLMcH/NvwEezAOEnCV0W7kRuKx2R6g2XEtM3AYfBd+PebteHOAvTWlHC+1X4s5x4tYp5WRGqf2HgzF5BfLqwrS6eM3CsREYYEjBP90ZrUWXYNdjEJD9ebz+6//zvYyhMEKAAA=" -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: './', 3 | testEnvironment: './jest.environment', 4 | moduleFileExtensions: ['ts', 'js'], 5 | testMatch: ['**/__tests__/**/*.test.ts'], 6 | coverageProvider: 'v8', 7 | coveragePathIgnorePatterns: [ 8 | '/node_modules/', 9 | './cypress', 10 | './jest.config.js', 11 | './__tests__', 12 | './src/index.ts' 13 | ], 14 | reporters: [ 15 | 'default', 16 | ['jest-junit', { outputDirectory: 'test-results/jest' }] 17 | ], 18 | coverageReporters: ['lcov', 'text', 'text-summary'], 19 | preset: 'ts-jest', 20 | setupFiles: ['jest-localstorage-mock', './jest.setup.js'], 21 | globals: { 22 | 'ts-jest': { 23 | tsconfig: './tsconfig.test.json' 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /jest.environment.js: -------------------------------------------------------------------------------- 1 | const JSDOMEnvironment = require('jest-environment-jsdom').default; 2 | const util = require('util'); 3 | 4 | /** 5 | * Custom Jest Environment based on JSDOMEnvironment to support TextEncoder and TextDecoder. 6 | * 7 | * ref: https://github.com/jsdom/jsdom/issues/2524 8 | */ 9 | class CustomJSDOMEnvironment extends JSDOMEnvironment { 10 | constructor(config, context) { 11 | super(config, context); 12 | } 13 | 14 | async setup() { 15 | await super.setup(); 16 | this.global.TextEncoder = util.TextEncoder; 17 | this.global.TextDecoder = util.TextDecoder; 18 | } 19 | } 20 | 21 | module.exports = CustomJSDOMEnvironment; 22 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require('jest-fetch-mock').enableMocks(); 2 | -------------------------------------------------------------------------------- /opslevel.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | repository: 4 | owner: dx_sdks 5 | tier: 6 | tags: 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Auth0", 3 | "name": "@auth0/auth0-spa-js", 4 | "description": "Auth0 SDK for Single Page Applications using Authorization Code Grant Flow with PKCE", 5 | "license": "MIT", 6 | "version": "2.2.0", 7 | "main": "dist/lib/auth0-spa-js.cjs.js", 8 | "types": "dist/typings/index.d.ts", 9 | "module": "dist/auth0-spa-js.production.esm.js", 10 | "scripts": { 11 | "dev": "rimraf dist && rollup -c --watch", 12 | "start": "npm run dev", 13 | "docs": "typedoc --options ./typedoc.js src", 14 | "build": "rimraf dist && rollup -m -c --environment NODE_ENV:production && npm run test:es-check", 15 | "build:stats": "rimraf dist && rollup -m -c --environment NODE_ENV:production --environment WITH_STATS:true && npm run test:es-check && open bundle-stats/index.html", 16 | "lint:security": "eslint ./src --ext ts --no-eslintrc --config ./.eslintrc.security", 17 | "test": "jest --coverage --silent", 18 | "test:watch": "jest --coverage --watch", 19 | "test:debug": "node --inspect node_modules/.bin/jest --runInBand", 20 | "test:open:integration": "cypress open", 21 | "test:watch:integration": "concurrently --raw npm:dev 'npm:test:open:integration'", 22 | "test:es-check": "npm run test:es-check:es2017 && npm run test:es-check:es2017:module", 23 | "test:es-check:es2017": "es-check es2017 'dist/auth0-spa-js.production.js'", 24 | "test:es-check:es2017:module": "es-check es2017 'dist/auth0-spa-js.production.esm.js' --module ", 25 | "test:integration:server": "npm run dev", 26 | "test:integration:tests": "wait-on http://localhost:3000/ && cypress run", 27 | "test:integration": "concurrently --raw --kill-others --success first npm:test:integration:server npm:test:integration:tests", 28 | "serve:coverage": "serve coverage/lcov-report -n", 29 | "serve:stats": "serve bundle-stats -n", 30 | "print-bundle-size": "node ./scripts/print-bundle-size.mjs", 31 | "prepack": "npm run build && node ./scripts/prepack", 32 | "publish:cdn": "ccu --trace" 33 | }, 34 | "devDependencies": { 35 | "@auth0/component-cdn-uploader": "github:auth0/component-cdn-uploader#v2.2.2", 36 | "@rollup/plugin-replace": "^4.0.0", 37 | "@types/cypress": "^1.1.3", 38 | "@types/jest": "^28.1.7", 39 | "@typescript-eslint/eslint-plugin-tslint": "^5.33.1", 40 | "@typescript-eslint/parser": "^5.33.1", 41 | "browser-tabs-lock": "^1.2.15", 42 | "browserstack-cypress-cli": "1.28.0", 43 | "cli-table": "^0.3.6", 44 | "concurrently": "^7.3.0", 45 | "cypress": "13.6.1", 46 | "es-check": "^7.0.1", 47 | "es-cookie": "~1.3.2", 48 | "eslint": "^8.22.0", 49 | "eslint-plugin-security": "^1.5.0", 50 | "gzip-size": "^7.0.0", 51 | "husky": "^7.0.4", 52 | "idtoken-verifier": "^2.2.2", 53 | "jest": "^28.1.3", 54 | "jest-environment-jsdom": "^28.1.3", 55 | "jest-fetch-mock": "^3.0.3", 56 | "jest-junit": "^14.0.0", 57 | "jest-localstorage-mock": "^2.4.22", 58 | "jsonwebtoken": "^9.0.0", 59 | "oidc-provider": "^7.14.0", 60 | "prettier": "^2.7.1", 61 | "pretty-quick": "^3.1.2", 62 | "rimraf": "^3.0.2", 63 | "rollup": "^2.78.0", 64 | "rollup-plugin-analyzer": "^4.0.0", 65 | "rollup-plugin-commonjs": "^10.1.0", 66 | "rollup-plugin-dev": "^1.1.3", 67 | "rollup-plugin-livereload": "^2.0.5", 68 | "rollup-plugin-node-resolve": "^5.2.0", 69 | "rollup-plugin-sourcemaps": "^0.6.3", 70 | "rollup-plugin-terser": "^7.0.2", 71 | "rollup-plugin-typescript2": "^0.36.0", 72 | "rollup-plugin-visualizer": "^5.7.1", 73 | "rollup-plugin-web-worker-loader": "^1.6.1", 74 | "serve": "^14.0.1", 75 | "ts-jest": "^28.0.8", 76 | "tslib": "^2.4.0", 77 | "typedoc": "^0.25.1", 78 | "typescript": "^4.7.4", 79 | "wait-on": "^7.2.0" 80 | }, 81 | "files": [ 82 | "src", 83 | "dist" 84 | ], 85 | "repository": { 86 | "type": "git", 87 | "url": "git://github.com/auth0/auth0-spa-js.git" 88 | }, 89 | "bugs": { 90 | "url": "https://github.com/auth0/auth0-spa-js/issues" 91 | }, 92 | "homepage": "https://github.com/auth0/auth0-spa-js#readme", 93 | "keywords": [ 94 | "auth0", 95 | "login", 96 | "Authorization Code Grant Flow", 97 | "PKCE", 98 | "Single Page Application authentication", 99 | "SPA authentication" 100 | ], 101 | "ccu": { 102 | "name": "auth0-spa-js", 103 | "cdn": "https://cdn.auth0.com", 104 | "mainBundleFile": "auth0-spa-js.production.js", 105 | "bucket": "assets.us.auth0.com", 106 | "localPath": "dist", 107 | "digest": { 108 | "hashes": [ 109 | "sha384" 110 | ], 111 | "extensions": [ 112 | ".js" 113 | ] 114 | } 115 | }, 116 | "husky": { 117 | "hooks": { 118 | "pre-commit": "pretty-quick --staged" 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import typescript from 'rollup-plugin-typescript2'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import sourcemaps from 'rollup-plugin-sourcemaps'; 6 | import livereload from 'rollup-plugin-livereload'; 7 | import visualizer from 'rollup-plugin-visualizer'; 8 | import webWorkerLoader from 'rollup-plugin-web-worker-loader'; 9 | import replace from '@rollup/plugin-replace'; 10 | import analyze from 'rollup-plugin-analyzer'; 11 | import dev from 'rollup-plugin-dev'; 12 | import { createApp } from './scripts/oidc-provider'; 13 | 14 | import pkg from './package.json'; 15 | 16 | const EXPORT_NAME = 'auth0'; 17 | 18 | const isProduction = process.env.NODE_ENV === 'production'; 19 | const shouldGenerateStats = process.env.WITH_STATS === 'true'; 20 | const defaultDevPort = 3000; 21 | const serverPort = process.env.DEV_PORT || defaultDevPort; 22 | 23 | const visualizerOptions = { 24 | filename: 'bundle-stats/index.html' 25 | }; 26 | 27 | const getPlugins = shouldMinify => { 28 | return [ 29 | webWorkerLoader({ 30 | targetPlatform: 'browser', 31 | sourceMap: !isProduction, 32 | preserveSource: !isProduction, 33 | pattern: /^(?!(?:[a-zA-Z]:)|\/).+\.worker\.ts$/ 34 | }), 35 | resolve({ 36 | browser: true 37 | }), 38 | commonjs(), 39 | typescript({ 40 | clean: true, 41 | useTsconfigDeclarationDir: true, 42 | tsconfigOverride: { 43 | noEmit: false, 44 | sourceMap: true, 45 | compilerOptions: { 46 | lib: ['dom', 'es6'] 47 | } 48 | } 49 | }), 50 | replace({ 51 | 'process.env.NODE_ENV': `'${process.env.NODE_ENV}'`, 52 | preventAssignment: false 53 | }), 54 | shouldMinify 55 | ? terser() 56 | : terser({ 57 | compress: false, 58 | mangle: false, 59 | format: { beautify: true } 60 | }), 61 | sourcemaps() 62 | ]; 63 | }; 64 | 65 | const getStatsPlugins = () => { 66 | if (!shouldGenerateStats) return []; 67 | return [visualizer(visualizerOptions), analyze({ summaryOnly: true })]; 68 | }; 69 | 70 | let bundles = [ 71 | { 72 | input: 'src/worker/token.worker.ts', 73 | output: { 74 | name: EXPORT_NAME, 75 | file: 'dist/auth0-spa-js.worker.development.js', 76 | format: 'umd', 77 | sourcemap: true 78 | }, 79 | plugins: [...getPlugins(false)], 80 | watch: { 81 | clearScreen: false 82 | } 83 | }, 84 | { 85 | input: 'src/index.ts', 86 | output: { 87 | name: EXPORT_NAME, 88 | file: 'dist/auth0-spa-js.development.js', 89 | format: 'umd', 90 | sourcemap: true 91 | }, 92 | plugins: [ 93 | ...getPlugins(false), 94 | !isProduction && 95 | dev({ 96 | dirs: ['dist', 'static'], 97 | port: serverPort, 98 | extend(app, modules) { 99 | app.use(modules.mount(createApp({ port: serverPort }))); 100 | } 101 | }), 102 | !isProduction && livereload() 103 | ], 104 | watch: { 105 | clearScreen: false 106 | } 107 | } 108 | ]; 109 | 110 | if (isProduction) { 111 | bundles = bundles.concat( 112 | { 113 | input: 'src/worker/token.worker.ts', 114 | output: [ 115 | { 116 | name: EXPORT_NAME, 117 | file: 'dist/auth0-spa-js.worker.production.js', 118 | format: 'umd' 119 | } 120 | ], 121 | plugins: [...getPlugins(isProduction), ...getStatsPlugins()] 122 | }, 123 | { 124 | input: 'src/index.ts', 125 | output: [ 126 | { 127 | name: EXPORT_NAME, 128 | file: 'dist/auth0-spa-js.production.js', 129 | format: 'umd' 130 | } 131 | ], 132 | plugins: [...getPlugins(isProduction), ...getStatsPlugins()] 133 | }, 134 | { 135 | input: 'src/index.ts', 136 | output: [ 137 | { 138 | file: pkg.module, 139 | format: 'esm' 140 | } 141 | ], 142 | plugins: getPlugins(isProduction) 143 | }, 144 | { 145 | input: 'src/index.ts', 146 | output: [ 147 | { 148 | name: EXPORT_NAME, 149 | file: pkg.main, 150 | format: 'cjs' 151 | } 152 | ], 153 | plugins: getPlugins(false) 154 | } 155 | ); 156 | } 157 | export default bundles; 158 | -------------------------------------------------------------------------------- /scripts/exec.js: -------------------------------------------------------------------------------- 1 | const exec = require('child_process').exec; 2 | 3 | module.exports = cmd => { 4 | return new Promise((resolve, reject) => { 5 | exec(cmd, (error, stdout, stderr) => { 6 | if (error) { 7 | reject(error); 8 | } 9 | resolve(stdout ? stdout : stderr); 10 | }); 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /scripts/oidc-provider.js: -------------------------------------------------------------------------------- 1 | import { Provider, interactionPolicy } from 'oidc-provider'; 2 | 3 | const { base, Prompt, Check } = interactionPolicy; 4 | const policy = base(); 5 | 6 | policy.add( 7 | new Prompt( 8 | { name: 'noop', requestable: false }, 9 | new Check('foo', 'bar', ctx => { 10 | if (ctx.query?.scope?.includes('offline_access')) { 11 | ctx.oidc.params.scope = `${ctx.oidc.params.scope} offline_access`; 12 | } 13 | return Check.NO_NEED_TO_PROMPT; 14 | }) 15 | ), 16 | 0 17 | ); 18 | 19 | const config = { 20 | clients: [ 21 | { 22 | client_id: 'testing', 23 | redirect_uris: ['http://127.0.0.1:3000', 'http://localhost:3000'], 24 | token_endpoint_auth_method: 'none', 25 | grant_types: ['authorization_code', 'refresh_token'] 26 | }, 27 | { 28 | client_id: 'multi-client-1', 29 | redirect_uris: [ 30 | 'http://127.0.0.1:3000/multiple_clients.html', 31 | 'http://localhost:3000/multiple_clients.html' 32 | ], 33 | token_endpoint_auth_method: 'none', 34 | grant_types: ['authorization_code', 'refresh_token'] 35 | }, 36 | { 37 | client_id: 'multi-client-2', 38 | redirect_uris: [ 39 | 'http://127.0.0.1:3000/multiple_clients.html', 40 | 'http://localhost:3000/multiple_clients.html' 41 | ], 42 | token_endpoint_auth_method: 'none', 43 | grant_types: ['authorization_code', 'refresh_token'] 44 | }, 45 | { 46 | client_id: 'multi-client-3', 47 | redirect_uris: [ 48 | 'http://127.0.0.1:3000/multiple_clients.html', 49 | 'http://localhost:3000/multiple_clients.html' 50 | ], 51 | token_endpoint_auth_method: 'none', 52 | grant_types: ['authorization_code', 'refresh_token'] 53 | } 54 | ], 55 | claims: { 56 | org_id: null 57 | }, 58 | routes: { 59 | authorization: '/authorize', // lgtm [js/hardcoded-credentials] 60 | token: '/oauth/token', 61 | end_session: '/v2/logout' 62 | }, 63 | scopes: ['openid', 'offline_access'], 64 | clientBasedCORS(ctx, origin, client) { 65 | return true; 66 | }, 67 | features: { 68 | webMessageResponseMode: { 69 | enabled: true 70 | }, 71 | claimsParameter: { 72 | enabled: true 73 | } 74 | }, 75 | rotateRefreshToken: true, 76 | interactions: { 77 | policy 78 | }, 79 | findAccount(ctx, id) { 80 | return { 81 | accountId: id, 82 | claims(use, scope, claims) { 83 | return { 84 | sub: id, 85 | ...(claims?.org_id ? { org_id: claims.org_id.values[0] } : null) 86 | }; 87 | } 88 | }; 89 | } 90 | }; 91 | 92 | export function createApp(opts) { 93 | const issuer = `http://127.0.0.1:${opts.port || 3000}/`; 94 | const provider = new Provider(issuer, config); 95 | 96 | provider.use(async (ctx, next) => { 97 | await next(); 98 | 99 | if (ctx.oidc?.route === 'end_session_success') { 100 | ctx.redirect('http://127.0.0.1:3000'); 101 | } 102 | }); 103 | 104 | return provider.app; 105 | } 106 | -------------------------------------------------------------------------------- /scripts/prepack.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { join } = require('path'); 3 | const rimraf = require('rimraf'); 4 | 5 | const source = './dist/typings/src'; 6 | const dest = './dist/typings'; 7 | 8 | if (!fs.existsSync(source)) { 9 | return; 10 | } 11 | 12 | const files = fs.readdirSync(source, { 13 | withFileTypes: true, 14 | }); 15 | 16 | let fileCount = 0; 17 | 18 | files.forEach((file) => { 19 | if (file.isFile()) { 20 | fs.copyFileSync(join(source, file.name), join(dest, file.name)); 21 | fileCount++; 22 | } 23 | }); 24 | 25 | rimraf.sync(source); 26 | 27 | console.log(`Moved ${fileCount} type definition files`); 28 | -------------------------------------------------------------------------------- /scripts/print-bundle-size.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import Table from 'cli-table'; 3 | import { gzipSizeFromFileSync } from 'gzip-size'; 4 | 5 | const toKb = b => `${(b / Math.pow(1024, 1)).toFixed(2)} kb`; 6 | 7 | const table = new Table({ 8 | head: ['File', 'Size', 'GZIP size'] 9 | }); 10 | 11 | if (fs.existsSync('./dist')) { 12 | fs.readdirSync('./dist') 13 | .filter(f => f.endsWith('.js')) 14 | .forEach(f => { 15 | const path = `./dist/${f}`; 16 | table.push([ 17 | f, 18 | toKb(fs.statSync(path).size), 19 | toKb(gzipSizeFromFileSync(path)) 20 | ]); 21 | }); 22 | 23 | console.log(table.toString()); 24 | } else { 25 | console.log(`Can't print bundle size because ./dist doesn't exist.`); 26 | } 27 | -------------------------------------------------------------------------------- /src/Auth0Client.utils.ts: -------------------------------------------------------------------------------- 1 | import { ICache, InMemoryCache, LocalStorageCache } from './cache'; 2 | import { 3 | Auth0ClientOptions, 4 | AuthorizationParams, 5 | AuthorizeOptions, 6 | LogoutOptions 7 | } from './global'; 8 | import { getUniqueScopes } from './scope'; 9 | 10 | /** 11 | * @ignore 12 | */ 13 | export const GET_TOKEN_SILENTLY_LOCK_KEY = 'auth0.lock.getTokenSilently'; 14 | 15 | /** 16 | * @ignore 17 | */ 18 | export const buildOrganizationHintCookieName = (clientId: string) => 19 | `auth0.${clientId}.organization_hint`; 20 | 21 | /** 22 | * @ignore 23 | */ 24 | export const OLD_IS_AUTHENTICATED_COOKIE_NAME = 'auth0.is.authenticated'; 25 | 26 | /** 27 | * @ignore 28 | */ 29 | export const buildIsAuthenticatedCookieName = (clientId: string) => 30 | `auth0.${clientId}.is.authenticated`; 31 | 32 | /** 33 | * @ignore 34 | */ 35 | const cacheLocationBuilders: Record ICache> = { 36 | memory: () => new InMemoryCache().enclosedCache, 37 | localstorage: () => new LocalStorageCache() 38 | }; 39 | 40 | /** 41 | * @ignore 42 | */ 43 | export const cacheFactory = (location: string) => { 44 | return cacheLocationBuilders[location]; 45 | }; 46 | 47 | /** 48 | * @ignore 49 | */ 50 | export const getAuthorizeParams = ( 51 | clientOptions: Auth0ClientOptions & { 52 | authorizationParams: AuthorizationParams; 53 | }, 54 | scope: string, 55 | authorizationParams: AuthorizationParams, 56 | state: string, 57 | nonce: string, 58 | code_challenge: string, 59 | redirect_uri: string | undefined, 60 | response_mode: string | undefined 61 | ): AuthorizeOptions => { 62 | return { 63 | client_id: clientOptions.clientId, 64 | ...clientOptions.authorizationParams, 65 | ...authorizationParams, 66 | scope: getUniqueScopes(scope, authorizationParams.scope), 67 | response_type: 'code', 68 | response_mode: response_mode || 'query', 69 | state, 70 | nonce, 71 | redirect_uri: 72 | redirect_uri || clientOptions.authorizationParams.redirect_uri, 73 | code_challenge, 74 | code_challenge_method: 'S256' 75 | }; 76 | }; 77 | 78 | /** 79 | * @ignore 80 | * 81 | * Function used to provide support for the deprecated onRedirect through openUrl. 82 | */ 83 | export const patchOpenUrlWithOnRedirect = < 84 | T extends Pick 85 | >( 86 | options: T 87 | ) => { 88 | const { openUrl, onRedirect, ...originalOptions } = options; 89 | 90 | const result = { 91 | ...originalOptions, 92 | openUrl: openUrl === false || openUrl ? openUrl : onRedirect 93 | }; 94 | 95 | return result as T; 96 | }; 97 | -------------------------------------------------------------------------------- /src/TokenExchange.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the configuration options required for initiating a Custom Token Exchange request 3 | * following RFC 8693 specifications. 4 | * 5 | * @see {@link https://www.rfc-editor.org/rfc/rfc8693 | RFC 8693: OAuth 2.0 Token Exchange} 6 | */ 7 | export type CustomTokenExchangeOptions = { 8 | /** 9 | * The type identifier for the subject token being exchanged 10 | * 11 | * @pattern 12 | * - Must be a namespaced URI under your organization's control 13 | * - Forbidden patterns: 14 | * - `^urn:ietf:params:oauth:*` (IETF reserved) 15 | * - `^https:\/\/auth0\.com/*` (Auth0 reserved) 16 | * - `^urn:auth0:*` (Auth0 reserved) 17 | * 18 | * @example 19 | * "urn:acme:legacy-system-token" 20 | * "https://api.yourcompany.com/token-type/v1" 21 | */ 22 | subject_token_type: string; 23 | 24 | /** 25 | * The opaque token value being exchanged for Auth0 tokens 26 | * 27 | * @security 28 | * - Must be validated in Auth0 Actions using strong cryptographic verification 29 | * - Implement replay attack protection 30 | * - Recommended validation libraries: `jose`, `jsonwebtoken` 31 | * 32 | * @example 33 | * "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 34 | */ 35 | subject_token: string; 36 | 37 | /** 38 | * The target audience for the requested Auth0 token 39 | * 40 | * @remarks 41 | * Must match exactly with an API identifier configured in your Auth0 tenant 42 | * 43 | * @example 44 | * "https://api.your-service.com/v1" 45 | */ 46 | audience: string; 47 | 48 | /** 49 | * Space-separated list of OAuth 2.0 scopes being requested 50 | * 51 | * @remarks 52 | * Subject to API authorization policies configured in Auth0 53 | * 54 | * @example 55 | * "openid profile email read:data write:data" 56 | */ 57 | scope?: string; 58 | 59 | /** 60 | * Additional custom parameters for Auth0 Action processing 61 | * 62 | * @remarks 63 | * Accessible in Action code via `event.request.body` 64 | * 65 | * @example 66 | * ```typescript 67 | * { 68 | * custom_parameter: "session_context", 69 | * device_fingerprint: "a3d8f7...", 70 | * } 71 | * ``` 72 | */ 73 | [key: string]: unknown; 74 | }; 75 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { TokenEndpointOptions, TokenEndpointResponse } from './global'; 2 | import { DEFAULT_AUTH0_CLIENT } from './constants'; 3 | import { getJSON } from './http'; 4 | import { createQueryParams } from './utils'; 5 | 6 | export async function oauthToken( 7 | { 8 | baseUrl, 9 | timeout, 10 | audience, 11 | scope, 12 | auth0Client, 13 | useFormData, 14 | ...options 15 | }: TokenEndpointOptions, 16 | worker?: Worker 17 | ) { 18 | const body = useFormData 19 | ? createQueryParams(options) 20 | : JSON.stringify(options); 21 | 22 | return await getJSON( 23 | `${baseUrl}/oauth/token`, 24 | timeout, 25 | audience || 'default', 26 | scope, 27 | { 28 | method: 'POST', 29 | body, 30 | headers: { 31 | 'Content-Type': useFormData 32 | ? 'application/x-www-form-urlencoded' 33 | : 'application/json', 34 | 'Auth0-Client': btoa( 35 | JSON.stringify(auth0Client || DEFAULT_AUTH0_CLIENT) 36 | ) 37 | } 38 | }, 39 | worker, 40 | useFormData 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/cache/cache-localstorage.ts: -------------------------------------------------------------------------------- 1 | import { ICache, Cacheable, CACHE_KEY_PREFIX, MaybePromise } from './shared'; 2 | 3 | export class LocalStorageCache implements ICache { 4 | public set(key: string, entry: T) { 5 | localStorage.setItem(key, JSON.stringify(entry)); 6 | } 7 | 8 | public get(key: string): MaybePromise { 9 | const json = window.localStorage.getItem(key); 10 | 11 | if (!json) return; 12 | 13 | try { 14 | const payload = JSON.parse(json) as T; 15 | return payload; 16 | /* c8 ignore next 3 */ 17 | } catch (e) { 18 | return; 19 | } 20 | } 21 | 22 | public remove(key: string) { 23 | localStorage.removeItem(key); 24 | } 25 | 26 | public allKeys() { 27 | return Object.keys(window.localStorage).filter(key => 28 | key.startsWith(CACHE_KEY_PREFIX) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/cache/cache-manager.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_NOW_PROVIDER } from '../constants'; 2 | import { CacheKeyManifest } from './key-manifest'; 3 | 4 | import { 5 | CacheEntry, 6 | ICache, 7 | CacheKey, 8 | CACHE_KEY_PREFIX, 9 | WrappedCacheEntry, 10 | DecodedToken, 11 | CACHE_KEY_ID_TOKEN_SUFFIX, 12 | IdTokenEntry 13 | } from './shared'; 14 | 15 | const DEFAULT_EXPIRY_ADJUSTMENT_SECONDS = 0; 16 | 17 | export class CacheManager { 18 | private nowProvider: () => number | Promise; 19 | 20 | constructor( 21 | private cache: ICache, 22 | private keyManifest?: CacheKeyManifest, 23 | nowProvider?: () => number | Promise 24 | ) { 25 | this.nowProvider = nowProvider || DEFAULT_NOW_PROVIDER; 26 | } 27 | 28 | async setIdToken( 29 | clientId: string, 30 | idToken: string, 31 | decodedToken: DecodedToken 32 | ): Promise { 33 | const cacheKey = this.getIdTokenCacheKey(clientId); 34 | await this.cache.set(cacheKey, { 35 | id_token: idToken, 36 | decodedToken 37 | }); 38 | await this.keyManifest?.add(cacheKey); 39 | } 40 | 41 | async getIdToken(cacheKey: CacheKey): Promise { 42 | const entry = await this.cache.get( 43 | this.getIdTokenCacheKey(cacheKey.clientId) 44 | ); 45 | 46 | if (!entry && cacheKey.scope && cacheKey.audience) { 47 | const entryByScope = await this.get(cacheKey); 48 | 49 | if (!entryByScope) { 50 | return; 51 | } 52 | 53 | if (!entryByScope.id_token || !entryByScope.decodedToken) { 54 | return; 55 | } 56 | 57 | return { 58 | id_token: entryByScope.id_token, 59 | decodedToken: entryByScope.decodedToken 60 | }; 61 | } 62 | 63 | if (!entry) { 64 | return; 65 | } 66 | 67 | return { id_token: entry.id_token, decodedToken: entry.decodedToken }; 68 | } 69 | 70 | async get( 71 | cacheKey: CacheKey, 72 | expiryAdjustmentSeconds = DEFAULT_EXPIRY_ADJUSTMENT_SECONDS 73 | ): Promise | undefined> { 74 | let wrappedEntry = await this.cache.get( 75 | cacheKey.toKey() 76 | ); 77 | 78 | if (!wrappedEntry) { 79 | const keys = await this.getCacheKeys(); 80 | 81 | if (!keys) return; 82 | 83 | const matchedKey = this.matchExistingCacheKey(cacheKey, keys); 84 | 85 | if (matchedKey) { 86 | wrappedEntry = await this.cache.get(matchedKey); 87 | } 88 | } 89 | 90 | // If we still don't have an entry, exit. 91 | if (!wrappedEntry) { 92 | return; 93 | } 94 | 95 | const now = await this.nowProvider(); 96 | const nowSeconds = Math.floor(now / 1000); 97 | 98 | if (wrappedEntry.expiresAt - expiryAdjustmentSeconds < nowSeconds) { 99 | if (wrappedEntry.body.refresh_token) { 100 | wrappedEntry.body = { 101 | refresh_token: wrappedEntry.body.refresh_token 102 | }; 103 | 104 | await this.cache.set(cacheKey.toKey(), wrappedEntry); 105 | return wrappedEntry.body; 106 | } 107 | 108 | await this.cache.remove(cacheKey.toKey()); 109 | await this.keyManifest?.remove(cacheKey.toKey()); 110 | 111 | return; 112 | } 113 | 114 | return wrappedEntry.body; 115 | } 116 | 117 | async set(entry: CacheEntry): Promise { 118 | const cacheKey = new CacheKey({ 119 | clientId: entry.client_id, 120 | scope: entry.scope, 121 | audience: entry.audience 122 | }); 123 | 124 | const wrappedEntry = await this.wrapCacheEntry(entry); 125 | 126 | await this.cache.set(cacheKey.toKey(), wrappedEntry); 127 | await this.keyManifest?.add(cacheKey.toKey()); 128 | } 129 | 130 | async clear(clientId?: string): Promise { 131 | const keys = await this.getCacheKeys(); 132 | 133 | /* c8 ignore next */ 134 | if (!keys) return; 135 | 136 | await keys 137 | .filter(key => (clientId ? key.includes(clientId) : true)) 138 | .reduce(async (memo, key) => { 139 | await memo; 140 | await this.cache.remove(key); 141 | }, Promise.resolve()); 142 | 143 | await this.keyManifest?.clear(); 144 | } 145 | 146 | private async wrapCacheEntry(entry: CacheEntry): Promise { 147 | const now = await this.nowProvider(); 148 | const expiresInTime = Math.floor(now / 1000) + entry.expires_in; 149 | 150 | return { 151 | body: entry, 152 | expiresAt: expiresInTime 153 | }; 154 | } 155 | 156 | private async getCacheKeys(): Promise { 157 | if (this.keyManifest) { 158 | return (await this.keyManifest.get())?.keys; 159 | } else if (this.cache.allKeys) { 160 | return this.cache.allKeys(); 161 | } 162 | } 163 | 164 | /** 165 | * Returns the cache key to be used to store the id token 166 | * @param clientId The client id used to link to the id token 167 | * @returns The constructed cache key, as a string, to store the id token 168 | */ 169 | private getIdTokenCacheKey(clientId: string) { 170 | return new CacheKey( 171 | { clientId }, 172 | CACHE_KEY_PREFIX, 173 | CACHE_KEY_ID_TOKEN_SUFFIX 174 | ).toKey(); 175 | } 176 | 177 | /** 178 | * Finds the corresponding key in the cache based on the provided cache key. 179 | * The keys inside the cache are in the format {prefix}::{clientId}::{audience}::{scope}. 180 | * The first key in the cache that satisfies the following conditions is returned 181 | * - `prefix` is strict equal to Auth0's internally configured `keyPrefix` 182 | * - `clientId` is strict equal to the `cacheKey.clientId` 183 | * - `audience` is strict equal to the `cacheKey.audience` 184 | * - `scope` contains at least all the `cacheKey.scope` values 185 | * * 186 | * @param keyToMatch The provided cache key 187 | * @param allKeys A list of existing cache keys 188 | */ 189 | private matchExistingCacheKey(keyToMatch: CacheKey, allKeys: Array) { 190 | return allKeys.filter(key => { 191 | const cacheKey = CacheKey.fromKey(key); 192 | const scopeSet = new Set(cacheKey.scope && cacheKey.scope.split(' ')); 193 | const scopesToMatch = keyToMatch.scope?.split(' ') || []; 194 | 195 | const hasAllScopes = 196 | cacheKey.scope && 197 | scopesToMatch.reduce( 198 | (acc, current) => acc && scopeSet.has(current), 199 | true 200 | ); 201 | 202 | return ( 203 | cacheKey.prefix === CACHE_KEY_PREFIX && 204 | cacheKey.clientId === keyToMatch.clientId && 205 | cacheKey.audience === keyToMatch.audience && 206 | hasAllScopes 207 | ); 208 | })[0]; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/cache/cache-memory.ts: -------------------------------------------------------------------------------- 1 | import { Cacheable, ICache, MaybePromise } from './shared'; 2 | 3 | export class InMemoryCache { 4 | public enclosedCache: ICache = (function () { 5 | let cache: Record = {}; 6 | 7 | return { 8 | set(key: string, entry: T) { 9 | cache[key] = entry; 10 | }, 11 | 12 | get(key: string): MaybePromise { 13 | const cacheEntry = cache[key] as T; 14 | 15 | if (!cacheEntry) { 16 | return; 17 | } 18 | 19 | return cacheEntry; 20 | }, 21 | 22 | remove(key: string) { 23 | delete cache[key]; 24 | }, 25 | 26 | allKeys(): string[] { 27 | return Object.keys(cache); 28 | } 29 | }; 30 | })(); 31 | } 32 | -------------------------------------------------------------------------------- /src/cache/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cache-localstorage'; 2 | export * from './cache-memory'; 3 | export * from './cache-manager'; 4 | export * from './shared'; 5 | -------------------------------------------------------------------------------- /src/cache/key-manifest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CACHE_KEY_PREFIX, 3 | ICache, 4 | KeyManifestEntry, 5 | MaybePromise 6 | } from './shared'; 7 | 8 | export class CacheKeyManifest { 9 | private readonly manifestKey: string; 10 | 11 | constructor(private cache: ICache, private clientId: string) { 12 | this.manifestKey = this.createManifestKeyFrom(this.clientId); 13 | } 14 | 15 | async add(key: string): Promise { 16 | const keys = new Set( 17 | (await this.cache.get(this.manifestKey))?.keys || [] 18 | ); 19 | 20 | keys.add(key); 21 | 22 | await this.cache.set(this.manifestKey, { 23 | keys: [...keys] 24 | }); 25 | } 26 | 27 | async remove(key: string): Promise { 28 | const entry = await this.cache.get(this.manifestKey); 29 | 30 | if (entry) { 31 | const keys = new Set(entry.keys); 32 | keys.delete(key); 33 | 34 | if (keys.size > 0) { 35 | return await this.cache.set(this.manifestKey, { keys: [...keys] }); 36 | } 37 | 38 | return await this.cache.remove(this.manifestKey); 39 | } 40 | } 41 | 42 | get(): MaybePromise { 43 | return this.cache.get(this.manifestKey); 44 | } 45 | 46 | clear(): MaybePromise { 47 | return this.cache.remove(this.manifestKey); 48 | } 49 | 50 | private createManifestKeyFrom(clientId: string): string { 51 | return `${CACHE_KEY_PREFIX}::${clientId}`; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/cache/shared.ts: -------------------------------------------------------------------------------- 1 | import { IdToken, User } from '../global'; 2 | 3 | export const CACHE_KEY_PREFIX = '@@auth0spajs@@'; 4 | export const CACHE_KEY_ID_TOKEN_SUFFIX = '@@user@@'; 5 | 6 | export type CacheKeyData = { 7 | audience?: string; 8 | scope?: string; 9 | clientId: string; 10 | }; 11 | 12 | export class CacheKey { 13 | public clientId: string; 14 | public scope?: string; 15 | public audience?: string; 16 | 17 | constructor( 18 | data: CacheKeyData, 19 | public prefix: string = CACHE_KEY_PREFIX, 20 | public suffix?: string 21 | ) { 22 | this.clientId = data.clientId; 23 | this.scope = data.scope; 24 | this.audience = data.audience; 25 | } 26 | 27 | /** 28 | * Converts this `CacheKey` instance into a string for use in a cache 29 | * @returns A string representation of the key 30 | */ 31 | toKey(): string { 32 | return [this.prefix, this.clientId, this.audience, this.scope, this.suffix] 33 | .filter(Boolean) 34 | .join('::'); 35 | } 36 | 37 | /** 38 | * Converts a cache key string into a `CacheKey` instance. 39 | * @param key The key to convert 40 | * @returns An instance of `CacheKey` 41 | */ 42 | static fromKey(key: string): CacheKey { 43 | const [prefix, clientId, audience, scope] = key.split('::'); 44 | 45 | return new CacheKey({ clientId, scope, audience }, prefix); 46 | } 47 | 48 | /** 49 | * Utility function to build a `CacheKey` instance from a cache entry 50 | * @param entry The entry 51 | * @returns An instance of `CacheKey` 52 | */ 53 | static fromCacheEntry(entry: CacheEntry): CacheKey { 54 | const { scope, audience, client_id: clientId } = entry; 55 | 56 | return new CacheKey({ 57 | scope, 58 | audience, 59 | clientId 60 | }); 61 | } 62 | } 63 | 64 | export interface DecodedToken { 65 | claims: IdToken; 66 | user: User; 67 | } 68 | 69 | export interface IdTokenEntry { 70 | id_token: string; 71 | decodedToken: DecodedToken; 72 | } 73 | 74 | export type CacheEntry = { 75 | id_token?: string; 76 | access_token: string; 77 | expires_in: number; 78 | decodedToken?: DecodedToken; 79 | audience: string; 80 | scope: string; 81 | client_id: string; 82 | refresh_token?: string; 83 | oauthTokenScope?: string; 84 | }; 85 | 86 | export type WrappedCacheEntry = { 87 | body: Partial; 88 | expiresAt: number; 89 | }; 90 | 91 | export type KeyManifestEntry = { 92 | keys: string[]; 93 | }; 94 | 95 | export type Cacheable = WrappedCacheEntry | KeyManifestEntry; 96 | 97 | export type MaybePromise = Promise | T; 98 | 99 | export interface ICache { 100 | set(key: string, entry: T): MaybePromise; 101 | get(key: string): MaybePromise; 102 | remove(key: string): MaybePromise; 103 | allKeys?(): MaybePromise; 104 | } 105 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { PopupConfigOptions } from './global'; 2 | import version from './version'; 3 | 4 | /** 5 | * @ignore 6 | */ 7 | export const DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS = 60; 8 | 9 | /** 10 | * @ignore 11 | */ 12 | export const DEFAULT_POPUP_CONFIG_OPTIONS: PopupConfigOptions = { 13 | timeoutInSeconds: DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS 14 | }; 15 | 16 | /** 17 | * @ignore 18 | */ 19 | export const DEFAULT_SILENT_TOKEN_RETRY_COUNT = 3; 20 | 21 | /** 22 | * @ignore 23 | */ 24 | export const CLEANUP_IFRAME_TIMEOUT_IN_SECONDS = 2; 25 | 26 | /** 27 | * @ignore 28 | */ 29 | export const DEFAULT_FETCH_TIMEOUT_MS = 10000; 30 | 31 | export const CACHE_LOCATION_MEMORY = 'memory'; 32 | export const CACHE_LOCATION_LOCAL_STORAGE = 'localstorage'; 33 | 34 | /** 35 | * @ignore 36 | */ 37 | export const MISSING_REFRESH_TOKEN_ERROR_MESSAGE = 'Missing Refresh Token'; 38 | 39 | /** 40 | * @ignore 41 | */ 42 | export const INVALID_REFRESH_TOKEN_ERROR_MESSAGE = 'invalid refresh token'; 43 | 44 | /** 45 | * @ignore 46 | */ 47 | export const DEFAULT_SCOPE = 'openid profile email'; 48 | 49 | /** 50 | * @ignore 51 | */ 52 | export const DEFAULT_SESSION_CHECK_EXPIRY_DAYS = 1; 53 | 54 | /** 55 | * @ignore 56 | */ 57 | export const DEFAULT_AUTH0_CLIENT = { 58 | name: 'auth0-spa-js', 59 | version: version 60 | }; 61 | 62 | export const DEFAULT_NOW_PROVIDER = () => Date.now(); 63 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Thrown when network requests to the Auth server fail. 3 | */ 4 | export class GenericError extends Error { 5 | constructor(public error: string, public error_description: string) { 6 | super(error_description); 7 | Object.setPrototypeOf(this, GenericError.prototype); 8 | } 9 | 10 | static fromPayload({ 11 | error, 12 | error_description 13 | }: { 14 | error: string; 15 | error_description: string; 16 | }) { 17 | return new GenericError(error, error_description); 18 | } 19 | } 20 | 21 | /** 22 | * Thrown when handling the redirect callback fails, will be one of Auth0's 23 | * Authentication API's Standard Error Responses: https://auth0.com/docs/api/authentication?javascript#standard-error-responses 24 | */ 25 | export class AuthenticationError extends GenericError { 26 | constructor( 27 | error: string, 28 | error_description: string, 29 | public state: string, 30 | public appState: any = null 31 | ) { 32 | super(error, error_description); 33 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 34 | Object.setPrototypeOf(this, AuthenticationError.prototype); 35 | } 36 | } 37 | 38 | /** 39 | * Thrown when silent auth times out (usually due to a configuration issue) or 40 | * when network requests to the Auth server timeout. 41 | */ 42 | export class TimeoutError extends GenericError { 43 | constructor() { 44 | super('timeout', 'Timeout'); 45 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 46 | Object.setPrototypeOf(this, TimeoutError.prototype); 47 | } 48 | } 49 | 50 | /** 51 | * Error thrown when the login popup times out (if the user does not complete auth) 52 | */ 53 | export class PopupTimeoutError extends TimeoutError { 54 | constructor(public popup: Window) { 55 | super(); 56 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 57 | Object.setPrototypeOf(this, PopupTimeoutError.prototype); 58 | } 59 | } 60 | 61 | export class PopupCancelledError extends GenericError { 62 | constructor(public popup: Window) { 63 | super('cancelled', 'Popup closed'); 64 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 65 | Object.setPrototypeOf(this, PopupCancelledError.prototype); 66 | } 67 | } 68 | 69 | /** 70 | * Error thrown when the token exchange results in a `mfa_required` error 71 | */ 72 | export class MfaRequiredError extends GenericError { 73 | constructor( 74 | error: string, 75 | error_description: string, 76 | public mfa_token: string 77 | ) { 78 | super(error, error_description); 79 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 80 | Object.setPrototypeOf(this, MfaRequiredError.prototype); 81 | } 82 | } 83 | 84 | /** 85 | * Error thrown when there is no refresh token to use 86 | */ 87 | export class MissingRefreshTokenError extends GenericError { 88 | constructor(public audience: string, public scope: string) { 89 | super( 90 | 'missing_refresh_token', 91 | `Missing Refresh Token (audience: '${valueOrEmptyString(audience, [ 92 | 'default' 93 | ])}', scope: '${valueOrEmptyString(scope)}')` 94 | ); 95 | Object.setPrototypeOf(this, MissingRefreshTokenError.prototype); 96 | } 97 | } 98 | 99 | /** 100 | * Returns an empty string when value is falsy, or when it's value is included in the exclude argument. 101 | * @param value The value to check 102 | * @param exclude An array of values that should result in an empty string. 103 | * @returns The value, or an empty string when falsy or included in the exclude argument. 104 | */ 105 | function valueOrEmptyString(value: string, exclude: string[] = []) { 106 | return value && !exclude.includes(value) ? value : ''; 107 | } 108 | -------------------------------------------------------------------------------- /src/http.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_FETCH_TIMEOUT_MS, 3 | DEFAULT_SILENT_TOKEN_RETRY_COUNT 4 | } from './constants'; 5 | 6 | import { sendMessage } from './worker/worker.utils'; 7 | import { FetchOptions } from './global'; 8 | import { 9 | GenericError, 10 | MfaRequiredError, 11 | MissingRefreshTokenError 12 | } from './errors'; 13 | 14 | export const createAbortController = () => new AbortController(); 15 | 16 | const dofetch = async (fetchUrl: string, fetchOptions: FetchOptions) => { 17 | const response = await fetch(fetchUrl, fetchOptions); 18 | 19 | return { 20 | ok: response.ok, 21 | json: await response.json() 22 | }; 23 | }; 24 | 25 | const fetchWithoutWorker = async ( 26 | fetchUrl: string, 27 | fetchOptions: FetchOptions, 28 | timeout: number 29 | ) => { 30 | const controller = createAbortController(); 31 | fetchOptions.signal = controller.signal; 32 | 33 | let timeoutId: NodeJS.Timeout; 34 | 35 | // The promise will resolve with one of these two promises (the fetch or the timeout), whichever completes first. 36 | return Promise.race([ 37 | dofetch(fetchUrl, fetchOptions), 38 | 39 | new Promise((_, reject) => { 40 | timeoutId = setTimeout(() => { 41 | controller.abort(); 42 | reject(new Error("Timeout when executing 'fetch'")); 43 | }, timeout); 44 | }) 45 | ]).finally(() => { 46 | clearTimeout(timeoutId); 47 | }); 48 | }; 49 | 50 | const fetchWithWorker = async ( 51 | fetchUrl: string, 52 | audience: string, 53 | scope: string, 54 | fetchOptions: FetchOptions, 55 | timeout: number, 56 | worker: Worker, 57 | useFormData?: boolean 58 | ) => { 59 | return sendMessage( 60 | { 61 | auth: { 62 | audience, 63 | scope 64 | }, 65 | timeout, 66 | fetchUrl, 67 | fetchOptions, 68 | useFormData 69 | }, 70 | worker 71 | ); 72 | }; 73 | 74 | export const switchFetch = async ( 75 | fetchUrl: string, 76 | audience: string, 77 | scope: string, 78 | fetchOptions: FetchOptions, 79 | worker?: Worker, 80 | useFormData?: boolean, 81 | timeout = DEFAULT_FETCH_TIMEOUT_MS 82 | ): Promise => { 83 | if (worker) { 84 | return fetchWithWorker( 85 | fetchUrl, 86 | audience, 87 | scope, 88 | fetchOptions, 89 | timeout, 90 | worker, 91 | useFormData 92 | ); 93 | } else { 94 | return fetchWithoutWorker(fetchUrl, fetchOptions, timeout); 95 | } 96 | }; 97 | 98 | export async function getJSON( 99 | url: string, 100 | timeout: number | undefined, 101 | audience: string, 102 | scope: string, 103 | options: FetchOptions, 104 | worker?: Worker, 105 | useFormData?: boolean 106 | ): Promise { 107 | let fetchError: null | Error = null; 108 | let response: any; 109 | 110 | for (let i = 0; i < DEFAULT_SILENT_TOKEN_RETRY_COUNT; i++) { 111 | try { 112 | response = await switchFetch( 113 | url, 114 | audience, 115 | scope, 116 | options, 117 | worker, 118 | useFormData, 119 | timeout 120 | ); 121 | fetchError = null; 122 | break; 123 | } catch (e) { 124 | // Fetch only fails in the case of a network issue, so should be 125 | // retried here. Failure status (4xx, 5xx, etc) return a resolved Promise 126 | // with the failure in the body. 127 | // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 128 | fetchError = e; 129 | } 130 | } 131 | 132 | if (fetchError) { 133 | throw fetchError; 134 | } 135 | 136 | const { 137 | json: { error, error_description, ...data }, 138 | ok 139 | } = response; 140 | 141 | if (!ok) { 142 | const errorMessage = 143 | error_description || `HTTP error. Unable to fetch ${url}`; 144 | 145 | if (error === 'mfa_required') { 146 | throw new MfaRequiredError(error, errorMessage, data.mfa_token); 147 | } 148 | 149 | if (error === 'missing_refresh_token') { 150 | throw new MissingRefreshTokenError(audience, scope); 151 | } 152 | 153 | throw new GenericError(error || 'request_error', errorMessage); 154 | } 155 | 156 | return data; 157 | } 158 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Auth0Client } from './Auth0Client'; 2 | import { Auth0ClientOptions } from './global'; 3 | 4 | import './global'; 5 | 6 | export * from './global'; 7 | 8 | /** 9 | * Asynchronously creates the Auth0Client instance and calls `checkSession`. 10 | * 11 | * **Note:** There are caveats to using this in a private browser tab, which may not silently authenticae 12 | * a user on page refresh. Please see [the checkSession docs](https://auth0.github.io/auth0-spa-js/classes/Auth0Client.html#checksession) for more info. 13 | * 14 | * @param options The client options 15 | * @returns An instance of Auth0Client 16 | */ 17 | export async function createAuth0Client(options: Auth0ClientOptions) { 18 | const auth0 = new Auth0Client(options); 19 | await auth0.checkSession(); 20 | return auth0; 21 | } 22 | 23 | export { Auth0Client }; 24 | 25 | export { 26 | GenericError, 27 | AuthenticationError, 28 | TimeoutError, 29 | PopupTimeoutError, 30 | PopupCancelledError, 31 | MfaRequiredError, 32 | MissingRefreshTokenError 33 | } from './errors'; 34 | 35 | export { 36 | ICache, 37 | LocalStorageCache, 38 | InMemoryCache, 39 | Cacheable, 40 | DecodedToken, 41 | CacheEntry, 42 | WrappedCacheEntry, 43 | KeyManifestEntry, 44 | MaybePromise, 45 | CacheKey, 46 | CacheKeyData 47 | } from './cache'; 48 | -------------------------------------------------------------------------------- /src/promise-utils.ts: -------------------------------------------------------------------------------- 1 | const singlePromiseMap: Record> = {}; 2 | 3 | export const singlePromise = ( 4 | cb: () => Promise, 5 | key: string 6 | ): Promise => { 7 | let promise: null | Promise = singlePromiseMap[key]; 8 | if (!promise) { 9 | promise = cb().finally(() => { 10 | delete singlePromiseMap[key]; 11 | promise = null; 12 | }); 13 | singlePromiseMap[key] = promise; 14 | } 15 | return promise; 16 | }; 17 | 18 | export const retryPromise = async ( 19 | cb: () => Promise, 20 | maxNumberOfRetries = 3 21 | ) => { 22 | for (let i = 0; i < maxNumberOfRetries; i++) { 23 | if (await cb()) { 24 | return true; 25 | } 26 | } 27 | 28 | return false; 29 | }; 30 | -------------------------------------------------------------------------------- /src/scope.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @ignore 3 | */ 4 | const dedupe = (arr: string[]) => Array.from(new Set(arr)); 5 | 6 | /** 7 | * @ignore 8 | */ 9 | /** 10 | * Returns a string of unique scopes by removing duplicates and unnecessary whitespace. 11 | * 12 | * @param {...(string | undefined)[]} scopes - A list of scope strings or undefined values. 13 | * @returns {string} A string containing unique scopes separated by a single space. 14 | */ 15 | export const getUniqueScopes = (...scopes: (string | undefined)[]) => { 16 | return dedupe(scopes.filter(Boolean).join(' ').trim().split(/\s+/)).join(' '); 17 | }; 18 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import * as Cookies from 'es-cookie'; 2 | 3 | interface ClientStorageOptions { 4 | daysUntilExpire?: number; 5 | cookieDomain?: string; 6 | } 7 | 8 | /** 9 | * Defines a type that handles storage to/from a storage location 10 | */ 11 | export type ClientStorage = { 12 | get(key: string): T | undefined; 13 | save(key: string, value: any, options?: ClientStorageOptions): void; 14 | remove(key: string, options?: ClientStorageOptions): void; 15 | }; 16 | 17 | /** 18 | * A storage protocol for marshalling data to/from cookies 19 | */ 20 | export const CookieStorage = { 21 | get(key: string) { 22 | const value = Cookies.get(key); 23 | 24 | if (typeof value === 'undefined') { 25 | return; 26 | } 27 | 28 | return JSON.parse(value); 29 | }, 30 | 31 | save(key: string, value: any, options?: ClientStorageOptions): void { 32 | let cookieAttributes: Cookies.CookieAttributes = {}; 33 | 34 | if ('https:' === window.location.protocol) { 35 | cookieAttributes = { 36 | secure: true, 37 | sameSite: 'none' 38 | }; 39 | } 40 | 41 | if (options?.daysUntilExpire) { 42 | cookieAttributes.expires = options.daysUntilExpire; 43 | } 44 | 45 | if (options?.cookieDomain) { 46 | cookieAttributes.domain = options.cookieDomain; 47 | } 48 | 49 | Cookies.set(key, JSON.stringify(value), cookieAttributes); 50 | }, 51 | 52 | remove(key: string, options?: ClientStorageOptions) { 53 | let cookieAttributes: Cookies.CookieAttributes = {}; 54 | 55 | if (options?.cookieDomain) { 56 | cookieAttributes.domain = options.cookieDomain; 57 | } 58 | 59 | Cookies.remove(key, cookieAttributes); 60 | } 61 | } as ClientStorage; 62 | 63 | /** 64 | * @ignore 65 | */ 66 | const LEGACY_PREFIX = '_legacy_'; 67 | 68 | /** 69 | * Cookie storage that creates a cookie for modern and legacy browsers. 70 | * See: https://web.dev/samesite-cookie-recipes/#handling-incompatible-clients 71 | */ 72 | export const CookieStorageWithLegacySameSite = { 73 | get(key: string) { 74 | const value = CookieStorage.get(key); 75 | 76 | if (value) { 77 | return value; 78 | } 79 | 80 | return CookieStorage.get(`${LEGACY_PREFIX}${key}`); 81 | }, 82 | 83 | save(key: string, value: any, options?: ClientStorageOptions): void { 84 | let cookieAttributes: Cookies.CookieAttributes = {}; 85 | 86 | if ('https:' === window.location.protocol) { 87 | cookieAttributes = { secure: true }; 88 | } 89 | 90 | if (options?.daysUntilExpire) { 91 | cookieAttributes.expires = options.daysUntilExpire; 92 | } 93 | 94 | if (options?.cookieDomain) { 95 | cookieAttributes.domain = options.cookieDomain; 96 | } 97 | 98 | Cookies.set( 99 | `${LEGACY_PREFIX}${key}`, 100 | JSON.stringify(value), 101 | cookieAttributes 102 | ); 103 | CookieStorage.save(key, value, options); 104 | }, 105 | 106 | remove(key: string, options?: ClientStorageOptions) { 107 | let cookieAttributes: Cookies.CookieAttributes = {}; 108 | 109 | if (options?.cookieDomain) { 110 | cookieAttributes.domain = options.cookieDomain; 111 | } 112 | 113 | Cookies.remove(key, cookieAttributes); 114 | CookieStorage.remove(key, options); 115 | CookieStorage.remove(`${LEGACY_PREFIX}${key}`, options); 116 | } 117 | } as ClientStorage; 118 | 119 | /** 120 | * A storage protocol for marshalling data to/from session storage 121 | */ 122 | export const SessionStorage = { 123 | get(key: string) { 124 | /* c8 ignore next 3 */ 125 | if (typeof sessionStorage === 'undefined') { 126 | return; 127 | } 128 | 129 | const value = sessionStorage.getItem(key); 130 | 131 | if (value == null) { 132 | return; 133 | } 134 | 135 | return JSON.parse(value); 136 | }, 137 | 138 | save(key: string, value: any): void { 139 | sessionStorage.setItem(key, JSON.stringify(value)); 140 | }, 141 | 142 | remove(key: string) { 143 | sessionStorage.removeItem(key); 144 | } 145 | } as ClientStorage; 146 | -------------------------------------------------------------------------------- /src/transaction-manager.ts: -------------------------------------------------------------------------------- 1 | import { ClientStorage } from './storage'; 2 | 3 | const TRANSACTION_STORAGE_KEY_PREFIX = 'a0.spajs.txs'; 4 | 5 | interface Transaction { 6 | nonce: string; 7 | scope: string; 8 | audience: string; 9 | appState?: any; 10 | code_verifier: string; 11 | redirect_uri?: string; 12 | organization?: string; 13 | state?: string; 14 | } 15 | 16 | export class TransactionManager { 17 | private storageKey: string; 18 | 19 | constructor( 20 | private storage: ClientStorage, 21 | private clientId: string, 22 | private cookieDomain?: string 23 | ) { 24 | this.storageKey = `${TRANSACTION_STORAGE_KEY_PREFIX}.${this.clientId}`; 25 | } 26 | 27 | public create(transaction: Transaction) { 28 | this.storage.save(this.storageKey, transaction, { 29 | daysUntilExpire: 1, 30 | cookieDomain: this.cookieDomain 31 | }); 32 | } 33 | 34 | public get(): Transaction | undefined { 35 | return this.storage.get(this.storageKey); 36 | } 37 | 38 | public remove() { 39 | this.storage.remove(this.storageKey, { 40 | cookieDomain: this.cookieDomain 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export default '2.2.0'; 2 | -------------------------------------------------------------------------------- /src/worker/__mocks__/token.worker.ts: -------------------------------------------------------------------------------- 1 | const { messageHandler } = jest.requireActual('../token.worker'); 2 | 3 | export default class { 4 | postMessage(data, ports) { 5 | messageHandler({ 6 | data, 7 | ports 8 | }); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/worker/token.worker.ts: -------------------------------------------------------------------------------- 1 | import { MissingRefreshTokenError } from '../errors'; 2 | import { createQueryParams } from '../utils'; 3 | import { WorkerRefreshTokenMessage } from './worker.types'; 4 | 5 | let refreshTokens: Record = {}; 6 | 7 | const cacheKey = (audience: string, scope: string) => `${audience}|${scope}`; 8 | 9 | const getRefreshToken = (audience: string, scope: string) => 10 | refreshTokens[cacheKey(audience, scope)]; 11 | 12 | const setRefreshToken = ( 13 | refreshToken: string, 14 | audience: string, 15 | scope: string 16 | ) => (refreshTokens[cacheKey(audience, scope)] = refreshToken); 17 | 18 | const deleteRefreshToken = (audience: string, scope: string) => 19 | delete refreshTokens[cacheKey(audience, scope)]; 20 | 21 | const wait = (time: number) => 22 | new Promise(resolve => setTimeout(resolve, time)); 23 | 24 | const formDataToObject = (formData: string): Record => { 25 | const queryParams = new URLSearchParams(formData); 26 | const parsedQuery: any = {}; 27 | 28 | queryParams.forEach((val, key) => { 29 | parsedQuery[key] = val; 30 | }); 31 | 32 | return parsedQuery; 33 | }; 34 | 35 | const messageHandler = async ({ 36 | data: { timeout, auth, fetchUrl, fetchOptions, useFormData }, 37 | ports: [port] 38 | }: MessageEvent) => { 39 | let json: { 40 | refresh_token?: string; 41 | }; 42 | 43 | const { audience, scope } = auth || {}; 44 | 45 | try { 46 | const body = useFormData 47 | ? formDataToObject(fetchOptions.body as string) 48 | : JSON.parse(fetchOptions.body as string); 49 | 50 | if (!body.refresh_token && body.grant_type === 'refresh_token') { 51 | const refreshToken = getRefreshToken(audience, scope); 52 | 53 | if (!refreshToken) { 54 | throw new MissingRefreshTokenError(audience, scope); 55 | } 56 | 57 | fetchOptions.body = useFormData 58 | ? createQueryParams({ 59 | ...body, 60 | refresh_token: refreshToken 61 | }) 62 | : JSON.stringify({ 63 | ...body, 64 | refresh_token: refreshToken 65 | }); 66 | } 67 | 68 | let abortController: AbortController | undefined; 69 | 70 | if (typeof AbortController === 'function') { 71 | abortController = new AbortController(); 72 | fetchOptions.signal = abortController.signal; 73 | } 74 | 75 | let response: any; 76 | 77 | try { 78 | response = await Promise.race([ 79 | wait(timeout), 80 | fetch(fetchUrl, { ...fetchOptions }) 81 | ]); 82 | } catch (error) { 83 | // fetch error, reject `sendMessage` using `error` key so that we retry. 84 | port.postMessage({ 85 | error: error.message 86 | }); 87 | 88 | return; 89 | } 90 | 91 | if (!response) { 92 | // If the request times out, abort it and let `switchFetch` raise the error. 93 | if (abortController) abortController.abort(); 94 | 95 | port.postMessage({ 96 | error: "Timeout when executing 'fetch'" 97 | }); 98 | 99 | return; 100 | } 101 | 102 | json = await response.json(); 103 | 104 | if (json.refresh_token) { 105 | setRefreshToken(json.refresh_token, audience, scope); 106 | delete json.refresh_token; 107 | } else { 108 | deleteRefreshToken(audience, scope); 109 | } 110 | 111 | port.postMessage({ 112 | ok: response.ok, 113 | json 114 | }); 115 | } catch (error) { 116 | port.postMessage({ 117 | ok: false, 118 | json: { 119 | error: error.error, 120 | error_description: error.message 121 | } 122 | }); 123 | } 124 | }; 125 | 126 | // Don't run `addEventListener` in our tests (this is replaced in rollup) 127 | if (process.env.NODE_ENV === 'test') { 128 | module.exports = { messageHandler }; 129 | /* c8 ignore next 4 */ 130 | } else { 131 | // @ts-ignore 132 | addEventListener('message', messageHandler); 133 | } 134 | -------------------------------------------------------------------------------- /src/worker/worker.types.ts: -------------------------------------------------------------------------------- 1 | import { FetchOptions } from '../global'; 2 | 3 | /** 4 | * @ts-ignore 5 | */ 6 | export type WorkerRefreshTokenMessage = { 7 | timeout: number; 8 | fetchUrl: string; 9 | fetchOptions: FetchOptions; 10 | useFormData?: boolean; 11 | auth: { 12 | audience: string; 13 | scope: string; 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/worker/worker.utils.ts: -------------------------------------------------------------------------------- 1 | import { WorkerRefreshTokenMessage } from './worker.types'; 2 | 3 | /** 4 | * Sends the specified message to the web worker 5 | * @param message The message to send 6 | * @param to The worker to send the message to 7 | */ 8 | export const sendMessage = (message: WorkerRefreshTokenMessage, to: Worker) => 9 | new Promise(function (resolve, reject) { 10 | const messageChannel = new MessageChannel(); 11 | 12 | messageChannel.port1.onmessage = function (event) { 13 | // Only for fetch errors, as these get retried 14 | if (event.data.error) { 15 | reject(new Error(event.data.error)); 16 | } else { 17 | resolve(event.data); 18 | } 19 | messageChannel.port1.close(); 20 | }; 21 | 22 | to.postMessage(message, [messageChannel.port2]); 23 | }); 24 | -------------------------------------------------------------------------------- /static/perf.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /static/v1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Auth0 5 | 6 | 12 | 19 | 20 | 21 | 22 |
23 |
24 | loaded 25 |
26 | 27 |

Auth0 SPA JS v1 Migration Playground

28 | 29 |
30 |
Client Options
31 |
32 |
33 | 34 | 42 | Scopes used to create the Auth0Client instance 45 |
46 | 47 |
48 | 49 | 57 | Value used to send as the custom `Foo` parameter to Auth0 60 |
61 | 62 | 70 |
71 |
72 | 73 |
74 |
Login Options
75 |
76 |
77 | 78 | 86 | Scopes used when calling `loginWith*` 89 |
90 | 91 | 94 | 101 | 104 |
105 |
106 | 107 |
108 |
Profile
109 |
110 |
111 |             
112 | {{ JSON.stringify(user, null, 2) }} 
113 |             
114 |           
115 |
116 |
117 |
118 | 119 | 120 | 121 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /static/v2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Auth0 5 | 6 | 12 | 19 | 20 | 21 | 22 |
23 |
24 | loaded 25 |
26 | 27 |

Auth0 SPA JS v1 Migration Playground

28 | 29 |
30 |
Client Options
31 |
32 |
33 | 34 | 42 | Scopes used to create the Auth0Client instance 45 |
46 | 47 |
48 | 49 | 57 | Value used to send as the custom `Foo` parameter to Auth0 60 |
61 | 62 | 70 |
71 |
72 | 73 |
74 |
Get Token Options
75 |
76 |
77 | 78 | 86 | Scopes used to call getTokenSilently 89 |
90 | 91 | 98 |
99 |
100 | 101 |
102 |
Profile
103 |
104 |
105 |             
106 | {{ JSON.stringify(user, null, 2) }}
107 |             
108 |           
109 |
110 |
111 |
112 | 113 | 114 | 115 | 189 | 190 | 191 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "sourceMap": true, 7 | "declaration": true, 8 | "declarationDir": "./dist/typings", 9 | "moduleResolution": "node", 10 | "noImplicitAny": true, 11 | "downlevelIteration": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "strictBindCallApply": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "strictPropertyInitialization": true, 18 | }, 19 | "exclude": ["./__tests__", "./dist/typings", "**/__mocks__/**"] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noImplicitAny": false, 5 | "target": "es6", 6 | 7 | "noImplicitThis": false, 8 | "alwaysStrict": false, 9 | "strictBindCallApply": false, 10 | "strictNullChecks": false, 11 | "strictFunctionTypes": false, 12 | "strictPropertyInitialization": false, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | const excludeFiles = [ 2 | 'cache', 3 | 'jwt', 4 | 'storage', 5 | 'transaction-manager', 6 | 'utils', 7 | 'promise-utils', 8 | 'user-agent', 9 | 'api', 10 | 'http' 11 | ]; 12 | 13 | module.exports = { 14 | out: './docs/', 15 | readme: './README.MD', 16 | includes: './src', 17 | exclude: [ 18 | '**/__tests__/**/*', 19 | '**/cypress/**/*', 20 | '**/node_modules/**/*', 21 | '**/__mocks__/**/*', 22 | 'src/worker/**/*', 23 | ...excludeFiles.map(f => `./src/${f}.ts`) 24 | ], 25 | excludeExternals: false, 26 | excludePrivate: true, 27 | hideGenerator: true 28 | }; 29 | --------------------------------------------------------------------------------