├── .version ├── codecov.yml ├── src ├── version.ts ├── cache │ ├── index.ts │ ├── cache-memory.ts │ ├── cache-localstorage.ts │ ├── key-manifest.ts │ └── shared.ts ├── worker │ ├── __mocks__ │ │ └── token.worker.ts │ ├── worker.types.ts │ ├── worker.utils.ts │ └── token.worker.ts ├── promise-utils.ts ├── constants.ts ├── transaction-manager.ts ├── dpop │ ├── dpop.ts │ ├── utils.ts │ └── storage.ts ├── index.ts ├── api.ts ├── TokenExchange.ts ├── scope.ts ├── storage.ts ├── MyAccountApiClient.ts ├── Auth0Client.utils.ts ├── errors.ts └── http.ts ├── .prettierignore ├── jest.setup.js ├── .github ├── CODEOWNERS ├── dependabot.yml ├── workflows │ ├── claude-code-review.yml │ ├── snyk.yml │ ├── browserstack.yml │ ├── codeql.yml │ ├── release.yml │ ├── rl-secure.yml │ ├── npm-release.yml │ └── test.yml ├── actions │ ├── get-version │ │ └── action.yml │ ├── build │ │ └── action.yml │ ├── get-prerelease │ │ └── action.yml │ ├── tag-exists │ │ └── action.yml │ ├── release-create │ │ └── action.yml │ ├── npm-publish │ │ └── action.yml │ ├── get-release-notes │ │ └── action.yml │ ├── framework │ │ └── action.yml │ └── rl-scanner │ │ └── action.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── Feature Request.yml │ └── Bug Report.yml ├── stale.yml └── PULL_REQUEST_TEMPLATE.md ├── __mocks__ ├── promise-polyfill │ └── src │ │ └── polyfill.js └── browser-tabs-lock │ └── index.ts ├── opslevel.yml ├── .semgrepignore ├── cypress ├── fixtures │ ├── profile.json │ └── example.json ├── tsconfig.json ├── e2e │ ├── handleRedirectCallback.cy.js │ ├── logout.cy.js │ ├── initialisation.cy.js │ ├── multiple_clients.cy.js │ └── loginWithRedirect.cy.js ├── plugins │ └── index.js └── support │ ├── e2e.js │ ├── utils.js │ └── commands.js ├── .prettierrc ├── docs ├── .nojekyll └── assets │ ├── navigation.js │ └── highlight.css ├── .npmignore ├── .shiprc ├── .vscode ├── settings.json └── launch.json ├── .gitignore ├── scripts ├── exec.js ├── prepack.js ├── print-bundle-size.mjs └── oidc-provider.mjs ├── Makefile ├── tsconfig.test.json ├── cypress.config.js ├── .eslintrc.security ├── typedoc.js ├── __tests__ ├── helpers.ts ├── ssr.test.ts ├── cache │ ├── shared.ts │ ├── key-manifest.test.ts │ └── cache.test.ts ├── constants.ts ├── promise-utils.test.ts ├── Auth0Client │ ├── createFetcher.test.ts │ ├── dpop.test.ts │ ├── isAuthenticated.test.ts │ ├── connectAccountWithRedirect.test.ts │ ├── getIdTokenClaims.test.ts │ └── checkSession.test.ts ├── dpop │ ├── utils.test.ts │ └── dpop.test.ts ├── scope.test.ts ├── transaction-manager.test.ts └── MyAccountApiClient.test.ts ├── tsconfig.json ├── jest.environment.js ├── jest.config.js ├── browserstack.json ├── LICENSE ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── static └── perf.html ├── rollup.config.mjs └── package.json /.version: -------------------------------------------------------------------------------- 1 | v2.11.0 -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export default '2.11.0'; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | docs 2 | static/auth0-spa-js.development_old.js -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require('jest-fetch-mock').enableMocks(); 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @auth0/project-dx-sdks-engineer-codeowner 2 | -------------------------------------------------------------------------------- /__mocks__/promise-polyfill/src/polyfill.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /opslevel.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | repository: 4 | owner: dx_sdks 5 | tier: 6 | tags: 7 | -------------------------------------------------------------------------------- /.semgrepignore: -------------------------------------------------------------------------------- 1 | __mocks__/ 2 | __tests__/ 3 | cypress/ 4 | docs/ 5 | static/ 6 | scripts/prepack.js 7 | *.md 8 | -------------------------------------------------------------------------------- /cypress/fixtures/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 8739, 3 | "name": "Jane", 4 | "email": "jane@example.com" 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 80, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/cache/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cache-localstorage'; 2 | export * from './cache-memory'; 3 | export * from './cache-manager'; 4 | export * from './shared'; 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | .npmrc 17 | 18 | # BrowserStack 19 | log/ 20 | *.log 21 | results/ -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | useMrrt?: boolean; 12 | auth: { 13 | audience: string; 14 | scope: string; 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install lint test build publish 2 | 3 | install: 4 | @echo "Running install..." 5 | npm ci 6 | 7 | lint: 8 | @echo "Running lint..." 9 | npm run lint 10 | 11 | test: 12 | @echo "Running test..." 13 | npm run test 14 | 15 | build: 16 | @echo "Running build..." 17 | rm -rf build && npm run build 18 | 19 | publish: 20 | @echo "Running cdn-publish..." 21 | npm run publish:cdn -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noImplicitAny": false, 5 | "target": "es6", 6 | "allowJs": true, 7 | "noImplicitThis": false, 8 | "alwaysStrict": false, 9 | "strictBindCallApply": false, 10 | "strictNullChecks": false, 11 | "strictFunctionTypes": false, 12 | "strictPropertyInitialization": false, 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.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/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 | -------------------------------------------------------------------------------- /.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/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code PR Review 2 | 3 | on: 4 | issue_comment: 5 | types: [ created ] 6 | pull_request_review_comment: 7 | types: [ created ] 8 | pull_request_review: 9 | types: [ submitted ] 10 | 11 | jobs: 12 | claude-review: 13 | permissions: 14 | contents: write 15 | issues: write 16 | pull-requests: write 17 | id-token: write 18 | uses: auth0/auth0-ai-pr-analyzer-gh-action/.github/workflows/claude-code-review.yml@main -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["ES2019.Object", "DOM", "DOM.Iterable"], 5 | "allowSyntheticDefaultImports": true, 6 | "esModuleInterop": true, 7 | "sourceMap": true, 8 | "declaration": true, 9 | "declarationDir": "./dist/typings", 10 | "moduleResolution": "node", 11 | "noImplicitAny": true, 12 | "downlevelIteration": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "strictBindCallApply": true, 16 | "strictNullChecks": true, 17 | "strictFunctionTypes": true, 18 | "strictPropertyInitialization": true, 19 | }, 20 | "exclude": ["./__tests__", "./dist/typings", "**/__mocks__/**"] 21 | } 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 advanced features. 6 | * 7 | * ref: https://github.com/jsdom/jsdom/issues/2524 8 | * ref: https://github.com/jsdom/jsdom/issues/3363 9 | */ 10 | class CustomJSDOMEnvironment extends JSDOMEnvironment { 11 | constructor(config, context) { 12 | super(config, context); 13 | } 14 | 15 | async setup() { 16 | await super.setup(); 17 | this.global.TextEncoder = util.TextEncoder; 18 | this.global.TextDecoder = util.TextDecoder; 19 | this.global.structuredClone = structuredClone; 20 | } 21 | } 22 | 23 | module.exports = CustomJSDOMEnvironment; 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/presets/js-with-ts', 20 | transformIgnorePatterns: ['node_modules\/(?!(dpop)\/)'], 21 | setupFiles: ['jest-localstorage-mock', './jest.setup.js'], 22 | globals: { 23 | 'ts-jest': { 24 | tsconfig: './tsconfig.test.json' 25 | } 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /docs/assets/navigation.js: -------------------------------------------------------------------------------- 1 | window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAE43XXU/bMBQG4P+S62oFNNjWOwQMVdBRla+LaRfGOUksEtuzTySyif8+NUlH4tjHuT6vH8eOc+r+/JsgvGGySnZgtZIWHhoNySLRDItklYCsK7sc1j4VWJXJInkVMk1WX98X/4XzGouji1KAxA+Al8xasMtBcSwcn7gGSBScoVDyyhhl/JYToswLxgu4gWYKHSrkaCUlcAw8yrBKKd8BeQEeoC9QY69BghE88ATDKqWs5QYqZZp2zVNmVKacW8VZeY/KsBwC1iRCeZuM7eB3LQykgRW6CVIT1gqZ7yAzYIsH9QqhQxRKknpzzrmqJZ5rEWLdCOVtla71BZMcyjK4fE8oat7p4LrH9aj0ICpQdej4TyKUR1NzlUcLl1rpH0pyCFCTSMTzE+SoQTu70/suZD8MIRFMxvi46/WxMXpyeuagyog/bVvbMsOqsOrkKLabfy4eTFNTXAJXKaTtV+RVhwEKugZsM/eiBIllQ21uIDuHfxZY9B9C3HfD1ARrpyUOuLWnFTqDiQ1cx/fuVuWqJo/jKBGnHk0Z1z5CFNg1MSUzkVPkNBZFb1UuZNQcpihyB6kwwLH/Xe97OcWTI+ZMFV2ALzgb3oGtS4y7XY5sI/vjeyXRDC5S2OjDNaqtOOOPvn05Pj3xXMUuGTKv0tdmOftrxn4zvNChOEtiLyV4lX0hKoxe/GFT3X3vUSIbnae2qKr2vrgRUlSsvKtR19M5Arl5fncReuMFkzlMTuVwBl8yNkd/2+2+cJcdFWOS2/yfwLwoC4d/Kq4dicdmu4Fmw6TIwKL3C3DrMW/DmhfYGlWJ6aMOazGnewcy1Ursj5N/7d5QTH42TGtIwx/9JBATuQGG4P2TmNWSt+dnOQmN1bPP77/+ATxvqWizDgAA" -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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/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@v6 35 | with: 36 | ref: ${{ github.event.pull_request.head.sha || github.ref }} 37 | 38 | - uses: snyk/actions/node@9adf32b1121593767fc3c057af55b55db032dc04 # pin@1.0.0 39 | env: 40 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 41 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /.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/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/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: 22 23 | 24 | jobs: 25 | browserstack: 26 | name: BrowserStack Tests 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v6 32 | with: 33 | ref: ${{ github.event.pull_request.head.sha || github.ref }} 34 | 35 | - name: Build package 36 | uses: ./.github/actions/build 37 | with: 38 | node: ${{ env.NODE_VERSION }} 39 | 40 | - name: Run tests 41 | shell: bash 42 | 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 }}" 43 | env: 44 | BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 45 | BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} 46 | -------------------------------------------------------------------------------- /.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/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 }} -------------------------------------------------------------------------------- /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 | 64 | export const DEFAULT_AUDIENCE = 'default'; 65 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/transaction-manager.ts: -------------------------------------------------------------------------------- 1 | import { ClientStorage } from './storage'; 2 | 3 | const TRANSACTION_STORAGE_KEY_PREFIX = 'a0.spajs.txs'; 4 | 5 | export interface LoginTransaction { 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 | response_type: 'code'; 15 | } 16 | 17 | export interface ConnectAccountTransaction { 18 | appState?: any; 19 | audience?: string; 20 | auth_session: string; 21 | code_verifier: string; 22 | redirect_uri: string; 23 | scope?: string; 24 | state: string; 25 | connection: string; 26 | response_type: 'connect_code'; 27 | } 28 | 29 | export class TransactionManager { 30 | private storageKey: string; 31 | 32 | constructor( 33 | private storage: ClientStorage, 34 | private clientId: string, 35 | private cookieDomain?: string 36 | ) { 37 | this.storageKey = `${TRANSACTION_STORAGE_KEY_PREFIX}.${this.clientId}`; 38 | } 39 | 40 | public create(transaction: T) { 41 | this.storage.save(this.storageKey, transaction, { 42 | daysUntilExpire: 1, 43 | cookieDomain: this.cookieDomain 44 | }); 45 | } 46 | 47 | public get(): T | undefined { 48 | return this.storage.get(this.storageKey); 49 | } 50 | 51 | public remove() { 52 | this.storage.remove(this.storageKey, { 53 | cookieDomain: this.cookieDomain 54 | }); 55 | } 56 | } -------------------------------------------------------------------------------- /.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@v6 42 | 43 | - name: Initialize CodeQL 44 | uses: github/codeql-action/init@v4 45 | with: 46 | languages: ${{ matrix.language }} 47 | queries: +security-and-quality 48 | 49 | - name: Autobuild 50 | uses: github/codeql-action/autobuild@v4 51 | 52 | - name: Perform CodeQL Analysis 53 | uses: github/codeql-action/analyze@v4 54 | with: 55 | category: '/language:${{ matrix.language }}' 56 | -------------------------------------------------------------------------------- /src/dpop/dpop.ts: -------------------------------------------------------------------------------- 1 | import { DpopStorage } from './storage'; 2 | import * as dpopUtils from './utils'; 3 | 4 | export class Dpop { 5 | protected readonly storage: DpopStorage; 6 | 7 | public constructor(clientId: string) { 8 | this.storage = new DpopStorage(clientId); 9 | } 10 | 11 | public getNonce(id?: string): Promise { 12 | return this.storage.findNonce(id); 13 | } 14 | 15 | public setNonce(nonce: string, id?: string): Promise { 16 | return this.storage.setNonce(nonce, id); 17 | } 18 | 19 | protected async getOrGenerateKeyPair(): Promise { 20 | let keyPair = await this.storage.findKeyPair(); 21 | 22 | if (!keyPair) { 23 | keyPair = await dpopUtils.generateKeyPair(); 24 | await this.storage.setKeyPair(keyPair); 25 | } 26 | 27 | return keyPair; 28 | } 29 | 30 | public async generateProof(params: { 31 | url: string; 32 | method: string; 33 | nonce?: string; 34 | accessToken?: string; 35 | }): Promise { 36 | const keyPair = await this.getOrGenerateKeyPair(); 37 | 38 | return dpopUtils.generateProof({ 39 | keyPair, 40 | ...params 41 | }); 42 | } 43 | 44 | public async calculateThumbprint(): Promise { 45 | const keyPair = await this.getOrGenerateKeyPair(); 46 | 47 | return dpopUtils.calculateThumbprint(keyPair); 48 | } 49 | 50 | public async clear(): Promise { 51 | await Promise.all([ 52 | this.storage.clearNonces(), 53 | this.storage.clearKeyPairs() 54 | ]); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | ConnectError, 27 | GenericError, 28 | AuthenticationError, 29 | TimeoutError, 30 | PopupTimeoutError, 31 | PopupCancelledError, 32 | PopupOpenError, 33 | MfaRequiredError, 34 | MissingRefreshTokenError, 35 | UseDpopNonceError 36 | } from './errors'; 37 | 38 | export { 39 | ICache, 40 | LocalStorageCache, 41 | InMemoryCache, 42 | Cacheable, 43 | DecodedToken, 44 | CacheEntry, 45 | WrappedCacheEntry, 46 | KeyManifestEntry, 47 | MaybePromise, 48 | CacheKey, 49 | CacheKeyData 50 | } from './cache'; 51 | 52 | export type { 53 | FetcherConfig, 54 | Fetcher, 55 | CustomFetchMinimalOutput 56 | } from './fetcher'; 57 | 58 | export { MyAccountApiError } from './MyAccountApiClient'; 59 | 60 | export { CustomTokenExchangeOptions } from './TokenExchange'; 61 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/dpop/utils.ts: -------------------------------------------------------------------------------- 1 | import * as dpopLib from 'dpop'; 2 | 3 | export const DPOP_NONCE_HEADER = 'dpop-nonce'; 4 | 5 | const KEY_PAIR_ALGORITHM: dpopLib.JWSAlgorithm = 'ES256'; 6 | 7 | const SUPPORTED_GRANT_TYPES = [ 8 | 'authorization_code', 9 | 'refresh_token', 10 | 'urn:ietf:params:oauth:grant-type:token-exchange' 11 | ]; 12 | 13 | export type KeyPair = Readonly; 14 | 15 | type GenerateProofParams = { 16 | keyPair: KeyPair; 17 | url: string; 18 | method: string; 19 | nonce?: string; 20 | accessToken?: string; 21 | }; 22 | 23 | export function generateKeyPair(): Promise { 24 | return dpopLib.generateKeyPair(KEY_PAIR_ALGORITHM, { extractable: false }); 25 | } 26 | 27 | export function calculateThumbprint( 28 | keyPair: Pick 29 | ): Promise { 30 | return dpopLib.calculateThumbprint(keyPair.publicKey); 31 | } 32 | 33 | function normalizeUrl(url: string): string { 34 | const parsedUrl = new URL(url); 35 | 36 | /** 37 | * "The HTTP target URI (...) without query and fragment parts" 38 | * @see {@link https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6} 39 | */ 40 | parsedUrl.search = ''; 41 | parsedUrl.hash = ''; 42 | 43 | return parsedUrl.href; 44 | } 45 | 46 | export function generateProof({ 47 | keyPair, 48 | url, 49 | method, 50 | nonce, 51 | accessToken 52 | }: GenerateProofParams): Promise { 53 | const normalizedUrl = normalizeUrl(url); 54 | 55 | return dpopLib.generateProof( 56 | keyPair, 57 | normalizedUrl, 58 | method, 59 | nonce, 60 | accessToken 61 | ); 62 | } 63 | 64 | export function isGrantTypeSupported(grantType: string): boolean { 65 | return SUPPORTED_GRANT_TYPES.includes(grantType); 66 | } 67 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { TokenEndpointOptions, TokenEndpointResponse } from './global'; 2 | import { DEFAULT_AUTH0_CLIENT, DEFAULT_AUDIENCE } from './constants'; 3 | import * as dpopUtils from './dpop/utils'; 4 | import { getJSON } from './http'; 5 | import { createQueryParams, stripAuth0Client } from './utils'; 6 | 7 | export async function oauthToken( 8 | { 9 | baseUrl, 10 | timeout, 11 | audience, 12 | scope, 13 | auth0Client, 14 | useFormData, 15 | useMrrt, 16 | dpop, 17 | ...options 18 | }: TokenEndpointOptions, 19 | worker?: Worker 20 | ) { 21 | const isTokenExchange = 22 | options.grant_type === 'urn:ietf:params:oauth:grant-type:token-exchange'; 23 | 24 | const refreshWithMrrt = options.grant_type === 'refresh_token' && useMrrt; 25 | 26 | const allParams = { 27 | ...options, 28 | ...(isTokenExchange && audience && { audience }), 29 | ...(isTokenExchange && scope && { scope }), 30 | ...(refreshWithMrrt && { audience, scope }) 31 | }; 32 | 33 | const body = useFormData 34 | ? createQueryParams(allParams) 35 | : JSON.stringify(allParams); 36 | 37 | const isDpopSupported = dpopUtils.isGrantTypeSupported(options.grant_type); 38 | 39 | return await getJSON( 40 | `${baseUrl}/oauth/token`, 41 | timeout, 42 | audience || DEFAULT_AUDIENCE, 43 | scope, 44 | { 45 | method: 'POST', 46 | body, 47 | headers: { 48 | 'Content-Type': useFormData 49 | ? 'application/x-www-form-urlencoded' 50 | : 'application/json', 51 | 'Auth0-Client': btoa( 52 | JSON.stringify(stripAuth0Client(auth0Client || DEFAULT_AUTH0_CLIENT)) 53 | ) 54 | } 55 | }, 56 | worker, 57 | useFormData, 58 | useMrrt, 59 | isDpopSupported ? dpop : undefined 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /__tests__/constants.ts: -------------------------------------------------------------------------------- 1 | import version from '../src/version'; 2 | import { DEFAULT_SCOPE } from '../src/constants'; 3 | import type * as dpopUtils from '../src/dpop/utils'; 4 | 5 | export const TEST_AUTH0_CLIENT_QUERY_STRING = `&auth0Client=${encodeURIComponent( 6 | btoa( 7 | JSON.stringify({ 8 | name: 'auth0-spa-js', 9 | version: version 10 | }) 11 | ) 12 | )}`; 13 | 14 | export const TEST_DOMAIN = 'auth0_domain'; 15 | export const TEST_CLIENT_ID = 'auth0_client_id'; 16 | export const TEST_REDIRECT_URI = 'my_callback_url'; 17 | export const TEST_AUDIENCE = 'my_audience'; 18 | export const TEST_ID_TOKEN = 'my_id_token'; 19 | export const TEST_ACCESS_TOKEN = 'my_access_token'; 20 | export const TEST_REFRESH_TOKEN = 'my_refresh_token'; 21 | export const TEST_TOKEN_TYPE = 'Bearer'; 22 | export const TEST_STATE = 'MTIz'; 23 | export const TEST_NONCE = 'MTIz'; 24 | export const TEST_CODE = 'my_code'; 25 | export const TEST_SCOPES = DEFAULT_SCOPE; 26 | export const TEST_CODE_CHALLENGE = 'TEST_CODE_CHALLENGE'; 27 | export const TEST_CODE_VERIFIER = '123'; 28 | export const GET_TOKEN_SILENTLY_LOCK_KEY = 'auth0.lock.getTokenSilently'; 29 | export const TEST_QUERY_PARAMS = 'query=params'; 30 | export const TEST_ENCODED_STATE = 'encoded-state'; 31 | export const TEST_RANDOM_STRING = 'random-string'; 32 | export const TEST_ARRAY_BUFFER = 'this-is-an-array-buffer'; 33 | export const TEST_BASE64_ENCODED_STRING = 'base64-url-encoded-string'; 34 | export const TEST_USER_ID = 'user-id'; 35 | export const TEST_USER_EMAIL = 'user@email.com'; 36 | export const TEST_APP_STATE = { bestPet: 'dog' }; 37 | export const TEST_ORG_ID = 'org_id_123'; 38 | export const TEST_DPOP_NONCE = 'dpop_nonce_abc123'; 39 | export const TEST_DPOP_KEYPAIR = { 40 | publicKey: { type: 'public' }, 41 | privateKey: { type: 'private' } 42 | } as dpopUtils.KeyPair; 43 | export const TEST_DPOP_PROOF = 'dpop.proof.abc123'; 44 | 45 | export const nowSeconds = () => Math.floor(Date.now() / 1000); 46 | export const dayInSeconds = 86400; 47 | -------------------------------------------------------------------------------- /.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@v6 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/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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | * If not provided, falls back to the client's default audience. 43 | * 44 | * @example 45 | * "https://api.your-service.com/v1" 46 | */ 47 | audience?: string; 48 | 49 | /** 50 | * Space-separated list of OAuth 2.0 scopes being requested 51 | * 52 | * @remarks 53 | * Subject to API authorization policies configured in Auth0 54 | * 55 | * @example 56 | * "openid profile email read:data write:data" 57 | */ 58 | scope?: string; 59 | 60 | /** 61 | * ID or name of the organization to use when authenticating a user. 62 | * When provided, the user will be authenticated using the organization context. 63 | * The organization ID will be present in the access token payload. 64 | */ 65 | organization?: string; 66 | 67 | /** 68 | * Additional custom parameters for Auth0 Action processing 69 | * 70 | * @remarks 71 | * Accessible in Action code via `event.request.body` 72 | * 73 | * @example 74 | * ```typescript 75 | * { 76 | * custom_parameter: "session_context", 77 | * device_fingerprint: "a3d8f7...", 78 | * } 79 | * ``` 80 | */ 81 | [key: string]: unknown; 82 | }; 83 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/createFetcher.test.ts: -------------------------------------------------------------------------------- 1 | import { Auth0Client } from '../../src/Auth0Client'; 2 | import 'fake-indexeddb/auto'; 3 | 4 | (global).crypto = { subtle: {} }; 5 | 6 | describe('Auth0Client', () => { 7 | let client: Auth0Client; 8 | 9 | beforeEach(() => { 10 | client = new Auth0Client({ 11 | domain: 'test.auth0.com', 12 | clientId: 'abc123', 13 | useDpop: true 14 | }); 15 | }); 16 | 17 | describe('createFetcher', () => { 18 | it('retries with dpop nonce in JWT payload after 401 with dpop-nonce header', async () => { 19 | const mockFetch = jest.fn(); 20 | mockFetch.mockImplementationOnce(async () => { 21 | return { 22 | status: 401, 23 | headers: Object.entries({ 24 | 'dpop-nonce': 'test-nonce', 25 | 'www-authenticate': `DPoP error="use_dpop_nonce"` 26 | }) 27 | }; 28 | }); 29 | mockFetch.mockImplementationOnce(async () => { 30 | return { 31 | status: 200, 32 | headers: { get: () => undefined }, 33 | }; 34 | }); 35 | (client as any).generateDpopProof = jest.fn().mockReturnValue('proof'); 36 | const fetcher = client.createFetcher({ 37 | dpopNonceId: 'nonce-id', 38 | getAccessToken: jest.fn().mockReturnValue('at'), 39 | fetch: mockFetch, 40 | }); 41 | 42 | await fetcher.fetchWithAuth('https://api.example.com/data'); 43 | 44 | const retry = (client as any).generateDpopProof.mock.calls[1][0]; 45 | expect(retry.nonce).toBe('test-nonce'); 46 | }); 47 | 48 | it('retries with expired dpop nonce', async () => { 49 | const mockFetch = jest.fn(); 50 | mockFetch.mockImplementationOnce(async () => { 51 | return { 52 | status: 401, 53 | headers: Object.entries({ 54 | 'dpop-nonce': 'test-nonce', 55 | 'www-authenticate': `DPoP error="invalid_dpop_nonce" error_description="DPoP nonce is too old"` 56 | }) 57 | }; 58 | }); 59 | mockFetch.mockImplementationOnce(async () => { 60 | return { 61 | status: 200, 62 | headers: { get: () => undefined }, 63 | }; 64 | }); 65 | (client as any).generateDpopProof = jest.fn().mockReturnValue('proof'); 66 | const fetcher = client.createFetcher({ 67 | dpopNonceId: 'nonce-id', 68 | getAccessToken: jest.fn().mockReturnValue('at'), 69 | fetch: mockFetch, 70 | }); 71 | 72 | await fetcher.fetchWithAuth('https://api.example.com/data'); 73 | 74 | const retry = (client as any).generateDpopProof.mock.calls[1][0]; 75 | expect(retry.nonce).toBe('test-nonce'); 76 | }); 77 | 78 | }); 79 | 80 | }); 81 | -------------------------------------------------------------------------------- /.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@v6 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 }} -------------------------------------------------------------------------------- /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 | token_type?: string; 77 | access_token: string; 78 | expires_in: number; 79 | decodedToken?: DecodedToken; 80 | audience: string; 81 | scope: string; 82 | client_id: string; 83 | refresh_token?: string; 84 | oauthTokenScope?: string; 85 | }; 86 | 87 | export type WrappedCacheEntry = { 88 | body: Partial; 89 | expiresAt: number; 90 | }; 91 | 92 | export type KeyManifestEntry = { 93 | keys: string[]; 94 | }; 95 | 96 | export type Cacheable = WrappedCacheEntry | KeyManifestEntry; 97 | 98 | export type MaybePromise = Promise | T; 99 | 100 | export interface ICache { 101 | set(key: string, entry: T): MaybePromise; 102 | get(key: string): MaybePromise; 103 | remove(key: string): MaybePromise; 104 | allKeys?(): MaybePromise; 105 | } 106 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /__tests__/dpop/utils.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * We don't need the DOM for this specific test suite. 3 | * 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, expect } from '@jest/globals'; 8 | import * as dpopLib from 'dpop'; 9 | import * as dpopUtils from '../../src/dpop/utils'; 10 | import { 11 | TEST_ACCESS_TOKEN, 12 | TEST_DPOP_KEYPAIR, 13 | TEST_DPOP_NONCE 14 | } from '../constants'; 15 | 16 | describe('utils', () => { 17 | beforeEach(() => { 18 | jest.resetAllMocks(); 19 | jest.restoreAllMocks(); 20 | }); 21 | 22 | describe('generateKeyPair()', () => { 23 | beforeEach(() => { 24 | jest 25 | .spyOn(dpopLib, 'generateKeyPair') 26 | .mockResolvedValue(TEST_DPOP_KEYPAIR); 27 | }); 28 | 29 | beforeEach(() => dpopUtils.generateKeyPair()); 30 | 31 | it('delegates to dpop lib properly', () => 32 | expect(dpopLib.generateKeyPair).toHaveBeenCalledWith('ES256', { 33 | extractable: false 34 | })); 35 | }); 36 | 37 | describe('calculateThumbprint()', () => { 38 | beforeEach(() => { 39 | jest.spyOn(dpopLib, 'calculateThumbprint').mockResolvedValue('abc123'); 40 | }); 41 | 42 | beforeEach(() => dpopUtils.calculateThumbprint(TEST_DPOP_KEYPAIR)); 43 | 44 | it('delegates to dpop lib properly', () => 45 | expect(dpopLib.calculateThumbprint).toHaveBeenCalledWith( 46 | TEST_DPOP_KEYPAIR.publicKey 47 | )); 48 | }); 49 | 50 | describe('generateProof()', () => { 51 | const originalUrl = 'https://user:pass@www.example.com:123/foo?bar=1#frag'; 52 | const expectedUrl = 'https://user:pass@www.example.com:123/foo'; 53 | 54 | const params = { 55 | keyPair: TEST_DPOP_KEYPAIR, 56 | url: originalUrl, 57 | method: 'PATCH', 58 | nonce: TEST_DPOP_NONCE, 59 | accessToken: TEST_ACCESS_TOKEN 60 | }; 61 | 62 | beforeEach(() => { 63 | jest.spyOn(dpopLib, 'generateProof').mockResolvedValue('abc123'); 64 | }); 65 | 66 | beforeEach(() => dpopUtils.generateProof(params)); 67 | 68 | it('delegates to dpop lib properly', () => 69 | expect(dpopLib.generateProof).toHaveBeenCalledWith( 70 | params.keyPair, 71 | expectedUrl, 72 | params.method, 73 | params.nonce, 74 | params.accessToken 75 | )); 76 | }); 77 | 78 | describe('isGrantTypeSupported()', () => { 79 | const cases: [string, boolean][] = [ 80 | ['authorization_code', true], 81 | ['client_credentials', false], 82 | ['implicit', false], 83 | ['password', false], 84 | ['refresh_token', true], 85 | ['urn:ietf:params:oauth:grant-type:device_code', false], 86 | ['urn:ietf:params:oauth:grant-type:jwt-bearer', false], 87 | ['urn:ietf:params:oauth:grant-type:saml2-bearer', false], 88 | ['urn:ietf:params:oauth:grant-type:token-exchange', true], 89 | ['urn:ietf:params:oauth:grant-type:uma-ticket', false] 90 | ]; 91 | 92 | describe.each(cases)('%s', (grantType, expected) => { 93 | it(`returns ${expected}`, () => 94 | expect(dpopUtils.isGrantTypeSupported(grantType)).toBe(expected)); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /scripts/oidc-provider.mjs: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /src/scope.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_AUDIENCE } from "./constants"; 2 | 3 | /** 4 | * @ignore 5 | */ 6 | const dedupe = (arr: string[]) => Array.from(new Set(arr)); 7 | 8 | /** 9 | * @ignore 10 | */ 11 | /** 12 | * Returns a string of unique scopes by removing duplicates and unnecessary whitespace. 13 | * 14 | * @param {...(string | undefined)[]} scopes - A list of scope strings or undefined values. 15 | * @returns {string} A string containing unique scopes separated by a single space. 16 | */ 17 | export const getUniqueScopes = (...scopes: (string | undefined)[]) => { 18 | return dedupe(scopes.filter(Boolean).join(' ').trim().split(/\s+/)).join(' '); 19 | }; 20 | 21 | /** 22 | * @ignore 23 | */ 24 | /** 25 | * We will check if the developer has created the client with a string or object of audience:scopes. We will inject 26 | * the base scopes to each audience, and store the base ones inside default key. As well, if the developer created the Auth0Client 27 | * with a string of scopes, we will store the requested ones with the base scopes inside the default key as well. 28 | * @param authScopes The scopes requested by the user when creating the Auth0Client 29 | * @param openIdScope openId scope 30 | * @param extraScopes Other scopes to accumulate such as offline_access 31 | * @returns {Record} An object with all scopes that are going to be accumulated. 32 | */ 33 | export const injectDefaultScopes = (authScopes: string | Record | undefined, openIdScope: string, ...extraScopes: string[]): Record => { 34 | if (typeof authScopes !== 'object') { 35 | return { [DEFAULT_AUDIENCE]: getUniqueScopes(openIdScope, authScopes, ...extraScopes) }; 36 | } 37 | 38 | let requestedScopes: Record = { 39 | [DEFAULT_AUDIENCE]: getUniqueScopes(openIdScope, ...extraScopes), 40 | }; 41 | 42 | Object.keys(authScopes).forEach((key) => { 43 | const audienceScopes = authScopes[key]; 44 | 45 | requestedScopes[key] = getUniqueScopes(openIdScope, audienceScopes, ...extraScopes); 46 | }); 47 | 48 | return requestedScopes; 49 | } 50 | 51 | /** 52 | * @ignore 53 | */ 54 | /** 55 | * Will return a string of scopes. If a specific audience was requested and it exist inside the scopes object, we will return those 56 | * related to that audience that we want to accumulate. If not, we will return the ones stored inside the default key. 57 | * @param authScopes Object of audience:scopes that are going to be accumulated 58 | * @param methodScopes The scopes requested for the developer in a specific request 59 | * @param audience The audience the developer requested for an specific request or the one they configured in the Auth0Client 60 | * @returns {string} A combination of Auth0Client scopes and the ones requested by the developer for a specific request 61 | */ 62 | export const scopesToRequest = (authScopes: Record, methodScopes: string | undefined, audience: string | undefined): string => { 63 | let scope: string | undefined; 64 | 65 | if (audience) { 66 | scope = authScopes[audience]; 67 | } 68 | 69 | if (!scope) { 70 | scope = authScopes[DEFAULT_AUDIENCE]; 71 | } 72 | 73 | return getUniqueScopes(scope, methodScopes); 74 | } -------------------------------------------------------------------------------- /static/perf.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /__tests__/scope.test.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_AUDIENCE } from '../src/constants'; 2 | import { getUniqueScopes, injectDefaultScopes, scopesToRequest } from '../src/scope'; 3 | import { expect } from '@jest/globals'; 4 | 5 | describe('getUniqueScopes', () => { 6 | it('removes duplicates', () => { 7 | expect(getUniqueScopes('openid openid', 'email')).toBe('openid email'); 8 | }); 9 | 10 | it('handles whitespace', () => { 11 | expect(getUniqueScopes(' openid profile ', ' ')).toBe('openid profile'); 12 | }); 13 | 14 | it('handles undefined/empty/null/whitespace', () => { 15 | expect( 16 | getUniqueScopes('openid profile', ' ', undefined, 'email', '', null) 17 | ).toBe('openid profile email'); 18 | }); 19 | }); 20 | 21 | describe('injectDefaultScopes', () => { 22 | describe('when authScopes are not an object', () => { 23 | it('returns an object with default key', () => { 24 | const authScopes = 'read:orders create:orders'; 25 | 26 | expect( 27 | injectDefaultScopes(authScopes, 'openId'), 28 | ).toMatchObject({ 29 | [DEFAULT_AUDIENCE]: 'openId read:orders create:orders', 30 | }); 31 | }); 32 | }); 33 | 34 | describe('when authScopes does not exist', () => { 35 | it('returns an object with default key', () => { 36 | const authScopes = undefined; 37 | 38 | expect( 39 | injectDefaultScopes(authScopes, 'openId'), 40 | ).toMatchObject({ 41 | [DEFAULT_AUDIENCE]: 'openId', 42 | }); 43 | }); 44 | }); 45 | 46 | describe('when auth0Scopes is an object', () => { 47 | it('returns an object with audience:scope and default key', () => { 48 | const authScopes = { 49 | orders: 'read:orders', 50 | users: 'create:users', 51 | }; 52 | 53 | expect( 54 | injectDefaultScopes(authScopes, 'openId'), 55 | ).toMatchObject({ 56 | [DEFAULT_AUDIENCE]: 'openId', 57 | orders: 'openId read:orders', 58 | users: 'openId create:users', 59 | }); 60 | }); 61 | }); 62 | }); 63 | 64 | describe('scopesToRequest', () => { 65 | describe('when audience exist inside authScopes', () => { 66 | it('returns a string of joined scopes', () => { 67 | const authScopes = { 68 | orders: 'openId read:orders', 69 | users: 'openId create:users', 70 | [DEFAULT_AUDIENCE]: 'openId' 71 | }; 72 | const methodScopes = 'read:users'; 73 | const audience = 'users'; 74 | 75 | expect( 76 | scopesToRequest(authScopes, methodScopes, audience), 77 | ).toBe('openId create:users read:users'); 78 | }); 79 | }); 80 | 81 | describe('when audience does not exist inside authScopes', () => { 82 | it('returns a string of joined default scopes and method scopes', () => { 83 | const authScopes = { 84 | orders: 'openId read:orders', 85 | users: 'openId create:users', 86 | [DEFAULT_AUDIENCE]: 'openId' 87 | }; 88 | const methodScopes = 'read:users'; 89 | const audience = 'books'; 90 | 91 | expect( 92 | scopesToRequest(authScopes, methodScopes, audience), 93 | ).toBe('openId read:users'); 94 | }); 95 | }); 96 | 97 | describe('when audience does not exist', () => { 98 | it('returns a string of joined default scopes and method scopes', () => { 99 | const authScopes = { 100 | orders: 'openId read:orders', 101 | users: 'openId create:users', 102 | [DEFAULT_AUDIENCE]: 'openId' 103 | }; 104 | const methodScopes = 'read:users'; 105 | const audience = undefined; 106 | 107 | expect( 108 | scopesToRequest(authScopes, methodScopes, audience), 109 | ).toBe('openId read:users'); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/dpop.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * We don't need the DOM for this specific test suite. 3 | * 4 | * @jest-environment node 5 | */ 6 | 7 | import { Auth0Client, Auth0ClientOptions } from '../../src'; 8 | import { 9 | TEST_CLIENT_ID, 10 | TEST_DOMAIN, 11 | TEST_DPOP_NONCE, 12 | TEST_DPOP_PROOF 13 | } from '../constants'; 14 | 15 | import { beforeEach, describe, expect } from '@jest/globals'; 16 | import { Dpop } from '../../src/dpop/dpop'; 17 | 18 | function newTestAuth0Client( 19 | extraOpts?: Partial 20 | ): Auth0Client { 21 | return new Auth0Client({ 22 | ...extraOpts, 23 | domain: TEST_DOMAIN, 24 | clientId: TEST_CLIENT_ID 25 | }); 26 | } 27 | 28 | describe('Auth0Client', () => { 29 | beforeEach(() => { 30 | jest.resetAllMocks(); 31 | jest.restoreAllMocks(); 32 | }); 33 | 34 | describe('_assertDpop()', () => { 35 | const auth0 = newTestAuth0Client(); 36 | 37 | describe('DPoP disabled', () => { 38 | it('throws an error', () => 39 | expect(() => auth0['_assertDpop'](undefined)).toThrow( 40 | '`useDpop` option must be enabled before using DPoP.' 41 | )); 42 | }); 43 | 44 | describe('DPoP enabled', () => { 45 | const dpop = new Dpop(TEST_CLIENT_ID); 46 | 47 | it('does not throw', () => 48 | expect(() => auth0['_assertDpop'](dpop)).not.toThrow()); 49 | }); 50 | }); 51 | 52 | describe('getDpopNonce()', () => { 53 | const id = 'my_custom_api'; 54 | const auth0 = newTestAuth0Client({ useDpop: true }); 55 | const dpop = auth0['dpop']!; 56 | 57 | beforeEach(() => { 58 | auth0['_assertDpop'] = jest.fn(); 59 | jest.spyOn(dpop, 'getNonce').mockResolvedValue(TEST_DPOP_NONCE); 60 | }); 61 | 62 | let output: unknown; 63 | 64 | beforeEach(async () => { 65 | output = await auth0.getDpopNonce(id); 66 | }); 67 | 68 | it('asserts DPoP is enabled', () => 69 | expect(auth0['_assertDpop']).toHaveBeenCalled()); 70 | 71 | it('delegates into Dpop.getNonce()', () => { 72 | expect(dpop.getNonce).toHaveBeenCalledWith(id); 73 | expect(output).toBe(TEST_DPOP_NONCE); 74 | }); 75 | }); 76 | 77 | describe('setDpopNonce()', () => { 78 | const id = 'my_custom_api'; 79 | const auth0 = newTestAuth0Client({ useDpop: true }); 80 | const dpop = auth0['dpop']!; 81 | 82 | beforeEach(() => { 83 | auth0['_assertDpop'] = jest.fn(); 84 | jest.spyOn(dpop, 'setNonce').mockResolvedValue(); 85 | }); 86 | 87 | beforeEach(() => auth0.setDpopNonce(TEST_DPOP_NONCE, id)); 88 | 89 | it('asserts DPoP is enabled', () => 90 | expect(auth0['_assertDpop']).toHaveBeenCalled()); 91 | 92 | it('delegates into Dpop.setNonce()', () => 93 | expect(dpop.setNonce).toHaveBeenCalledWith(TEST_DPOP_NONCE, id)); 94 | }); 95 | 96 | describe('generateDpopProof()', () => { 97 | const auth0 = newTestAuth0Client({ useDpop: true }); 98 | const dpop = auth0['dpop']!; 99 | 100 | const params: Parameters[0] = { 101 | accessToken: 'test-access-token', 102 | method: 'test-method', 103 | url: 'test-url', 104 | nonce: 'test-nonce' 105 | }; 106 | 107 | beforeEach(() => { 108 | auth0['_assertDpop'] = jest.fn(); 109 | jest.spyOn(dpop, 'generateProof').mockResolvedValue(TEST_DPOP_PROOF); 110 | }); 111 | 112 | let output: string; 113 | 114 | beforeEach(async () => { 115 | output = await auth0.generateDpopProof(params); 116 | }); 117 | 118 | it('asserts DPoP is enabled', () => 119 | expect(auth0['_assertDpop']).toHaveBeenCalled()); 120 | 121 | it('delegates into Dpop.generateProof()', () => 122 | expect(dpop.generateProof).toHaveBeenCalledWith(params)); 123 | 124 | it('returns the proof', () => expect(output).toBe(TEST_DPOP_PROOF)); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /src/dpop/storage.ts: -------------------------------------------------------------------------------- 1 | import { type KeyPair } from './utils'; 2 | 3 | const VERSION = 1; 4 | const NAME = 'auth0-spa-js'; 5 | const TABLES = { 6 | NONCE: 'nonce', 7 | KEYPAIR: 'keypair' 8 | } as const; 9 | 10 | const AUTH0_NONCE_ID = 'auth0'; 11 | 12 | type Table = (typeof TABLES)[keyof typeof TABLES]; 13 | 14 | export class DpopStorage { 15 | protected readonly clientId: string; 16 | protected dbHandle: IDBDatabase | undefined; 17 | 18 | constructor(clientId: string) { 19 | this.clientId = clientId; 20 | } 21 | 22 | protected getVersion(): number { 23 | return VERSION; 24 | } 25 | 26 | protected createDbHandle(): Promise { 27 | const req = window.indexedDB.open(NAME, this.getVersion()); 28 | 29 | return new Promise((resolve, reject) => { 30 | req.onupgradeneeded = () => 31 | Object.values(TABLES).forEach(t => req.result.createObjectStore(t)); 32 | 33 | req.onerror = () => reject(req.error); 34 | req.onsuccess = () => resolve(req.result); 35 | }); 36 | } 37 | 38 | protected async getDbHandle(): Promise { 39 | if (!this.dbHandle) { 40 | this.dbHandle = await this.createDbHandle(); 41 | } 42 | 43 | return this.dbHandle; 44 | } 45 | 46 | protected async executeDbRequest( 47 | table: string, 48 | mode: IDBTransactionMode, 49 | requestFactory: (table: IDBObjectStore) => IDBRequest 50 | ): Promise { 51 | const db = await this.getDbHandle(); 52 | 53 | const txn = db.transaction(table, mode); 54 | const store = txn.objectStore(table); 55 | 56 | const request = requestFactory(store); 57 | 58 | return new Promise((resolve, reject) => { 59 | request.onsuccess = () => resolve(request.result); 60 | request.onerror = () => reject(request.error); 61 | }); 62 | } 63 | 64 | protected buildKey(id?: string): string { 65 | const finalId = id 66 | ? `_${id}` // prefix to avoid collisions 67 | : AUTH0_NONCE_ID; 68 | 69 | return `${this.clientId}::${finalId}`; 70 | } 71 | 72 | public setNonce(nonce: string, id?: string): Promise { 73 | return this.save(TABLES.NONCE, this.buildKey(id), nonce); 74 | } 75 | 76 | public setKeyPair(keyPair: KeyPair): Promise { 77 | return this.save(TABLES.KEYPAIR, this.buildKey(), keyPair); 78 | } 79 | 80 | protected async save( 81 | table: Table, 82 | key: IDBValidKey, 83 | obj: unknown 84 | ): Promise { 85 | return void await this.executeDbRequest(table, 'readwrite', table => 86 | table.put(obj, key) 87 | ); 88 | } 89 | 90 | public findNonce(id?: string): Promise { 91 | return this.find(TABLES.NONCE, this.buildKey(id)); 92 | } 93 | 94 | public findKeyPair(): Promise { 95 | return this.find(TABLES.KEYPAIR, this.buildKey()); 96 | } 97 | 98 | protected find( 99 | table: Table, 100 | key: IDBValidKey 101 | ): Promise { 102 | return this.executeDbRequest(table, 'readonly', table => table.get(key)); 103 | } 104 | 105 | protected async deleteBy( 106 | table: Table, 107 | predicate: (key: IDBValidKey) => boolean 108 | ): Promise { 109 | const allKeys = await this.executeDbRequest(table, 'readonly', table => 110 | table.getAllKeys() 111 | ); 112 | 113 | allKeys 114 | ?.filter(predicate) 115 | .map(k => 116 | this.executeDbRequest(table, 'readwrite', table => table.delete(k)) 117 | ); 118 | } 119 | 120 | protected deleteByClientId(table: Table, clientId: string): Promise { 121 | return this.deleteBy( 122 | table, 123 | k => typeof k === 'string' && k.startsWith(`${clientId}::`) 124 | ); 125 | } 126 | 127 | public clearNonces(): Promise { 128 | return this.deleteByClientId(TABLES.NONCE, this.clientId); 129 | } 130 | 131 | public clearKeyPairs(): Promise { 132 | return this.deleteByClientId(TABLES.KEYPAIR, this.clientId); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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-hl-14: #000000; 31 | --dark-hl-14: #C8C8C8; 32 | --light-code-background: #FFFFFF; 33 | --dark-code-background: #1E1E1E; 34 | } 35 | 36 | @media (prefers-color-scheme: light) { :root { 37 | --hl-0: var(--light-hl-0); 38 | --hl-1: var(--light-hl-1); 39 | --hl-2: var(--light-hl-2); 40 | --hl-3: var(--light-hl-3); 41 | --hl-4: var(--light-hl-4); 42 | --hl-5: var(--light-hl-5); 43 | --hl-6: var(--light-hl-6); 44 | --hl-7: var(--light-hl-7); 45 | --hl-8: var(--light-hl-8); 46 | --hl-9: var(--light-hl-9); 47 | --hl-10: var(--light-hl-10); 48 | --hl-11: var(--light-hl-11); 49 | --hl-12: var(--light-hl-12); 50 | --hl-13: var(--light-hl-13); 51 | --hl-14: var(--light-hl-14); 52 | --code-background: var(--light-code-background); 53 | } } 54 | 55 | @media (prefers-color-scheme: dark) { :root { 56 | --hl-0: var(--dark-hl-0); 57 | --hl-1: var(--dark-hl-1); 58 | --hl-2: var(--dark-hl-2); 59 | --hl-3: var(--dark-hl-3); 60 | --hl-4: var(--dark-hl-4); 61 | --hl-5: var(--dark-hl-5); 62 | --hl-6: var(--dark-hl-6); 63 | --hl-7: var(--dark-hl-7); 64 | --hl-8: var(--dark-hl-8); 65 | --hl-9: var(--dark-hl-9); 66 | --hl-10: var(--dark-hl-10); 67 | --hl-11: var(--dark-hl-11); 68 | --hl-12: var(--dark-hl-12); 69 | --hl-13: var(--dark-hl-13); 70 | --hl-14: var(--dark-hl-14); 71 | --code-background: var(--dark-code-background); 72 | } } 73 | 74 | :root[data-theme='light'] { 75 | --hl-0: var(--light-hl-0); 76 | --hl-1: var(--light-hl-1); 77 | --hl-2: var(--light-hl-2); 78 | --hl-3: var(--light-hl-3); 79 | --hl-4: var(--light-hl-4); 80 | --hl-5: var(--light-hl-5); 81 | --hl-6: var(--light-hl-6); 82 | --hl-7: var(--light-hl-7); 83 | --hl-8: var(--light-hl-8); 84 | --hl-9: var(--light-hl-9); 85 | --hl-10: var(--light-hl-10); 86 | --hl-11: var(--light-hl-11); 87 | --hl-12: var(--light-hl-12); 88 | --hl-13: var(--light-hl-13); 89 | --hl-14: var(--light-hl-14); 90 | --code-background: var(--light-code-background); 91 | } 92 | 93 | :root[data-theme='dark'] { 94 | --hl-0: var(--dark-hl-0); 95 | --hl-1: var(--dark-hl-1); 96 | --hl-2: var(--dark-hl-2); 97 | --hl-3: var(--dark-hl-3); 98 | --hl-4: var(--dark-hl-4); 99 | --hl-5: var(--dark-hl-5); 100 | --hl-6: var(--dark-hl-6); 101 | --hl-7: var(--dark-hl-7); 102 | --hl-8: var(--dark-hl-8); 103 | --hl-9: var(--dark-hl-9); 104 | --hl-10: var(--dark-hl-10); 105 | --hl-11: var(--dark-hl-11); 106 | --hl-12: var(--dark-hl-12); 107 | --hl-13: var(--dark-hl-13); 108 | --hl-14: var(--dark-hl-14); 109 | --code-background: var(--dark-code-background); 110 | } 111 | 112 | .hl-0 { color: var(--hl-0); } 113 | .hl-1 { color: var(--hl-1); } 114 | .hl-2 { color: var(--hl-2); } 115 | .hl-3 { color: var(--hl-3); } 116 | .hl-4 { color: var(--hl-4); } 117 | .hl-5 { color: var(--hl-5); } 118 | .hl-6 { color: var(--hl-6); } 119 | .hl-7 { color: var(--hl-7); } 120 | .hl-8 { color: var(--hl-8); } 121 | .hl-9 { color: var(--hl-9); } 122 | .hl-10 { color: var(--hl-10); } 123 | .hl-11 { color: var(--hl-11); } 124 | .hl-12 { color: var(--hl-12); } 125 | .hl-13 { color: var(--hl-13); } 126 | .hl-14 { color: var(--hl-14); } 127 | pre, code { background: var(--code-background); } 128 | -------------------------------------------------------------------------------- /__tests__/MyAccountApiClient.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MyAccountApiClient, 3 | MyAccountApiError 4 | } from '../src/MyAccountApiClient'; 5 | import { Fetcher } from '../src/fetcher'; 6 | 7 | const mockFetcher = { 8 | fetchWithAuth: jest.fn() 9 | } as unknown as Fetcher; 10 | 11 | const apiBase = 'https://api.example.com/'; 12 | const api = new MyAccountApiClient(mockFetcher, apiBase); 13 | 14 | describe('MyAccountApiClient', () => { 15 | beforeEach(() => { 16 | jest.clearAllMocks(); 17 | }); 18 | 19 | it('connectAccount returns response on success', async () => { 20 | const mockResponse = { 21 | ok: true, 22 | text: jest 23 | .fn() 24 | .mockResolvedValue(JSON.stringify({ 25 | connect_uri: 'uri', 26 | auth_session: 'session', 27 | connect_params: { ticket: 'ticket' }, 28 | expires_in: 3600 29 | })) 30 | }; 31 | mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); 32 | 33 | const params = { 34 | connection: 'google-oauth2', 35 | redirect_uri: 'https://redirect' 36 | }; 37 | const result = await api.connectAccount(params); 38 | 39 | expect(mockFetcher.fetchWithAuth).toHaveBeenCalledWith( 40 | `${apiBase}v1/connected-accounts/connect`, 41 | expect.objectContaining({ method: 'POST' }) 42 | ); 43 | expect(result.connect_uri).toBe('uri'); 44 | }); 45 | 46 | it('completeAccount returns response on success', async () => { 47 | const mockResponse = { 48 | ok: true, 49 | text: jest 50 | .fn() 51 | .mockResolvedValue(JSON.stringify({ 52 | id: '123', 53 | connection: 'google-oauth2', 54 | access_type: 'offline', 55 | created_at: '2024-01-01T00:00:00Z' 56 | })) 57 | }; 58 | mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); 59 | 60 | const params = { 61 | auth_session: 'session', 62 | connect_code: 'code', 63 | redirect_uri: 'https://redirect' 64 | }; 65 | const result = await api.completeAccount(params); 66 | 67 | expect(mockFetcher.fetchWithAuth).toHaveBeenCalledWith( 68 | `${apiBase}v1/connected-accounts/complete`, 69 | expect.objectContaining({ method: 'POST' }) 70 | ); 71 | expect(result.id).toBe('123'); 72 | }); 73 | 74 | it('throws MyAccountApiError on API error response with validation errors', async () => { 75 | const errorBody = { 76 | type: 'error', 77 | status: 400, 78 | title: 'Bad Request', 79 | detail: 'Invalid input', 80 | validation_errors: [ 81 | { detail: 'Connection is invalid', field: 'connection' }, 82 | { detail: 'Redirect URI is missing', field: 'redirect_uri' } 83 | ] 84 | }; 85 | const mockResponse = { 86 | ok: false, 87 | text: jest.fn().mockResolvedValue(JSON.stringify(errorBody)) 88 | }; 89 | mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); 90 | 91 | await expect( 92 | api.connectAccount({ connection: 'bad', redirect_uri: 'uri' }) 93 | ).rejects.toThrow(MyAccountApiError); 94 | 95 | try { 96 | await api.connectAccount({ connection: 'bad', redirect_uri: 'uri' }); 97 | } catch (err) { 98 | expect(err).toBeInstanceOf(MyAccountApiError); 99 | expect(err.validation_errors).toEqual(errorBody.validation_errors); 100 | } 101 | }); 102 | 103 | it('throws MyAccountApiError on invalid JSON', async () => { 104 | const mockResponse = { 105 | ok: false, 106 | text: jest.fn().mockResolvedValue('Not JSON') 107 | }; 108 | mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); 109 | 110 | await expect( 111 | api.connectAccount({ connection: 'bad', redirect_uri: 'uri' }) 112 | ).rejects.toThrow(MyAccountApiError); 113 | }); 114 | 115 | it('throws MyAccountApiError on empty response', async () => { 116 | const mockResponse = { 117 | ok: false, 118 | text: jest.fn().mockResolvedValue('') 119 | }; 120 | mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); 121 | 122 | await expect( 123 | api.connectAccount({ connection: 'bad', redirect_uri: 'uri' }) 124 | ).rejects.toThrow('SyntaxError: Unexpected end of JSON input'); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /__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/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 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 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.mjs'; 13 | 14 | import pkg from './package.json' with { type: '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 | -------------------------------------------------------------------------------- /__tests__/Auth0Client/connectAccountWithRedirect.test.ts: -------------------------------------------------------------------------------- 1 | import { Auth0Client, RedirectConnectAccountOptions } from '../../src'; 2 | 3 | (global).crypto = { 4 | subtle: { 5 | digest: () => '' 6 | }, 7 | getRandomValues: () => '' 8 | }; 9 | 10 | describe('Auth0Client', () => { 11 | let client: Auth0Client; 12 | let mockMyAccountApi: any; 13 | let mockTransactionManager: any; 14 | const oldLocation = window.location; 15 | 16 | beforeEach(() => { 17 | delete (window as any).location; 18 | window.location = { 19 | ...oldLocation, 20 | assign: jest.fn() 21 | } as Location; 22 | mockMyAccountApi = { 23 | connectAccount: jest.fn().mockResolvedValue({ 24 | connect_uri: 'https://connect.example.com', 25 | connect_params: { ticket: 'test-ticket' }, 26 | auth_session: 'test-session' 27 | }) 28 | }; 29 | mockTransactionManager = { 30 | create: jest.fn() 31 | }; 32 | client = new Auth0Client({ 33 | domain: 'test', 34 | clientId: 'abc', 35 | useDpop: true, 36 | useMrrt: true, 37 | authorizationParams: {} 38 | } as any); 39 | (client as any).myAccountApi = mockMyAccountApi; 40 | (client as any).transactionManager = 41 | mockTransactionManager; 42 | }); 43 | 44 | afterEach(() => { 45 | window.location = oldLocation; 46 | }); 47 | 48 | describe('connectAccountWithRedirect', () => { 49 | it('should call myAccountApi.connectAccount with correct params', async () => { 50 | const options: RedirectConnectAccountOptions = { 51 | connection: 'google-oauth2', 52 | scopes: ['profile', 'email'], 53 | authorization_params: { 54 | prompt: 'consent', 55 | } 56 | }; 57 | 58 | await client.connectAccountWithRedirect(options); 59 | 60 | expect(mockMyAccountApi.connectAccount).toHaveBeenCalledWith( 61 | expect.objectContaining({ 62 | connection: 'google-oauth2', 63 | scopes: ['profile', 'email'], 64 | state: expect.any(String), 65 | code_challenge: expect.any(String), 66 | code_challenge_method: 'S256', 67 | authorization_params: { 68 | prompt: 'consent', 69 | } 70 | }) 71 | ); 72 | }); 73 | 74 | it('should create a transaction with correct state and code_verifier', async () => { 75 | const options: RedirectConnectAccountOptions = { 76 | connection: 'github', 77 | appState: { 'returnTo': '/dashboard' } 78 | }; 79 | 80 | await client.connectAccountWithRedirect(options); 81 | 82 | expect(mockTransactionManager.create).toHaveBeenCalledWith( 83 | expect.objectContaining({ 84 | state: expect.any(String), 85 | code_verifier: expect.any(String), 86 | auth_session: 'test-session', 87 | redirect_uri: expect.any(String), 88 | appState: { 'returnTo': '/dashboard' }, 89 | connection: 'github', 90 | response_type: 'connect_code' 91 | }) 92 | ); 93 | }); 94 | 95 | it('should use openUrl if provided', async () => { 96 | const openUrl = jest.fn(); 97 | const options: RedirectConnectAccountOptions = { 98 | connection: 'github', 99 | openUrl 100 | }; 101 | 102 | await client.connectAccountWithRedirect(options); 103 | 104 | expect(openUrl).toHaveBeenCalledWith( 105 | 'https://connect.example.com/?ticket=test-ticket' 106 | ); 107 | }); 108 | 109 | it('should fallback to window.location.assign if openUrl is not provided', async () => { 110 | const options: RedirectConnectAccountOptions = { 111 | connection: 'github' 112 | }; 113 | 114 | await client.connectAccountWithRedirect(options); 115 | 116 | expect(window.location.assign).toHaveBeenCalledWith( 117 | expect.objectContaining({ href: 'https://connect.example.com/?ticket=test-ticket' }) 118 | ); 119 | }); 120 | 121 | it('should throw if connection is not provided', async () => { 122 | await expect((client as any).connectAccountWithRedirect({})).rejects.toThrow( 123 | 'connection is required' 124 | ); 125 | }); 126 | 127 | it('should throw if myAccountApi.connectAccount fails', async () => { 128 | mockMyAccountApi.connectAccount.mockRejectedValue( 129 | new Error('API error') 130 | ); 131 | const options: RedirectConnectAccountOptions = { 132 | connection: 'github' 133 | }; 134 | 135 | await expect(client.connectAccountWithRedirect(options)).rejects.toThrow( 136 | 'API error' 137 | ); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /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.11.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 | "ccu": { 11 | "name": "auth0-spa-js", 12 | "cdn": "https://cdn.auth0.com", 13 | "bucket": "assets.us.auth0.com", 14 | "localPath": "dist", 15 | "mainBundleFile": "auth0-spa-js.production.js", 16 | "digest": { 17 | "hashes": [ 18 | "sha384" 19 | ], 20 | "extensions": [ 21 | ".js" 22 | ] 23 | } 24 | }, 25 | "dependencies": { 26 | "browser-tabs-lock": "^1.2.15", 27 | "dpop": "^2.1.1", 28 | "es-cookie": "~1.3.2" 29 | }, 30 | "scripts": { 31 | "dev": "rimraf dist && rollup -c --watch", 32 | "start": "npm run dev", 33 | "docs": "typedoc --options ./typedoc.js src", 34 | "build": "rimraf dist && rollup -m -c --environment NODE_ENV:production && npm run test:es-check", 35 | "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", 36 | "lint": "eslint --ext .jsx,.js src/", 37 | "lint:security": "eslint ./src --ext ts --no-eslintrc --config ./.eslintrc.security", 38 | "test": "jest --coverage --silent", 39 | "test:watch": "jest --coverage --watch", 40 | "test:debug": "node --inspect node_modules/.bin/jest --runInBand", 41 | "test:open:integration": "cypress open", 42 | "test:watch:integration": "concurrently --raw npm:dev 'npm:test:open:integration'", 43 | "test:es-check": "npm run test:es-check:es2017 && npm run test:es-check:es2017:module", 44 | "test:es-check:es2017": "es-check es2017 'dist/auth0-spa-js.production.js'", 45 | "test:es-check:es2017:module": "es-check es2017 'dist/auth0-spa-js.production.esm.js' --module ", 46 | "test:integration:server": "npm run dev", 47 | "test:integration:tests": "wait-on http://localhost:3000/ && cypress run", 48 | "test:integration": "concurrently --raw --kill-others --success first npm:test:integration:server npm:test:integration:tests", 49 | "serve:coverage": "serve coverage/lcov-report -n", 50 | "serve:stats": "serve bundle-stats -n", 51 | "print-bundle-size": "node ./scripts/print-bundle-size.mjs", 52 | "prepack": "npm run build && node ./scripts/prepack", 53 | "publish:cdn": "ccu --trace" 54 | }, 55 | "devDependencies": { 56 | "@auth0/component-cdn-uploader": "^2.4.2", 57 | "@rollup/plugin-replace": "^4.0.0", 58 | "@types/cypress": "^1.1.3", 59 | "@types/jest": "^28.1.7", 60 | "@typescript-eslint/eslint-plugin-tslint": "^5.33.1", 61 | "@typescript-eslint/parser": "^5.33.1", 62 | "browserstack-cypress-cli": "1.36.0", 63 | "cli-table": "^0.3.6", 64 | "concurrently": "^7.3.0", 65 | "cypress": "13.17.0", 66 | "es-check": "^7.0.1", 67 | "eslint": "^8.22.0", 68 | "eslint-plugin-security": "^1.5.0", 69 | "fake-indexeddb": "^6.0.1", 70 | "gzip-size": "^7.0.0", 71 | "husky": "^7.0.4", 72 | "idtoken-verifier": "^2.2.2", 73 | "jest": "^28.1.3", 74 | "jest-environment-jsdom": "^28.1.3", 75 | "jest-fetch-mock": "^3.0.3", 76 | "jest-junit": "^14.0.0", 77 | "jest-localstorage-mock": "^2.4.22", 78 | "jsonwebtoken": "^9.0.0", 79 | "oidc-provider": "^9.6.0", 80 | "prettier": "^2.7.1", 81 | "pretty-quick": "^3.1.2", 82 | "rimraf": "^3.0.2", 83 | "rollup": "^2.78.0", 84 | "rollup-plugin-analyzer": "^4.0.0", 85 | "rollup-plugin-commonjs": "^10.1.0", 86 | "rollup-plugin-dev": "^1.1.3", 87 | "rollup-plugin-livereload": "^2.0.5", 88 | "rollup-plugin-node-resolve": "^5.2.0", 89 | "rollup-plugin-sourcemaps": "^0.6.3", 90 | "rollup-plugin-terser": "^7.0.2", 91 | "rollup-plugin-typescript2": "^0.36.0", 92 | "rollup-plugin-visualizer": "^5.7.1", 93 | "rollup-plugin-web-worker-loader": "^1.6.1", 94 | "serve": "^14.0.1", 95 | "ts-jest": "^28.0.8", 96 | "tslib": "^2.4.0", 97 | "typedoc": "^0.25.1", 98 | "typescript": "^4.7.4", 99 | "wait-on": "^7.2.0" 100 | }, 101 | "files": [ 102 | "src", 103 | "dist" 104 | ], 105 | "repository": { 106 | "type": "git", 107 | "url": "git://github.com/auth0/auth0-spa-js.git" 108 | }, 109 | "bugs": { 110 | "url": "https://github.com/auth0/auth0-spa-js/issues" 111 | }, 112 | "homepage": "https://github.com/auth0/auth0-spa-js#readme", 113 | "keywords": [ 114 | "auth0", 115 | "login", 116 | "Authorization Code Grant Flow", 117 | "PKCE", 118 | "Single Page Application authentication", 119 | "SPA authentication" 120 | ], 121 | "husky": { 122 | "hooks": { 123 | "pre-commit": "pretty-quick --staged" 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /.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: 22 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@v6 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@v5 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@v6 61 | 62 | - name: Setup Node 63 | uses: actions/setup-node@v6 64 | with: 65 | node-version: ${{ env.NODE_VERSION }} 66 | cache: 'npm' 67 | 68 | - name: Restore build artifacts 69 | uses: actions/cache/restore@v5 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@671740ac38dd9b0130fbe1cec585b89eea48d3de # pin@5.5.2 79 | with: 80 | token: ${{ secrets.CODECOV_TOKEN }} 81 | gatsby: 82 | needs: test 83 | 84 | name: Gatsby Tests 85 | runs-on: ubuntu-latest 86 | 87 | env: 88 | IMPORT_STATEMENT: | 89 | import './../auth0'; 90 | 91 | steps: 92 | - name: Checkout code 93 | uses: actions/checkout@v6 94 | 95 | - name: Run framework tests 96 | uses: ./.github/actions/framework 97 | with: 98 | node: ${{ env.NODE_VERSION }} 99 | cache: ${{ env.CACHE_KEY }} 100 | install: | 101 | npx gatsby new my-app < /dev/null 102 | content: | 103 | echo -e "${{ env.AUTH0_CONTENT }}" > src/auth0.js; 104 | import: | 105 | echo -e "${{ env.IMPORT_STATEMENT }}"|cat - src/pages/index.js > /tmp/out && mv /tmp/out src/pages/index.js; 106 | 107 | react: 108 | needs: test 109 | 110 | name: React Tests 111 | runs-on: ubuntu-latest 112 | 113 | steps: 114 | - name: Checkout code 115 | uses: actions/checkout@v6 116 | 117 | - name: Run framework tests 118 | uses: ./.github/actions/framework 119 | with: 120 | node: ${{ env.NODE_VERSION }} 121 | cache: ${{ env.CACHE_KEY }} 122 | install: | 123 | npx create-react-app my-app < /dev/null 124 | content: | 125 | echo -e "${{ env.AUTH0_CONTENT }}" > src/auth0.js; 126 | import: | 127 | echo -e "${{ env.IMPORT_STATEMENT }}"|cat - src/index.js > /tmp/out && mv /tmp/out src/index.js; 128 | 129 | vue: 130 | needs: test 131 | 132 | name: Vue Tests 133 | runs-on: ubuntu-latest 134 | 135 | steps: 136 | - name: Checkout code 137 | uses: actions/checkout@v6 138 | 139 | - name: Run framework tests 140 | uses: ./.github/actions/framework 141 | with: 142 | node: ${{ env.NODE_VERSION }} 143 | cache: ${{ env.CACHE_KEY }} 144 | install: | 145 | npx -p @vue/cli vue create my-app -d --packageManager npm < /dev/null 146 | content: | 147 | echo -e "${{ env.AUTH0_CONTENT }}" > src/auth0.js; 148 | import: | 149 | echo -e "${{ env.IMPORT_STATEMENT }}"|cat - src/main.js > /tmp/out && mv /tmp/out src/main.js; 150 | 151 | angular: 152 | needs: test 153 | 154 | name: Angular Tests 155 | runs-on: ubuntu-latest 156 | 157 | steps: 158 | - name: Checkout code 159 | uses: actions/checkout@v6 160 | 161 | - name: Run framework tests 162 | uses: ./.github/actions/framework 163 | with: 164 | node: ${{ env.NODE_VERSION }} 165 | cache: ${{ env.CACHE_KEY }} 166 | install: | 167 | npx -p @angular/cli ng new my-app --defaults=true < /dev/null 168 | content: | 169 | echo -e "${{ env.AUTH0_CONTENT }}" > src/auth0.js; 170 | import: | 171 | echo -e "${{ env.IMPORT_STATEMENT }}"|cat - src/main.ts > /tmp/out && mv /tmp/out src/main.ts; 172 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /src/MyAccountApiClient.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizationParams } from './global'; 2 | import { Fetcher } from './fetcher'; 3 | 4 | interface ConnectRequest { 5 | /** The name of the connection to link the account with (e.g., 'google-oauth2', 'facebook'). */ 6 | connection: string; 7 | /** Array of scopes to request from the Identity Provider during the connect account flow. */ 8 | scopes?: string[]; 9 | /** The URI to redirect to after the connection process completes. */ 10 | redirect_uri: string; 11 | /** An opaque value used to maintain state between the request and callback. */ 12 | state?: string; 13 | /** The PKCE code challenge derived from the code verifier. */ 14 | code_challenge?: string; 15 | /** The method used to derive the code challenge. Required when code_challenge is provided. */ 16 | code_challenge_method?: 'S256'; 17 | authorization_params?: AuthorizationParams; 18 | } 19 | 20 | interface ConnectResponse { 21 | /** The base URI to initiate the account connection flow. */ 22 | connect_uri: string; 23 | /** The authentication session identifier. */ 24 | auth_session: string; 25 | /** Parameters to be used with the connect URI. */ 26 | connect_params: { 27 | /** The ticket identifier to be used with the connection URI. */ 28 | ticket: string; 29 | }; 30 | /** The number of seconds until the ticket expires. */ 31 | expires_in: number; 32 | } 33 | 34 | interface CompleteRequest { 35 | /** The authentication session identifier */ 36 | auth_session: string; 37 | /** The authorization code returned from the connect flow */ 38 | connect_code: string; 39 | /** The redirect URI used in the original request */ 40 | redirect_uri: string; 41 | /** The PKCE code verifier */ 42 | code_verifier?: string; 43 | } 44 | 45 | export interface CompleteResponse { 46 | /** The unique identifier of the connected account */ 47 | id: string; 48 | /** The connection name */ 49 | connection: string; 50 | /** The access type, always 'offline' */ 51 | access_type: 'offline'; 52 | /** Array of scopes granted */ 53 | scopes?: string[]; 54 | /** ISO date string of when the connected account was created */ 55 | created_at: string; 56 | /** ISO date string of when the refresh token expires (optional) */ 57 | expires_at?: string; 58 | } 59 | 60 | // Validation error returned from MyAccount API 61 | export interface ErrorResponse { 62 | type: string; 63 | status: number; 64 | title: string; 65 | detail: string; 66 | validation_errors?: { 67 | detail: string; 68 | field?: string; 69 | pointer?: string; 70 | source?: string; 71 | }[]; 72 | } 73 | 74 | /** 75 | * Subset of the MyAccount API that handles the connect accounts flow. 76 | */ 77 | export class MyAccountApiClient { 78 | constructor( 79 | private myAccountFetcher: Fetcher, 80 | private apiBase: string 81 | ) {} 82 | 83 | /** 84 | * Get a ticket for the connect account flow. 85 | */ 86 | async connectAccount(params: ConnectRequest): Promise { 87 | const res = await this.myAccountFetcher.fetchWithAuth( 88 | `${this.apiBase}v1/connected-accounts/connect`, 89 | { 90 | method: 'POST', 91 | headers: { 'Content-Type': 'application/json' }, 92 | body: JSON.stringify(params) 93 | } 94 | ); 95 | return this._handleResponse(res); 96 | } 97 | 98 | /** 99 | * Verify the redirect from the connect account flow and complete the connecting of the account. 100 | */ 101 | async completeAccount(params: CompleteRequest): Promise { 102 | const res = await this.myAccountFetcher.fetchWithAuth( 103 | `${this.apiBase}v1/connected-accounts/complete`, 104 | { 105 | method: 'POST', 106 | headers: { 'Content-Type': 'application/json' }, 107 | body: JSON.stringify(params) 108 | } 109 | ); 110 | return this._handleResponse(res); 111 | } 112 | 113 | private async _handleResponse(res: Response) { 114 | let body: any; 115 | try { 116 | body = await res.text(); 117 | body = JSON.parse(body); 118 | } catch (err) { 119 | throw new MyAccountApiError({ 120 | type: 'invalid_json', 121 | status: res.status, 122 | title: 'Invalid JSON response', 123 | detail: body || String(err) 124 | }); 125 | } 126 | 127 | if (res.ok) { 128 | return body; 129 | } else { 130 | throw new MyAccountApiError(body); 131 | } 132 | } 133 | } 134 | 135 | export class MyAccountApiError extends Error { 136 | public readonly type: string; 137 | public readonly status: number; 138 | public readonly title: string; 139 | public readonly detail: string; 140 | public readonly validation_errors?: ErrorResponse['validation_errors']; 141 | 142 | constructor({ 143 | type, 144 | status, 145 | title, 146 | detail, 147 | validation_errors 148 | }: ErrorResponse) { 149 | super(detail); 150 | this.name = 'MyAccountApiError'; 151 | this.type = type; 152 | this.status = status; 153 | this.title = title; 154 | this.detail = detail; 155 | this.validation_errors = validation_errors; 156 | Object.setPrototypeOf(this, MyAccountApiError.prototype); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /__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__/dpop/dpop.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * We don't need the DOM for this specific test suite. 3 | * 4 | * @jest-environment node 5 | */ 6 | 7 | import { beforeEach, describe, expect } from '@jest/globals'; 8 | import { Dpop } from '../../src/dpop/dpop'; 9 | import * as dpopUtils from '../../src/dpop/utils'; 10 | import { 11 | TEST_ACCESS_TOKEN, 12 | TEST_CLIENT_ID, 13 | TEST_DPOP_KEYPAIR, 14 | TEST_DPOP_NONCE, 15 | TEST_DPOP_PROOF 16 | } from '../constants'; 17 | 18 | function newTestDpop() { 19 | const dpop = new Dpop(TEST_CLIENT_ID); 20 | 21 | return { dpop, storage: dpop['storage'] }; 22 | } 23 | 24 | describe('Dpop', () => { 25 | beforeEach(() => { 26 | jest.resetAllMocks(); 27 | jest.restoreAllMocks(); 28 | }); 29 | 30 | describe('getNonce', () => { 31 | const { dpop, storage } = newTestDpop(); 32 | 33 | const id = 'my_custom_api'; 34 | 35 | beforeEach(() => { 36 | jest.spyOn(storage, 'findNonce').mockResolvedValue(TEST_DPOP_NONCE); 37 | }); 38 | 39 | let output: unknown; 40 | 41 | beforeEach(async () => { 42 | output = await dpop.getNonce(id); 43 | }); 44 | 45 | it('delegates to storage.findNonce()', () => { 46 | expect(storage.findNonce).toHaveBeenCalledWith(id); 47 | expect(output).toBe(TEST_DPOP_NONCE); 48 | }); 49 | }); 50 | 51 | describe('setNonce', () => { 52 | const { dpop, storage } = newTestDpop(); 53 | 54 | const id = 'my_custom_api'; 55 | 56 | beforeEach(() => { 57 | jest.spyOn(storage, 'setNonce').mockResolvedValue(); 58 | }); 59 | 60 | beforeEach(() => dpop.setNonce(TEST_DPOP_NONCE, id)); 61 | 62 | it('delegates to storage.setNonce()', () => 63 | expect(storage.setNonce).toHaveBeenCalledWith(TEST_DPOP_NONCE, id)); 64 | }); 65 | 66 | describe('getOrGenerateKeyPair()', () => { 67 | const { dpop, storage } = newTestDpop(); 68 | 69 | describe('key pair already exists', () => { 70 | beforeEach(() => { 71 | storage.findKeyPair = () => Promise.resolve(TEST_DPOP_KEYPAIR); 72 | jest.spyOn(dpopUtils, 'generateKeyPair'); 73 | jest.spyOn(storage, 'setKeyPair'); 74 | }); 75 | 76 | let output: dpopUtils.KeyPair; 77 | 78 | beforeEach(async () => { 79 | output = await dpop['getOrGenerateKeyPair'](); 80 | }); 81 | 82 | it('does not generate a key pair', () => 83 | expect(dpopUtils.generateKeyPair).not.toHaveBeenCalled()); 84 | 85 | it('does not store a key pair', () => 86 | expect(storage.setKeyPair).not.toHaveBeenCalled()); 87 | 88 | it('returns the key pair', () => expect(output).toBe(TEST_DPOP_KEYPAIR)); 89 | }); 90 | 91 | describe('otherwise', () => { 92 | beforeEach(() => { 93 | storage.findKeyPair = () => Promise.resolve(undefined); 94 | 95 | jest 96 | .spyOn(dpopUtils, 'generateKeyPair') 97 | .mockResolvedValue(TEST_DPOP_KEYPAIR); 98 | 99 | jest.spyOn(storage, 'setKeyPair').mockResolvedValue(); 100 | }); 101 | 102 | let output: dpopUtils.KeyPair; 103 | 104 | beforeEach(async () => { 105 | output = await dpop['getOrGenerateKeyPair'](); 106 | }); 107 | 108 | it('generates a key pair', () => 109 | expect(dpopUtils.generateKeyPair).toHaveBeenCalled()); 110 | 111 | it('stores the key pair', () => 112 | expect(storage.setKeyPair).toHaveBeenCalled()); 113 | 114 | it('returns the key pair', () => expect(output).toBe(TEST_DPOP_KEYPAIR)); 115 | }); 116 | }); 117 | 118 | describe('generateProof()', () => { 119 | const url = 'https://example.com'; 120 | const method = 'POST'; 121 | 122 | const { dpop } = newTestDpop(); 123 | 124 | const fakeNonce = 'this-is-my-fake-nonce'; 125 | 126 | beforeEach(() => { 127 | dpop['getOrGenerateKeyPair'] = () => Promise.resolve(TEST_DPOP_KEYPAIR); 128 | jest.spyOn(dpopUtils, 'generateProof').mockResolvedValue(TEST_DPOP_PROOF); 129 | }); 130 | 131 | let output: string; 132 | 133 | beforeEach(async () => { 134 | output = await dpop.generateProof({ 135 | url, 136 | method, 137 | nonce: fakeNonce, 138 | accessToken: TEST_ACCESS_TOKEN 139 | }); 140 | }); 141 | 142 | it('delegates to generateProof() properly', () => 143 | expect(dpopUtils.generateProof).toHaveBeenCalledWith({ 144 | keyPair: TEST_DPOP_KEYPAIR, 145 | url, 146 | method, 147 | nonce: fakeNonce, 148 | accessToken: TEST_ACCESS_TOKEN 149 | })); 150 | 151 | it('returns as expected', () => expect(output).toBe(TEST_DPOP_PROOF)); 152 | }); 153 | 154 | describe('calculateThumbprint()', () => { 155 | const { dpop } = newTestDpop(); 156 | 157 | const fakeThumbprint = 'aaabbbccc123'; 158 | 159 | beforeEach(() => { 160 | dpop['getOrGenerateKeyPair'] = () => Promise.resolve(TEST_DPOP_KEYPAIR); 161 | jest 162 | .spyOn(dpopUtils, 'calculateThumbprint') 163 | .mockResolvedValue(fakeThumbprint); 164 | }); 165 | 166 | let output: string; 167 | 168 | beforeEach(async () => { 169 | output = await dpop.calculateThumbprint(); 170 | }); 171 | 172 | it('returns as expected', () => expect(output).toBe(fakeThumbprint)); 173 | }); 174 | 175 | describe('clear()', () => { 176 | const { dpop, storage } = newTestDpop(); 177 | 178 | beforeEach(() => { 179 | jest.spyOn(storage, 'clearNonces').mockResolvedValue(); 180 | jest.spyOn(storage, 'clearKeyPairs').mockResolvedValue(); 181 | }); 182 | 183 | beforeEach(() => dpop.clear()); 184 | 185 | it('clears nonces', () => expect(storage.clearNonces).toHaveBeenCalled()); 186 | 187 | it('clears keyPairs', () => 188 | expect(storage.clearKeyPairs).toHaveBeenCalled()); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /src/Auth0Client.utils.ts: -------------------------------------------------------------------------------- 1 | import { ICache, InMemoryCache, LocalStorageCache } from './cache'; 2 | import { 3 | Auth0ClientOptions, 4 | AuthorizationParams, 5 | AuthorizeOptions, 6 | ClientAuthorizationParams, 7 | LogoutOptions 8 | } from './global'; 9 | import { scopesToRequest } from './scope'; 10 | 11 | /** 12 | * @ignore 13 | */ 14 | export const GET_TOKEN_SILENTLY_LOCK_KEY = 'auth0.lock.getTokenSilently'; 15 | 16 | /** 17 | * @ignore 18 | */ 19 | export const buildGetTokenSilentlyLockKey = ( 20 | clientId: string, 21 | audience: string 22 | ) => `${GET_TOKEN_SILENTLY_LOCK_KEY}.${clientId}.${audience}`; 23 | 24 | /** 25 | * @ignore 26 | */ 27 | export const buildOrganizationHintCookieName = (clientId: string) => 28 | `auth0.${clientId}.organization_hint`; 29 | 30 | /** 31 | * @ignore 32 | */ 33 | export const OLD_IS_AUTHENTICATED_COOKIE_NAME = 'auth0.is.authenticated'; 34 | 35 | /** 36 | * @ignore 37 | */ 38 | export const buildIsAuthenticatedCookieName = (clientId: string) => 39 | `auth0.${clientId}.is.authenticated`; 40 | 41 | /** 42 | * @ignore 43 | */ 44 | const cacheLocationBuilders: Record ICache> = { 45 | memory: () => new InMemoryCache().enclosedCache, 46 | localstorage: () => new LocalStorageCache() 47 | }; 48 | 49 | /** 50 | * @ignore 51 | */ 52 | export const cacheFactory = (location: string) => { 53 | return cacheLocationBuilders[location]; 54 | }; 55 | 56 | /** 57 | * @ignore 58 | */ 59 | export const getAuthorizeParams = ( 60 | clientOptions: Auth0ClientOptions & { 61 | authorizationParams: ClientAuthorizationParams; 62 | }, 63 | scope: Record, 64 | authorizationParams: AuthorizationParams & { scope?: string }, 65 | state: string, 66 | nonce: string, 67 | code_challenge: string, 68 | redirect_uri: string | undefined, 69 | response_mode: string | undefined, 70 | thumbprint: string | undefined 71 | ): AuthorizeOptions => { 72 | return { 73 | client_id: clientOptions.clientId, 74 | ...clientOptions.authorizationParams, 75 | ...authorizationParams, 76 | scope: scopesToRequest(scope, authorizationParams.scope, authorizationParams.audience), 77 | response_type: 'code', 78 | response_mode: response_mode || 'query', 79 | state, 80 | nonce, 81 | redirect_uri: 82 | redirect_uri || clientOptions.authorizationParams.redirect_uri, 83 | code_challenge, 84 | code_challenge_method: 'S256', 85 | dpop_jkt: thumbprint 86 | }; 87 | }; 88 | 89 | /** 90 | * @ignore 91 | * 92 | * Function used to provide support for the deprecated onRedirect through openUrl. 93 | */ 94 | export const patchOpenUrlWithOnRedirect = < 95 | T extends Pick 96 | >( 97 | options: T 98 | ) => { 99 | const { openUrl, onRedirect, ...originalOptions } = options; 100 | 101 | const result = { 102 | ...originalOptions, 103 | openUrl: openUrl === false || openUrl ? openUrl : onRedirect 104 | }; 105 | 106 | return result as T; 107 | }; 108 | 109 | /** 110 | * @ignore 111 | * 112 | * Checks if all scopes are included inside other array of scopes 113 | */ 114 | export const allScopesAreIncluded = (scopeToInclude?: string, scopes?: string): boolean => { 115 | const scopeGroup = scopes?.split(" ") || []; 116 | const scopesToInclude = scopeToInclude?.split(" ") || []; 117 | return scopesToInclude.every((key) => scopeGroup.includes(key)); 118 | } 119 | 120 | /** 121 | * @ignore 122 | * 123 | * Returns the scopes that are missing after a refresh 124 | */ 125 | export const getMissingScopes = (requestedScope?: string, respondedScope?: string): string => { 126 | const requestedScopes = requestedScope?.split(" ") || []; 127 | const respondedScopes = respondedScope?.split(" ") || []; 128 | 129 | const missingScopes = requestedScopes.filter((scope) => respondedScopes.indexOf(scope) == -1); 130 | 131 | return missingScopes.join(","); 132 | } 133 | 134 | /** 135 | * @ignore 136 | * 137 | * For backward compatibility we are going to check if we are going to downscope while doing a refresh request 138 | * while MRRT is allowed. If the audience is the same for the refresh_token we are going to use and it has 139 | * lower scopes than the ones originally in the token, we are going to return the scopes that were stored 140 | * with the refresh_token in the tokenset. 141 | * @param useMrrt Setting that the user can activate to use MRRT in their requests 142 | * @param authorizationParams Contains the audience and scope that the user requested to obtain a token 143 | * @param cachedAudience Audience stored with the refresh_token wich we are going to use in the request 144 | * @param cachedScope Scope stored with the refresh_token wich we are going to use in the request 145 | */ 146 | export const getScopeToRequest = ( 147 | useMrrt: boolean | undefined, 148 | authorizationParams: { audience?: string, scope: string }, 149 | cachedAudience?: string, 150 | cachedScope?: string 151 | ): string => { 152 | if (useMrrt && cachedAudience && cachedScope) { 153 | if (authorizationParams.audience !== cachedAudience) { 154 | return authorizationParams.scope; 155 | } 156 | 157 | const cachedScopes = cachedScope.split(" "); 158 | const newScopes = authorizationParams.scope?.split(" ") || []; 159 | const newScopesAreIncluded = newScopes.every((scope) => cachedScopes.includes(scope)); 160 | 161 | return cachedScopes.length >= newScopes.length && newScopesAreIncluded ? cachedScope : authorizationParams.scope; 162 | } 163 | 164 | return authorizationParams.scope; 165 | } 166 | 167 | /** 168 | * @ignore 169 | * 170 | * Checks if the refresh request has been done using MRRT 171 | * @param cachedAudience Audience from the refresh token used to refresh 172 | * @param cachedScope Scopes from the refresh token used to refresh 173 | * @param requestAudience Audience sent to the server 174 | * @param requestScope Scopes sent to the server 175 | */ 176 | export const isRefreshWithMrrt = ( 177 | cachedAudience: string | undefined, 178 | cachedScope: string | undefined, 179 | requestAudience: string | undefined, 180 | requestScope: string, 181 | ): boolean => { 182 | if (cachedAudience !== requestAudience) { 183 | return true; 184 | } 185 | 186 | return !allScopesAreIncluded(requestScope, cachedScope); 187 | } -------------------------------------------------------------------------------- /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 handling the redirect callback for the connect flow fails, will be one of Auth0's 40 | * Authentication API's Standard Error Responses: https://auth0.com/docs/api/authentication?javascript#standard-error-responses 41 | */ 42 | export class ConnectError extends GenericError { 43 | constructor( 44 | error: string, 45 | error_description: string, 46 | public connection: string, 47 | public state: string, 48 | public appState: any = null 49 | ) { 50 | super(error, error_description); 51 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 52 | Object.setPrototypeOf(this, ConnectError.prototype); 53 | } 54 | } 55 | 56 | /** 57 | * Thrown when silent auth times out (usually due to a configuration issue) or 58 | * when network requests to the Auth server timeout. 59 | */ 60 | export class TimeoutError extends GenericError { 61 | constructor() { 62 | super('timeout', 'Timeout'); 63 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 64 | Object.setPrototypeOf(this, TimeoutError.prototype); 65 | } 66 | } 67 | 68 | /** 69 | * Error thrown when the login popup times out (if the user does not complete auth) 70 | */ 71 | export class PopupTimeoutError extends TimeoutError { 72 | constructor(public popup: Window) { 73 | super(); 74 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 75 | Object.setPrototypeOf(this, PopupTimeoutError.prototype); 76 | } 77 | } 78 | 79 | export class PopupCancelledError extends GenericError { 80 | constructor(public popup: Window) { 81 | super('cancelled', 'Popup closed'); 82 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 83 | Object.setPrototypeOf(this, PopupCancelledError.prototype); 84 | } 85 | } 86 | 87 | export class PopupOpenError extends GenericError { 88 | constructor() { 89 | super('popup_open', 'Unable to open a popup for loginWithPopup - window.open returned `null`'); 90 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 91 | Object.setPrototypeOf(this, PopupOpenError.prototype); 92 | } 93 | } 94 | 95 | /** 96 | * Error thrown when the token exchange results in a `mfa_required` error 97 | */ 98 | export class MfaRequiredError extends GenericError { 99 | constructor( 100 | error: string, 101 | error_description: string, 102 | public mfa_token: string 103 | ) { 104 | super(error, error_description); 105 | //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 106 | Object.setPrototypeOf(this, MfaRequiredError.prototype); 107 | } 108 | } 109 | 110 | /** 111 | * Error thrown when there is no refresh token to use 112 | */ 113 | export class MissingRefreshTokenError extends GenericError { 114 | constructor(public audience: string, public scope: string) { 115 | super( 116 | 'missing_refresh_token', 117 | `Missing Refresh Token (audience: '${valueOrEmptyString(audience, [ 118 | 'default' 119 | ])}', scope: '${valueOrEmptyString(scope)}')` 120 | ); 121 | Object.setPrototypeOf(this, MissingRefreshTokenError.prototype); 122 | } 123 | } 124 | 125 | /** 126 | * Error thrown when there are missing scopes after refreshing a token 127 | */ 128 | export class MissingScopesError extends GenericError { 129 | constructor(public audience: string, public scope: string) { 130 | super( 131 | 'missing_scopes', 132 | `Missing requested scopes after refresh (audience: '${valueOrEmptyString(audience, [ 133 | 'default' 134 | ])}', missing scope: '${valueOrEmptyString(scope)}')` 135 | ); 136 | Object.setPrototypeOf(this, MissingScopesError.prototype); 137 | } 138 | } 139 | 140 | /** 141 | * Error thrown when the wrong DPoP nonce is used and a potential subsequent retry wasn't able to fix it. 142 | */ 143 | export class UseDpopNonceError extends GenericError { 144 | constructor(public newDpopNonce: string | undefined) { 145 | super('use_dpop_nonce', 'Server rejected DPoP proof: wrong nonce'); 146 | 147 | Object.setPrototypeOf(this, UseDpopNonceError.prototype); 148 | } 149 | } 150 | 151 | /** 152 | * Returns an empty string when value is falsy, or when it's value is included in the exclude argument. 153 | * @param value The value to check 154 | * @param exclude An array of values that should result in an empty string. 155 | * @returns The value, or an empty string when falsy or included in the exclude argument. 156 | */ 157 | function valueOrEmptyString(value: string, exclude: string[] = []) { 158 | return value && !exclude.includes(value) ? value : ''; 159 | } 160 | -------------------------------------------------------------------------------- /src/http.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_FETCH_TIMEOUT_MS, 3 | DEFAULT_SILENT_TOKEN_RETRY_COUNT 4 | } from './constants'; 5 | 6 | import { fromEntries } from './utils'; 7 | import { sendMessage } from './worker/worker.utils'; 8 | import { FetchOptions, FetchResponse } from './global'; 9 | import { 10 | GenericError, 11 | MfaRequiredError, 12 | MissingRefreshTokenError, 13 | UseDpopNonceError 14 | } from './errors'; 15 | import { Dpop } from './dpop/dpop'; 16 | import { DPOP_NONCE_HEADER } from './dpop/utils'; 17 | 18 | export const createAbortController = () => new AbortController(); 19 | 20 | const dofetch = async (fetchUrl: string, fetchOptions: FetchOptions) => { 21 | const response = await fetch(fetchUrl, fetchOptions); 22 | 23 | return { 24 | ok: response.ok, 25 | json: await response.json(), 26 | 27 | /** 28 | * This is not needed, but do it anyway so the object shape is the 29 | * same as when using a Web Worker (which *does* need this, see 30 | * src/worker/token.worker.ts). 31 | */ 32 | headers: fromEntries(response.headers) 33 | }; 34 | }; 35 | 36 | const fetchWithoutWorker = async ( 37 | fetchUrl: string, 38 | fetchOptions: FetchOptions, 39 | timeout: number 40 | ) => { 41 | const controller = createAbortController(); 42 | fetchOptions.signal = controller.signal; 43 | 44 | let timeoutId: NodeJS.Timeout; 45 | 46 | // The promise will resolve with one of these two promises (the fetch or the timeout), whichever completes first. 47 | return Promise.race([ 48 | dofetch(fetchUrl, fetchOptions), 49 | 50 | new Promise((_, reject) => { 51 | timeoutId = setTimeout(() => { 52 | controller.abort(); 53 | reject(new Error("Timeout when executing 'fetch'")); 54 | }, timeout); 55 | }) 56 | ]).finally(() => { 57 | clearTimeout(timeoutId); 58 | }); 59 | }; 60 | 61 | const fetchWithWorker = async ( 62 | fetchUrl: string, 63 | audience: string, 64 | scope: string, 65 | fetchOptions: FetchOptions, 66 | timeout: number, 67 | worker: Worker, 68 | useFormData?: boolean, 69 | useMrrt?: boolean 70 | ) => { 71 | return sendMessage( 72 | { 73 | auth: { 74 | audience, 75 | scope 76 | }, 77 | timeout, 78 | fetchUrl, 79 | fetchOptions, 80 | useFormData, 81 | useMrrt 82 | }, 83 | worker 84 | ); 85 | }; 86 | 87 | export const switchFetch = async ( 88 | fetchUrl: string, 89 | audience: string, 90 | scope: string, 91 | fetchOptions: FetchOptions, 92 | worker?: Worker, 93 | useFormData?: boolean, 94 | timeout = DEFAULT_FETCH_TIMEOUT_MS, 95 | useMrrt?: boolean, 96 | ): Promise => { 97 | if (worker) { 98 | return fetchWithWorker( 99 | fetchUrl, 100 | audience, 101 | scope, 102 | fetchOptions, 103 | timeout, 104 | worker, 105 | useFormData, 106 | useMrrt 107 | ); 108 | } else { 109 | return fetchWithoutWorker(fetchUrl, fetchOptions, timeout); 110 | } 111 | }; 112 | 113 | export async function getJSON( 114 | url: string, 115 | timeout: number | undefined, 116 | audience: string, 117 | scope: string, 118 | options: FetchOptions, 119 | worker?: Worker, 120 | useFormData?: boolean, 121 | useMrrt?: boolean, 122 | dpop?: Pick, 123 | isDpopRetry?: boolean 124 | ): Promise { 125 | if (dpop) { 126 | const dpopProof = await dpop.generateProof({ 127 | url, 128 | method: options.method || 'GET', 129 | nonce: await dpop.getNonce() 130 | }); 131 | 132 | options.headers = { ...options.headers, dpop: dpopProof }; 133 | } 134 | 135 | let fetchError: null | Error = null; 136 | let response!: FetchResponse; 137 | 138 | for (let i = 0; i < DEFAULT_SILENT_TOKEN_RETRY_COUNT; i++) { 139 | try { 140 | response = await switchFetch( 141 | url, 142 | audience, 143 | scope, 144 | options, 145 | worker, 146 | useFormData, 147 | timeout, 148 | useMrrt, 149 | ); 150 | fetchError = null; 151 | break; 152 | } catch (e) { 153 | // Fetch only fails in the case of a network issue, so should be 154 | // retried here. Failure status (4xx, 5xx, etc) return a resolved Promise 155 | // with the failure in the body. 156 | // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 157 | fetchError = e; 158 | } 159 | } 160 | 161 | if (fetchError) { 162 | throw fetchError; 163 | } 164 | 165 | const { 166 | json: { error, error_description, ...data }, 167 | headers, 168 | ok 169 | } = response; 170 | 171 | let newDpopNonce: string | undefined; 172 | 173 | if (dpop) { 174 | /** 175 | * Note that a new DPoP nonce can appear in both error and success responses! 176 | * 177 | * @see {@link https://www.rfc-editor.org/rfc/rfc9449.html#section-8.2-3} 178 | */ 179 | newDpopNonce = headers[DPOP_NONCE_HEADER]; 180 | 181 | if (newDpopNonce) { 182 | await dpop.setNonce(newDpopNonce); 183 | } 184 | } 185 | 186 | if (!ok) { 187 | const errorMessage = 188 | error_description || `HTTP error. Unable to fetch ${url}`; 189 | 190 | if (error === 'mfa_required') { 191 | throw new MfaRequiredError(error, errorMessage, data.mfa_token); 192 | } 193 | 194 | if (error === 'missing_refresh_token') { 195 | throw new MissingRefreshTokenError(audience, scope); 196 | } 197 | 198 | /** 199 | * When DPoP is used and we get a `use_dpop_nonce` error from the server, 200 | * we must retry ONCE with any new nonce received in the rejected request. 201 | * 202 | * If a new nonce was not received or the retry fails again, we give up and 203 | * throw the error as is. 204 | */ 205 | if (error === 'use_dpop_nonce') { 206 | if (!dpop || !newDpopNonce || isDpopRetry) { 207 | throw new UseDpopNonceError(newDpopNonce); 208 | } 209 | 210 | // repeat the call but with isDpopRetry=true to avoid any more retries 211 | return getJSON( 212 | url, 213 | timeout, 214 | audience, 215 | scope, 216 | options, 217 | worker, 218 | useFormData, 219 | useMrrt, 220 | dpop, 221 | true // ! 222 | ); 223 | } 224 | 225 | throw new GenericError(error || 'request_error', errorMessage); 226 | } 227 | 228 | return data; 229 | } 230 | -------------------------------------------------------------------------------- /src/worker/token.worker.ts: -------------------------------------------------------------------------------- 1 | import { MissingRefreshTokenError } from '../errors'; 2 | import { FetchResponse } from '../global'; 3 | import { createQueryParams, fromEntries } from '../utils'; 4 | import { WorkerRefreshTokenMessage } from './worker.types'; 5 | 6 | let refreshTokens: Record = {}; 7 | 8 | const cacheKey = (audience: string, scope: string) => `${audience}|${scope}`; 9 | 10 | const cacheKeyContainsAudience = (audience: string, cacheKey: string) => cacheKey.startsWith(`${audience}|`); 11 | 12 | const getRefreshToken = (audience: string, scope: string): string | undefined => 13 | refreshTokens[cacheKey(audience, scope)]; 14 | 15 | const setRefreshToken = ( 16 | refreshToken: string, 17 | audience: string, 18 | scope: string 19 | ) => (refreshTokens[cacheKey(audience, scope)] = refreshToken); 20 | 21 | const deleteRefreshToken = (audience: string, scope: string) => 22 | delete refreshTokens[cacheKey(audience, scope)]; 23 | 24 | const wait = (time: number) => 25 | new Promise(resolve => setTimeout(resolve, time)); 26 | 27 | const formDataToObject = (formData: string): Record => { 28 | const queryParams = new URLSearchParams(formData); 29 | const parsedQuery: any = {}; 30 | 31 | queryParams.forEach((val, key) => { 32 | parsedQuery[key] = val; 33 | }); 34 | 35 | return parsedQuery; 36 | }; 37 | 38 | const updateRefreshTokens = (oldRefreshToken: string | undefined, newRefreshToken: string): void => { 39 | Object.entries(refreshTokens).forEach(([key, token]) => { 40 | if (token === oldRefreshToken) { 41 | refreshTokens[key] = newRefreshToken; 42 | } 43 | }); 44 | } 45 | 46 | const checkDownscoping = (scope: string, audience: string): boolean => { 47 | const findCoincidence = Object.keys(refreshTokens).find((key) => { 48 | if (key !== 'latest_refresh_token') { 49 | const isSameAudience = cacheKeyContainsAudience(audience, key); 50 | const scopesKey = key.split('|')[1].split(" "); 51 | const requestedScopes = scope.split(" "); 52 | const scopesAreIncluded = requestedScopes.every((key) => scopesKey.includes(key)); 53 | 54 | return isSameAudience && scopesAreIncluded; 55 | } 56 | }) 57 | 58 | return findCoincidence ? true : false; 59 | } 60 | 61 | const messageHandler = async ({ 62 | data: { timeout, auth, fetchUrl, fetchOptions, useFormData, useMrrt }, 63 | ports: [port] 64 | }: MessageEvent) => { 65 | let headers: FetchResponse['headers'] = {}; 66 | 67 | let json: { 68 | refresh_token?: string; 69 | }; 70 | let refreshToken: string | undefined; 71 | 72 | const { audience, scope } = auth || {}; 73 | 74 | try { 75 | const body = useFormData 76 | ? formDataToObject(fetchOptions.body as string) 77 | : JSON.parse(fetchOptions.body as string); 78 | 79 | if (!body.refresh_token && body.grant_type === 'refresh_token') { 80 | refreshToken = getRefreshToken(audience, scope); 81 | 82 | // When we don't have any refresh_token that matches the audience and scopes 83 | // stored, and useMrrt is configured to true, we will use the last refresh_token 84 | // returned by the server to do a refresh 85 | // We will avoid doing MRRT if we were to downscope while doing refresh in the same audience 86 | if (!refreshToken && useMrrt) { 87 | const latestRefreshToken = refreshTokens["latest_refresh_token"]; 88 | 89 | const isDownscoping = checkDownscoping(scope, audience); 90 | 91 | if (latestRefreshToken && !isDownscoping) { 92 | refreshToken = latestRefreshToken; 93 | } 94 | } 95 | 96 | if (!refreshToken) { 97 | throw new MissingRefreshTokenError(audience, scope); 98 | } 99 | 100 | fetchOptions.body = useFormData 101 | ? createQueryParams({ 102 | ...body, 103 | refresh_token: refreshToken 104 | }) 105 | : JSON.stringify({ 106 | ...body, 107 | refresh_token: refreshToken 108 | }); 109 | } 110 | 111 | let abortController: AbortController | undefined; 112 | 113 | if (typeof AbortController === 'function') { 114 | abortController = new AbortController(); 115 | fetchOptions.signal = abortController.signal; 116 | } 117 | 118 | let response: void | Response; 119 | 120 | try { 121 | response = await Promise.race([ 122 | wait(timeout), 123 | fetch(fetchUrl, { ...fetchOptions }) 124 | ]); 125 | } catch (error) { 126 | // fetch error, reject `sendMessage` using `error` key so that we retry. 127 | port.postMessage({ 128 | error: error.message 129 | }); 130 | 131 | return; 132 | } 133 | 134 | if (!response) { 135 | // If the request times out, abort it and let `switchFetch` raise the error. 136 | if (abortController) abortController.abort(); 137 | 138 | port.postMessage({ 139 | error: "Timeout when executing 'fetch'" 140 | }); 141 | 142 | return; 143 | } 144 | 145 | headers = fromEntries(response.headers); 146 | json = await response.json(); 147 | 148 | if (json.refresh_token) { 149 | // If useMrrt is configured to true we want to save the latest refresh_token 150 | // to be used when refreshing tokens with MRRT 151 | if (useMrrt) { 152 | refreshTokens["latest_refresh_token"] = json.refresh_token; 153 | 154 | // To avoid having some refresh_token that has already been used 155 | // we will update those inside the list with the new one obtained 156 | // by the server 157 | updateRefreshTokens(refreshToken, json.refresh_token); 158 | } 159 | 160 | setRefreshToken(json.refresh_token, audience, scope); 161 | delete json.refresh_token; 162 | } else { 163 | deleteRefreshToken(audience, scope); 164 | } 165 | 166 | port.postMessage({ 167 | ok: response.ok, 168 | json, 169 | headers 170 | }); 171 | } catch (error) { 172 | port.postMessage({ 173 | ok: false, 174 | json: { 175 | error: error.error, 176 | error_description: error.message 177 | }, 178 | headers 179 | }); 180 | } 181 | }; 182 | 183 | // Don't run `addEventListener` in our tests (this is replaced in rollup) 184 | if (process.env.NODE_ENV === 'test') { 185 | module.exports = { messageHandler }; 186 | /* c8 ignore next 4 */ 187 | } else { 188 | // @ts-ignore 189 | addEventListener('message', messageHandler); 190 | } 191 | --------------------------------------------------------------------------------