From 72605a9303f60c364149f93cda067b4e9a0a4804 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 5 Dec 2022 10:52:54 +1100 Subject: [PATCH 01/17] chore: add workflows --- .github/workflows/audit.yml | 14 ++++++ .github/workflows/ci.yml | 60 +++++++++++++++++++++++ .github/workflows/codeql-analysis.yml | 68 +++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 .github/workflows/audit.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..47c1d80 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,14 @@ +name: Audit + +on: + push: + schedule: + - cron: "40 10 * * *" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + audit: + uses: inrupt/typescript-sdk-tools/.github/workflows/reusable-audit.yml@v1.4.2 + secrets: + WEBHOOK_E2E_FAILURE: ${{ secrets.WEBHOOK_E2E_FAILURE }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1d1bf83 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: [push] + +env: + CI: true +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + lint: + uses: inrupt/typescript-sdk-tools/.github/workflows/reusable-lint.yml@v1.4.2 + + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 16.x, 14.x] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test + # Upload coverage for sonarcube (only matching OS and one node version required) + - uses: actions/upload-artifact@v3 + if: ${{ matrix.node-version == '18.x' }} + with: + name: code-coverage-ubuntu-latest-${{matrix.node-version}} + path: coverage/ + + sonar-scan: + needs: [test] + runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' }} + strategy: + matrix: + # Since this is a monorepo, the Sonar scan must be run on each of the packages but this will pull in the test + # coverage information produced by the tests already run. + project-root: ["packages/solid-vscode-auth", "extensions/solidauth", "extensions/solidfs"] + steps: + - uses: actions/checkout@v3 + with: + # Sonar analysis needs the full history for features like automatic assignment of bugs. If the following step + # is not included the project will show a warning about incomplete information. + fetch-depth: 0 + - uses: actions/download-artifact@v3 + with: + name: code-coverage-ubuntu-latest-18.x + path: coverage/ + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@v1.8 + with: + projectBaseDir: ${{ matrix.project-root }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..ecfb55b --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,68 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "Static Application security Testing (CodeQL)" + +on: + push: + branches: + - "*" + pull_request: + # The branches below must be a subset of the branches above + branches: + - main + schedule: + - cron: "0 12 * * 6" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + # Re-enable this when repo is public + if: false + + strategy: + fail-fast: false + matrix: + language: ["javascript", "python"] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/config.yml + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 From 91390b0aadac622cfea1c5bf03153d8e95ce2079 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 5 Dec 2022 10:56:00 +1100 Subject: [PATCH 02/17] chore: add lint script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 5913a51..6e11b77 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "ncu": "npm run ncu:global && npm run ncu:packages && npm i", "lint:fix": "npm run lint:eslint -- --fix && npm run lint:prettier -- --write", "lint:check": "npm run lint:eslint && npm run lint:prettier -- --check", + "lint": "npm run lint:check", "lint:eslint": "eslint --config .eslintrc.js --ext .ts \"packages\" \"extensions\"", "lint:prettier": "prettier \"packages/**/*.{ts,tsx,css}\" \"extensions/**/*.{ts,tsx,css}\" \"**/*.{md,mdx,yml}\"", "licenses:check:root": "license-checker --out license.csv --failOn --development \"AGPL-1.0-only; AGPL-1.0-or-later; AGPL-3.0-only; AGPL-3.0-or-later; Beerware; CC-BY-NC-1.0; CC-BY-NC-2.0; CC-BY-NC-2.5; CC-BY-NC-3.0; CC-BY-NC-4.0; CC-BY-NC-ND-1.0; CC-BY-NC-ND-2.0; CC-BY-NC-ND-2.5; CC-BY-NC-ND-3.0; CC-BY-NC-ND-4.0; CC-BY-NC-SA-1.0; CC-BY-NC-SA-2.0; CC-BY-NC-SA-2.5; CC-BY-NC-SA-3.0; CC-BY-NC-SA-4.0; CPAL-1.0; EUPL-1.0; EUPL-1.1; EUPL-1.1; GPL-1.0-only; GPL-1.0-or-later; GPL-2.0-only; GPL-2.0-or-later; GPL-3.0; GPL-3.0-only; GPL-3.0-or-later; SISSL; SISSL-1.2; WTFPL\"", From 9550ab60bc2548acf861e783e128caace340fd8e Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 5 Dec 2022 11:16:15 +1100 Subject: [PATCH 03/17] chore: fix linting errors --- .eslintignore | 1 + .github/workflows/ci.yml | 7 ++++++- .prettierignore | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .eslintignore create mode 100644 .prettierignore diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..fe17425 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +**/dist \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d1bf83..ec9b990 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,12 @@ jobs: matrix: # Since this is a monorepo, the Sonar scan must be run on each of the packages but this will pull in the test # coverage information produced by the tests already run. - project-root: ["packages/solid-vscode-auth", "extensions/solidauth", "extensions/solidfs"] + project-root: + [ + "packages/solid-vscode-auth", + "extensions/solidauth", + "extensions/solidfs", + ] steps: - uses: actions/checkout@v3 with: diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..819bcec --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +**/.vscode-test +**/dist From accbf3f75bba29b1c8f29daf4efcaac353f9db57 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 5 Dec 2022 12:04:14 +1100 Subject: [PATCH 04/17] chore: add jest config --- .eslintrc.js | 4 +- .../src/auth/solidAuthenticationProvider.ts | 5 +- jest.config.ts | 66 + jest.setup.ts | 22 + package-lock.json | 1123 +++++++++++++++-- package.json | 9 +- ...auth.test.js => solid-vscode-auth.test.ts} | 0 packages/solid-vscode-auth/lib/index.ts | 2 +- tsconfig.json | 5 +- 9 files changed, 1115 insertions(+), 121 deletions(-) create mode 100644 jest.config.ts create mode 100644 jest.setup.ts rename packages/solid-vscode-auth/__tests__/{solid-vscode-auth.test.js => solid-vscode-auth.test.ts} (100%) diff --git a/.eslintrc.js b/.eslintrc.js index 51c8396..df20750 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,3 @@ - require("@rushstack/eslint-patch/modern-module-resolution"); module.exports = { @@ -24,4 +23,7 @@ module.exports = { "@typescript-eslint/no-floating-promises": "off", "consistent-return": "off" }, + "env": { + "jest": true + } } diff --git a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts index 47f5763..a137e1d 100644 --- a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts +++ b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts @@ -25,8 +25,7 @@ import { getSessionIdFromStorageAll, getSessionFromStorage, } from "@inrupt/solid-client-authn-node"; - -// import { StorageUtility } from "@inrupt/solid-client-authn-core"; +import { SOLID_AUTHENTICATION_PROVIDER_ID } from "@inrupt/solid-vscode-auth"; import { interactiveLogin } from "solid-node-interactive-auth"; import { v4 } from "uuid"; @@ -63,7 +62,7 @@ function getTimeLeft(timeout: any): number { export class SolidAuthenticationProvider implements AuthenticationProvider, Disposable { - public static readonly id = "solidauth"; + public static readonly id = SOLID_AUTHENTICATION_PROVIDER_ID; private sessionChangeEmitter = new EventEmitter(); diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..b6e678c --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,66 @@ +// +// Copyright 2022 Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import type { Config } from "jest"; + +type ArrayElement = MyArray extends Array ? T : never; + +const baseConfig: ArrayElement> = { + roots: [""], + testMatch: ["**/*.spec.ts"], + // This combination of preset/transformIgnorePatterns enforces that both TS and + // JS files are transformed to CJS, and that the transform also applies to the + // dependencies in the node_modules, so that ESM-only dependencies are supported. + preset: "ts-jest/presets/js-with-ts", + // deliberately set to an empty array to allow including node_modules when transforming code: + transformIgnorePatterns: [], + modulePathIgnorePatterns: ["dist/", "/examples/"], + coveragePathIgnorePatterns: [".*.spec.ts", "dist/"], + clearMocks: true, + injectGlobals: false, + setupFilesAfterEnv: ["/jest.setup.ts"], +}; + +// Required by @peculiar/webcrypto, which comes from the polyfills +// loaded in the setup file. +process.env.OPENSSL_CONF = "/dev/null"; + +export default { + reporters: ["default", "github-actions"], + collectCoverage: true, + coverageReporters: process.env.CI ? ["text", "lcov"] : ["text"], + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, + collectCoverageFrom: ["/lib/**/*.ts"], + projects: [ + { + ...baseConfig, + displayName: "solid-vscode-auth", + roots: ["/packages/solid-vscode-auth"], + } + ], +} as Config; \ No newline at end of file diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..aa8a614 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import "@inrupt/jest-jsdom-polyfills"; diff --git a/package-lock.json b/package-lock.json index 088d7b8..1fae9f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,10 @@ "@babel/core": "^7.20.2", "@babel/preset-env": "^7.20.2", "@inrupt/eslint-config-lib": "^1.4.2", + "@inrupt/jest-jsdom-polyfills": "^1.4.3", "@rushstack/eslint-patch": "^1.2.0", "@types/glob": "^8.0.0", + "@types/jest": "^29.2.3", "@types/mocha": "^10.0.0", "@types/node": "^18.11.9", "@types/vscode": "^1.73.1", @@ -24,13 +26,16 @@ "depcheck": "^1.4.3", "eslint-plugin-unused-imports": "^2.0.0", "glob": "^8.0.3", + "jest": "^29.3.1", "lerna": "^6.0.3", "lerna-audit": "^1.3.3", "license-checker": "^25.0.1", "mocha": "^10.1.0", "npm-check-updates": "^16.4.1", "nx": "^15.2.1", + "ts-jest": "^29.0.3", "ts-loader": "^9.4.1", + "ts-node": "^10.9.1", "typescript": "^4.9.3", "vsce": "^2.14.0", "webpack": "^5.75.0", @@ -4461,6 +4466,28 @@ "sparqlalgebrajs": "^4.0.5" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "license": "MIT", @@ -4589,6 +4616,17 @@ "typescript": ">=4.7.4" } }, + "node_modules/@inrupt/jest-jsdom-polyfills": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@inrupt/jest-jsdom-polyfills/-/jest-jsdom-polyfills-1.4.3.tgz", + "integrity": "sha512-up3NoeUuSTkdG5dg2aGtN6JPjPWFkBszWV5oHoTUvoxF9kTbh5LZghhhwwgQagHO/8VjgdPBNyI5AY704Td/Uw==", + "dev": true, + "dependencies": { + "@peculiar/webcrypto": "^1.4.0", + "@web-std/blob": "^3.0.4", + "@web-std/file": "^3.0.2" + } + }, "node_modules/@inrupt/oidc-client": { "version": "1.11.6", "license": "Apache-2.0", @@ -4612,6 +4650,88 @@ "uuid": "^8.3.1" } }, + "node_modules/@inrupt/oidc-client-ext/node_modules/@types/jest": { + "version": "27.5.2", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz", + "integrity": "sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==", + "dependencies": { + "jest-matcher-utils": "^27.0.0", + "pretty-format": "^27.0.0" + } + }, + "node_modules/@inrupt/oidc-client-ext/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@inrupt/oidc-client-ext/node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@inrupt/oidc-client-ext/node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@inrupt/oidc-client-ext/node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@inrupt/oidc-client-ext/node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@inrupt/oidc-client-ext/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@inrupt/oidc-client-ext/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "node_modules/@inrupt/oidc-client/node_modules/acorn": { "version": "7.4.1", "license": "MIT", @@ -6596,6 +6716,45 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.3.tgz", + "integrity": "sha512-6GptMYDMyWBHTUKndHaDsRZUO/XMSgIns2krxcm2L7SEExRHwawFvSwNBhqNPR9HJwv3MruAiF1bhN0we6j6GQ==", + "dev": true, + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.4.1.tgz", + "integrity": "sha512-eK4C6WTNYxoI7JOabMoZICiyqRRtJB220bh0Mbj5RwRycleZf9BPyZoxsTvpP0FpmVS2aS13NKOuh5/tN3sIRw==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.0", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.1", + "webcrypto-core": "^1.7.4" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@phenomnomnominal/tsquery": { "version": "4.1.1", "dev": true, @@ -6722,6 +6881,30 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", + "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.1.20", "dev": true, @@ -6833,79 +7016,15 @@ } }, "node_modules/@types/jest": { - "version": "27.5.2", - "license": "MIT", - "dependencies": { - "jest-matcher-utils": "^27.0.0", - "pretty-format": "^27.0.0" - } - }, - "node_modules/@types/jest/node_modules/ansi-styles": { - "version": "5.2.0", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@types/jest/node_modules/diff-sequences": { - "version": "27.5.1", - "license": "MIT", - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@types/jest/node_modules/jest-diff": { - "version": "27.5.1", - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@types/jest/node_modules/jest-get-type": { - "version": "27.5.1", - "license": "MIT", - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@types/jest/node_modules/jest-matcher-utils": { - "version": "27.5.1", - "license": "MIT", + "version": "29.2.3", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.2.3.tgz", + "integrity": "sha512-6XwoEbmatfyoCjWRX7z0fKMmgYKe9+/HrviJ5k0X/tjJWHGAezZOfYaxqQKuzG/TvQyr+ktjm4jgbk0s4/oF2w==", + "dev": true, "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "expect": "^29.0.0", + "pretty-format": "^29.0.0" } }, - "node_modules/@types/jest/node_modules/pretty-format": { - "version": "27.5.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@types/jest/node_modules/react-is": { - "version": "17.0.2", - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.11", "dev": true, @@ -7311,6 +7430,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@web-std/blob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@web-std/blob/-/blob-3.0.4.tgz", + "integrity": "sha512-+dibyiw+uHYK4dX5cJ7HA+gtDAaUUe6JsOryp2ZpAC7h4ICsh49E34JwHoEKPlPvP0llCrNzz45vvD+xX5QDBg==", + "dev": true, + "dependencies": { + "@web-std/stream": "1.0.0", + "web-encoding": "1.1.5" + } + }, + "node_modules/@web-std/file": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@web-std/file/-/file-3.0.2.tgz", + "integrity": "sha512-pIH0uuZsmY8YFvSHP1NsBIiMT/1ce0suPrX74fEeO3Wbr1+rW0fUGEe4d0R99iLwXtyCwyserqCFI4BJkJlkRA==", + "dev": true, + "dependencies": { + "@web-std/blob": "^3.0.3" + } + }, + "node_modules/@web-std/stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@web-std/stream/-/stream-1.0.0.tgz", + "integrity": "sha512-jyIbdVl+0ZJyKGTV0Ohb9E6UnxP+t7ZzX4Do3AHjZKxUXKMs9EmqnBDQgHF7bEw0EzbQygOjtt/7gvtmi//iCQ==", + "dev": true, + "dependencies": { + "web-streams-polyfill": "^3.1.1" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.1", "dev": true, @@ -7533,6 +7680,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "dev": true, + "optional": true + }, "node_modules/abbrev": { "version": "1.1.1", "dev": true, @@ -7575,6 +7729,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/add-stream": { "version": "1.0.0", "dev": true, @@ -7765,6 +7928,12 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -7847,6 +8016,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dev": true, + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/async": { "version": "3.2.4", "license": "MIT" @@ -7875,6 +8058,18 @@ "node": ">= 4.0.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.1.3", "dev": true, @@ -8372,6 +8567,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "dev": true, @@ -9324,6 +9531,12 @@ "node": ">=10" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-fetch": { "version": "3.1.5", "license": "MIT", @@ -10884,6 +11097,15 @@ } } }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/form-data": { "version": "4.0.0", "dev": true, @@ -11404,6 +11626,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/got": { "version": "12.5.2", "dev": true, @@ -11954,6 +12188,22 @@ "dev": true, "license": "MIT" }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "dev": true, @@ -12084,6 +12334,21 @@ "node": ">=6" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "dev": true, @@ -12282,6 +12547,25 @@ "node": ">=0.10.0" } }, + "node_modules/is-typed-array": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", + "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typedarray": { "version": "1.0.0", "dev": true, @@ -12433,8 +12717,9 @@ }, "node_modules/jest": { "version": "29.3.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.3.1.tgz", + "integrity": "sha512-6iWfL5DTT0Np6UYs/y5Niu7WIfNv/wRTtN5RSXt2DIEft3dx3zPuw/3WJQBCJfmEzvDiEKwoqMbGD9n49+qLSA==", "dev": true, - "license": "MIT", "dependencies": { "@jest/core": "^29.3.1", "@jest/types": "^29.3.1", @@ -13866,6 +14151,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "dev": true, @@ -13957,6 +14248,12 @@ "semver": "bin/semver.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "dev": true, @@ -16789,6 +17086,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pvtsutils": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.2.tgz", + "integrity": "sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/q": { "version": "1.5.1", "dev": true, @@ -18958,6 +19273,58 @@ "version": "1.3.0", "license": "MIT" }, + "node_modules/ts-jest": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.0.3.tgz", + "integrity": "sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==", + "dev": true, + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.1", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "7.x", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/ts-loader": { "version": "9.4.1", "dev": true, @@ -18976,6 +19343,58 @@ "webpack": "^5.0.0" } }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tsconfig-paths": { "version": "3.14.1", "dev": true, @@ -19386,6 +19805,19 @@ "dev": true, "license": "MIT" }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "license": "MIT" @@ -19407,6 +19839,12 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "node_modules/v8-to-istanbul": { "version": "9.0.1", "dev": true, @@ -19617,10 +20055,44 @@ "defaults": "^1.0.3" } }, + "node_modules/web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "dev": true, + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "devOptional": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/web-streams-ponyfill": { "version": "1.4.2", "license": "MIT" }, + "node_modules/webcrypto-core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.7.5.tgz", + "integrity": "sha512-gaExY2/3EHQlRNNNVSrbG2Cg94Rutl7fAaKILS1w8ZDhGxdFOaw6EbCfHIxPy9vt/xwp5o0VQAx9aySPF6hU1A==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.1.6", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.1", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "license": "BSD-2-Clause" @@ -19787,6 +20259,26 @@ "dev": true, "license": "ISC" }, + "node_modules/which-typed-array": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", + "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wide-align": { "version": "1.1.5", "dev": true, @@ -20240,6 +20732,15 @@ "buffer-crc32": "~0.2.3" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, @@ -23477,6 +23978,27 @@ "sparqlalgebrajs": "^4.0.5" } }, + "@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } + } + }, "@dabh/diagnostics": { "version": "2.0.3", "requires": { @@ -23563,6 +24085,17 @@ "typescript": ">=4.7.4" } }, + "@inrupt/jest-jsdom-polyfills": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@inrupt/jest-jsdom-polyfills/-/jest-jsdom-polyfills-1.4.3.tgz", + "integrity": "sha512-up3NoeUuSTkdG5dg2aGtN6JPjPWFkBszWV5oHoTUvoxF9kTbh5LZghhhwwgQagHO/8VjgdPBNyI5AY704Td/Uw==", + "dev": true, + "requires": { + "@peculiar/webcrypto": "^1.4.0", + "@web-std/blob": "^3.0.4", + "@web-std/file": "^3.0.2" + } + }, "@inrupt/oidc-client": { "version": "1.11.6", "requires": { @@ -23593,6 +24126,69 @@ "@types/uuid": "^8.3.0", "jose": "^4.3.7", "uuid": "^8.3.1" + }, + "dependencies": { + "@types/jest": { + "version": "27.5.2", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz", + "integrity": "sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==", + "requires": { + "jest-matcher-utils": "^27.0.0", + "pretty-format": "^27.0.0" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + }, + "diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==" + }, + "jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==" + }, + "jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } } }, "@inrupt/solid-client": { @@ -25027,6 +25623,39 @@ "node-gyp-build": "^4.3.0" } }, + "@peculiar/asn1-schema": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.3.tgz", + "integrity": "sha512-6GptMYDMyWBHTUKndHaDsRZUO/XMSgIns2krxcm2L7SEExRHwawFvSwNBhqNPR9HJwv3MruAiF1bhN0we6j6GQ==", + "dev": true, + "requires": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.0" + } + }, + "@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "requires": { + "tslib": "^2.0.0" + } + }, + "@peculiar/webcrypto": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.4.1.tgz", + "integrity": "sha512-eK4C6WTNYxoI7JOabMoZICiyqRRtJB220bh0Mbj5RwRycleZf9BPyZoxsTvpP0FpmVS2aS13NKOuh5/tN3sIRw==", + "dev": true, + "requires": { + "@peculiar/asn1-schema": "^2.3.0", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.1", + "webcrypto-core": "^1.7.4" + } + }, "@phenomnomnominal/tsquery": { "version": "4.1.1", "dev": true, @@ -25112,6 +25741,30 @@ "version": "2.0.0", "dev": true }, + "@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", + "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", + "dev": true + }, "@types/babel__core": { "version": "7.1.20", "dev": true, @@ -25209,50 +25862,13 @@ } }, "@types/jest": { - "version": "27.5.2", + "version": "29.2.3", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.2.3.tgz", + "integrity": "sha512-6XwoEbmatfyoCjWRX7z0fKMmgYKe9+/HrviJ5k0X/tjJWHGAezZOfYaxqQKuzG/TvQyr+ktjm4jgbk0s4/oF2w==", + "dev": true, "requires": { - "jest-matcher-utils": "^27.0.0", - "pretty-format": "^27.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "5.2.0" - }, - "diff-sequences": { - "version": "27.5.1" - }, - "jest-diff": { - "version": "27.5.1", - "requires": { - "chalk": "^4.0.0", - "diff-sequences": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - } - }, - "jest-get-type": { - "version": "27.5.1" - }, - "jest-matcher-utils": { - "version": "27.5.1", - "requires": { - "chalk": "^4.0.0", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - } - }, - "pretty-format": { - "version": "27.5.1", - "requires": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - } - }, - "react-is": { - "version": "17.0.2" - } + "expect": "^29.0.0", + "pretty-format": "^29.0.0" } }, "@types/json-schema": { @@ -25524,6 +26140,34 @@ "version": "3.2.44", "dev": true }, + "@web-std/blob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@web-std/blob/-/blob-3.0.4.tgz", + "integrity": "sha512-+dibyiw+uHYK4dX5cJ7HA+gtDAaUUe6JsOryp2ZpAC7h4ICsh49E34JwHoEKPlPvP0llCrNzz45vvD+xX5QDBg==", + "dev": true, + "requires": { + "@web-std/stream": "1.0.0", + "web-encoding": "1.1.5" + } + }, + "@web-std/file": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@web-std/file/-/file-3.0.2.tgz", + "integrity": "sha512-pIH0uuZsmY8YFvSHP1NsBIiMT/1ce0suPrX74fEeO3Wbr1+rW0fUGEe4d0R99iLwXtyCwyserqCFI4BJkJlkRA==", + "dev": true, + "requires": { + "@web-std/blob": "^3.0.3" + } + }, + "@web-std/stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@web-std/stream/-/stream-1.0.0.tgz", + "integrity": "sha512-jyIbdVl+0ZJyKGTV0Ohb9E6UnxP+t7ZzX4Do3AHjZKxUXKMs9EmqnBDQgHF7bEw0EzbQygOjtt/7gvtmi//iCQ==", + "dev": true, + "requires": { + "web-streams-polyfill": "^3.1.1" + } + }, "@webassemblyjs/ast": { "version": "1.11.1", "dev": true, @@ -25701,6 +26345,13 @@ "argparse": "^2.0.1" } }, + "@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "dev": true, + "optional": true + }, "abbrev": { "version": "1.1.1", "dev": true @@ -25725,6 +26376,12 @@ "dev": true, "requires": {} }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, "add-stream": { "version": "1.0.0", "dev": true @@ -25844,6 +26501,12 @@ "readable-stream": "^3.6.0" } }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "argparse": { "version": "2.0.1", "dev": true @@ -25893,6 +26556,17 @@ "version": "2.0.6", "dev": true }, + "asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dev": true, + "requires": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + } + }, "async": { "version": "3.2.4" }, @@ -25913,6 +26587,12 @@ "version": "1.0.0", "dev": true }, + "available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true + }, "axios": { "version": "1.1.3", "dev": true, @@ -26220,6 +26900,15 @@ "update-browserslist-db": "^1.0.9" } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.1", "dev": true, @@ -26865,6 +27554,12 @@ "yaml": "^1.10.0" } }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "cross-fetch": { "version": "3.1.5", "requires": { @@ -27842,6 +28537,15 @@ "version": "1.15.2", "dev": true }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "requires": { + "is-callable": "^1.1.3" + } + }, "form-data": { "version": "4.0.0", "dev": true, @@ -28186,6 +28890,15 @@ "slash": "^3.0.0" } }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.3" + } + }, "got": { "version": "12.5.2", "dev": true, @@ -28533,6 +29246,16 @@ "version": "2.0.0", "dev": true }, + "is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, "is-arrayish": { "version": "0.2.1", "dev": true @@ -28604,6 +29327,15 @@ "version": "2.1.0", "dev": true }, + "is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, "is-glob": { "version": "4.0.3", "dev": true, @@ -28708,6 +29440,19 @@ "text-extensions": "^1.0.0" } }, + "is-typed-array": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", + "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + } + }, "is-typedarray": { "version": "1.0.0", "dev": true @@ -28804,6 +29549,8 @@ }, "jest": { "version": "29.3.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.3.1.tgz", + "integrity": "sha512-6iWfL5DTT0Np6UYs/y5Niu7WIfNv/wRTtN5RSXt2DIEft3dx3zPuw/3WJQBCJfmEzvDiEKwoqMbGD9n49+qLSA==", "dev": true, "requires": { "@jest/core": "^29.3.1", @@ -29788,6 +30535,12 @@ "version": "4.4.0", "dev": true }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "lodash.merge": { "version": "4.6.2", "dev": true @@ -29845,6 +30598,12 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "make-fetch-happen": { "version": "10.2.1", "dev": true, @@ -31684,6 +32443,21 @@ "escape-goat": "^4.0.0" } }, + "pvtsutils": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.2.tgz", + "integrity": "sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ==", + "dev": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "dev": true + }, "q": { "version": "1.5.1", "dev": true @@ -33123,6 +33897,30 @@ "triple-beam": { "version": "1.3.0" }, + "ts-jest": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.0.3.tgz", + "integrity": "sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.1", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "7.x", + "yargs-parser": "^21.0.1" + }, + "dependencies": { + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true + } + } + }, "ts-loader": { "version": "9.4.1", "dev": true, @@ -33133,6 +33931,35 @@ "semver": "^7.3.4" } }, + "ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "requires": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "dependencies": { + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + } + } + }, "tsconfig-paths": { "version": "3.14.1", "dev": true, @@ -33400,6 +34227,19 @@ "version": "4.0.1", "dev": true }, + "util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "util-deprecate": { "version": "1.0.2" }, @@ -33414,6 +34254,12 @@ "version": "2.3.0", "dev": true }, + "v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "v8-to-istanbul": { "version": "9.0.1", "dev": true, @@ -33566,9 +34412,38 @@ "defaults": "^1.0.3" } }, + "web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "dev": true, + "requires": { + "@zxing/text-encoding": "0.9.0", + "util": "^0.12.3" + } + }, + "web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "devOptional": true + }, "web-streams-ponyfill": { "version": "1.4.2" }, + "webcrypto-core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.7.5.tgz", + "integrity": "sha512-gaExY2/3EHQlRNNNVSrbG2Cg94Rutl7fAaKILS1w8ZDhGxdFOaw6EbCfHIxPy9vt/xwp5o0VQAx9aySPF6hU1A==", + "dev": true, + "requires": { + "@peculiar/asn1-schema": "^2.1.6", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.1", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.0" + } + }, "webidl-conversions": { "version": "3.0.1" }, @@ -33667,6 +34542,20 @@ "version": "2.0.0", "dev": true }, + "which-typed-array": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", + "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0", + "is-typed-array": "^1.1.10" + } + }, "wide-align": { "version": "1.1.5", "dev": true, @@ -33959,6 +34848,12 @@ "buffer-crc32": "~0.2.3" } }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + }, "yocto-queue": { "version": "0.1.0", "dev": true diff --git a/package.json b/package.json index 6e11b77..7363fdc 100644 --- a/package.json +++ b/package.json @@ -21,14 +21,18 @@ "licenses:check": "npm run licenses:check:root && npm run licenses:check:dep", "solidauth:link": "lerna run solidauth:link", "solidauth:install": "lerna run solidauth:install", - "test": "lerna run test --concurrency 1 --stream" + "test:extensions": "lerna run test --concurrency 1 --stream", + "test:packages": "jest --coverage --verbose", + "test": "npm run test:extensions" }, "devDependencies": { "@babel/core": "^7.20.2", "@babel/preset-env": "^7.20.2", "@inrupt/eslint-config-lib": "^1.4.2", + "@inrupt/jest-jsdom-polyfills": "^1.4.3", "@rushstack/eslint-patch": "^1.2.0", "@types/glob": "^8.0.0", + "@types/jest": "^29.2.3", "@types/mocha": "^10.0.0", "@types/node": "^18.11.9", "@types/vscode": "^1.73.1", @@ -38,13 +42,16 @@ "depcheck": "^1.4.3", "eslint-plugin-unused-imports": "^2.0.0", "glob": "^8.0.3", + "jest": "^29.3.1", "lerna": "^6.0.3", "lerna-audit": "^1.3.3", "license-checker": "^25.0.1", "mocha": "^10.1.0", "npm-check-updates": "^16.4.1", "nx": "^15.2.1", + "ts-jest": "^29.0.3", "ts-loader": "^9.4.1", + "ts-node": "^10.9.1", "typescript": "^4.9.3", "vsce": "^2.14.0", "webpack": "^5.75.0", diff --git a/packages/solid-vscode-auth/__tests__/solid-vscode-auth.test.js b/packages/solid-vscode-auth/__tests__/solid-vscode-auth.test.ts similarity index 100% rename from packages/solid-vscode-auth/__tests__/solid-vscode-auth.test.js rename to packages/solid-vscode-auth/__tests__/solid-vscode-auth.test.ts diff --git a/packages/solid-vscode-auth/lib/index.ts b/packages/solid-vscode-auth/lib/index.ts index 23483e2..8d799f3 100644 --- a/packages/solid-vscode-auth/lib/index.ts +++ b/packages/solid-vscode-auth/lib/index.ts @@ -23,7 +23,7 @@ import { fetch as crossFetch } from "cross-fetch"; import { importJWK } from "jose"; import * as vscode from "vscode"; -const SOLID_AUTHENTICATION_PROVIDER_ID = "solidauth"; +export const SOLID_AUTHENTICATION_PROVIDER_ID = "solidauth"; async function buildAuthenticatedFetchFromAccessToken( accessToken: string diff --git a/tsconfig.json b/tsconfig.json index 0ec5c70..5eac076 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -98,7 +98,8 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, /* Skip type checking all .d.ts files. */ + "allowJs": true, }, "include": [ "packages/**/bin/**/*", @@ -109,6 +110,8 @@ "exclude": [ "**/node_modules", "**/test/*", + "**/__test__/*", + "**/*.spec.ts", "**/*.d.ts", "**/*.d.ts" ] From fab289fb53faeb8f45459cd037a845f37e47edb0 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Tue, 6 Dec 2022 12:46:01 +1100 Subject: [PATCH 05/17] chore: add authn packages to monorepo --- .gitignore | 2 +- extensions/solidauth/package.json | 4 + .../src/auth/AuthCodeRedirectHandler.ts | 6 +- .../src/auth/RefreshTokenOidcHandler.ts | 224 ++++++ .../solidauth/src/auth/TokenRefresher.ts | 150 ++++ .../src/auth/solidAuthenticationProvider.ts | 131 +++- .../solidauth/src/storage/secretStorage.ts | 8 +- package-lock.json | 240 +++++- package.json | 2 + packages/core/.eslintrc.js | 6 + packages/core/.npmignore | 5 + packages/core/.prettierignore | 2 + packages/core/package-lock.json | 216 ++++++ packages/core/package.json | 62 ++ packages/core/rollup.config.mjs | 54 ++ packages/core/sonar-project.properties | 15 + packages/core/src/ILoginInputOptions.ts | 58 ++ .../src/authenticatedFetch/dpopUtils.spec.ts | 107 +++ .../core/src/authenticatedFetch/dpopUtils.ts | 82 ++ .../authenticatedFetch/fetchFactory.spec.ts | 700 ++++++++++++++++++ .../src/authenticatedFetch/fetchFactory.ts | 292 ++++++++ packages/core/src/constant.ts | 59 ++ .../core/src/errors/ConfigurationError.ts | 42 ++ .../core/src/errors/InvalidResponseError.ts | 44 ++ .../core/src/errors/NotImplementedError.ts | 36 + packages/core/src/errors/OidcProviderError.ts | 46 ++ packages/core/src/errors/errors.spec.ts | 59 ++ packages/core/src/index.ts | 113 +++ packages/core/src/login/ILoginHandler.ts | 41 + packages/core/src/login/ILoginOptions.ts | 56 ++ packages/core/src/login/oidc/IClient.ts | 38 + .../src/login/oidc/IClientRegistrar.test.ts | 134 ++++ .../core/src/login/oidc/IClientRegistrar.ts | 135 ++++ .../login/oidc/IIncomingRedirectHandler.ts | 45 ++ packages/core/src/login/oidc/IIssuerConfig.ts | 70 ++ .../src/login/oidc/IIssuerConfigFetcher.ts | 38 + packages/core/src/login/oidc/IOidcHandler.ts | 40 + packages/core/src/login/oidc/IOidcOptions.ts | 69 ++ packages/core/src/login/oidc/IRedirector.ts | 40 + .../oidc/__mocks__/IncomingRedirectHandler.ts | 46 ++ .../src/login/oidc/__mocks__/IssuerConfig.ts | 35 + .../oidc/__mocks__/IssuerConfigFetcher.ts | 31 + .../src/login/oidc/refresh/ITokenRefresher.ts | 74 ++ .../oidc/refresh/__mocks__/TokenRefresher.ts | 60 ++ packages/core/src/logout/ILogoutHandler.ts | 33 + packages/core/src/mocks.spec.ts | 38 + packages/core/src/mocks.ts | 33 + .../core/src/sessionInfo/ISessionInfo.spec.ts | 37 + packages/core/src/sessionInfo/ISessionInfo.ts | 94 +++ .../sessionInfo/ISessionInfoManager.spec.ts | 30 + .../src/sessionInfo/ISessionInfoManager.ts | 74 ++ packages/core/src/storage/IStorage.ts | 29 + packages/core/src/storage/IStorageUtility.ts | 57 ++ .../core/src/storage/InMemoryStorage.spec.ts | 41 + packages/core/src/storage/InMemoryStorage.ts | 46 ++ .../core/src/storage/StorageUtility.spec.ts | 571 ++++++++++++++ packages/core/src/storage/StorageUtility.ts | 267 +++++++ .../src/storage/__mocks__/StorageUtility.ts | 93 +++ .../handlerPattern/AggregateHandler.spec.ts | 141 ++++ .../util/handlerPattern/AggregateHandler.ts | 102 +++ .../src/util/handlerPattern/IHandleable.ts | 36 + packages/core/src/util/token.spec.ts | 256 +++++++ packages/core/src/util/token.ts | 110 +++ packages/core/tsconfig.eslint.json | 6 + packages/core/tsconfig.json | 17 + packages/node/.eslintrc.js | 6 + packages/node/.npmignore | 6 + packages/node/.prettierignore | 3 + packages/node/README.md | 51 ++ packages/node/package-lock.json | 347 +++++++++ packages/node/package.json | 40 + packages/node/sonar-project.properties | 15 + .../node/src/ClientAuthentication.spec.ts | 309 ++++++++ packages/node/src/ClientAuthentication.ts | 138 ++++ packages/node/src/Session.spec.ts | 659 +++++++++++++++++ packages/node/src/Session.ts | 285 +++++++ .../src/__mocks__/ClientAuthentication.ts | 60 ++ .../headers/HeadersUtils.spec.ts | 71 ++ .../headers/HeadersUtils.ts | 55 ++ packages/node/src/constant.ts | 24 + packages/node/src/dependencies.spec.ts | 203 +++++ packages/node/src/dependencies.ts | 147 ++++ packages/node/src/index.spec.ts | 28 + packages/node/src/index.ts | 39 + .../node/src/login/__mocks__/LoginHandler.ts | 39 + .../oidc/AggregateIncomingRedirectHandler.ts | 50 ++ .../login/oidc/AggregateOidcHandler.spec.ts | 41 + .../src/login/oidc/AggregateOidcHandler.ts | 47 ++ .../src/login/oidc/ClientRegistrar.spec.ts | 378 ++++++++++ .../node/src/login/oidc/ClientRegistrar.ts | 151 ++++ .../login/oidc/IssuerConfigFetcher.spec.ts | 197 +++++ .../src/login/oidc/IssuerConfigFetcher.ts | 169 +++++ .../src/login/oidc/OidcLoginHandler.spec.ts | 352 +++++++++ .../node/src/login/oidc/OidcLoginHandler.ts | 124 ++++ .../node/src/login/oidc/Redirector.spec.ts | 46 ++ packages/node/src/login/oidc/Redirector.ts | 45 ++ .../src/login/oidc/TokenRequester.spec.ts | 119 +++ .../node/src/login/oidc/TokenRequester.ts | 57 ++ .../login/oidc/__mocks__/ClientRegistrar.ts | 97 +++ .../src/login/oidc/__mocks__/IOidcHandler.ts | 40 + .../src/login/oidc/__mocks__/IOidcOptions.ts | 58 ++ .../oidc/__mocks__/IssuerConfigFetcher.ts | 95 +++ .../src/login/oidc/__mocks__/Redirector.ts | 30 + .../AuthCodeRedirectHandler.spec.ts | 531 +++++++++++++ .../AuthCodeRedirectHandler.ts | 206 ++++++ .../FallbackRedirectHandler.spec.ts | 62 ++ .../FallbackRedirectHandler.ts | 62 ++ ...thorizationCodeWithPkceOidcHandler.spec.ts | 151 ++++ .../AuthorizationCodeWithPkceOidcHandler.ts | 104 +++ .../ClientCredentialsOidcHandler.spec.ts | 441 +++++++++++ .../ClientCredentialsOidcHandler.ts | 154 ++++ .../oidcHandlers/OidcHandlerCanHandleTests.ts | 122 +++ .../RefreshTokenOidcHandler.spec.ts | 457 ++++++++++++ .../oidcHandlers/RefreshTokenOidcHandler.ts | 222 ++++++ .../login/oidc/refresh/TokenRefresher.spec.ts | 427 +++++++++++ .../src/login/oidc/refresh/TokenRefresher.ts | 149 ++++ .../oidc/refresh/__mocks__/TokenRefresher.ts | 57 ++ .../src/logout/GeneralLogoutHandler.spec.ts | 72 ++ .../node/src/logout/GeneralLogoutHandler.ts | 45 ++ .../src/logout/__mocks__/LogoutHandler.ts | 38 + packages/node/src/multiSession.spec.ts | 235 ++++++ packages/node/src/multiSession.ts | 135 ++++ .../sessionInfo/SessionInfoManager.spec.ts | 317 ++++++++ .../src/sessionInfo/SessionInfoManager.ts | 213 ++++++ .../__mocks__/SessionInfoManager.ts | 60 ++ packages/node/src/storage/StorageUtility.ts | 43 ++ packages/node/src/util/UuidGenerator.spec.ts | 35 + packages/node/src/util/UuidGenerator.ts | 47 ++ .../node/src/util/__mocks__/UuidGenerator.ts | 30 + packages/node/src/util/urlPath.spec.ts | 64 ++ packages/node/src/util/urlPath.ts | 52 ++ packages/node/tsconfig.eslint.json | 6 + packages/node/tsconfig.json | 18 + tsconfig.build.json | 40 + 134 files changed, 14977 insertions(+), 67 deletions(-) create mode 100644 extensions/solidauth/src/auth/RefreshTokenOidcHandler.ts create mode 100644 extensions/solidauth/src/auth/TokenRefresher.ts create mode 100644 packages/core/.eslintrc.js create mode 100644 packages/core/.npmignore create mode 100644 packages/core/.prettierignore create mode 100644 packages/core/package-lock.json create mode 100644 packages/core/package.json create mode 100644 packages/core/rollup.config.mjs create mode 100644 packages/core/sonar-project.properties create mode 100644 packages/core/src/ILoginInputOptions.ts create mode 100644 packages/core/src/authenticatedFetch/dpopUtils.spec.ts create mode 100644 packages/core/src/authenticatedFetch/dpopUtils.ts create mode 100644 packages/core/src/authenticatedFetch/fetchFactory.spec.ts create mode 100644 packages/core/src/authenticatedFetch/fetchFactory.ts create mode 100644 packages/core/src/constant.ts create mode 100644 packages/core/src/errors/ConfigurationError.ts create mode 100644 packages/core/src/errors/InvalidResponseError.ts create mode 100644 packages/core/src/errors/NotImplementedError.ts create mode 100644 packages/core/src/errors/OidcProviderError.ts create mode 100644 packages/core/src/errors/errors.spec.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/login/ILoginHandler.ts create mode 100644 packages/core/src/login/ILoginOptions.ts create mode 100644 packages/core/src/login/oidc/IClient.ts create mode 100644 packages/core/src/login/oidc/IClientRegistrar.test.ts create mode 100644 packages/core/src/login/oidc/IClientRegistrar.ts create mode 100644 packages/core/src/login/oidc/IIncomingRedirectHandler.ts create mode 100644 packages/core/src/login/oidc/IIssuerConfig.ts create mode 100644 packages/core/src/login/oidc/IIssuerConfigFetcher.ts create mode 100644 packages/core/src/login/oidc/IOidcHandler.ts create mode 100644 packages/core/src/login/oidc/IOidcOptions.ts create mode 100644 packages/core/src/login/oidc/IRedirector.ts create mode 100644 packages/core/src/login/oidc/__mocks__/IncomingRedirectHandler.ts create mode 100644 packages/core/src/login/oidc/__mocks__/IssuerConfig.ts create mode 100644 packages/core/src/login/oidc/__mocks__/IssuerConfigFetcher.ts create mode 100644 packages/core/src/login/oidc/refresh/ITokenRefresher.ts create mode 100644 packages/core/src/login/oidc/refresh/__mocks__/TokenRefresher.ts create mode 100644 packages/core/src/logout/ILogoutHandler.ts create mode 100644 packages/core/src/mocks.spec.ts create mode 100644 packages/core/src/mocks.ts create mode 100644 packages/core/src/sessionInfo/ISessionInfo.spec.ts create mode 100644 packages/core/src/sessionInfo/ISessionInfo.ts create mode 100644 packages/core/src/sessionInfo/ISessionInfoManager.spec.ts create mode 100644 packages/core/src/sessionInfo/ISessionInfoManager.ts create mode 100644 packages/core/src/storage/IStorage.ts create mode 100644 packages/core/src/storage/IStorageUtility.ts create mode 100644 packages/core/src/storage/InMemoryStorage.spec.ts create mode 100644 packages/core/src/storage/InMemoryStorage.ts create mode 100644 packages/core/src/storage/StorageUtility.spec.ts create mode 100644 packages/core/src/storage/StorageUtility.ts create mode 100644 packages/core/src/storage/__mocks__/StorageUtility.ts create mode 100644 packages/core/src/util/handlerPattern/AggregateHandler.spec.ts create mode 100644 packages/core/src/util/handlerPattern/AggregateHandler.ts create mode 100644 packages/core/src/util/handlerPattern/IHandleable.ts create mode 100644 packages/core/src/util/token.spec.ts create mode 100644 packages/core/src/util/token.ts create mode 100644 packages/core/tsconfig.eslint.json create mode 100644 packages/core/tsconfig.json create mode 100644 packages/node/.eslintrc.js create mode 100644 packages/node/.npmignore create mode 100644 packages/node/.prettierignore create mode 100644 packages/node/README.md create mode 100644 packages/node/package-lock.json create mode 100644 packages/node/package.json create mode 100644 packages/node/sonar-project.properties create mode 100644 packages/node/src/ClientAuthentication.spec.ts create mode 100644 packages/node/src/ClientAuthentication.ts create mode 100644 packages/node/src/Session.spec.ts create mode 100644 packages/node/src/Session.ts create mode 100644 packages/node/src/__mocks__/ClientAuthentication.ts create mode 100644 packages/node/src/authenticatedFetch/headers/HeadersUtils.spec.ts create mode 100644 packages/node/src/authenticatedFetch/headers/HeadersUtils.ts create mode 100644 packages/node/src/constant.ts create mode 100644 packages/node/src/dependencies.spec.ts create mode 100644 packages/node/src/dependencies.ts create mode 100644 packages/node/src/index.spec.ts create mode 100644 packages/node/src/index.ts create mode 100644 packages/node/src/login/__mocks__/LoginHandler.ts create mode 100644 packages/node/src/login/oidc/AggregateIncomingRedirectHandler.ts create mode 100644 packages/node/src/login/oidc/AggregateOidcHandler.spec.ts create mode 100644 packages/node/src/login/oidc/AggregateOidcHandler.ts create mode 100644 packages/node/src/login/oidc/ClientRegistrar.spec.ts create mode 100644 packages/node/src/login/oidc/ClientRegistrar.ts create mode 100644 packages/node/src/login/oidc/IssuerConfigFetcher.spec.ts create mode 100644 packages/node/src/login/oidc/IssuerConfigFetcher.ts create mode 100644 packages/node/src/login/oidc/OidcLoginHandler.spec.ts create mode 100644 packages/node/src/login/oidc/OidcLoginHandler.ts create mode 100644 packages/node/src/login/oidc/Redirector.spec.ts create mode 100644 packages/node/src/login/oidc/Redirector.ts create mode 100644 packages/node/src/login/oidc/TokenRequester.spec.ts create mode 100644 packages/node/src/login/oidc/TokenRequester.ts create mode 100644 packages/node/src/login/oidc/__mocks__/ClientRegistrar.ts create mode 100644 packages/node/src/login/oidc/__mocks__/IOidcHandler.ts create mode 100644 packages/node/src/login/oidc/__mocks__/IOidcOptions.ts create mode 100644 packages/node/src/login/oidc/__mocks__/IssuerConfigFetcher.ts create mode 100644 packages/node/src/login/oidc/__mocks__/Redirector.ts create mode 100644 packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.spec.ts create mode 100644 packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts create mode 100644 packages/node/src/login/oidc/incomingRedirectHandler/FallbackRedirectHandler.spec.ts create mode 100644 packages/node/src/login/oidc/incomingRedirectHandler/FallbackRedirectHandler.ts create mode 100644 packages/node/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.spec.ts create mode 100644 packages/node/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.ts create mode 100644 packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.spec.ts create mode 100644 packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts create mode 100644 packages/node/src/login/oidc/oidcHandlers/OidcHandlerCanHandleTests.ts create mode 100644 packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.spec.ts create mode 100644 packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts create mode 100644 packages/node/src/login/oidc/refresh/TokenRefresher.spec.ts create mode 100644 packages/node/src/login/oidc/refresh/TokenRefresher.ts create mode 100644 packages/node/src/login/oidc/refresh/__mocks__/TokenRefresher.ts create mode 100644 packages/node/src/logout/GeneralLogoutHandler.spec.ts create mode 100644 packages/node/src/logout/GeneralLogoutHandler.ts create mode 100644 packages/node/src/logout/__mocks__/LogoutHandler.ts create mode 100644 packages/node/src/multiSession.spec.ts create mode 100644 packages/node/src/multiSession.ts create mode 100644 packages/node/src/sessionInfo/SessionInfoManager.spec.ts create mode 100644 packages/node/src/sessionInfo/SessionInfoManager.ts create mode 100644 packages/node/src/sessionInfo/__mocks__/SessionInfoManager.ts create mode 100644 packages/node/src/storage/StorageUtility.ts create mode 100644 packages/node/src/util/UuidGenerator.spec.ts create mode 100644 packages/node/src/util/UuidGenerator.ts create mode 100644 packages/node/src/util/__mocks__/UuidGenerator.ts create mode 100644 packages/node/src/util/urlPath.spec.ts create mode 100644 packages/node/src/util/urlPath.ts create mode 100644 packages/node/tsconfig.eslint.json create mode 100644 packages/node/tsconfig.json create mode 100644 tsconfig.build.json diff --git a/.gitignore b/.gitignore index 0a4889e..d97bcd2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ node_modules/ license.csv - +dist/ diff --git a/extensions/solidauth/package.json b/extensions/solidauth/package.json index 11361ef..88702f9 100644 --- a/extensions/solidauth/package.json +++ b/extensions/solidauth/package.json @@ -71,5 +71,9 @@ "@inrupt/solid-client-authn-node": "^1.12.2", "@inrupt/solid-vscode-auth": "^0.0.0", "solid-node-interactive-auth": "1.1.0" + }, + "overrides": { + "@inrupt/solid-client-authn-node": "$@inrupt/solid-client-authn-node", + "@inrupt/solid-client-authn-core": "$@inrupt/solid-client-authn-core" } } diff --git a/extensions/solidauth/src/auth/AuthCodeRedirectHandler.ts b/extensions/solidauth/src/auth/AuthCodeRedirectHandler.ts index 980c9ac..c6ae4e3 100644 --- a/extensions/solidauth/src/auth/AuthCodeRedirectHandler.ts +++ b/extensions/solidauth/src/auth/AuthCodeRedirectHandler.ts @@ -35,10 +35,12 @@ import { loadOidcContextFromStorage, generateDpopKeyPair, EVENTS, - buildAuthenticatedFetch, getWebidFromTokenPayload, saveSessionInfoToStorage, } from "@inrupt/solid-client-authn-core"; +import { + buildAuthenticatedFetch, +} from "./fetchFactory"; import { configToIssuerMetadata } from "@inrupt/solid-client-authn-node/dist/login/oidc/IssuerConfigFetcher"; import type { KeyObject } from "crypto"; import { fetch as globalFetch } from "cross-fetch"; @@ -206,7 +208,7 @@ export default class AuthCodeRedirectHandler } else if (typeof tokenSet.expires_in === "number") { await this.storageUtility.setForUser( sessionId, - { expires_at: (tokenSet.expires_in + Date.now()).toString() }, + { expires_at: Math.floor(tokenSet.expires_in + (Date.now() / 1000)).toString() }, { secure: true } ); } diff --git a/extensions/solidauth/src/auth/RefreshTokenOidcHandler.ts b/extensions/solidauth/src/auth/RefreshTokenOidcHandler.ts new file mode 100644 index 0000000..0756f15 --- /dev/null +++ b/extensions/solidauth/src/auth/RefreshTokenOidcHandler.ts @@ -0,0 +1,224 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Handler for the Refresh Token Flow + */ + import { + IOidcHandler, + IOidcOptions, + IStorageUtility, + LoginResult, + saveSessionInfoToStorage, + getWebidFromTokenPayload, + ISessionInfo, + generateDpopKeyPair, + KeyPair, + PREFERRED_SIGNING_ALG, + RefreshOptions, + ITokenRefresher, + TokenEndpointResponse, +} from "@inrupt/solid-client-authn-core"; +import { JWK, importJWK } from "jose"; +import { fetch as globalFetch } from "cross-fetch"; +import { EventEmitter } from "events"; +import { KeyObject } from "crypto"; +import { + buildAuthenticatedFetch, +} from "./fetchFactory"; + +function validateOptions( + oidcLoginOptions: IOidcOptions +): oidcLoginOptions is IOidcOptions & { + refreshToken: string; + client: { clientId: string; clientSecret: string }; +} { + return ( + oidcLoginOptions.refreshToken !== undefined && + oidcLoginOptions.client.clientId !== undefined + ); +} + +/** + * Go through the refresh flow to get a new valid access token, and build an + * authenticated fetch with it. + * @param refreshOptions + * @param dpop + */ +async function refreshAccess( + refreshOptions: RefreshOptions, + dpop: boolean, + refreshBindingKey?: KeyPair, + eventEmitter?: EventEmitter +): Promise { + try { + let dpopKey: KeyPair | undefined; + if (dpop) { + dpopKey = refreshBindingKey || (await generateDpopKeyPair()); + // The alg property isn't set by exportJWK, so set it manually. + [dpopKey.publicKey.alg] = PREFERRED_SIGNING_ALG; + } + const tokens = await refreshOptions.tokenRefresher.refresh( + refreshOptions.sessionId, + refreshOptions.refreshToken, + dpopKey + ); + // Rotate the refresh token if applicable + const rotatedRefreshOptions = { + ...refreshOptions, + refreshToken: tokens.refreshToken ?? refreshOptions.refreshToken, + }; + const authFetch = await buildAuthenticatedFetch( + globalFetch, + tokens.accessToken, + { + dpopKey, + refreshOptions: rotatedRefreshOptions, + eventEmitter, + } + ); + return Object.assign(tokens, { + fetch: authFetch, + }); + } catch (e) { + throw new Error(`Invalid refresh credentials: ${e}`); + } +} + +/** + * @hidden + * Refresh token flow spec: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens + */ +export default class RefreshTokenOidcHandler implements IOidcHandler { + constructor( + private tokenRefresher: ITokenRefresher, + private storageUtility: IStorageUtility + ) {} + + async canHandle(oidcLoginOptions: IOidcOptions): Promise { + return validateOptions(oidcLoginOptions); + } + + async handle(oidcLoginOptions: IOidcOptions): Promise { + if (!(await this.canHandle(oidcLoginOptions))) { + throw new Error( + `RefreshTokenOidcHandler cannot handle the provided options, missing one of 'refreshToken', 'clientId' in: ${JSON.stringify( + oidcLoginOptions + )}` + ); + } + const refreshOptions: RefreshOptions = { + // The type assertion is okay, because it is tested for in canHandle. + refreshToken: oidcLoginOptions.refreshToken as string, + sessionId: oidcLoginOptions.sessionId, + tokenRefresher: this.tokenRefresher, + }; + + // This information must be in storage for the refresh flow to succeed. + await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { + issuer: oidcLoginOptions.issuer, + dpop: oidcLoginOptions.dpop ? "true" : "false", + clientId: oidcLoginOptions.client.clientId, + // Note: We assume here that a client secret is present, which is checked for when validating the options. + clientSecret: oidcLoginOptions.client.clientSecret as string, + }); + + // In the case when the refresh token is bound to a DPoP key, said key must + // be used during the refresh grant. + const publicKey = await this.storageUtility.getForUser( + oidcLoginOptions.sessionId, + "publicKey" + ); + const privateKey = await this.storageUtility.getForUser( + oidcLoginOptions.sessionId, + "privateKey" + ); + let keyPair: undefined | KeyPair; + if (publicKey !== undefined && privateKey !== undefined) { + keyPair = { + publicKey: JSON.parse(publicKey) as JWK, + privateKey: (await importJWK( + JSON.parse(privateKey), + PREFERRED_SIGNING_ALG[0] + )) as KeyObject, + }; + } + + const accessInfo = await refreshAccess( + refreshOptions, + oidcLoginOptions.dpop, + keyPair + ); + + const sessionInfo: ISessionInfo = { + isLoggedIn: true, + sessionId: oidcLoginOptions.sessionId, + }; + + if (accessInfo.idToken === undefined) { + throw new Error( + `The Identity Provider [${oidcLoginOptions.issuer}] did not return an ID token on refresh, which prevents us from getting the user's WebID.` + ); + } + sessionInfo.webId = await getWebidFromTokenPayload( + accessInfo.idToken, + oidcLoginOptions.issuerConfiguration.jwksUri, + oidcLoginOptions.issuer, + oidcLoginOptions.client.clientId + ); + + await saveSessionInfoToStorage( + this.storageUtility, + oidcLoginOptions.sessionId, + undefined, + "true", + accessInfo.refreshToken ?? refreshOptions.refreshToken, + undefined, + keyPair + ); + + await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { + issuer: oidcLoginOptions.issuer, + dpop: oidcLoginOptions.dpop ? "true" : "false", + clientId: oidcLoginOptions.client.clientId, + }); + + if (oidcLoginOptions.client.clientSecret) { + await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { + clientSecret: oidcLoginOptions.client.clientSecret, + }); + } + if (oidcLoginOptions.client.clientName) { + await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { + clientName: oidcLoginOptions.client.clientName, + }); + } + + return Object.assign(sessionInfo, { + fetch: accessInfo.fetch, + }); + } +} diff --git a/extensions/solidauth/src/auth/TokenRefresher.ts b/extensions/solidauth/src/auth/TokenRefresher.ts new file mode 100644 index 0000000..c899e2f --- /dev/null +++ b/extensions/solidauth/src/auth/TokenRefresher.ts @@ -0,0 +1,150 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + + import { + IClient, + IClientRegistrar, + IIssuerConfigFetcher, + IStorageUtility, + loadOidcContextFromStorage, + PREFERRED_SIGNING_ALG, + KeyPair, + ITokenRefresher, + TokenEndpointResponse, + EVENTS, +} from "@inrupt/solid-client-authn-core"; +import { Issuer, IssuerMetadata, TokenSet } from "openid-client"; +import { KeyObject } from "crypto"; +import { EventEmitter } from "events"; +import { configToIssuerMetadata } from "../IssuerConfigFetcher"; +import { negotiateClientSigningAlg } from "../ClientRegistrar"; + +// Some identifiers are not in camelcase on purpose, as they are named using the +// official names from the OIDC/OAuth2 specifications. +/* eslint-disable camelcase */ + +const tokenSetToTokenEndpointResponse = ( + tokenSet: TokenSet, + issuerMetadata: IssuerMetadata +): TokenEndpointResponse => { + if (tokenSet.access_token === undefined) { + // The error message is left minimal on purpose not to leak the tokens. + throw new Error( + `The Identity Provider [${issuerMetadata.issuer}] did not return an access token on refresh.` + ); + } + + if (tokenSet.token_type !== "Bearer" && tokenSet.token_type !== "DPoP") { + throw new Error( + `The Identity Provider [${issuerMetadata.issuer}] returned an unknown token type: [${tokenSet.token_type}].` + ); + } + return { + accessToken: tokenSet.access_token, + tokenType: tokenSet.token_type, + idToken: tokenSet.id_token, + refreshToken: tokenSet.refresh_token, + expiresAt: tokenSet.expires_at, + }; +}; + +/** + * @hidden + */ +export default class TokenRefresher implements ITokenRefresher { + constructor( + private storageUtility: IStorageUtility, + private issuerConfigFetcher: IIssuerConfigFetcher, + private clientRegistrar: IClientRegistrar + ) {} + + async refresh( + sessionId: string, + refreshToken?: string, + dpopKey?: KeyPair, + eventEmitter?: EventEmitter + ): Promise { + const oidcContext = await loadOidcContextFromStorage( + sessionId, + this.storageUtility, + this.issuerConfigFetcher + ); + + const issuer = new Issuer(configToIssuerMetadata(oidcContext.issuerConfig)); + // This should also retrieve the client from storage + const clientInfo: IClient = await this.clientRegistrar.getClient( + { sessionId }, + oidcContext.issuerConfig + ); + if (clientInfo.idTokenSignedResponseAlg === undefined) { + clientInfo.idTokenSignedResponseAlg = negotiateClientSigningAlg( + oidcContext.issuerConfig, + PREFERRED_SIGNING_ALG + ); + } + const client = new issuer.Client({ + client_id: clientInfo.clientId, + client_secret: clientInfo.clientSecret, + token_endpoint_auth_method: clientInfo.clientSecret + ? "client_secret_basic" + : "none", + id_token_signed_response_alg: clientInfo.idTokenSignedResponseAlg, + }); + + if (refreshToken === undefined) { + // TODO: in a next PR, look up storage for a refresh token + throw new Error( + `Session [${sessionId}] has no refresh token to allow it to refresh its access token.` + ); + } + + if (oidcContext.dpop && dpopKey === undefined) { + throw new Error( + `For session [${sessionId}], the key bound to the DPoP access token must be provided to refresh said access token.` + ); + } + + const tokenSet = tokenSetToTokenEndpointResponse( + await client.refresh(refreshToken, { + // openid-client does not support yet jose@3.x, and expects + // type definitions that are no longer present. However, the JWK + // type that we pass here is compatible with the API, hence the type + // assertion. + DPoP: dpopKey ? (dpopKey.privateKey as KeyObject) : undefined, + }), + issuer.metadata + ); + + if (tokenSet.refreshToken !== undefined) { + eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refreshToken); + await this.storageUtility.setForUser(sessionId, { + refreshToken: tokenSet.refreshToken, + }); + } + return tokenSet; + } +} + \ No newline at end of file diff --git a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts index a137e1d..d43e9f3 100644 --- a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts +++ b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts @@ -38,7 +38,8 @@ import type { ExtensionContext, } from "vscode"; import { EventEmitter } from "vscode"; -import type { IStorageUtility } from "@inrupt/solid-client-authn-core"; +import { EventEmitter as EE } from "stream"; +import { EVENTS, IStorageUtility } from "@inrupt/solid-client-authn-core"; import { StorageUtility } from "@inrupt/solid-client-authn-core"; // TODO: Finish this based on https://www.eliostruyf.com/create-authentication-provider-visual-studio-code/ @@ -53,7 +54,7 @@ import { refreshAccessToken } from "./fetchFactory"; // TODO: Introduce -// Get the time left on a NodeJS timeout +// Get the time left on a NodeJS timeout (in milliseconds) function getTimeLeft(timeout: any): number { // eslint-disable-next-line no-underscore-dangle return timeout._idleStart + timeout._idleTimeout - Date.now(); @@ -258,28 +259,89 @@ export class SolidAuthenticationProvider isLoggedIn: true, webId: session.id, }, + }); // Monkey patch the AuthCodeRedirectHandler with our custom one that saves the access_token to secret storage const currentHandler = (s2 as any).clientAuthentication.redirectHandler .handleables[0]; + console.log('the redirecthandleables are ', (s2 as any).clientAuthentication.redirectHandler.handleables) + // TODO: See if we need to be handling redirects as part of the refresh flow (I don't *think* we do). // const redirectUrl = await this.storage.getForUser(sessionId, 'redirectUrl', { secure: true, errorIfNull: true }) - const result = await refreshAccessToken( - { - refreshToken: (await this.storage.getForUser( - sessionId, - "refresh_token", - { secure: true, errorIfNull: true } - ))!, - sessionId, - tokenRefresher: currentHandler.tokenRefresher, - }, + const refreshToken = (await this.storage.getForUser( + sessionId, + "refresh_token", + { secure: true, errorIfNull: true } + )); + + console.log('about to update with refreshToken', refreshToken) + + if (!refreshToken) { + throw new Error('refresh token is undefined'); + } + + console.log( + await this.storage.getForUser(sessionId, "expires_at"), + await this.storage.getForUser(sessionId, "expires_in"), + await this.storage.getForUser(sessionId, "refresh_token"), + await this.storage.getForUser(sessionId, "access_token") + ) + + console.log('pre refresh token -') + + const result = await currentHandler.tokenRefresher.refresh( + sessionId, + refreshToken, dpopKey ); + console.log('post refresh token', result) + + // const emitter = new EE(); + + // emitter.on(EVENTS.NEW_REFRESH_TOKEN, (t) => { + // console.log('new refresh token event called', t) + // }) + + // emitter.on(EVENTS.SESSION_EXTENDED, async (t) => { + // console.log('session extension', t) + // await this.storage.setForUser( + // sessionId, + // { expires_in: t.toString() }, + // { secure: true } + // ); + // await this.storage.setForUser( + // sessionId, + // { expires_at: Math.floor(t + (Date.now() / 1000)).toString() }, + // { secure: true } + // ); + // }) + + + + // TODO: Use refreshAccess from RefreshTokenOidcHandler here! + + // const result = await refreshAccessToken( + // { + // refreshToken: refreshToken, + // sessionId, + // tokenRefresher: currentHandler.tokenRefresher, + // }, + // dpopKey, + // // s2 + // emitter + // ); + + console.log( + await this.storage.getForUser(sessionId, "expires_at"), + await this.storage.getForUser(sessionId, "expires_in") + ) + + console.log('refresh result', result) + if (typeof result.expiresIn === "number") { await this.storage.setForUser( sessionId, @@ -288,17 +350,18 @@ export class SolidAuthenticationProvider ); await this.storage.setForUser( sessionId, - { expires_at: (result.expiresIn + Date.now()).toString() }, + { expires_at: Math.floor(result.expiresIn + (Date.now() / 1000)).toString() }, { secure: true } ); } else { - await this.storage.deleteForUser(sessionId, "expires_in"); - await this.storage.deleteForUser(sessionId, "expires_at"); + // await this.storage.deleteForUser(sessionId, "expires_in"); + // await this.storage.deleteForUser(sessionId, "expires_at"); } // await this.storage.setForUser(sessionId, { 'access_token': result.accessToken }, { secure: true }) if (typeof result.refreshToken === "string") { + console.log('-'.repeat(50), 'setting refresh token', result.refreshToken, '-'.repeat(50)) await this.storage.setForUser( sessionId, { refresh_token: result.refreshToken }, @@ -306,6 +369,14 @@ export class SolidAuthenticationProvider ); } + if (typeof result.accessToken === "string") { + await this.storage.setForUser( + sessionId, + { access_token: result.accessToken }, + { secure: true } + ); + } + // const s3 = new Session({ // storage: this.storage, // sessionInfo: { @@ -352,7 +423,7 @@ export class SolidAuthenticationProvider // When we do this operation we update any sessions that // are set to expire in the next 2 minutes - const REFRESH_EXPIRY_BEFORE = Date.now() + 120 * 1000; + const REFRESH_EXPIRY_BEFORE = Date.now() + 1200 * 1000; let toRefresh: string[]; do { console.log("about to get expiries"); @@ -374,13 +445,14 @@ export class SolidAuthenticationProvider REFRESH_EXPIRY_BEFORE ); toRefresh.push(sessionId); + } else { + console.log( + "to early to refresh", + expiries[sessionId] * 1000, + Date.now(), + REFRESH_EXPIRY_BEFORE + ); } - console.log( - "to early to refresh", - expiries[sessionId] * 1000, - Date.now(), - REFRESH_EXPIRY_BEFORE - ); } console.log("toRefresh", toRefresh); @@ -389,6 +461,9 @@ export class SolidAuthenticationProvider await Promise.all( toRefresh.map((sessionId) => this.runRefresh(sessionId)) ); + + // Make sure the sessions are resolved + await this.sessions; } while (toRefresh.length > 0); this.runningRefresh = false; @@ -398,13 +473,14 @@ export class SolidAuthenticationProvider console.log("next expiry is", nextExpiry); if (typeof nextExpiry === "number") { - console.log("updating timeout for", nextExpiry * 1000 - Date.now()); - this.updateTimeout(nextExpiry * 1000 - Date.now()); + console.log("updating timeout for", (nextExpiry * 1000) - Date.now()); + this.updateTimeout(nextExpiry - Date.now()); } // Refreshes all necessary tokens } + // Get all expiries (in seconds since 1970-01-01T00:00:00Z) public async getAllExpiries(): Promise> { const sessions = await this.sessions; console.log("awaited sessions", sessions); @@ -436,6 +512,7 @@ export class SolidAuthenticationProvider return expiries; } + // Get next expiry (in seconds since 1970-01-01T00:00:00Z) public async getNextExpiry(): Promise { const expiries = Object.values(await this.getAllExpiries()); @@ -443,8 +520,8 @@ export class SolidAuthenticationProvider } public updateTimeout(endsIn: number): void { - // 20 seconds to be safe - const REFRESH_BEFORE_EXPIRATION = 20 * 1000; + // 30 seconds to be safe + const REFRESH_BEFORE_EXPIRATION = 3000 * 1000; const newEndsIn = endsIn - REFRESH_BEFORE_EXPIRATION; @@ -466,7 +543,7 @@ export class SolidAuthenticationProvider const nextExpiry = await this.getNextExpiry(); if (typeof nextExpiry === "number") { - this.updateTimeout(nextExpiry * 1000 - Date.now()); + this.updateTimeout((nextExpiry * 1000) - Date.now()); } }, newEndsIn); } diff --git a/extensions/solidauth/src/storage/secretStorage.ts b/extensions/solidauth/src/storage/secretStorage.ts index c152672..12d477f 100644 --- a/extensions/solidauth/src/storage/secretStorage.ts +++ b/extensions/solidauth/src/storage/secretStorage.ts @@ -28,10 +28,16 @@ export class ISecretStorage implements IStorage { ) {} async get(key: string): Promise { - return this.secrets.get(this.prefix + key); + const result = await this.secrets.get(this.prefix + key); + // console.log('getting', key, result) + return result; } async set(key: string, value: string): Promise { + // console.log('setting', key, value) + if (key.includes('solidClientAuthenticationUser:')) { + console.log('-'.repeat(50), 'refresh token being set is', JSON.parse(value)['refresh_token']) + } return this.secrets.store(this.prefix + key, value); } diff --git a/package-lock.json b/package-lock.json index 1fae9f6..9fdfd6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,8 @@ "mocha": "^10.1.0", "npm-check-updates": "^16.4.1", "nx": "^15.2.1", + "rollup": "^3.6.0", + "rollup-plugin-typescript2": "^0.34.1", "ts-jest": "^29.0.3", "ts-loader": "^9.4.1", "ts-node": "^10.9.1", @@ -4785,39 +4787,12 @@ } }, "node_modules/@inrupt/solid-client-authn-core": { - "version": "1.12.3", - "license": "MIT", - "dependencies": { - "cross-fetch": "^3.1.5", - "events": "^3.3.0", - "jose": "^4.10.0", - "lodash.clonedeep": "^4.5.0", - "uuid": "^9.0.0" - }, - "engines": { - "node": "^14.0.0 || ^16.0.0" - } - }, - "node_modules/@inrupt/solid-client-authn-core/node_modules/uuid": { - "version": "9.0.0", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } + "resolved": "packages/core", + "link": true }, "node_modules/@inrupt/solid-client-authn-node": { - "version": "1.12.2", - "license": "MIT", - "dependencies": { - "@inrupt/solid-client-authn-core": "^1.12.2", - "cross-fetch": "^3.1.5", - "jose": "^4.3.7", - "openid-client": "^5.1.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": "^14.0.0 || ^16.0.0" - } + "resolved": "packages/node", + "link": true }, "node_modules/@inrupt/solid-vscode-auth": { "resolved": "packages/solid-vscode-auth", @@ -6816,6 +6791,19 @@ "@types/node": "*" } }, + "node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@rubensworks/solid-client-authn-isomorphic": { "version": "2.0.1", "license": "MIT", @@ -11170,6 +11158,20 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/fstream": { "version": "1.0.12", "dev": true, @@ -18099,6 +18101,53 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.6.0.tgz", + "integrity": "sha512-qCgiBeSu2/AIOKWGFMiRkjPlGlcVwxAjwpGKQZOQYng+83Hip4PjrWHm7EQX1wnrvRqfTytEihRRfLHdX+hR4g==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-typescript2": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.34.1.tgz", + "integrity": "sha512-P4cHLtGikESmqi1CA+tdMDUv8WbQV48mzPYt77TSTOPJpERyZ9TXdDgjSDix8Fkqce6soYz3+fa4lrC93IEkcw==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^4.1.2", + "find-cache-dir": "^3.3.2", + "fs-extra": "^10.0.0", + "semver": "^7.3.7", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "rollup": ">=1.26.3", + "typescript": ">=2.4.0" + } + }, + "node_modules/rollup-plugin-typescript2/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/run-async": { "version": "2.4.1", "dev": true, @@ -20752,6 +20801,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/core": { + "name": "@inrupt/solid-client-authn-core", + "version": "1.12.3", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "events": "^3.3.0", + "jose": "^4.10.0", + "lodash.clonedeep": "^4.5.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/lodash.clonedeep": "^4.5.7", + "@types/uuid": "^8.3.4" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0" + } + }, + "packages/core/node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "packages/node": { + "name": "@inrupt/solid-client-authn-node", + "version": "1.12.3", + "license": "MIT", + "dependencies": { + "@inrupt/solid-client-authn-core": "^1.12.3", + "cross-fetch": "^3.1.5", + "jose": "^4.3.7", + "openid-client": "^5.1.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/node": "^18.0.3", + "@types/uuid": "^8.3.0" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0" + } + }, + "packages/node/node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "packages/solid-vscode-auth": { "name": "@inrupt/solid-vscode-auth", "version": "0.0.0", @@ -24219,8 +24322,10 @@ } }, "@inrupt/solid-client-authn-core": { - "version": "1.12.3", + "version": "file:packages/core", "requires": { + "@types/lodash.clonedeep": "^4.5.7", + "@types/uuid": "^8.3.4", "cross-fetch": "^3.1.5", "events": "^3.3.0", "jose": "^4.10.0", @@ -24229,18 +24334,29 @@ }, "dependencies": { "uuid": { - "version": "9.0.0" + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" } } }, "@inrupt/solid-client-authn-node": { - "version": "1.12.2", + "version": "file:packages/node", "requires": { - "@inrupt/solid-client-authn-core": "^1.12.2", + "@inrupt/solid-client-authn-core": "^1.12.3", + "@types/node": "^18.0.3", + "@types/uuid": "^8.3.0", "cross-fetch": "^3.1.5", "jose": "^4.3.7", "openid-client": "^5.1.0", - "uuid": "^8.3.2" + "uuid": "^9.0.0" + }, + "dependencies": { + "uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" + } } }, "@inrupt/solid-vscode-auth": { @@ -25696,6 +25812,16 @@ "@types/node": "*" } }, + "@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "requires": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + } + }, "@rubensworks/solid-client-authn-isomorphic": { "version": "2.0.1", "requires": { @@ -28588,6 +28714,13 @@ "version": "1.0.0", "dev": true }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, "fstream": { "version": "1.0.12", "dev": true, @@ -33129,6 +33262,41 @@ } } }, + "rollup": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.6.0.tgz", + "integrity": "sha512-qCgiBeSu2/AIOKWGFMiRkjPlGlcVwxAjwpGKQZOQYng+83Hip4PjrWHm7EQX1wnrvRqfTytEihRRfLHdX+hR4g==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "rollup-plugin-typescript2": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.34.1.tgz", + "integrity": "sha512-P4cHLtGikESmqi1CA+tdMDUv8WbQV48mzPYt77TSTOPJpERyZ9TXdDgjSDix8Fkqce6soYz3+fa4lrC93IEkcw==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^4.1.2", + "find-cache-dir": "^3.3.2", + "fs-extra": "^10.0.0", + "semver": "^7.3.7", + "tslib": "^2.4.0" + }, + "dependencies": { + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + } + } + }, "run-async": { "version": "2.4.1", "dev": true diff --git a/package.json b/package.json index 7363fdc..28d7efe 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "mocha": "^10.1.0", "npm-check-updates": "^16.4.1", "nx": "^15.2.1", + "rollup": "^3.6.0", + "rollup-plugin-typescript2": "^0.34.1", "ts-jest": "^29.0.3", "ts-loader": "^9.4.1", "ts-node": "^10.9.1", diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js new file mode 100644 index 0000000..2269000 --- /dev/null +++ b/packages/core/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: ["../../.eslintrc.js"], + parserOptions: { + project: "./tsconfig.eslint.json", + }, +}; diff --git a/packages/core/.npmignore b/packages/core/.npmignore new file mode 100644 index 0000000..34199b8 --- /dev/null +++ b/packages/core/.npmignore @@ -0,0 +1,5 @@ +.git +docs +coverage +report +.vscode diff --git a/packages/core/.prettierignore b/packages/core/.prettierignore new file mode 100644 index 0000000..60ee2e9 --- /dev/null +++ b/packages/core/.prettierignore @@ -0,0 +1,2 @@ +# TODO: figure out why the root .prettierignore file isn't detected: +docs/**/*.md diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json new file mode 100644 index 0000000..74e8d3d --- /dev/null +++ b/packages/core/package-lock.json @@ -0,0 +1,216 @@ +{ + "name": "@inrupt/solid-client-authn-core", + "version": "1.12.3", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@inrupt/solid-client-authn-core", + "version": "1.12.3", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "events": "^3.3.0", + "jose": "^4.10.0", + "lodash.clonedeep": "^4.5.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/lodash.clonedeep": "^4.5.7", + "@types/uuid": "^8.3.4" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0" + } + }, + "../../../sandbox/jose-browser-cjs": { + "version": "0.0.0", + "extraneous": true, + "license": "ISC", + "dependencies": { + "jose": "^4.10.0" + }, + "devDependencies": { + "rollup": "^3.1.0", + "rollup-plugin-typescript2": "^0.34.1", + "typescript": "^4.8.4" + } + }, + "node_modules/@types/lodash": { + "version": "4.14.186", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz", + "integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==", + "dev": true + }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.7.tgz", + "integrity": "sha512-ccNqkPptFIXrpVqUECi60/DFxjNKsfoQxSQsgcBJCX/fuX1wgyQieojkcWH/KpE3xzLoWN/2k+ZeGqIN3paSvw==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "dependencies": { + "node-fetch": "2.6.7" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/jose": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.11.1.tgz", + "integrity": "sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + } + }, + "dependencies": { + "@types/lodash": { + "version": "4.14.186", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz", + "integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==", + "dev": true + }, + "@types/lodash.clonedeep": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.7.tgz", + "integrity": "sha512-ccNqkPptFIXrpVqUECi60/DFxjNKsfoQxSQsgcBJCX/fuX1wgyQieojkcWH/KpE3xzLoWN/2k+ZeGqIN3paSvw==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, + "cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "requires": { + "node-fetch": "2.6.7" + } + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + }, + "jose": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.11.1.tgz", + "integrity": "sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==" + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + }, + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "requires": { + "whatwg-url": "^5.0.0" + }, + "dependencies": { + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, + "uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" + } + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..973bb07 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,62 @@ +{ + "name": "@inrupt/solid-client-authn-core", + "private": true, + "version": "1.12.3", + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + }, + "./mocks": { + "require": "./dist/mocks.js", + "import": "./dist/mocks.mjs", + "types": "./dist/mocks.d.ts" + } + }, + "typesVersions": { + "*": { + "mocks": [ + "dist/mocks.d.ts" + ] + } + }, + "repository": { + "url": "https://github.com/inrupt/solid-client-authn" + }, + "scripts": { + "prepublishOnly": "npm run build", + "clean": "npm run clean-module", + "clean-module": "rimraf ./dist", + "build": "rollup --config rollup.config.mjs", + "lint:fix": "npm run lint:eslint -- --fix && npm run lint:prettier -- --write", + "lint:check": "npm run lint:eslint && npm run lint:prettier -- --check", + "lint:eslint": "eslint --config .eslintrc.js \"src/\"", + "lint:prettier": "prettier \"src/**/*.{ts,tsx,js,jsx,css}\" \"**/*.{md,mdx,yml}\"", + "licenses:check": "license-checker --production --out license.csv --failOn \"AGPL-1.0-only; AGPL-1.0-or-later; AGPL-3.0-only; AGPL-3.0-or-later; Beerware; CC-BY-NC-1.0; CC-BY-NC-2.0; CC-BY-NC-2.5; CC-BY-NC-3.0; CC-BY-NC-4.0; CC-BY-NC-ND-1.0; CC-BY-NC-ND-2.0; CC-BY-NC-ND-2.5; CC-BY-NC-ND-3.0; CC-BY-NC-ND-4.0; CC-BY-NC-SA-1.0; CC-BY-NC-SA-2.0; CC-BY-NC-SA-2.5; CC-BY-NC-SA-3.0; CC-BY-NC-SA-4.0; CPAL-1.0; EUPL-1.0; EUPL-1.1; EUPL-1.1; GPL-1.0-only; GPL-1.0-or-later; GPL-2.0-only; GPL-2.0-or-later; GPL-3.0; GPL-3.0-only; GPL-3.0-or-later; SISSL; SISSL-1.2; WTFPL\"", + "dev": "tsc-watch --preserveWatchOutput --noClear", + "build-api-docs": "npx typedoc --out docs/api/source/api --readme none", + "build-docs-preview-site": "npm run build-api-docs; cd docs/api; make html" + }, + "dependencies": { + "cross-fetch": "^3.1.5", + "events": "^3.3.0", + "jose": "^4.10.0", + "lodash.clonedeep": "^4.5.0", + "uuid": "^9.0.0" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0" + }, + "devDependencies": { + "@types/lodash.clonedeep": "^4.5.7", + "@types/uuid": "^8.3.4" + } +} diff --git a/packages/core/rollup.config.mjs b/packages/core/rollup.config.mjs new file mode 100644 index 0000000..8c16431 --- /dev/null +++ b/packages/core/rollup.config.mjs @@ -0,0 +1,54 @@ +// The following is only possible from Node 18 onwards +// import pkg from "./package.json" assert { type: "json" }; + +// Until we only support Node 18+, this should be used instead +// (see https://rollupjs.org/guide/en/#importing-packagejson) +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const pkg = require('./package.json'); + +import typescript from "rollup-plugin-typescript2"; + +const sharedConfig = { + plugins: [ + typescript({ + // Use our own version of TypeScript, rather than the one bundled with the plugin: + typescript: require("typescript"), + tsconfigOverride: { + compilerOptions: { + module: "esnext", + }, + }, + }) + ], + // The following option is useful because symlinks are used in monorepos + preserveSymlinks: true, +} + +export default [{ + input: "./src/index.ts", + output: [ + { + file: pkg.main, + format: "cjs", + }, + { + file: pkg.module, + format: "esm", + }, + ], + ...sharedConfig +}, { + input: "./src/mocks.ts", + output: [ + { + file: pkg.exports['./mocks'].require, + format: "cjs", + }, + { + file: pkg.exports['./mocks'].import, + format: "esm", + }, + ], + ...sharedConfig +}]; diff --git a/packages/core/sonar-project.properties b/packages/core/sonar-project.properties new file mode 100644 index 0000000..c0eea9d --- /dev/null +++ b/packages/core/sonar-project.properties @@ -0,0 +1,15 @@ +sonar.projectKey=inrupt_solid-client-authn-core +sonar.projectName=solid-client-authn-core +sonar.organization=inrupt + +# Path is relative to the sonar-project.properties file. Defaults to . +sonar.sources=src + +# Typescript tsconfigPath JSON file +sonar.typescript.tsconfigPath=. + +# Comma-delimited list of paths to LCOV coverage report files. Paths may be absolute or relative to the project root. +sonar.javascript.lcov.reportPaths=./coverage/lcov.info + +# Exclude tests from analysis +sonar.exclusions=**/*.test.ts diff --git a/packages/core/src/ILoginInputOptions.ts b/packages/core/src/ILoginInputOptions.ts new file mode 100644 index 0000000..62e1ae2 --- /dev/null +++ b/packages/core/src/ILoginInputOptions.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export default interface ILoginInputOptions { + /** + * The user's identity provider, e.g. `https://inrupt.net`. Usually provided by the user. + */ + oidcIssuer?: string; + /** + * The URL within this application that the user should be redirected to after successful login. This can be either a web URL or a mobile URL scheme. + */ + redirectUrl?: string; + /** + * A ID for your application, **previously registered to the identity provider**. Only required if users of your app log in an identity provider known a priori, which only supports a predefined set of apps and prevents [dynamic registration](https://tools.ietf.org/html/rfc7591). + */ + clientId?: string; + /** + * A secret associated with your client ID during client registration to the the identity provider. Only required if users of your app log in an identity provider known a priori, which only supports a predefined set of apps and prevents [dynamic registration](https://tools.ietf.org/html/rfc7591). + */ + clientSecret?: string; + /** + * Human-readable name for the client (as opposed to the client ID) + */ + clientName?: string; + /** + * If a function is provided, the browser will not auto-redirect and will instead trigger that function to redirect. + * Required in non-browser environments, ignored in the browser. + */ + handleRedirect?: (redirectUrl: string) => unknown; + /** + * The type of access token you want to use. Using a cookie-based system requires Bearer tokens, but DPoP tokens provide + * an additional safety against replay. By default, a DPoP token will be used. + */ + tokenType?: "DPoP" | "Bearer"; + /** + * If you already have a refresh token available, it may be used to log in along with the associated client ID and + * secret to authenticate. + */ + refreshToken?: string; +} diff --git a/packages/core/src/authenticatedFetch/dpopUtils.spec.ts b/packages/core/src/authenticatedFetch/dpopUtils.spec.ts new file mode 100644 index 0000000..529bc2f --- /dev/null +++ b/packages/core/src/authenticatedFetch/dpopUtils.spec.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { it, describe, expect } from "@jest/globals"; +import { KeyLike, generateKeyPair, exportJWK, jwtVerify } from "jose"; +import { createDpopHeader, generateDpopKeyPair } from "./dpopUtils"; + +let publicKey: KeyLike | undefined; +let privateKey: KeyLike | undefined; + +const mockJwk = async (): Promise<{ + publicKey: KeyLike; + privateKey: KeyLike; +}> => { + if (typeof publicKey === "undefined" || typeof privateKey === "undefined") { + const generatedPair = await generateKeyPair("ES256"); + publicKey = generatedPair.publicKey; + privateKey = generatedPair.privateKey; + } + return { + publicKey, + privateKey, + }; +}; + +const mockKeyPair = async () => { + const { privateKey: prvt, publicKey: pblc } = await mockJwk(); + const dpopKeyPair = { + privateKey: prvt, + publicKey: await exportJWK(pblc), + }; + // The alg property isn't set by exportJWK, so set it manually. + dpopKeyPair.publicKey.alg = "ES256"; + return dpopKeyPair; +}; + +describe("createDpopHeader", () => { + it("creates a JWT with 'htm', 'htu' and 'jti' claims in the payload", async () => { + const header = await createDpopHeader( + "https://some.resource", + "GET", + await mockKeyPair() + ); + const { payload } = await jwtVerify(header, (await mockJwk()).publicKey); + expect(payload.htm).toBe("GET"); + expect(payload.jti).toBeDefined(); + // The IRI is normalized, hence the trailing '/' + expect(payload.htu).toBe("https://some.resource/"); + }); + + it("creates a JWT with 'htu' that needs to be normalized", async () => { + const header = await createDpopHeader( + "https://user:pass@some.resource/?query#hash", + "GET", + await mockKeyPair() + ); + const { payload } = await jwtVerify(header, (await mockJwk()).publicKey); + expect(payload.htm).toBe("GET"); + expect(payload.jti).toBeDefined(); + // The IRI is normalized, hence the trailing '/' + expect(payload.htu).toBe("https://some.resource/"); + }); + + it("creates a JWT with the appropriate protected header", async () => { + const header = await createDpopHeader( + "https://some.resource", + "GET", + await mockKeyPair() + ); + const { protectedHeader } = await jwtVerify( + header, + ( + await mockJwk() + ).publicKey + ); + expect(protectedHeader.alg).toBe("ES256"); + expect(protectedHeader.typ).toBe("dpop+jwt"); + expect(protectedHeader.jwk).toEqual((await mockKeyPair()).publicKey); + }); +}); + +describe("generateDpopKeyPair", () => { + it("generates a public, private key pair", async () => { + const keyPair = await generateDpopKeyPair(); + expect(keyPair.publicKey).toBeDefined(); + expect(keyPair.privateKey).toBeDefined(); + expect(keyPair.publicKey.alg).toBe("ES256"); + }); +}); diff --git a/packages/core/src/authenticatedFetch/dpopUtils.ts b/packages/core/src/authenticatedFetch/dpopUtils.ts new file mode 100644 index 0000000..10177b5 --- /dev/null +++ b/packages/core/src/authenticatedFetch/dpopUtils.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { JWK, KeyLike, SignJWT, generateKeyPair, exportJWK } from "jose"; +import { v4 } from "uuid"; +import { PREFERRED_SIGNING_ALG } from "../constant"; + +/** + * Normalizes a URL in order to generate the DPoP token based on a consistent scheme. + * + * @param audience The URL to normalize. + * @returns The normalized URL as a string. + * @hidden + */ +function normalizeHTU(audience: string): string { + const audienceUrl = new URL(audience); + return new URL(audienceUrl.pathname, audienceUrl.origin).toString(); +} + +export type KeyPair = { + privateKey: KeyLike; + publicKey: JWK; +}; + +/** + * Creates a DPoP header according to https://tools.ietf.org/html/draft-fett-oauth-dpop-04, + * based on the target URL and method, using the provided key. + * + * @param audience Target URL. + * @param method HTTP method allowed. + * @param key Key used to sign the token. + * @returns A JWT that can be used as a DPoP Authorization header. + */ +export async function createDpopHeader( + audience: string, + method: string, + dpopKey: KeyPair +): Promise { + return new SignJWT({ + htu: normalizeHTU(audience), + htm: method.toUpperCase(), + jti: v4(), + }) + .setProtectedHeader({ + alg: PREFERRED_SIGNING_ALG[0], + jwk: dpopKey.publicKey, + typ: "dpop+jwt", + }) + .setIssuedAt() + .sign(dpopKey.privateKey, {}); +} + +export async function generateDpopKeyPair(): Promise { + const { privateKey, publicKey } = await generateKeyPair( + PREFERRED_SIGNING_ALG[0] + ); + const dpopKeyPair = { + privateKey, + publicKey: await exportJWK(publicKey), + }; + // The alg property isn't set by exportJWK, so set it manually. + [dpopKeyPair.publicKey.alg] = PREFERRED_SIGNING_ALG; + return dpopKeyPair; +} diff --git a/packages/core/src/authenticatedFetch/fetchFactory.spec.ts b/packages/core/src/authenticatedFetch/fetchFactory.spec.ts new file mode 100644 index 0000000..fe837b2 --- /dev/null +++ b/packages/core/src/authenticatedFetch/fetchFactory.spec.ts @@ -0,0 +1,700 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// This is necessary to mock fetch +/* eslint-disable no-shadow */ + +import { jest, it, describe, expect, afterEach } from "@jest/globals"; +import { KeyLike, jwtVerify, generateKeyPair, exportJWK } from "jose"; +import { EventEmitter } from "events"; +import { Response, Headers } from "cross-fetch"; +import type * as CrossFetch from "cross-fetch"; +import { + buildAuthenticatedFetch, + DEFAULT_EXPIRATION_TIME_SECONDS, +} from "./fetchFactory"; +import { + mockDefaultTokenRefresher, + mockDefaultTokenSet, + mockTokenRefresher, +} from "../login/oidc/refresh/__mocks__/TokenRefresher"; +import { EVENTS } from "../constant"; +import { OidcProviderError } from "../errors/OidcProviderError"; +import { InvalidResponseError } from "../errors/InvalidResponseError"; +import { ITokenRefresher } from "../login/oidc/refresh/ITokenRefresher"; + +jest.mock("cross-fetch", () => { + return { + ...(jest.requireActual("cross-fetch") as typeof CrossFetch), + default: jest.fn(), + fetch: jest.fn(), + } as typeof CrossFetch; +}); + +const mockNotRedirectedResponse = () => { + const mockedResponse = new Response(undefined); + jest.spyOn(mockedResponse, "redirected", "get").mockReturnValueOnce(false); + jest + .spyOn(mockedResponse, "url", "get") + .mockReturnValueOnce("http://some.url"); + return mockedResponse; +}; + +let publicKey: KeyLike | undefined; +let privateKey: KeyLike | undefined; + +const mockJwk = async (): Promise<{ + publicKey: KeyLike; + privateKey: KeyLike; +}> => { + if (typeof publicKey === "undefined" || typeof privateKey === "undefined") { + const generatedPair = await generateKeyPair("ES256"); + publicKey = generatedPair.publicKey; + privateKey = generatedPair.privateKey; + } + return { + publicKey, + privateKey, + }; +}; + +const mockKeyPair = async () => { + const { privateKey: prvt, publicKey: pblc } = await mockJwk(); + const dpopKeyPair = { + privateKey: prvt, + publicKey: await exportJWK(pblc), + }; + // The alg property isn't set by exportJWK, so set it manually. + dpopKeyPair.publicKey.alg = "ES256"; + return dpopKeyPair; +}; + +const mockFetch = (response: Response, url: string) => { + const { fetch } = jest.requireMock("cross-fetch") as jest.Mocked< + typeof CrossFetch + >; + const mockedResponse = response; + jest.spyOn(mockedResponse, "url", "get").mockReturnValue(url); + fetch.mockResolvedValueOnce(mockedResponse); + return fetch; +}; + +describe("buildAuthenticatedFetch", () => { + const spyTimeout = jest.spyOn(global, "setTimeout"); + + afterEach(() => { + // Clear the latest timeout to avoid dangling open handles. + // FIXME: Should just use fake timers, but that chokes on recursive calls. + const handle = spyTimeout.mock.results[spyTimeout.mock.results.length - 1]; + if (handle !== undefined) { + (handle.value as ReturnType).unref(); + } + }); + + it("builds a DPoP fetch if a DPoP key is provided", async () => { + const mockedFetch = mockFetch( + new Response(undefined, { + status: 401, + }), + "https://my.pod/resource" + ); + const keylikePair = await mockJwk(); + const myFetch = await buildAuthenticatedFetch(mockedFetch, "myToken", { + dpopKey: { + privateKey: keylikePair.privateKey, + publicKey: await exportJWK(keylikePair.publicKey), + }, + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: { + refresh: + jest.fn< + ( + ...params: Parameters + ) => ReturnType + >(), + }, + }, + }); + await myFetch("https://my.pod/resource"); + expect(mockedFetch.mock.calls[0][0]).toBe("https://my.pod/resource"); + const headers = new Headers(mockedFetch.mock.calls[0][1]?.headers); + const decodedHeader = await jwtVerify( + headers.get("DPoP") as string, + ( + await mockJwk() + ).publicKey + ); + expect(decodedHeader.payload).toMatchObject({ + htu: "https://my.pod/resource", + }); + }); + + it("builds the appropriate DPoP header for a given HTTP verb.", async () => { + const mockedFetch = mockFetch( + mockNotRedirectedResponse(), + "https://my.pod/resource" + ); + + const myFetch = await buildAuthenticatedFetch(mockedFetch, "myToken", { + dpopKey: await mockKeyPair(), + }); + await myFetch("http://some.url", { + method: "POST", + }); + + const headers = new Headers(mockedFetch.mock.calls[0][1]?.headers); + const { payload } = await jwtVerify( + headers.get("DPoP") as string, + ( + await mockKeyPair() + ).privateKey + ); + expect(payload.htu).toBe("http://some.url/"); + expect(payload.htm).toBe("POST"); + }); + + it("builds a Bearer fetch if no DPoP key is provided", async () => { + const mockedFetch = mockFetch( + new Response(undefined, { status: 401 }), + "https://my.pod/resource" + ); + const myFetch = await buildAuthenticatedFetch( + mockedFetch, + "myToken", + undefined + ); + await myFetch("https://my.pod/resource"); + expect(mockedFetch.mock.calls[0][0]).toBe("https://my.pod/resource"); + const headers = new Headers(mockedFetch.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")?.startsWith("Bearer")).toBe(true); + }); + + it("returns a fetch that rebuilds the DPoP token if redirected", async () => { + const mockedFetch = mockFetch( + new Response(undefined, { status: 403 }), + "https://my.pod/container/" + ); + // Redirects once + mockedFetch.mockResolvedValueOnce({ + url: "https://my.pod/container/", + ok: true, + status: 200, + } as Response); + + const myFetch = await buildAuthenticatedFetch(mockedFetch, "myToken", { + dpopKey: await mockKeyPair(), + }); + await myFetch("https://my.pod/container"); + + expect(mockedFetch.mock.calls[1][0]).toBe("https://my.pod/container/"); + const headers = new Headers(mockedFetch.mock.calls[1][1]?.headers); + const { payload } = await jwtVerify( + headers.get("DPoP") as string, + ( + await mockKeyPair() + ).privateKey + ); + expect(payload.htu).toBe("https://my.pod/container/"); + }); + + it("returns a fetch that does not retry fetching with a Bearer token if redirected", async () => { + const mockedFetch = mockFetch( + new Response(undefined, { status: 403 }), + "https://my.pod/container/" + ); + + const myFetch = await buildAuthenticatedFetch(mockedFetch, "myToken"); + const response = await myFetch("https://my.pod/container"); + + expect(response.status).toBe(403); + expect(mockedFetch.mock.calls).toHaveLength(1); + }); + + it("returns a fetch preserving optional headers passed as a map", async () => { + const mockedFetch = mockFetch( + mockNotRedirectedResponse(), + "https://my.pod/container/" + ); + const myFetch = await buildAuthenticatedFetch( + mockedFetch, + "myToken", + undefined + ); + await myFetch("someUrl", { headers: { someHeader: "SomeValue" } }); + + const headers = new Headers(mockedFetch.mock.calls[0][1]?.headers); + + expect(headers.get("Authorization")).toBe("Bearer myToken"); + expect(headers.get("someHeader")).toBe("SomeValue"); + }); + + it("returns a fetch preserving optional headers passed as a Header object", async () => { + const mockedFetch = mockFetch( + mockNotRedirectedResponse(), + "https://my.pod/container/" + ); + const myFetch = await buildAuthenticatedFetch( + mockedFetch, + "myToken", + undefined + ); + await myFetch("someUrl", { + headers: new Headers({ someHeader: "SomeValue" }), + }); + + const headers = new Headers(mockedFetch.mock.calls[0][1]?.headers); + + expect(headers.get("Authorization")).toBe("Bearer myToken"); + expect(headers.get("someHeader")).toBe("SomeValue"); + }); + + it("returns a fetch overriding any pre-existing Authorization or DPoP headers", async () => { + const mockedFetch = mockFetch( + mockNotRedirectedResponse(), + "https://my.pod/container/" + ); + + const myFetch = await buildAuthenticatedFetch(mockedFetch, "myToken", { + dpopKey: await mockKeyPair(), + }); + await myFetch("http://some.url", { + headers: { + Authorization: "some token", + DPoP: "some header", + }, + }); + const headers = new Headers(mockedFetch.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toBe("DPoP myToken"); + }); + + it("does not retry a **redirected** fetch if the error is not auth-related", async () => { + // Mimics a redirect that lead to a non-auth error. + const mockedFetch = mockFetch( + new Response(undefined, { status: 400 }), + "https://my.pod/container/" + ); + + const myFetch = await buildAuthenticatedFetch(mockedFetch, "myToken", { + dpopKey: await mockKeyPair(), + }); + const response = await myFetch("https://my.pod/container"); + + expect(mockedFetch.mock.calls).toHaveLength(1); + expect(response.status).toBe(400); + }); + + it("returns the initial response in case of non-redirected auth error", async () => { + const mockedFetch = mockFetch( + new Response(undefined, { status: 401 }), + "https://my.pod/container/" + ); + const myFetch = await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: { + refresh: () => { + throw new Error("Some error"); + }, + }, + }, + }); + const response = await myFetch("someUrl"); + // The mocked fetch will 401, which triggers the refresh flow. + // The test checks that the mocked refreshed token is used silently. + expect(response.status).toBe(401); + }); + + // For some reasons Jest doesn't play nice with timers, so right now the tests + // run actual timers on very short refresh rates rather than running mock timers. + + // jest.useFakeTimers(); + function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + + it("refreshes the token before it expires", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const mockRefresher = mockDefaultTokenRefresher(); + await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockRefresher, + }, + expiresIn: 6, + }); + // It should not refresh the tokens right away... + expect(mockRefresher.refresh).not.toHaveBeenCalled(); + // Run the timer but not quite close to the token's expiration + await sleep(500); + // jest.advanceTimersByTime(1000 * 1000); + // It should not have refreshed the tokens yet... + expect(mockRefresher.refresh).not.toHaveBeenCalled(); + + await sleep(500); + // Run the timer to pretend the token is about to expire + // jest.advanceTimersByTime((800 - REFRESH_BEFORE_EXPIRATION_SECONDS) * 1000); + expect(mockRefresher.refresh).toHaveBeenCalled(); + }); + + it("sets a default timeout if the OIDC provider did not return one", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const mockRefresher = mockDefaultTokenRefresher(); + await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockRefresher, + // No expiration date is provided + }, + }); + + expect(spyTimeout).toHaveBeenLastCalledWith( + expect.any(Function), + DEFAULT_EXPIRATION_TIME_SECONDS * 1000 + ); + }); + + it("does not rebind the DPoP token on refresh", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const keylikePair = await mockJwk(); + // Mocks a refresher which refreshes only once to prevent re-scheduling timeouts. + // This would not be necessary with mock timers. + const mockedTokenRefresher: ITokenRefresher = { + refresh: jest + .fn< + ( + ...params: Parameters + ) => ReturnType + >() + .mockResolvedValueOnce({ + ...mockDefaultTokenSet(), + refreshToken: "some rotated refresh token", + expiresIn: 0, + }) + .mockResolvedValue({ ...mockDefaultTokenSet(), expiresIn: 1000 }), + }; + await buildAuthenticatedFetch(mockedFetch, "myToken", { + dpopKey: { + privateKey: keylikePair.privateKey, + publicKey: await exportJWK(keylikePair.publicKey), + }, + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockedTokenRefresher, + }, + expiresIn: 0, + }); + + await sleep(200); + // jest.runOnlyPendingTimers(); + + expect(mockedTokenRefresher.refresh).toHaveBeenCalledWith( + expect.anything(), + "some refresh token", + { + privateKey: keylikePair.privateKey, + publicKey: await exportJWK(keylikePair.publicKey), + } + ); + }); + + it("sets up the timeout on refresh so that the tokens keep being valid", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const mockRefresher = mockTokenRefresher({ + ...mockDefaultTokenSet(), + // We get a new expiration date every time we refresh the tokens + expiresIn: 7, + }); + const spyTimeout = jest.spyOn(global, "setTimeout"); + const mockedEmitter = new EventEmitter(); + const spiedEmit = jest.spyOn(mockedEmitter, "emit"); + await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockRefresher, + }, + expiresIn: 6, + eventEmitter: mockedEmitter, + }); + // Run the timer to pretend the token is about to expire + // jest.advanceTimersByTime(1800 * 1000); + await sleep(1000); + expect(mockRefresher.refresh).toHaveBeenCalled(); + // A new timer should have been set. + // expect(spyTimeout).toHaveBeenCalledTimes(2); + expect(spyTimeout).toHaveBeenLastCalledWith( + expect.any(Function), + // 2000 is 7 - 5, which is the number of seconds before refreshing, converted to ms. + 2 * 1000 + ); + expect(spiedEmit).toHaveBeenCalledWith( + EVENTS.TIMEOUT_SET, + expect.anything() + ); + }); + + it("sets a default timeout on refresh if the OIDC provider does not return one", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const mockRefresher = mockTokenRefresher({ + ...mockDefaultTokenSet(), + // No new expiration date is provided on refresh + expiresIn: undefined, + }); + const spyTimeout = jest.spyOn(global, "setTimeout"); + await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockRefresher, + }, + expiresIn: 6, + }); + // Run the timer to pretend the token is about to expire + // jest.runOnlyPendingTimers(); + await sleep(1000); + expect(mockRefresher.refresh).toHaveBeenCalled(); + // A new timer should have been set. + expect(spyTimeout).toHaveBeenLastCalledWith( + expect.any(Function), + DEFAULT_EXPIRATION_TIME_SECONDS * 1000 + ); + }); + + it("calls the provided callback when the access token is refreshed", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const tokenSet = mockDefaultTokenSet(); + const mockedFreshener = mockTokenRefresher({ + ...tokenSet, + expiresIn: 1800, + }); + const eventEmitter = new EventEmitter(); + const spiedEmit = jest.spyOn(eventEmitter, "emit"); + await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockedFreshener, + }, + eventEmitter, + expiresIn: 0, + }); + await sleep(200); + expect(spiedEmit).toHaveBeenCalledWith(EVENTS.SESSION_EXTENDED, 1800); + }); + + it("calls the provided callback when a new refresh token is issued", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const tokenSet = mockDefaultTokenSet(); + tokenSet.refreshToken = "some rotated refresh token"; + const mockedFreshener = mockTokenRefresher(tokenSet); + const eventEmitter = new EventEmitter(); + const spiedEmit = jest.spyOn(eventEmitter, "emit"); + await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockedFreshener, + }, + eventEmitter, + expiresIn: 0, + }); + await sleep(200); + expect(spiedEmit).toHaveBeenCalledWith( + EVENTS.NEW_REFRESH_TOKEN, + "some rotated refresh token" + ); + }); + + it("rotates the refresh token if a new one is issued", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + // Mocks a refresher which refreshes only once to prevent re-scheduling timeouts. + // This would not be necessary with mock timers. + const mockedTokenRefresher: ITokenRefresher = { + refresh: jest + .fn< + ( + ...params: Parameters + ) => ReturnType + >() + .mockResolvedValueOnce({ + ...mockDefaultTokenSet(), + refreshToken: "some rotated refresh token", + expiresIn: 0, + }) + .mockResolvedValue({ ...mockDefaultTokenSet(), expiresIn: 1000 }), + }; + const refreshCall = jest.spyOn(mockedTokenRefresher, "refresh"); + + await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockedTokenRefresher, + }, + expiresIn: 0, + }); + await sleep(200); + expect(refreshCall.mock.calls[1][1]).toBe("some rotated refresh token"); + }); + + it("emits the appropriate events when refreshing the token fails", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const mockedFreshener = mockTokenRefresher(mockDefaultTokenSet()); + mockedFreshener.refresh = jest + .fn< + ( + ...params: Parameters + ) => ReturnType + >() + .mockRejectedValueOnce( + new OidcProviderError( + "Some error message", + "error_identifier", + "Some error description" + ) + ) as any; + const mockEmitter = new EventEmitter(); + // 'error' events must be listened to. + mockEmitter.on(EVENTS.ERROR, jest.fn()); + const spiedEmit = jest.spyOn(mockEmitter, "emit"); + + await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockedFreshener, + }, + expiresIn: 0, + eventEmitter: mockEmitter, + }); + await sleep(200); + expect(spiedEmit).toHaveBeenCalledTimes(3); + expect(spiedEmit).toHaveBeenCalledWith( + EVENTS.TIMEOUT_SET, + expect.anything() + ); + expect(spiedEmit).toHaveBeenCalledWith(EVENTS.SESSION_EXPIRED); + expect(spiedEmit).toHaveBeenCalledWith( + EVENTS.ERROR, + "error_identifier", + "Some error description" + ); + }); + + it("emits the appropriate events when an unexpected response is received", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const mockedFreshener = mockTokenRefresher(mockDefaultTokenSet()); + mockedFreshener.refresh = jest + .fn< + ( + ...params: Parameters + ) => ReturnType + >() + .mockRejectedValueOnce(new InvalidResponseError(["access_token"])) as any; + const mockEmitter = new EventEmitter(); + const spiedEmit = jest.spyOn(mockEmitter, "emit"); + + await buildAuthenticatedFetch(mockedFetch, "myToken", { + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockedFreshener, + }, + expiresIn: 0, + eventEmitter: mockEmitter, + }); + await sleep(100); + expect(spiedEmit).toHaveBeenCalledTimes(2); + expect(spiedEmit).toHaveBeenCalledWith( + EVENTS.TIMEOUT_SET, + expect.anything() + ); + expect(spiedEmit).toHaveBeenCalledWith(EVENTS.SESSION_EXPIRED); + }); + + it("emits the appropriate events when the access token expires and may not be refreshed", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const mockedFreshener = mockTokenRefresher(mockDefaultTokenSet()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedFreshener.refresh = jest + .fn< + ( + ...params: Parameters + ) => ReturnType + >() + .mockRejectedValueOnce(new InvalidResponseError(["access_token"])) as any; + const mockEmitter = new EventEmitter(); + const spiedEmit = jest.spyOn(mockEmitter, "emit"); + + await buildAuthenticatedFetch(mockedFetch, "myToken", { + expiresIn: 0, + eventEmitter: mockEmitter, + }); + await sleep(100); + expect(spiedEmit).toHaveBeenCalledTimes(2); + expect(spiedEmit).toHaveBeenCalledWith( + EVENTS.TIMEOUT_SET, + expect.anything() + ); + expect(spiedEmit).toHaveBeenCalledWith(EVENTS.SESSION_EXPIRED); + }); + + it("does not schedule any callback to be called if no event can be fired", async () => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + const spyTimeout = jest.spyOn(global, "setTimeout"); + await buildAuthenticatedFetch(mockedFetch, "myToken"); + await sleep(100); + // The only call to setTimeout should come from the `sleep` function + expect(spyTimeout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/authenticatedFetch/fetchFactory.ts b/packages/core/src/authenticatedFetch/fetchFactory.ts new file mode 100644 index 0000000..6c64b89 --- /dev/null +++ b/packages/core/src/authenticatedFetch/fetchFactory.ts @@ -0,0 +1,292 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// eslint-disable-next-line no-shadow +import { fetch, Headers } from "cross-fetch"; +import { EventEmitter } from "events"; +import { REFRESH_BEFORE_EXPIRATION_SECONDS, EVENTS } from "../constant"; +import { ITokenRefresher } from "../login/oidc/refresh/ITokenRefresher"; +import { createDpopHeader, KeyPair } from "./dpopUtils"; +import { OidcProviderError } from "../errors/OidcProviderError"; +import { InvalidResponseError } from "../errors/InvalidResponseError"; + +export type RefreshOptions = { + sessionId: string; + refreshToken: string; + tokenRefresher: ITokenRefresher; +}; + +/** + * If expires_in isn't specified for the access token, we assume its lifetime is + * 10 minutes. + */ +export const DEFAULT_EXPIRATION_TIME_SECONDS = 600; + +function isExpectedAuthError(statusCode: number): boolean { + // As per https://tools.ietf.org/html/rfc7235#section-3.1 and https://tools.ietf.org/html/rfc7235#section-3.1, + // a response failing because the provided credentials aren't accepted by the + // server can get a 401 or a 403 response. + return [401, 403].includes(statusCode); +} + +export type DpopHeaderPayload = { + htu: string; + htm: string; + jti: string; +}; + +async function buildDpopFetchOptions( + targetUrl: string, + authToken: string, + dpopKey: KeyPair, + defaultOptions?: RequestInit +): Promise { + const headers = new Headers(defaultOptions?.headers); + // Any pre-existing Authorization header should be overriden. + headers.set("Authorization", `DPoP ${authToken}`); + headers.set( + "DPoP", + await createDpopHeader(targetUrl, defaultOptions?.method ?? "get", dpopKey) + ); + return { + ...defaultOptions, + headers, + }; +} + +async function buildAuthenticatedHeaders( + targetUrl: string, + authToken: string, + dpopKey?: KeyPair, + defaultOptions?: RequestInit +): Promise { + if (dpopKey !== undefined) { + return buildDpopFetchOptions(targetUrl, authToken, dpopKey, defaultOptions); + } + const headers = new Headers(defaultOptions?.headers); + // Any pre-existing Authorization header should be overriden. + headers.set("Authorization", `Bearer ${authToken}`); + return { + ...defaultOptions, + headers, + }; +} + +async function makeAuthenticatedRequest( + unauthFetch: typeof fetch, + accessToken: string, + url: RequestInfo | URL, + defaultRequestInit?: RequestInit, + dpopKey?: KeyPair +) { + return unauthFetch( + url, + await buildAuthenticatedHeaders( + url.toString(), + accessToken, + dpopKey, + defaultRequestInit + ) + ); +} + +async function refreshAccessToken( + refreshOptions: RefreshOptions, + dpopKey?: KeyPair, + eventEmitter?: EventEmitter +): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> { + const tokenSet = await refreshOptions.tokenRefresher.refresh( + refreshOptions.sessionId, + refreshOptions.refreshToken, + dpopKey + ); + eventEmitter?.emit( + EVENTS.SESSION_EXTENDED, + tokenSet.expiresIn ?? DEFAULT_EXPIRATION_TIME_SECONDS + ); + if (typeof tokenSet.refreshToken === "string") { + eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refreshToken); + } + return { + accessToken: tokenSet.accessToken, + refreshToken: tokenSet.refreshToken, + expiresIn: tokenSet.expiresIn, + }; +} + +/** + * + * @param expiresIn Delay until the access token expires. + * @returns a delay until the access token should be refreshed. + */ +const computeRefreshDelay = (expiresIn?: number): number => { + if (expiresIn !== undefined) { + return expiresIn - REFRESH_BEFORE_EXPIRATION_SECONDS > 0 + ? // We want to refresh the token 5 seconds before they actually expire. + expiresIn - REFRESH_BEFORE_EXPIRATION_SECONDS + : expiresIn; + } + return DEFAULT_EXPIRATION_TIME_SECONDS; +}; + +/** + * @param unauthFetch a regular fetch function, compliant with the WHATWG spec. + * @param authToken an access token, either a Bearer token or a DPoP one. + * @param options The option object may contain two objects: the DPoP key token + * is bound to if applicable, and options to customise token renewal behaviour. + * + * @returns A fetch function that adds an appropriate Authorization header with + * the provided token, and adds a DPoP header if applicable. + */ +export async function buildAuthenticatedFetch( + unauthFetch: typeof fetch, + accessToken: string, + options?: { + dpopKey?: KeyPair; + refreshOptions?: RefreshOptions; + expiresIn?: number; + eventEmitter?: EventEmitter; + } +): Promise { + let currentAccessToken = accessToken; + let latestTimeout: Parameters[0]; + const currentRefreshOptions: RefreshOptions | undefined = + options?.refreshOptions; + // Setup the refresh timeout outside of the authenticated fetch, so that + // an idle app will not get logged out if it doesn't issue a fetch before + // the first expiration date. + if (currentRefreshOptions !== undefined) { + const proactivelyRefreshToken = async () => { + try { + const { + accessToken: refreshedAccessToken, + refreshToken, + expiresIn, + } = await refreshAccessToken( + currentRefreshOptions, + // If currentRefreshOptions is defined, options is necessarily defined too. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + options!.dpopKey, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + options!.eventEmitter + ); + // Update the tokens in the closure if appropriate. + currentAccessToken = refreshedAccessToken; + if (refreshToken !== undefined) { + currentRefreshOptions.refreshToken = refreshToken; + } + // Each time the access token is refreshed, we must plan fo the next + // refresh iteration. + clearTimeout(latestTimeout); + latestTimeout = setTimeout( + proactivelyRefreshToken, + computeRefreshDelay(expiresIn) * 1000 + ); + // If currentRefreshOptions is defined, options is necessarily defined too. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + options!.eventEmitter?.emit(EVENTS.TIMEOUT_SET, latestTimeout); + } catch (e) { + // It is possible that an underlying library throws an error on refresh flow failure. + // If we used a log framework, the error could be logged at the `debug` level, + // but otherwise the failure of the refresh flow should not blow up in the user's + // face, so we just swallow the error. + if (e instanceof OidcProviderError) { + // The OIDC provider refused to refresh the access token and returned an error instead. + /* istanbul ignore next 100% coverage would require testing that nothing + happens here if the emitter is undefined, which is more cumbersome + than what it's worth. */ + options?.eventEmitter?.emit( + EVENTS.ERROR, + e.error, + e.errorDescription + ); + /* istanbul ignore next 100% coverage would require testing that nothing + happens here if the emitter is undefined, which is more cumbersome + than what it's worth. */ + options?.eventEmitter?.emit(EVENTS.SESSION_EXPIRED); + } + if ( + e instanceof InvalidResponseError && + e.missingFields.includes("access_token") + ) { + // In this case, the OIDC provider returned a non-standard response, but + // did not specify that it was an error. We cannot refresh nonetheless. + /* istanbul ignore next 100% coverage would require testing that nothing + happens here if the emitter is undefined, which is more cumbersome + than what it's worth. */ + options?.eventEmitter?.emit(EVENTS.SESSION_EXPIRED); + } + } + }; + latestTimeout = setTimeout( + proactivelyRefreshToken, + // If currentRefreshOptions is defined, options is necessarily defined too. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + computeRefreshDelay(options!.expiresIn) * 1000 + ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + options!.eventEmitter?.emit(EVENTS.TIMEOUT_SET, latestTimeout); + } else if (options !== undefined && options.eventEmitter !== undefined) { + // If no refresh options are provided, the session expires when the access token does. + const expirationTimeout = setTimeout(() => { + // The event emitter is always defined in our code, and it would be tedious + // to test for conditions when it is not. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + options.eventEmitter!.emit(EVENTS.SESSION_EXPIRED); + }, computeRefreshDelay(options.expiresIn) * 1000); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + options.eventEmitter!.emit(EVENTS.TIMEOUT_SET, expirationTimeout); + } + return async (url, requestInit?): Promise => { + let response = await makeAuthenticatedRequest( + unauthFetch, + currentAccessToken, + url, + requestInit, + options?.dpopKey + ); + + const failedButNotExpectedAuthError = + !response.ok && !isExpectedAuthError(response.status); + if (response.ok || failedButNotExpectedAuthError) { + // If there hasn't been a redirection, or if there has been a non-auth related + // issue, it should be handled at the application level + return response; + } + const hasBeenRedirected = response.url !== url; + if (hasBeenRedirected && options?.dpopKey !== undefined) { + // If the request failed for auth reasons, and has been redirected, we should + // replay it generating a DPoP header for the rediration target IRI. This + // doesn't apply to Bearer tokens, as the Bearer tokens aren't specific + // to a given resource and method, while the DPoP header (associated to a + // DPoP token) is. + response = await makeAuthenticatedRequest( + unauthFetch, + currentAccessToken, + // Replace the original target IRI (`url`) by the redirection target + response.url, + requestInit, + options.dpopKey + ); + } + return response; + }; +} diff --git a/packages/core/src/constant.ts b/packages/core/src/constant.ts new file mode 100644 index 0000000..517025b --- /dev/null +++ b/packages/core/src/constant.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * Intended to be used by dependent packages as a common prefix for keys into + * storage mechanisms (so as to group all keys related to Solid Client Authn + * within those storage mechanisms, e.g., window.localStorage). + */ +export const SOLID_CLIENT_AUTHN_KEY_PREFIX = "solidClientAuthn:"; + +/** + * Ordered list of signature algorithms, from most preferred to least preferred. + */ +export const PREFERRED_SIGNING_ALG = ["ES256", "RS256"]; + +export const EVENTS = { + // Note that an `error` events MUST be listened to: https://nodejs.org/dist/latest-v16.x/docs/api/events.html#error-events. + ERROR: "error", + LOGIN: "login", + LOGOUT: "logout", + NEW_REFRESH_TOKEN: "newRefreshToken", + SESSION_EXPIRED: "sessionExpired", + SESSION_EXTENDED: "sessionExtended", + SESSION_RESTORED: "sessionRestore", + TIMEOUT_SET: "timeoutSet", +}; +/** + * We want to refresh a token 5 seconds before it expires. + */ +export const REFRESH_BEFORE_EXPIRATION_SECONDS = 5; + +// The openid scope requests an OIDC ID token token to be returned. +const SCOPE_OPENID = "openid"; +// The offline_access scope requests a refresh token to be returned. +const SCOPE_OFFLINE = "offline_access"; +// The webid scope is required as per https://solid.github.io/solid-oidc/#webid-scope +const SCOPE_WEBID = "webid"; +// The scopes are expected as a space-separated list. +export const DEFAULT_SCOPES = [SCOPE_OPENID, SCOPE_OFFLINE, SCOPE_WEBID].join( + " " +); diff --git a/packages/core/src/errors/ConfigurationError.ts b/packages/core/src/errors/ConfigurationError.ts new file mode 100644 index 0000000..542b90d --- /dev/null +++ b/packages/core/src/errors/ConfigurationError.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Error to be triggered when a poor configuration is received + */ + +// NOTE: There's a bug with istanbul and typescript that prevents full branch coverages +// https://github.com/gotwarlost/istanbul/issues/690 +// The workaround is to put istanbul ignore on the constructor +/** + * @hidden + */ +export default class ConfigurationError extends Error { + /* istanbul ignore next */ + constructor(message: string) { + super(message); + } +} diff --git a/packages/core/src/errors/InvalidResponseError.ts b/packages/core/src/errors/InvalidResponseError.ts new file mode 100644 index 0000000..2d03b41 --- /dev/null +++ b/packages/core/src/errors/InvalidResponseError.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Error to be triggered when receiving a response missing mandatory elements + */ + +// NOTE: There's a bug with istanbul and typescript that prevents full branch coverages +// https://github.com/gotwarlost/istanbul/issues/690 +// The workaround is to put istanbul ignore on the constructor +/** + * @hidden + */ +export class InvalidResponseError extends Error { + /* istanbul ignore next */ + constructor(public readonly missingFields: string[]) { + super( + `Invalid response from OIDC provider: missing fields ${missingFields}` + ); + } +} diff --git a/packages/core/src/errors/NotImplementedError.ts b/packages/core/src/errors/NotImplementedError.ts new file mode 100644 index 0000000..40cc9b1 --- /dev/null +++ b/packages/core/src/errors/NotImplementedError.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Error to be triggered if a method is not implemented + * @hidden + */ +export default class NotImplementedError extends Error { + /* istanbul ignore next */ + constructor(methodName: string) { + super(`[${methodName}] is not implemented`); + } +} diff --git a/packages/core/src/errors/OidcProviderError.ts b/packages/core/src/errors/OidcProviderError.ts new file mode 100644 index 0000000..2a2726f --- /dev/null +++ b/packages/core/src/errors/OidcProviderError.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Error to be triggered when receiving a response missing mandatory elements + */ + +// NOTE: There's a bug with istanbul and typescript that prevents full branch coverages +// https://github.com/gotwarlost/istanbul/issues/690 +// The workaround is to put istanbul ignore on the constructor +/** + * @hidden + */ +export class OidcProviderError extends Error { + /* istanbul ignore next */ + constructor( + message: string, + public readonly error: string, + public readonly errorDescription?: string + ) { + super(message); + } +} diff --git a/packages/core/src/errors/errors.spec.ts b/packages/core/src/errors/errors.spec.ts new file mode 100644 index 0000000..239b4d4 --- /dev/null +++ b/packages/core/src/errors/errors.spec.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { describe, it, expect } from "@jest/globals"; +/** + * Test for all custom errors + */ +import ConfigurationError from "./ConfigurationError"; +import NotImplementedError from "./NotImplementedError"; + +describe("errors", () => { + const errors: { + name: string; + class: any; + params: unknown[]; + message: string; + }[] = [ + { + name: "ConfigurationError", + class: ConfigurationError, + params: ["Bad Config"], + message: "Bad Config", + }, + { + name: "NotImplementedError", + class: NotImplementedError, + params: ["FunctionName"], + message: "[FunctionName] is not implemented", + }, + ]; + + errors.forEach((err) => { + it(`Should throw [${err.name}]`, () => { + expect(() => { + // eslint-disable-next-line new-cap + const error = new err.class(...err.params); + throw error; + }).toThrow(err.message); + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..9ea869e --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export * from "./constant"; + +export { default as ILoginInputOptions } from "./ILoginInputOptions"; + +export { default as ILoginHandler, LoginResult } from "./login/ILoginHandler"; +export { default as ILoginOptions } from "./login/ILoginOptions"; + +export { default as ILogoutHandler } from "./logout/ILogoutHandler"; + +export { default as IHandleable } from "./util/handlerPattern/IHandleable"; +export { default as AggregateHandler } from "./util/handlerPattern/AggregateHandler"; + +export { getWebidFromTokenPayload, fetchJwks } from "./util/token"; + +export { default as IOidcHandler } from "./login/oidc/IOidcHandler"; +export { default as IOidcOptions } from "./login/oidc/IOidcOptions"; + +export { + default as IIncomingRedirectHandler, + IncomingRedirectInput, + IncomingRedirectResult, +} from "./login/oidc/IIncomingRedirectHandler"; + +export { IRedirector, IRedirectorOptions } from "./login/oidc/IRedirector"; + +export { + ISessionInfo, + ISessionInternalInfo, + isSupportedTokenType, +} from "./sessionInfo/ISessionInfo"; +export { + ISessionInfoManager, + ISessionInfoManagerOptions, + USER_SESSION_PREFIX, +} from "./sessionInfo/ISessionInfoManager"; + +export { IIssuerConfigFetcher } from "./login/oidc/IIssuerConfigFetcher"; +export { IIssuerConfig } from "./login/oidc/IIssuerConfig"; +export { + IClientRegistrar, + IClientRegistrarOptions, + handleRegistration, + determineSigningAlg, +} from "./login/oidc/IClientRegistrar"; +export { IClient } from "./login/oidc/IClient"; + +// Storage. +export { default as IStorage } from "./storage/IStorage"; + +// Utility functions. +export { default as IStorageUtility } from "./storage/IStorageUtility"; +export { + default as StorageUtility, + OidcContext, + loadOidcContextFromStorage, + saveSessionInfoToStorage, + getSessionIdFromOauthState, +} from "./storage/StorageUtility"; +export { default as InMemoryStorage } from "./storage/InMemoryStorage"; + +export { default as ConfigurationError } from "./errors/ConfigurationError"; +export { default as NotImplementedError } from "./errors/NotImplementedError"; +export { InvalidResponseError } from "./errors/InvalidResponseError"; +export { OidcProviderError } from "./errors/OidcProviderError"; + +export { + createDpopHeader, + KeyPair, + generateDpopKeyPair, +} from "./authenticatedFetch/dpopUtils"; + +export { + buildAuthenticatedFetch, + DpopHeaderPayload, + RefreshOptions, +} from "./authenticatedFetch/fetchFactory"; + +export { + ITokenRefresher, + TokenEndpointResponse, +} from "./login/oidc/refresh/ITokenRefresher"; + +// Mocks. +/** + * @deprecated + */ +export { + mockStorage, + mockStorageUtility, + StorageUtilityMock, + StorageUtilityGetResponse, +} from "./storage/__mocks__/StorageUtility"; diff --git a/packages/core/src/login/ILoginHandler.ts b/packages/core/src/login/ILoginHandler.ts new file mode 100644 index 0000000..899f1f6 --- /dev/null +++ b/packages/core/src/login/ILoginHandler.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * A Login Handler will log a user in if it is able to use the provided Login Parameters + */ +import IHandleable from "../util/handlerPattern/IHandleable"; +import ILoginOptions from "./ILoginOptions"; +import { IncomingRedirectResult } from "./oidc/IIncomingRedirectHandler"; + +// FIXME: Remove this file as we only have one login handler: +export type LoginResult = IncomingRedirectResult | undefined; + +/** + * @hidden + */ +type ILoginHandler = IHandleable<[ILoginOptions], LoginResult>; +export default ILoginHandler; diff --git a/packages/core/src/login/ILoginOptions.ts b/packages/core/src/login/ILoginOptions.ts new file mode 100644 index 0000000..3f5eb80 --- /dev/null +++ b/packages/core/src/login/ILoginOptions.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { EventEmitter } from "events"; +import ILoginInputOptions from "../ILoginInputOptions"; + +/** + * We extend our public login option interface to add data and/or constraints + * necessary for our internal use. + * + * @hidden + */ +export default interface ILoginOptions extends ILoginInputOptions { + // This session ID is mandatory for internal use, so we could consider making + // it an explicit parameter on our login handler interface (rather than + // 'bundling' into this options interface), but it wouldn't be a significant + // improvement really... + sessionId: string; + /** + * Specify whether the Solid Identity Provider may, or may not, interact with the user (for example, + * the normal login process **_requires_** human interaction for them to enter their credentials, + * but if a user simply refreshes the current page in their browser, we'll want to log them in again + * automatically, i.e., without prompting them to manually provide their credentials again). + */ + prompt?: string; + // Force the token type to be required (i.e. no longer optional). + tokenType: "DPoP" | "Bearer"; + + /** + * Event emitter enabling calling user-specified callbacks. + */ + eventEmitter?: EventEmitter; +} diff --git a/packages/core/src/login/oidc/IClient.ts b/packages/core/src/login/oidc/IClient.ts new file mode 100644 index 0000000..bdd632c --- /dev/null +++ b/packages/core/src/login/oidc/IClient.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +export type ClientType = "static" | "dynamic" | "solid-oidc"; + +/** + * @hidden + */ +export interface IClient { + clientId: string; + clientSecret?: string; + clientName?: string; + idTokenSignedResponseAlg?: string; + clientType: ClientType; +} diff --git a/packages/core/src/login/oidc/IClientRegistrar.test.ts b/packages/core/src/login/oidc/IClientRegistrar.test.ts new file mode 100644 index 0000000..30a84d0 --- /dev/null +++ b/packages/core/src/login/oidc/IClientRegistrar.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { IIssuerConfig, ILoginOptions, IStorageUtility } from "../.."; +import { + determineSigningAlg, + handleRegistration, + IClientRegistrar, +} from "./IClientRegistrar"; + +describe("handleRegistration", () => { + it("should perform DCR if a client WebID is provided, but the target IdP does not support Solid-OIDC", async () => { + const options: ILoginOptions = { + clientId: "https://some.app/registration#app", + sessionId: "some session", + tokenType: "DPoP", + }; + const clientRegistrar = { + getClient: jest.fn(), + }; + await handleRegistration( + options, + { scopesSupported: ["openid"] } as IIssuerConfig, + jest.fn() as unknown as IStorageUtility, + clientRegistrar as IClientRegistrar + ); + expect(clientRegistrar.getClient).toHaveBeenCalled(); + }); + + it("should perform DCR if no client ID is provided", async () => { + const options: ILoginOptions = { + sessionId: "some session", + tokenType: "DPoP", + }; + const clientRegistrar = { + getClient: jest.fn(), + }; + await handleRegistration( + options, + { scopesSupported: ["openid"] } as IIssuerConfig, + jest.fn() as unknown as IStorageUtility, + clientRegistrar as IClientRegistrar + ); + expect(clientRegistrar.getClient).toHaveBeenCalled(); + }); + + it("should store provided client WebID if one provided and the Identity Provider supports Solid-OIDC", async () => { + const options: ILoginOptions = { + sessionId: "some session", + tokenType: "DPoP", + clientId: "https://my.app/registration#app", + }; + const clientRegistrar = { + getClient: jest.fn(), + }; + const storageUtility: IStorageUtility = { + setForUser: jest.fn(), + } as unknown as IStorageUtility; + const client = await handleRegistration( + options, + { + scopesSupported: ["openid", "offline_access", "webid"], + } as IIssuerConfig, + storageUtility, + clientRegistrar as IClientRegistrar + ); + expect(clientRegistrar.getClient).not.toHaveBeenCalled(); + expect(storageUtility.setForUser).toHaveBeenCalled(); + expect(client.clientType).toBe("solid-oidc"); + }); + + it("should store provided client registration information when the client ID is not a WebID", async () => { + const options: ILoginOptions = { + sessionId: "some session", + tokenType: "DPoP", + clientId: "some statically registered client ID", + clientName: "some statically registered client name", + clientSecret: "some statically registered client secret", + }; + const clientRegistrar = { + getClient: jest.fn(), + }; + const storageUtility: IStorageUtility = { + setForUser: jest.fn(), + } as unknown as IStorageUtility; + const client = await handleRegistration( + options, + { + scopesSupported: ["openid"], + } as IIssuerConfig, + storageUtility, + clientRegistrar as IClientRegistrar + ); + expect(clientRegistrar.getClient).not.toHaveBeenCalled(); + expect(storageUtility.setForUser).toHaveBeenCalled(); + expect(client.clientType).toBe("static"); + }); +}); + +describe("determineSigningAlg", () => { + it("returns the preferred algorithm of the supported list", () => { + expect( + determineSigningAlg(["ES256", "HS256", "RS256"], ["ES256", "RS256"]) + ).toBe("ES256"); + expect(determineSigningAlg(["ES256", "HS256", "RS256"], ["RS256"])).toBe( + "RS256" + ); + expect(determineSigningAlg(["RS256"], ["RS256"])).toBe("RS256"); + }); + + it("returns null if there are no matches", () => { + expect(determineSigningAlg(["RS256"], ["ES256"])).toBeNull(); + expect(determineSigningAlg(["RS256"], [])).toBeNull(); + }); +}); diff --git a/packages/core/src/login/oidc/IClientRegistrar.ts b/packages/core/src/login/oidc/IClientRegistrar.ts new file mode 100644 index 0000000..95e536c --- /dev/null +++ b/packages/core/src/login/oidc/IClientRegistrar.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import IStorageUtility from "../../storage/IStorageUtility"; +import ILoginOptions from "../ILoginOptions"; +import { ClientType, IClient } from "./IClient"; +import { IIssuerConfig } from "./IIssuerConfig"; + +export interface IClientRegistrarOptions { + sessionId: string; + clientName?: string; + redirectUrl?: string; +} + +/** + * @hidden + */ +export interface IClientRegistrar { + getClient( + options: IClientRegistrarOptions, + issuerConfig: IIssuerConfig + ): Promise; +} + +function isValidUrl(url: string): boolean { + try { + // Here, the URL constructor is just called to parse the given string and + // verify if it is a well-formed IRI. + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch { + return false; + } +} + +export function determineSigningAlg( + supported: string[], + preferred: string[] +): string | null { + return ( + preferred.find((signingAlg) => { + return supported.includes(signingAlg); + }) ?? null + ); +} + +function determineClientType( + options: ILoginOptions, + issuerConfig: IIssuerConfig +): ClientType { + if (options.clientId !== undefined && !isValidUrl(options.clientId)) { + return "static"; + } + if ( + issuerConfig.scopesSupported.includes("webid") && + options.clientId !== undefined && + isValidUrl(options.clientId) + ) { + return "solid-oidc"; + } + // If no client_id is provided, the client must go through Dynamic Client Registration. + // If a client_id is provided and it looks like a URI, yet the Identity Provider + // does *not* support Solid-OIDC, then we also perform DCR (and discard the + // provided client_id). + return "dynamic"; +} + +export async function handleRegistration( + options: ILoginOptions, + issuerConfig: IIssuerConfig, + storageUtility: IStorageUtility, + clientRegistrar: IClientRegistrar +): Promise { + const clientType = determineClientType(options, issuerConfig); + if (clientType === "dynamic") { + return clientRegistrar.getClient( + { + sessionId: options.sessionId, + clientName: options.clientName, + redirectUrl: options.redirectUrl, + }, + issuerConfig + ); + } + // If a client_id was provided, and the Identity Provider is Solid-OIDC compliant, + // or it is not compliant but the client_id isn't an IRI (we assume it has already + // been registered with the IdP), then the client registration information needs + // to be stored so that it can be retrieved later after redirect. + await storageUtility.setForUser(options.sessionId, { + // If the client is either static or solid-oidc compliant, its client ID cannot be undefined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + clientId: options.clientId!, + }); + if (options.clientSecret) { + await storageUtility.setForUser(options.sessionId, { + clientSecret: options.clientSecret, + }); + } + if (options.clientName) { + await storageUtility.setForUser(options.sessionId, { + clientName: options.clientName, + }); + } + return { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + clientId: options.clientId!, + clientSecret: options.clientSecret, + clientName: options.clientName, + clientType, + }; +} diff --git a/packages/core/src/login/oidc/IIncomingRedirectHandler.ts b/packages/core/src/login/oidc/IIncomingRedirectHandler.ts new file mode 100644 index 0000000..fd8175e --- /dev/null +++ b/packages/core/src/login/oidc/IIncomingRedirectHandler.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +// eslint-disable-next-line no-shadow +import { fetch } from "cross-fetch"; +import { EventEmitter } from "events"; +import IHandleable from "../../util/handlerPattern/IHandleable"; +import { ISessionInfo } from "../../sessionInfo/ISessionInfo"; + +export type IncomingRedirectResult = ISessionInfo & { fetch: typeof fetch }; +export type IncomingRedirectInput = [string, EventEmitter | undefined]; + +/** + * @hidden + */ +type IIncomingRedirectHandler = IHandleable< + // Tuple of the URL to redirect to, an optional event listener for when + // we receive a new refresh token, and, an optional onError function: + IncomingRedirectInput, + IncomingRedirectResult +>; +export default IIncomingRedirectHandler; diff --git a/packages/core/src/login/oidc/IIssuerConfig.ts b/packages/core/src/login/oidc/IIssuerConfig.ts new file mode 100644 index 0000000..349f09e --- /dev/null +++ b/packages/core/src/login/oidc/IIssuerConfig.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Interface to define the configuration that an identity provider can return. + */ + +/** + * @hidden + */ +export interface IIssuerConfig { + issuer: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint?: string; + jwksUri: string; + registrationEndpoint?: string; + scopesSupported: string[]; + responseTypesSupported?: string[]; + responseModesSupported?: string[]; + grantTypesSupported?: string[]; + acrValuesSupported?: string[]; + subjectTypesSupported: string[]; + idTokenSigningAlgValuesSupported?: string[]; + idTokenEncryptionAlgValuesSupported?: string[]; + idTokenEncryptionEncValuesSupported?: string[]; + userinfoSigningAlgValuesSupported?: string[]; + userinfoEncryptionAlgValuesSupported?: string[]; + userinfoEncryptionEncValuesSupported?: string[]; + requestObjectSigningAlgValuesSupported?: string[]; + requestObjectEncryptionAlgValuesSupported?: string[]; + requestObjectEncryptionEncValuesSupported?: string[]; + tokenEndpointAuthMethodsSupported?: string[]; + tokenEndpointAuthSigningAlgValuesSupported?: string[]; + displayValuesSupported?: string[]; + claimTypesSupported?: string[]; + claimsSupported: string[]; + serviceDocumentation?: string[]; + claimsLocalesSupported?: boolean; + uiLocalesSupported?: boolean; + claimsParameterSupported?: boolean; + requestParameterSupported?: boolean; + requestUriParameterSupported?: boolean; + requireRequestUriRegistration?: boolean; + opPolicyUri?: string; + opTosUri?: string; +} diff --git a/packages/core/src/login/oidc/IIssuerConfigFetcher.ts b/packages/core/src/login/oidc/IIssuerConfigFetcher.ts new file mode 100644 index 0000000..163aa38 --- /dev/null +++ b/packages/core/src/login/oidc/IIssuerConfigFetcher.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Responsible for fetching an IDP configuration + */ +import { IIssuerConfig } from "./IIssuerConfig"; + +export interface IIssuerConfigFetcher { + /** + * Fetches the configuration + * @param issuer URL of the IDP + */ + fetchConfig(issuer: string): Promise; +} diff --git a/packages/core/src/login/oidc/IOidcHandler.ts b/packages/core/src/login/oidc/IOidcHandler.ts new file mode 100644 index 0000000..2b3c33a --- /dev/null +++ b/packages/core/src/login/oidc/IOidcHandler.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * OidcHandlers handle the login process for a given IDP (as defined by the OIDC Options) + */ +import IHandleable from "../../util/handlerPattern/IHandleable"; +import { IncomingRedirectResult } from "./IIncomingRedirectHandler"; +import IOidcOptions from "./IOidcOptions"; + +export type OidcHandlerResult = IncomingRedirectResult | undefined; + +/** + * @hidden + */ +type IOidcHandler = IHandleable<[IOidcOptions], OidcHandlerResult>; +export default IOidcHandler; diff --git a/packages/core/src/login/oidc/IOidcOptions.ts b/packages/core/src/login/oidc/IOidcOptions.ts new file mode 100644 index 0000000..9d97a63 --- /dev/null +++ b/packages/core/src/login/oidc/IOidcOptions.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Defines how OIDC login should proceed + */ +import { EventEmitter } from "events"; +import { IIssuerConfig } from "./IIssuerConfig"; +import { IClient } from "./IClient"; + +/** + * @hidden + */ +export interface IOidcOptions { + /** + * The URL of the Solid Identity Provider. + */ + issuer: string; + /** + * The openid-configuration of the issuer. + */ + issuerConfiguration: IIssuerConfig; + client: IClient; + sessionId: string; + refreshToken?: string; + /** + * Specify whether the Solid Identity Provider may, or may not, interact with the user (for example, + * the normal login process **_requires_** human interaction for them to enter their credentials, + * but if a user simply refreshes the current page in their browser, we'll want to log them in again + * automatically, i.e., without prompting them to manually provide their credentials again). + */ + prompt?: string; + /** + * True if a DPoP compatible auth_token should be requested. + */ + dpop: boolean; + /** + * The URL to which the user should be redirected after logging in the Solid + * Identity Provider and authorizing the app to access data in their stead. + */ + redirectUrl: string; + handleRedirect?: (url: string) => unknown; + eventEmitter?: EventEmitter; +} + +export default IOidcOptions; diff --git a/packages/core/src/login/oidc/IRedirector.ts b/packages/core/src/login/oidc/IRedirector.ts new file mode 100644 index 0000000..35c4114 --- /dev/null +++ b/packages/core/src/login/oidc/IRedirector.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * @hidden + */ +export interface IRedirectorOptions { + handleRedirect?: (url: string) => unknown; + redirectByReplacingState?: boolean; +} + +/** + * @hidden + */ +export interface IRedirector { + redirect(redirectUrl: string, redirectorOptions: IRedirectorOptions): void; +} diff --git a/packages/core/src/login/oidc/__mocks__/IncomingRedirectHandler.ts b/packages/core/src/login/oidc/__mocks__/IncomingRedirectHandler.ts new file mode 100644 index 0000000..2f7b1f4 --- /dev/null +++ b/packages/core/src/login/oidc/__mocks__/IncomingRedirectHandler.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { EventEmitter } from "events"; +import { jest } from "@jest/globals"; + +import IIncomingRedirectHandler from "../IIncomingRedirectHandler"; + +const canHandle = jest.fn((_url: string) => Promise.resolve(true)); + +const handle = jest.fn((_url: string, _emitter: EventEmitter | undefined) => + Promise.resolve({ + sessionId: "global", + isLoggedIn: true, + webId: "https://pod.com/profile/card#me", + fetch: jest.fn(globalThis.fetch), + }) +); + +export const mockCanHandleIncomingRedirect = canHandle; +export const mockHandleIncomingRedirect = handle; + +export const mockIncomingRedirectHandler = (): IIncomingRedirectHandler => { + return { + canHandle, + handle, + }; +}; diff --git a/packages/core/src/login/oidc/__mocks__/IssuerConfig.ts b/packages/core/src/login/oidc/__mocks__/IssuerConfig.ts new file mode 100644 index 0000000..6ebf434 --- /dev/null +++ b/packages/core/src/login/oidc/__mocks__/IssuerConfig.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { IIssuerConfig } from "../../.."; + +export const mockIssuerConfig = (): IIssuerConfig => { + return { + issuer: "https://idp.com", + authorizationEndpoint: "https://idp.com/auth", + tokenEndpoint: "https://idp.com/token", + jwksUri: "https://idp.com/jwks", + subjectTypesSupported: [], + claimsSupported: [], + grantTypesSupported: ["refresh_token"], + scopesSupported: ["openid"], + }; +}; diff --git a/packages/core/src/login/oidc/__mocks__/IssuerConfigFetcher.ts b/packages/core/src/login/oidc/__mocks__/IssuerConfigFetcher.ts new file mode 100644 index 0000000..cc4825a --- /dev/null +++ b/packages/core/src/login/oidc/__mocks__/IssuerConfigFetcher.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { IIssuerConfig } from "../IIssuerConfig"; +import { IIssuerConfigFetcher } from "../IIssuerConfigFetcher"; + +export function mockIssuerConfigFetcher( + config: IIssuerConfig +): IIssuerConfigFetcher { + return { + fetchConfig: async (): Promise => config, + }; +} diff --git a/packages/core/src/login/oidc/refresh/ITokenRefresher.ts b/packages/core/src/login/oidc/refresh/ITokenRefresher.ts new file mode 100644 index 0000000..440890d --- /dev/null +++ b/packages/core/src/login/oidc/refresh/ITokenRefresher.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { EventEmitter } from "events"; +import { KeyPair } from "../../../authenticatedFetch/dpopUtils"; + +/** + * Based on openid-client's TokenSetParameters. Re-creating the type allows not + * to depend on the Node-specific library at the environment-agnostic level. + */ +export type TokenEndpointResponse = { + /** + * JWT-serialized access token + */ + accessToken: string; + /** + * Usually "Bearer" + */ + tokenType?: "Bearer" | "DPoP"; + /** + * JWT-serialized id token + */ + idToken?: string; + /** + * URL identifying the subject of the ID token. + */ + webId?: string; + /** + * Refresh token (not necessarily a JWT) + */ + refreshToken?: string; + + /** + * Expiration of the access token. + */ + expiresAt?: number; + + /** + * ExpiresAt should be computed from expiresIn when receiving the token. + */ + expiresIn?: number; + + /** + * DPoP key to which the access token, and potentially the refresh token, are bound. + */ + dpopKey?: KeyPair; +}; + +export interface ITokenRefresher { + refresh( + localUserId: string, + refreshToken?: string, + dpopKey?: KeyPair, + eventEmitter?: EventEmitter + ): Promise; +} diff --git a/packages/core/src/login/oidc/refresh/__mocks__/TokenRefresher.ts b/packages/core/src/login/oidc/refresh/__mocks__/TokenRefresher.ts new file mode 100644 index 0000000..97ca272 --- /dev/null +++ b/packages/core/src/login/oidc/refresh/__mocks__/TokenRefresher.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest } from "@jest/globals"; +import { ITokenRefresher, TokenEndpointResponse } from "../ITokenRefresher"; + +// Some identifiers are in camelcase on purpose. +/* eslint-disable camelcase */ + +export const mockTokenRefresher = ( + tokenSet: TokenEndpointResponse +): ITokenRefresher => { + return { + refresh: jest + .fn< + ( + ...params: Parameters + ) => ReturnType + >() + .mockResolvedValue(tokenSet), + }; +}; + +const mockIdTokenPayload = () => { + return { + sub: "https://my.webid", + iss: "https://my.idp/", + aud: "https://resource.example.org", + exp: 1662266216, + iat: 1462266216, + }; +}; + +export const mockDefaultTokenSet = (): TokenEndpointResponse => { + return { + accessToken: "some refreshed access token", + idToken: JSON.stringify(mockIdTokenPayload()), + }; +}; + +export const mockDefaultTokenRefresher = (): ITokenRefresher => + mockTokenRefresher(mockDefaultTokenSet()); diff --git a/packages/core/src/logout/ILogoutHandler.ts b/packages/core/src/logout/ILogoutHandler.ts new file mode 100644 index 0000000..8650f04 --- /dev/null +++ b/packages/core/src/logout/ILogoutHandler.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import IHandleable from "../util/handlerPattern/IHandleable"; + +/** + * @hidden + */ +type ILogoutHandler = IHandleable<[string], void>; +export default ILogoutHandler; diff --git a/packages/core/src/mocks.spec.ts b/packages/core/src/mocks.spec.ts new file mode 100644 index 0000000..fa39aac --- /dev/null +++ b/packages/core/src/mocks.spec.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { test, describe, expect } from "@jest/globals"; +import * as mocks from "./mocks"; + +describe("mocks", () => { + test("exposes the various mocks", () => { + // Storage: + expect(mocks.mockStorage).toBeDefined(); + expect(mocks.mockStorageUtility).toBeDefined(); + expect(mocks.StorageUtilityGetResponse).toBeDefined(); + expect(mocks.StorageUtilityMock).toBeDefined(); + + // Incoming Redirects: + expect(mocks.mockIncomingRedirectHandler).toBeDefined(); + expect(mocks.mockCanHandleIncomingRedirect).toBeDefined(); + expect(mocks.mockHandleIncomingRedirect).toBeDefined(); + }); +}); diff --git a/packages/core/src/mocks.ts b/packages/core/src/mocks.ts new file mode 100644 index 0000000..18e6031 --- /dev/null +++ b/packages/core/src/mocks.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export { + mockStorage, + mockStorageUtility, + StorageUtilityMock, + StorageUtilityGetResponse, +} from "./storage/__mocks__/StorageUtility"; + +export { + mockIncomingRedirectHandler, + mockCanHandleIncomingRedirect, + mockHandleIncomingRedirect, +} from "./login/oidc/__mocks__/IncomingRedirectHandler"; diff --git a/packages/core/src/sessionInfo/ISessionInfo.spec.ts b/packages/core/src/sessionInfo/ISessionInfo.spec.ts new file mode 100644 index 0000000..61a14d2 --- /dev/null +++ b/packages/core/src/sessionInfo/ISessionInfo.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { describe, it, expect } from "@jest/globals"; + +import { isSupportedTokenType } from "./ISessionInfo"; + +describe("isSupportedTokenType", () => { + it("returns true for supported token types", async () => { + expect(isSupportedTokenType("DPoP")).toBe(true); + expect(isSupportedTokenType("Bearer")).toBe(true); + }); + + it("returns false for unkonwn token types", async () => { + expect(isSupportedTokenType("SomeTokenType")).toBe(false); + expect(isSupportedTokenType("")).toBe(false); + expect(isSupportedTokenType(undefined as unknown as string)).toBe(false); + }); +}); diff --git a/packages/core/src/sessionInfo/ISessionInfo.ts b/packages/core/src/sessionInfo/ISessionInfo.ts new file mode 100644 index 0000000..3686724 --- /dev/null +++ b/packages/core/src/sessionInfo/ISessionInfo.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * Defines the data that should be persisted for each session. This interface + * is exposed as part of our public API. + */ +export interface ISessionInfo { + /** + * 'true' if the session is currently logged into an associated identity + * provider. + */ + isLoggedIn: boolean; + + /** + * The WebID if the user is logged into the session, and undefined if not. + */ + webId?: string; + + /** + * The WebID of the app, or a "Public app" WebID if the app does not provide its own. + * undefined until the session is logged in and the app WebID has been verified. + */ + clientAppId?: string; + + /** + * A unique identifier for the session. + */ + sessionId: string; + + /** + * UNIX timestamp (number of milliseconds since Jan 1st 1970) representing the + * time until which this session is valid. + */ + expirationDate?: number; +} + +/** + * Captures information about sessions that is persisted in storage, but that + * should not be exposed as part of our public API, and is only used for + * internal purposes. It is complementary to ISessionInfo when retrieving all + * information about a stored session, both public and internal. + */ +export interface ISessionInternalInfo { + /** + * The refresh token associated with the session (if any). + */ + refreshToken?: string; + + /** + * The OIDC issuer that issued the tokens authenticating the session. + */ + issuer?: string; + + /** + * The redirect URL registered when initially logging the session in. + */ + redirectUrl?: string; + + /** + * For public clients, and Solid Identity Providers that do not support Client + * WebIDs, the client secret is still required at the token endpoint. + */ + clientAppSecret?: string; + + /** + * The token type used by the session + */ + tokenType?: "DPoP" | "Bearer"; +} + +export function isSupportedTokenType( + token: string | "DPoP" | "Bearer" +): token is "DPoP" | "Bearer" { + return typeof token === "string" && ["DPoP", "Bearer"].includes(token); +} diff --git a/packages/core/src/sessionInfo/ISessionInfoManager.spec.ts b/packages/core/src/sessionInfo/ISessionInfoManager.spec.ts new file mode 100644 index 0000000..9e46907 --- /dev/null +++ b/packages/core/src/sessionInfo/ISessionInfoManager.spec.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { describe, it, expect } from "@jest/globals"; + +import { USER_SESSION_PREFIX } from "./ISessionInfoManager"; + +describe("ISessionInfoManager", () => { + it("should export a default user session prefix", () => { + expect(USER_SESSION_PREFIX).toBe("solidClientAuthenticationUser"); + }); +}); diff --git a/packages/core/src/sessionInfo/ISessionInfoManager.ts b/packages/core/src/sessionInfo/ISessionInfoManager.ts new file mode 100644 index 0000000..8e689cf --- /dev/null +++ b/packages/core/src/sessionInfo/ISessionInfoManager.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { ISessionInfo, ISessionInternalInfo } from "./ISessionInfo"; + +/** + * @hidden + */ +export interface ISessionInfoManagerOptions { + loggedIn?: boolean; + webId?: string; +} + +/** + * @hidden + */ +export interface ISessionInfoManager { + update(sessionId: string, options: ISessionInfoManagerOptions): Promise; + /** + * Returns all information about a registered session + * @param sessionId + */ + get( + sessionId: string + ): Promise<(ISessionInfo & ISessionInternalInfo) | undefined>; + /** + * Returns all information about all registered sessions + */ + getAll(): Promise<(ISessionInfo & ISessionInternalInfo)[]>; + /** + * Registers a new session, so that its ID can be retrieved. + * @param sessionId + */ + register(sessionId: string): Promise; + /** + * Returns all the registered session IDs. Differs from getAll, which also + * returns additional session information. + */ + getRegisteredSessionIdAll(): Promise; + /** + * Deletes all information regarding one session, including its registration. + * @param sessionId + */ + clear(sessionId: string): Promise; + /** + * Deletes all information about all sessions, including their registrations. + */ + clearAll(): Promise; +} + +export const USER_SESSION_PREFIX = "solidClientAuthenticationUser"; diff --git a/packages/core/src/storage/IStorage.ts b/packages/core/src/storage/IStorage.ts new file mode 100644 index 0000000..70e0d5f --- /dev/null +++ b/packages/core/src/storage/IStorage.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * Interface that various platforms should implement for their own storage implementation + */ +export default interface IStorage { + get: (key: string) => Promise; + set: (key: string, value: string) => Promise; + delete: (key: string) => Promise; +} diff --git a/packages/core/src/storage/IStorageUtility.ts b/packages/core/src/storage/IStorageUtility.ts new file mode 100644 index 0000000..f0c7c03 --- /dev/null +++ b/packages/core/src/storage/IStorageUtility.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +export default interface IStorageUtility { + get( + key: string, + options?: { errorIfNull?: boolean; secure?: boolean } + ): Promise; + set( + key: string, + value: string, + options?: { secure?: boolean } + ): Promise; + delete(key: string, options?: { secure?: boolean }): Promise; + getForUser( + userId: string, + key: string, + options?: { errorIfNull?: boolean; secure?: boolean } + ): Promise; + setForUser( + userId: string, + values: Record, + options?: { secure?: boolean } + ): Promise; + deleteForUser( + userId: string, + key: string, + options?: { secure?: boolean } + ): Promise; + deleteAllUserData( + userId: string, + options?: { secure?: boolean } + ): Promise; +} diff --git a/packages/core/src/storage/InMemoryStorage.spec.ts b/packages/core/src/storage/InMemoryStorage.spec.ts new file mode 100644 index 0000000..882e394 --- /dev/null +++ b/packages/core/src/storage/InMemoryStorage.spec.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { describe, it, expect } from "@jest/globals"; + +import InMemoryStorage from "./InMemoryStorage"; + +describe("InMemoryStorage", () => { + const nodeStorage = new InMemoryStorage(); + it("can set an item", async () => { + await expect(nodeStorage.set("a", "A")).resolves.not.toBeNull(); + }); + it("can get an item", async () => { + expect(await nodeStorage.get("a")).toBe("A"); + }); + it("returns undefined if the key does not exist", async () => { + expect(await nodeStorage.get("doesNotExist")).toBeUndefined(); + }); + it("can delete an item", async () => { + await nodeStorage.delete("a"); + expect(await nodeStorage.get("a")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/storage/InMemoryStorage.ts b/packages/core/src/storage/InMemoryStorage.ts new file mode 100644 index 0000000..dc59215 --- /dev/null +++ b/packages/core/src/storage/InMemoryStorage.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import IStorage from "./IStorage"; + +/** + * @hidden + */ +export default class InMemoryStorage implements IStorage { + private map: Record = {}; + + async get(key: string): Promise { + return this.map[key] || undefined; + } + + async set(key: string, value: string): Promise { + this.map[key] = value; + } + + async delete(key: string): Promise { + delete this.map[key]; + } +} diff --git a/packages/core/src/storage/StorageUtility.spec.ts b/packages/core/src/storage/StorageUtility.spec.ts new file mode 100644 index 0000000..9f1aa67 --- /dev/null +++ b/packages/core/src/storage/StorageUtility.spec.ts @@ -0,0 +1,571 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { exportJWK, generateKeyPair, KeyLike } from "jose"; +import { jest, describe, it, expect } from "@jest/globals"; +import { mockIssuerConfig } from "../login/oidc/__mocks__/IssuerConfig"; +import { mockIssuerConfigFetcher } from "../login/oidc/__mocks__/IssuerConfigFetcher"; +import StorageUtility, { + getSessionIdFromOauthState, + loadOidcContextFromStorage, + saveSessionInfoToStorage, +} from "./StorageUtility"; +import { mockStorage, mockStorageUtility } from "./__mocks__/StorageUtility"; + +describe("StorageUtility", () => { + const defaultMocks = { + // storage: StorageMock, + secureStorage: mockStorage({}), + insecureStorage: mockStorage({}), + }; + + const key = "the key"; + const value = "the value"; + const userId = "animals"; + + function getStorageUtility( + mocks: Partial = defaultMocks + ): StorageUtility { + return new StorageUtility( + mocks.secureStorage ?? defaultMocks.secureStorage, + mocks.insecureStorage ?? defaultMocks.insecureStorage + ); + } + + describe("get", () => { + it("gets an item from storage", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + await storageUtility.set(key, value); + const result = await storageUtility.get(key); + expect(result).toBe(value); + }); + + it("gets an item from (secure) storage", async () => { + const storageUtility = getStorageUtility({ + secureStorage: mockStorage({}), + }); + await storageUtility.set(key, value, { secure: true }); + const result = await storageUtility.get(key, { secure: true }); + expect(result).toBe(value); + }); + + it("returns undefined if the item is not in storage", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + const retrievedValue = await storageUtility.get("key"); + expect(retrievedValue).toBeUndefined(); + }); + + it("throws an error if the item is not in storage and errorOnNull is true", async () => { + const storageMock = defaultMocks.insecureStorage; + const storageUtility = getStorageUtility({ + insecureStorage: storageMock, + }); + await expect( + storageUtility.get("key", { errorIfNull: true }) + ).rejects.toThrow("[key] is not stored"); + }); + }); + + describe("set", () => { + it("sets an item in storage", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + await storageUtility.set(key, value); + await expect(storageUtility.get(key)).resolves.toEqual(value); + }); + }); + + describe("delete", () => { + it("deletes an item", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + + await expect(storageUtility.get(key)).resolves.toBeUndefined(); + await storageUtility.set(key, value); + await expect(storageUtility.get(key)).resolves.toEqual(value); + + await storageUtility.delete(key); + await expect(storageUtility.get(key)).resolves.toBeUndefined(); + }); + + it("deletes an item (from secure storage)", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + + await expect( + storageUtility.get(key, { secure: true }) + ).resolves.toBeUndefined(); + await storageUtility.set(key, value, { secure: true }); + await expect(storageUtility.get(key, { secure: true })).resolves.toEqual( + value + ); + + await storageUtility.delete(key, { secure: true }); + await expect( + storageUtility.get(key, { secure: true }) + ).resolves.toBeUndefined(); + }); + }); + + describe("getForUser", () => { + it("throws if data stored is invalid JSON", async () => { + const mockedStorageUtility = mockStorage({}); + mockedStorageUtility.get = jest + .fn() + .mockReturnValue( + "This response deliberately cannot be parsed as JSON!" + ) as typeof mockedStorageUtility.get; + const storageUtility = getStorageUtility({ + insecureStorage: mockedStorageUtility, + secureStorage: mockedStorageUtility, + }); + + await expect( + storageUtility.getForUser("irrelevant for this test", "Doesn't matter") + ).rejects.toThrow("cannot be parsed as JSON!"); + + await expect( + storageUtility.getForUser( + "irrelevant for this test", + "Doesn't matter", + { secure: true } + ) + ).rejects.toThrow("cannot be parsed as JSON!"); + }); + + it("gets an item from storage for a user", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + const userData = { + jackie: "The Cat", + sledge: "The Dog", + }; + await storageUtility.setForUser(userId, userData); + + const retrievedValue = await storageUtility.getForUser(userId, "jackie"); + + expect(retrievedValue).toBe("The Cat"); + }); + + it("gets an item from (secure) storage for a user", async () => { + const storageUtility = getStorageUtility({ + secureStorage: mockStorage({}), + }); + const userData = { + jackie: "The Cat", + sledge: "The Dog", + }; + await storageUtility.setForUser(userId, userData, { + secure: true, + }); + + const retrievedValue = await storageUtility.getForUser(userId, "jackie", { + secure: true, + }); + + expect(retrievedValue).toBe("The Cat"); + }); + + it("returns undefined if no item is in storage", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + const retrievedValue = await storageUtility.getForUser(userId, "jackie"); + expect(retrievedValue).toBeUndefined(); + }); + + it("returns null if the item in storage is corrupted", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + await storageUtility.setForUser(userId, { + cool: "bleep bloop not parsable", + }); + const retrievedValue = await storageUtility.getForUser(userId, "jackie"); + expect(retrievedValue).toBeUndefined(); + }); + + it("throws an error if the item is not in storage and errorOnNull is true", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + await expect( + storageUtility.getForUser(userId, "jackie", { errorIfNull: true }) + ).rejects.toThrow(`Field [jackie] for user [${userId}] is not stored`); + }); + }); + + describe("setForUser", () => { + it("sets a value for a user", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + await storageUtility.setForUser(userId, { + jackie: "The Pretty Kitty", + }); + + const retrievedValue = await storageUtility.getForUser(userId, "jackie"); + expect(retrievedValue).toBe("The Pretty Kitty"); + }); + + it("sets a value for a user if the original data was corrupted", async () => { + const storageMock = defaultMocks.insecureStorage; + await storageMock.set( + `solidClientAuthenticationUser:${userId}`, + 'cool: "bleep bloop not parsable"' + ); + + const storageUtility = getStorageUtility({ + insecureStorage: storageMock, + }); + await storageUtility.setForUser(userId, { + jackie: "The Pretty Kitty", + }); + const retrievedValue = await storageUtility.getForUser(userId, "jackie"); + expect(retrievedValue).toBe("The Pretty Kitty"); + }); + }); + + describe("deleteForUser", () => { + it("deletes a value for a user from unsecure storage", async () => { + const userData = { + jackie: "The Cat", + sledge: "The Dog", + }; + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + await storageUtility.setForUser(userId, userData); + + await storageUtility.deleteForUser(userId, "jackie"); + + await expect( + storageUtility.getForUser(userId, "jackie") + ).resolves.toBeUndefined(); + await expect(storageUtility.getForUser(userId, "sledge")).resolves.toBe( + "The Dog" + ); + }); + + it("deletes a value for a user from secure storage", async () => { + const storageUtility = getStorageUtility({ + secureStorage: mockStorage({ + "solidClientAuthenticationUser:someUser": { + jackie: "The Cat", + sledge: "The Dog", + }, + }), + }); + + await storageUtility.deleteForUser("someUser", "jackie", { + secure: true, + }); + + await expect( + storageUtility.getForUser("someUser", "jackie", { secure: true }) + ).resolves.toBeUndefined(); + await expect( + storageUtility.getForUser("someUser", "sledge", { secure: true }) + ).resolves.toBe("The Dog"); + }); + }); + + describe("deleteAllUserData", () => { + it("deletes all data for a particular user", async () => { + const storageUtility = getStorageUtility({ + insecureStorage: mockStorage({}), + }); + const userData = { + jackie: "The Cat", + sledge: "The Dog", + }; + + // Write some user data, and make sure it's there. + await storageUtility.setForUser(userId, userData); + await expect(storageUtility.getForUser(userId, "jackie")).resolves.toBe( + "The Cat" + ); + + // Delete that user data, and make sure it's gone. + await storageUtility.deleteAllUserData(userId); + await expect( + storageUtility.getForUser(userId, "jackie") + ).resolves.toBeUndefined(); + }); + + it("deletes all data for a particular user (from secure storage)", async () => { + const storageUtility = getStorageUtility({ + secureStorage: mockStorage({}), + }); + const userData = { + jackie: "The Cat", + sledge: "The Dog", + }; + + // Write some user data, and make sure it's there. + await storageUtility.setForUser(userId, userData, { secure: true }); + await expect( + storageUtility.getForUser(userId, "jackie", { secure: true }) + ).resolves.toBe("The Cat"); + + // Delete that user data, and make sure it's gone. + await storageUtility.deleteAllUserData(userId, { secure: true }); + await expect( + storageUtility.getForUser(userId, "jackie", { secure: true }) + ).resolves.toBeUndefined(); + }); + }); +}); + +describe("getSessionIdFromOauthState", () => { + it("returns stored OIDC 'state' for request's OIDC 'state' value", async () => { + const oauthState = "some existent 'state'"; + const oauthStateValue = "some existent 'state' value"; + const mockedStorage = mockStorageUtility( + { + [`solidClientAuthenticationUser:${oauthState}`]: { + sessionId: oauthStateValue, + }, + }, + false + ); + + await expect( + getSessionIdFromOauthState(mockedStorage, oauthState) + ).resolves.toBe(oauthStateValue); + }); + + it("returns undefined if no stored OIDC 'state' matches the current request's OIDC 'state' value", async () => { + const mockedStorage = mockStorageUtility({}); + + await expect( + getSessionIdFromOauthState( + mockedStorage, + "some non-existent 'state' value" + ) + ).resolves.toBeUndefined(); + }); +}); + +describe("loadOidcContextFromStorage", () => { + it("throws if no issuer is stored for the user", async () => { + const mockedStorage = mockStorageUtility({ + "solidClientAuthenticationUser:mySession": { + codeVerifier: "some code verifier", + redirectUrl: "https://my.app/redirect", + dpop: "true", + }, + }); + + await expect( + loadOidcContextFromStorage( + "mySession", + mockedStorage, + mockIssuerConfigFetcher(mockIssuerConfig()) + ) + ).rejects.toThrow( + "Failed to retrieve OIDC context from storage associated with session [mySession]" + ); + }); + + it("throws if no token type is stored for the user", async () => { + const mockedStorage = mockStorageUtility({ + "solidClientAuthenticationUser:mySession": { + issuer: "https://my.idp/", + codeVerifier: "some code verifier", + redirectUrl: "https://my.app/redirect", + }, + }); + + await expect( + loadOidcContextFromStorage( + "mySession", + mockedStorage, + mockIssuerConfigFetcher(mockIssuerConfig()) + ) + ).rejects.toThrow( + "Failed to retrieve OIDC context from storage associated with session [mySession]" + ); + }); + + it("Returns the value in storage if available", async () => { + const mockedStorage = mockStorageUtility({ + "solidClientAuthenticationUser:mySession": { + issuer: "https://my.idp/", + codeVerifier: "some code verifier", + redirectUrl: "https://my.app/redirect", + dpop: "true", + }, + }); + + await expect( + loadOidcContextFromStorage( + "mySession", + mockedStorage, + mockIssuerConfigFetcher(mockIssuerConfig()) + ) + ).resolves.toEqual({ + issuerConfig: mockIssuerConfig(), + codeVerifier: "some code verifier", + redirectUrl: "https://my.app/redirect", + dpop: true, + }); + }); + + it("Clears the code verifier", async () => { + const mockedStorage = mockStorageUtility({ + "solidClientAuthenticationUser:mySession": { + issuer: "https://my.idp/", + codeVerifier: "some code verifier", + redirectUrl: "https://my.app/redirect", + dpop: "true", + }, + }); + + await loadOidcContextFromStorage( + "mySession", + mockedStorage, + mockIssuerConfigFetcher(mockIssuerConfig()) + ); + await expect( + mockedStorage.getForUser("mySession", "codeVerifier") + ).resolves.toBeUndefined(); + }); +}); + +describe("saveSessionInfoToStorage", () => { + it("saves the refresh token if provided in the given storage", async () => { + const mockedStorage = mockStorageUtility({}); + await saveSessionInfoToStorage( + mockedStorage, + "some session", + "https://my.webid", + "true", + "a refresh token", + true + ); + + await expect( + mockedStorage.getForUser("some session", "refreshToken", { secure: true }) + ).resolves.toBe("a refresh token"); + }); + + it("saves the webid if provided in the given storage", async () => { + const mockedStorage = mockStorageUtility({}); + await saveSessionInfoToStorage( + mockedStorage, + "some session", + "https://my.webid", + undefined, + undefined, + true + ); + + await expect( + mockedStorage.getForUser("some session", "webId", { secure: true }) + ).resolves.toBe("https://my.webid"); + }); + + it("saves the logged in status if provided in the given storage", async () => { + const mockedStorage = mockStorageUtility({}); + await saveSessionInfoToStorage( + mockedStorage, + "some session", + undefined, + "true", + undefined, + true + ); + + await expect( + mockedStorage.getForUser("some session", "isLoggedIn", { secure: true }) + ).resolves.toBe("true"); + }); + + let publicKey: KeyLike | undefined; + let privateKey: KeyLike | undefined; + + const mockJwk = async (): Promise<{ + publicKey: KeyLike; + privateKey: KeyLike; + }> => { + if (typeof publicKey === "undefined" || typeof privateKey === "undefined") { + const generatedPair = await generateKeyPair("ES256"); + publicKey = generatedPair.publicKey; + privateKey = generatedPair.privateKey; + } + return { + publicKey, + privateKey, + }; + }; + + const mockKeyPair = async () => { + const { privateKey: prvt, publicKey: pblc } = await mockJwk(); + const dpopKeyPair = { + privateKey: prvt, + publicKey: await exportJWK(pblc), + }; + // The alg property isn't set by exportJWK, so set it manually. + dpopKeyPair.publicKey.alg = "ES256"; + return dpopKeyPair; + }; + + it("saves the DPoP key if provided in the given storage", async () => { + const mockedStorage = mockStorageUtility({}); + const dpopKey = await mockKeyPair(); + await saveSessionInfoToStorage( + mockedStorage, + "some session", + "https://my.webid", + "true", + "a refresh token", + true, + dpopKey + ); + + expect( + JSON.parse( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (await mockedStorage.getForUser("some session", "publicKey", { + secure: true, + }))! + ) + ).toEqual(dpopKey.publicKey); + const privateJwk = await mockedStorage.getForUser( + "some session", + "privateKey", + { secure: true } + ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(JSON.parse(privateJwk!)).toEqual( + await exportJWK(dpopKey.privateKey) + ); + }); +}); diff --git a/packages/core/src/storage/StorageUtility.ts b/packages/core/src/storage/StorageUtility.ts new file mode 100644 index 0000000..6bea936 --- /dev/null +++ b/packages/core/src/storage/StorageUtility.ts @@ -0,0 +1,267 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * A helper class that will validate items taken from local storage + */ +import { exportJWK } from "jose"; +import IStorage from "./IStorage"; +import IStorageUtility from "./IStorageUtility"; +import { IIssuerConfig } from "../login/oidc/IIssuerConfig"; +import { IIssuerConfigFetcher } from "../login/oidc/IIssuerConfigFetcher"; +import { KeyPair } from "../authenticatedFetch/dpopUtils"; + +export type OidcContext = { + issuerConfig: IIssuerConfig; + codeVerifier?: string; + redirectUrl?: string; + dpop: boolean; +}; + +export async function getSessionIdFromOauthState( + storageUtility: IStorageUtility, + oauthState: string +): Promise { + return storageUtility.getForUser(oauthState, "sessionId"); +} + +/** + * Based on the provided state, this looks up contextual information stored + * before redirecting the user to the OIDC issuer. + * @param sessionId The state (~ correlation ID) of the OIDC request + * @param storageUtility + * @param configFetcher + * @returns Information stored about the client issuing the request + */ +export async function loadOidcContextFromStorage( + sessionId: string, + storageUtility: IStorageUtility, + configFetcher: IIssuerConfigFetcher +): Promise { + try { + const [issuerIri, codeVerifier, storedRedirectIri, dpop] = + await Promise.all([ + storageUtility.getForUser(sessionId, "issuer", { + errorIfNull: true, + }), + storageUtility.getForUser(sessionId, "codeVerifier"), + storageUtility.getForUser(sessionId, "redirectUrl"), + storageUtility.getForUser(sessionId, "dpop", { errorIfNull: true }), + ]); + // Clear the code verifier, which is one-time use. + await storageUtility.deleteForUser(sessionId, "codeVerifier"); + + // Unlike openid-client, this looks up the configuration from storage + const issuerConfig = await configFetcher.fetchConfig(issuerIri as string); + return { + codeVerifier, + redirectUrl: storedRedirectIri, + issuerConfig, + dpop: dpop === "true", + }; + } catch (e) { + throw new Error( + `Failed to retrieve OIDC context from storage associated with session [${sessionId}]: ${e}` + ); + } +} + +/** + * Stores information about the session in the provided storage. Note that not + * all storage are equally secure, and it is strongly advised not to store either + * the refresh token or the DPoP key in the browser's local storage. + * + * @param storageUtility + * @param sessionId + * @param webId + * @param isLoggedIn + * @param refreshToken + * @param secure + * @param dpopKey + */ +export async function saveSessionInfoToStorage( + storageUtility: IStorageUtility, + sessionId: string, + webId?: string, + isLoggedIn?: string, + refreshToken?: string, + secure?: boolean, + dpopKey?: KeyPair +): Promise { + // TODO: Investigate why this does not work with a Promise.all + if (refreshToken !== undefined) { + await storageUtility.setForUser(sessionId, { refreshToken }, { secure }); + } + if (webId !== undefined) { + await storageUtility.setForUser(sessionId, { webId }, { secure }); + } + if (isLoggedIn !== undefined) { + await storageUtility.setForUser(sessionId, { isLoggedIn }, { secure }); + } + if (dpopKey !== undefined) { + await storageUtility.setForUser( + sessionId, + { + publicKey: JSON.stringify(dpopKey.publicKey), + privateKey: JSON.stringify(await exportJWK(dpopKey.privateKey)), + }, + { secure } + ); + } +} + +// TOTEST: this does not handle all possible bad inputs for example what if it's not proper JSON +/** + * @hidden + */ +export default class StorageUtility implements IStorageUtility { + constructor( + private secureStorage: IStorage, + private insecureStorage: IStorage + ) {} + + private getKey(userId: string): string { + return `solidClientAuthenticationUser:${userId}`; + } + + private async getUserData( + userId: string, + secure?: boolean + ): Promise> { + const stored = await (secure + ? this.secureStorage + : this.insecureStorage + ).get(this.getKey(userId)); + + if (stored === undefined) { + return {}; + } + + try { + return JSON.parse(stored); + } catch (err) { + throw new Error( + `Data for user [${userId}] in [${ + secure ? "secure" : "unsecure" + }] storage is corrupted - expected valid JSON, but got: ${stored}` + ); + } + } + + private async setUserData( + userId: string, + data: Record, + secure?: boolean + ): Promise { + await (secure ? this.secureStorage : this.insecureStorage).set( + this.getKey(userId), + JSON.stringify(data) + ); + } + + async get( + key: string, + options?: { errorIfNull?: boolean; secure?: boolean } + ): Promise { + const value = await (options?.secure + ? this.secureStorage + : this.insecureStorage + ).get(key); + if (value === undefined && options?.errorIfNull) { + throw new Error(`[${key}] is not stored`); + } + return value; + } + + async set( + key: string, + value: string, + options?: { secure?: boolean } + ): Promise { + return (options?.secure ? this.secureStorage : this.insecureStorage).set( + key, + value + ); + } + + async delete(key: string, options?: { secure?: boolean }): Promise { + return (options?.secure ? this.secureStorage : this.insecureStorage).delete( + key + ); + } + + async getForUser( + userId: string, + key: string, + options?: { errorIfNull?: boolean; secure?: boolean } + ): Promise { + const userData = await this.getUserData(userId, options?.secure); + let value; + if (!userData || !userData[key]) { + value = undefined; + } + value = userData[key]; + if (value === undefined && options?.errorIfNull) { + throw new Error(`Field [${key}] for user [${userId}] is not stored`); + } + return value || undefined; + } + + async setForUser( + userId: string, + values: Record, + options?: { secure?: boolean } + ): Promise { + let userData: Record; + try { + userData = await this.getUserData(userId, options?.secure); + } catch { + // if reading the user data throws, the data is corrupted, and we want to write over it + userData = {}; + } + + await this.setUserData(userId, { ...userData, ...values }, options?.secure); + } + + async deleteForUser( + userId: string, + key: string, + options?: { secure?: boolean } + ): Promise { + const userData = await this.getUserData(userId, options?.secure); + delete userData[key]; + await this.setUserData(userId, userData, options?.secure); + } + + async deleteAllUserData( + userId: string, + options?: { secure?: boolean } + ): Promise { + await (options?.secure ? this.secureStorage : this.insecureStorage).delete( + this.getKey(userId) + ); + } +} diff --git a/packages/core/src/storage/__mocks__/StorageUtility.ts b/packages/core/src/storage/__mocks__/StorageUtility.ts new file mode 100644 index 0000000..4e1c783 --- /dev/null +++ b/packages/core/src/storage/__mocks__/StorageUtility.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import StorageUtility from "../StorageUtility"; +import type IStorage from "../IStorage"; +import type IStorageUtility from "../IStorageUtility"; + +export const StorageUtilityGetResponse = "getResponse"; + +export const StorageUtilityMock: IStorageUtility = { + /* eslint-disable @typescript-eslint/no-unused-vars */ + get: async (key: string, options?: { errorIfNull?: boolean }) => + StorageUtilityGetResponse, + set: async (key: string, value: string) => { + /* do nothing */ + }, + delete: async (key: string) => { + /* do nothing */ + }, + getForUser: async ( + userId: string, + key: string, + options?: { errorIfNull?: boolean; secure?: boolean } + ) => StorageUtilityGetResponse, + setForUser: async ( + userId: string, + values: Record, + options?: { secure?: boolean } + ) => { + /* do nothing */ + }, + deleteForUser: async ( + userId: string, + key: string, + options?: { secure?: boolean } + ) => { + /* do nothing */ + }, + deleteAllUserData: async (userId: string, options?: { secure?: boolean }) => { + /* do nothing */ + }, +}; + +export const mockStorage = ( + stored: Record> +): IStorage => { + const store = stored; + return { + get: async (key: string): Promise => { + if (store[key] === undefined) { + return undefined; + } + if (typeof store[key] === "string") { + return store[key] as string; + } + return JSON.stringify(store[key]); + }, + set: async (key: string, value: string): Promise => { + store[key] = value; + }, + delete: async (key: string): Promise => { + delete store[key]; + }, + }; +}; + +export const mockStorageUtility = ( + stored: Record>, + isSecure = false +): IStorageUtility => { + if (isSecure) { + return new StorageUtility(mockStorage(stored), mockStorage({})); + } + return new StorageUtility(mockStorage({}), mockStorage(stored)); +}; diff --git a/packages/core/src/util/handlerPattern/AggregateHandler.spec.ts b/packages/core/src/util/handlerPattern/AggregateHandler.spec.ts new file mode 100644 index 0000000..732945e --- /dev/null +++ b/packages/core/src/util/handlerPattern/AggregateHandler.spec.ts @@ -0,0 +1,141 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, describe, it, expect } from "@jest/globals"; +import IHandleable from "./IHandleable"; +import AggregateHandler from "./AggregateHandler"; + +describe("AggregateHandler", () => { + // Set up mock extension + type MockHandler = IHandleable<[string], string>; + class AggregateMockHandler + extends AggregateHandler<[string], string> + implements MockHandler + { + constructor(mockHandlers: MockHandler[]) { + super(mockHandlers); + } + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + function initMocks( + configs: { canHandle: boolean; executeTime: number; toReturn: string }[] + ) { + const mockHandlerInfo = configs.map((config) => { + const canHandleFunction = jest.fn( + async (_input: string): Promise => { + return new Promise((resolve, _reject) => { + setTimeout(() => resolve(config.canHandle), config.executeTime); + }); + } + ); + const handleFunction = jest.fn( + async (_input: string): Promise => { + return new Promise((resolve, _reject) => { + setTimeout(() => resolve(config.toReturn), config.executeTime); + }); + } + ); + const mock: () => MockHandler = jest.fn(() => ({ + canHandle: canHandleFunction, + handle: handleFunction, + })); + return { + canHandleFunction, + handleFunction, + mock, + }; + }); + const aggregateMockHandler = new AggregateMockHandler( + mockHandlerInfo.map((info) => info.mock()) + ); + return { + mockHandlerInfo, + aggregateMockHandler, + }; + } + + describe("canHandle", () => { + it("should return correct handler", async () => { + const mocks = initMocks([ + { canHandle: true, executeTime: 0, toReturn: "" }, + { canHandle: false, executeTime: 0, toReturn: "" }, + ]); + const result = await mocks.aggregateMockHandler.canHandle("something"); + expect(result).toBe(true); + }); + + it("should error if there is no correct handler", async () => { + const mocks = initMocks([ + { canHandle: false, executeTime: 0, toReturn: "" }, + { canHandle: false, executeTime: 0, toReturn: "" }, + ]); + expect(await mocks.aggregateMockHandler.canHandle("something")).toBe( + false + ); + }); + }); + + describe("handle", () => { + it("should execute the handler", async () => { + const mocks = initMocks([ + { canHandle: true, executeTime: 0, toReturn: "allGood" }, + ]); + const result = await mocks.aggregateMockHandler.handle("something"); + expect(result).toBe("allGood"); + }); + + it.todo( + "should run the correct handler even when it is preceded by the incorrect handler" + ); + + it.todo( + "should run the first correct handler even when succeeded by a handler that takes a shorter time to execute" + ); + + it("should error when there is no correct handler", async () => { + const mocks = initMocks([ + { canHandle: false, executeTime: 0, toReturn: "" }, + { canHandle: false, executeTime: 0, toReturn: "" }, + ]); + await expect(() => + mocks.aggregateMockHandler.handle("something") + ).rejects.toThrow(); + }); + + it("should error when there is no correct handler, and handle invalid JSON", async () => { + const mocks = initMocks([ + { canHandle: false, executeTime: 0, toReturn: "" }, + { canHandle: false, executeTime: 0, toReturn: "" }, + ]); + + // Cyclical object that references itself causes JSON.stringify to throw! + const obj: any = { + prop: {}, + }; + obj.prop = obj; + + await expect(() => + mocks.aggregateMockHandler.handle(obj) + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/core/src/util/handlerPattern/AggregateHandler.ts b/packages/core/src/util/handlerPattern/AggregateHandler.ts new file mode 100644 index 0000000..97da7d7 --- /dev/null +++ b/packages/core/src/util/handlerPattern/AggregateHandler.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * An abstract class that will select the first handler that can handle certain parameters + */ +import IHandleable from "./IHandleable"; + +/** + * @hidden + */ +export default class AggregateHandler

, R> + implements IHandleable +{ + constructor(private handleables: IHandleable[]) {} + + /** + * Helper function that will asynchronously determine the proper handler to use. If multiple + * handlers can handle, it will choose the first one in the list + * @param params Paramerters to feed to the handler + */ + private async getProperHandler(params: P): Promise | null> { + // TODO : This function doesn't currently operate as described. Tests need to be written + + // return new Promise | null>((resolve, reject) => { + // const resolvedValues: Array = Array(this.handleables.length).map(() => null) + // let numberResolved = 0 + // this.handleables.forEach(async (handleable: IHandleable, index: number) => { + // resolvedValues[index] = await handleable.canHandle(...params) + // numberResolved++ + // let curResolvedValueIndex = 0 + // while ( + // resolvedValues[curResolvedValueIndex] !== null || + // resolvedValues[curResolvedValueIndex] !== undefined + // ) { + // if (resolvedValues[curResolvedValueIndex]) { + // resolve(this.handleables[curResolvedValueIndex]) + // } + // curResolvedValueIndex++ + // } + // }) + // }) + + const canHandleList = await Promise.all( + this.handleables.map((handleable) => handleable.canHandle(...params)) + ); + + for (let i = 0; i < canHandleList.length; i += 1) { + if (canHandleList[i]) { + return this.handleables[i]; + } + } + return null; + } + + async canHandle(...params: P): Promise { + return (await this.getProperHandler(params)) !== null; + } + + async handle(...params: P): Promise { + const handler = await this.getProperHandler(params); + if (handler) { + return handler.handle(...params); + } + + throw new Error( + `[${this.constructor.name}] cannot find a suitable handler for: ${params + .map((param) => { + try { + return JSON.stringify(param); + } catch (err) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return (param as any).toString(); + } + }) + .join(", ")}` + ); + } +} diff --git a/packages/core/src/util/handlerPattern/IHandleable.ts b/packages/core/src/util/handlerPattern/IHandleable.ts new file mode 100644 index 0000000..c30a339 --- /dev/null +++ b/packages/core/src/util/handlerPattern/IHandleable.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * A handler is an abstract concept for execution. It knows what it can handle, + * and will perform the action if needed. + * @hidden + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default interface IHandleable

, R> { + canHandle(...params: P): Promise; + handle(...params: P): Promise; +} diff --git a/packages/core/src/util/token.spec.ts b/packages/core/src/util/token.spec.ts new file mode 100644 index 0000000..21590d3 --- /dev/null +++ b/packages/core/src/util/token.spec.ts @@ -0,0 +1,256 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { JWTPayload, KeyLike, SignJWT, generateKeyPair, exportJWK } from "jose"; +import { Response as NodeResponse } from "cross-fetch"; +import type * as CrossFetch from "cross-fetch"; +import { getWebidFromTokenPayload } from "./token"; + +jest.mock("cross-fetch", () => { + return { + ...(jest.requireActual("cross-fetch") as typeof CrossFetch), + default: jest.fn(), + fetch: jest.fn(), + } as typeof CrossFetch; +}); + +describe("getWebidFromTokenPayload", () => { + // Singleton keys generated on the first call to mockJwk + let publicKey: KeyLike | undefined; + let privateKey: KeyLike | undefined; + + const mockJwk = async (): Promise<{ + publicKey: KeyLike; + privateKey: KeyLike; + }> => { + if (typeof publicKey === "undefined" || typeof privateKey === "undefined") { + const generatedPair = await generateKeyPair("ES256"); + publicKey = generatedPair.publicKey; + privateKey = generatedPair.privateKey; + } + return { + publicKey, + privateKey, + }; + }; + + const mockJwks = async (): Promise => { + const { publicKey: issuerPubKey } = await mockJwk(); + const jwk = await exportJWK(issuerPubKey); + // This is not set by 'exportJWK' + jwk.alg = "ES256"; + return JSON.stringify({ keys: [jwk] }); + }; + + const mockJwt = async ( + claims: JWTPayload, + issuer: string, + audience: string, + signingKey?: KeyLike + ): Promise => { + return new SignJWT(claims) + .setProtectedHeader({ alg: "ES256" }) + .setIssuedAt() + .setIssuer(issuer) + .setAudience(audience) + .setExpirationTime("2h") + .sign(signingKey ?? (await mockJwk()).privateKey); + }; + + const mockFetch = (payload: string, statusCode: number): void => { + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + mockedFetch.mockResolvedValueOnce( + new NodeResponse(payload, { status: statusCode }) + ); + }; + + it("throws if the JWKS cannot be fetched", async () => { + mockFetch("", 404); + const jwt = await mockJwt( + { someClaim: true }, + "https://some.issuer", + "https://some.clientId" + ); + await expect( + getWebidFromTokenPayload( + jwt, + "https://some.jwks", + "https://some.issuer", + "https://some.clientId" + ) + ).rejects.toThrow( + "Could not fetch JWKS for [https://some.issuer] at [https://some.jwks]: 404 Not Found" + ); + }); + + it("throws if the JWKS is malformed", async () => { + // Invalid JSON. + mockFetch("", 200); + const jwt = await mockJwt( + { someClaim: true }, + "https://some.issuer", + "https://some.clientId" + ); + await expect( + getWebidFromTokenPayload( + jwt, + "https://some.jwks", + "https://some.issuer", + "https://some.clientId" + ) + ).rejects.toThrow( + "Malformed JWKS for [https://some.issuer] at [https://some.jwks]:" + ); + }); + + it("throws if the ID token signature verification fails", async () => { + mockFetch(await mockJwks(), 200); + const { privateKey: anotherKey } = await generateKeyPair("ES256"); + // Sign the returned JWT with a private key unrelated to the public key in the JWKS + const jwt = await mockJwt( + { someClaim: true }, + "https://some.issuer", + "https://some.clientId", + anotherKey + ); + await expect( + getWebidFromTokenPayload( + jwt, + "https://some.jwks", + "https://some.issuer", + "https://some.clientId" + ) + ).rejects.toThrow( + "Token verification failed: JWSSignatureVerificationFailed: signature verification failed" + ); + }); + + it("throws if the ID token issuer verification fails", async () => { + mockFetch(await mockJwks(), 200); + const jwt = await mockJwt( + { someClaim: true }, + "https://some.other.issuer", + "https://some.clientId" + ); + await expect( + getWebidFromTokenPayload( + jwt, + "https://some.jwks", + "https://some.issuer", + "https://some.clientId" + ) + ).rejects.toThrow( + 'Token verification failed: JWTClaimValidationFailed: unexpected "iss" claim value' + ); + }); + + it("throws if the ID token audience verification fails", async () => { + mockFetch(await mockJwks(), 200); + const jwt = await mockJwt( + { someClaim: true }, + "https://some.issuer", + "https://some.other.clientId" + ); + await expect( + getWebidFromTokenPayload( + jwt, + "https://some.jwks", + "https://some.issuer", + "https://some.clientId" + ) + ).rejects.toThrow( + 'Token verification failed: JWTClaimValidationFailed: unexpected "aud" claim value' + ); + }); + + it("throws if the 'webid' and the 'sub' claims are missing", async () => { + mockFetch(await mockJwks(), 200); + const jwt = await mockJwt( + { someClaim: true }, + "https://some.issuer", + "https://some.clientId" + ); + await expect( + getWebidFromTokenPayload( + jwt, + "https://some.jwks", + "https://some.issuer", + "https://some.clientId" + ) + ).rejects.toThrow("it has no 'webid' claim and no 'sub' claim."); + }); + + it("throws if the 'webid' claims is missing and the 'sub' claim is not an IRI", async () => { + mockFetch(await mockJwks(), 200); + const jwt = await mockJwt( + { sub: "some user ID" }, + "https://some.issuer", + "https://some.clientId" + ); + await expect( + getWebidFromTokenPayload( + jwt, + "https://some.jwks", + "https://some.issuer", + "https://some.clientId" + ) + ).rejects.toThrow( + "The token has no 'webid' claim, and its 'sub' claim of [some user ID] is invalid as a URL - error" + ); + }); + + it("returns the WebID it the 'webid' claim exists", async () => { + mockFetch(await mockJwks(), 200); + const jwt = await mockJwt( + { webid: "https://some.webid#me" }, + "https://some.issuer", + "https://some.clientId" + ); + await expect( + getWebidFromTokenPayload( + jwt, + "https://some.jwks", + "https://some.issuer", + "https://some.clientId" + ) + ).resolves.toBe("https://some.webid#me"); + }); + + it("returns the WebID it the 'sub' claim exists and it is IRI-like", async () => { + mockFetch(await mockJwks(), 200); + const jwt = await mockJwt( + { sub: "https://some.webid#me" }, + "https://some.issuer", + "https://some.clientId" + ); + await expect( + getWebidFromTokenPayload( + jwt, + "https://some.jwks", + "https://some.issuer", + "https://some.clientId" + ) + ).resolves.toBe("https://some.webid#me"); + }); +}); diff --git a/packages/core/src/util/token.ts b/packages/core/src/util/token.ts new file mode 100644 index 0000000..6c06d1d --- /dev/null +++ b/packages/core/src/util/token.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// eslint-disable-next-line no-shadow +import { fetch } from "cross-fetch"; +import { JWK, JWTPayload, jwtVerify, importJWK } from "jose"; + +type WithMessage = { message: string }; +type WithStack = { stack: string }; + +export async function fetchJwks( + jwksIri: string, + issuerIri: string +): Promise { + // FIXME: the following line works, but the underlying network calls don't seem + // to be mocked properly by our test code. It would be nicer to replace calls to this + // function by the following line and to fix the mocks. + // const jwks = createRemoteJWKSet(new URL(jwksIri)); + const jwksResponse = await fetch(jwksIri); + if (jwksResponse.status !== 200) { + throw new Error( + `Could not fetch JWKS for [${issuerIri}] at [${jwksIri}]: ${jwksResponse.status} ${jwksResponse.statusText}` + ); + } + // The JWKS should only contain the current key for the issuer. + let jwk: JWK; + try { + jwk = (await jwksResponse.json()).keys[0] as JWK; + } catch (e) { + throw new Error( + `Malformed JWKS for [${issuerIri}] at [${jwksIri}]: ${ + (e as WithMessage).message + }` + ); + } + return jwk; +} + +/** + * Extract a WebID from an ID token payload based on https://github.com/solid/webid-oidc-spec. + * Note that this does not yet implement the user endpoint lookup, and only checks + * for `webid` or IRI-like `sub` claims. + * + * @param idToken the payload of the ID token from which the WebID can be extracted. + * @returns a WebID extracted from the ID token. + * @internal + */ +export async function getWebidFromTokenPayload( + idToken: string, + jwksIri: string, + issuerIri: string, + clientId: string +): Promise { + const jwk = await fetchJwks(jwksIri, issuerIri); + let payload: JWTPayload; + try { + const { payload: verifiedPayload } = await jwtVerify( + idToken, + await importJWK(jwk), + { + issuer: issuerIri, + audience: clientId, + } + ); + payload = verifiedPayload; + } catch (e) { + throw new Error(`Token verification failed: ${(e as WithStack).stack}`); + } + + if (typeof payload.webid === "string") { + return payload.webid; + } + if (typeof payload.sub !== "string") { + throw new Error( + `The token ${JSON.stringify( + payload + )} is invalid: it has no 'webid' claim and no 'sub' claim.` + ); + } + try { + // This parses the 'sub' claim to check if it is a well-formed IRI. + // However, the normalized value isn't returned to make sure the WebID is returned + // as specified by the Identity Provider. + // eslint-disable-next-line no-new + new URL(payload.sub); + return payload.sub; + } catch (e) { + throw new Error( + `The token has no 'webid' claim, and its 'sub' claim of [${payload.sub}] is invalid as a URL - error [${e}].` + ); + } +} diff --git a/packages/core/tsconfig.eslint.json b/packages/core/tsconfig.eslint.json new file mode 100644 index 0000000..0b8b619 --- /dev/null +++ b/packages/core/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + + "include": ["src/**/*", "__mocks__"], + "exclude": [] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..b67ebf4 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.build.json", + + "compilerOptions": { + "lib": ["es2018", "dom"], + "outDir": "./dist" + }, + + "typedocOptions": { + "out": "website/docs/api/core", + "entryPoints": ["./src/index.ts"], + "entryDocument": "index.md" + }, + + "include": ["src/**/*"], + "exclude": ["src/**/*.spec.ts", "**/__mocks__/*"] +} diff --git a/packages/node/.eslintrc.js b/packages/node/.eslintrc.js new file mode 100644 index 0000000..2269000 --- /dev/null +++ b/packages/node/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: ["../../.eslintrc.js"], + parserOptions: { + project: "./tsconfig.eslint.json", + }, +}; diff --git a/packages/node/.npmignore b/packages/node/.npmignore new file mode 100644 index 0000000..0104c66 --- /dev/null +++ b/packages/node/.npmignore @@ -0,0 +1,6 @@ +.git +docs +coverage +report +.vscode +examples diff --git a/packages/node/.prettierignore b/packages/node/.prettierignore new file mode 100644 index 0000000..86b5885 --- /dev/null +++ b/packages/node/.prettierignore @@ -0,0 +1,3 @@ +# TODO: figure out why the root .prettierignore file isn't detected: +docs/**/*.md +examples/**/*.md diff --git a/packages/node/README.md b/packages/node/README.md new file mode 100644 index 0000000..78271c8 --- /dev/null +++ b/packages/node/README.md @@ -0,0 +1,51 @@ +# Solid JavaScript authentication for Node.js - solid-client-authn-node + +`solid-client-authn-node` is a library designed to authenticate Node.js apps (both scripts and full-blown Web servers) with Solid identity servers. +The main documentation is at the [root of the repository](https://github.com/inrupt/solid-client-authn-js). + +## Underlying libraries + +`solid-client-authn-node` is based on [`jose`](https://github.com/panva/jose). + +# Other Inrupt Solid JavaScript Libraries + +[`@inrupt/solid-client-authn-node`](https://www.npmjs.com/package/@inrupt/solid-client-authn-node)is part of a family open source JavaScript libraries designed to support developers building Solid applications. + +## Inrupt Solid JavaScript Client Libraries + +### Data access and permissions management - solid-client + +[@inrupt/solid-client](https://docs.inrupt.com/client-libraries/solid-client-js/) allows developers to access data and manage permissions on data stored in Solid Pods. + +### Authentication - solid-client-authn + +[@inrupt/solid-client-authn](https://github.com/inrupt/solid-client-authn) allows developers to authenticate against a Solid server. This is necessary when the resources on your Pod are not public. + +### Vocabularies and interoperability - solid-common-vocab-rdf + +[@inrupt/solid-common-vocab-rdf](https://github.com/inrupt/solid-common-vocab-rdf) allows developers to build interoperable apps by reusing well-known vocabularies. These libraries provide vocabulary terms as constants that you just have to import. + +# Issues & Help + +## Solid Community Forum + +If you have questions about working with Solid or just want to share what you’re working on, visit the [Solid forum](https://forum.solidproject.org/). The Solid forum is a good place to meet the rest of the community. + +## Bugs and Feature Requests + +- For public feedback, bug reports, and feature requests please file an issue via [GitHub](https://github.com/inrupt/solid-client-authn/issues/). +- For non-public feedback or support inquiries please use the [Inrupt Service Desk](https://inrupt.atlassian.net/servicedesk). + +## Prerequisite + +This library requires at least: + +- Node.js >= 14 +- npm >= 8.7 + **Note**: We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage your node version. + +The `solid-client-authn` libraries are compatible with [NSS](https://github.com/solid/node-solid-server/releases/tag/v5.3.0) 5.3.X and higher. + +## Documentation + +- [Inrupt documentation Homepage](https://docs.inrupt.com/) diff --git a/packages/node/package-lock.json b/packages/node/package-lock.json new file mode 100644 index 0000000..f55934f --- /dev/null +++ b/packages/node/package-lock.json @@ -0,0 +1,347 @@ +{ + "name": "@inrupt/solid-client-authn-node", + "version": "1.12.3", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@inrupt/solid-client-authn-node", + "version": "1.12.3", + "license": "MIT", + "dependencies": { + "@inrupt/solid-client-authn-core": "^1.12.3", + "cross-fetch": "^3.1.5", + "jose": "^4.3.7", + "openid-client": "^5.1.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/node": "^18.0.3", + "@types/uuid": "^8.3.0" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0" + } + }, + "node_modules/@inrupt/solid-client-authn-core": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.12.2.tgz", + "integrity": "sha512-UitzMHfUKVHy5ogYgdRLo82CUkHrFLJfnYH8jxpELEK+EzOg3zDT0vmNyNjpZ9vL9th9BOzy8RBuwWusZuo/wg==", + "dependencies": { + "@inrupt/solid-common-vocab": "^1.0.0", + "@types/lodash.clonedeep": "^4.5.6", + "@types/uuid": "^8.3.0", + "cross-fetch": "^3.1.5", + "events": "^3.3.0", + "jose": "^4.3.7", + "lodash.clonedeep": "^4.5.0", + "uuid": "^8.3.1" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0" + } + }, + "node_modules/@inrupt/solid-client-authn-core/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@inrupt/solid-common-vocab": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@inrupt/solid-common-vocab/-/solid-common-vocab-1.0.0.tgz", + "integrity": "sha512-LcImhJqqPsNl/OlULzEEK2rYevty0eh1zaOLVz3lnydEU1DQkeaJ8fKBxKdp5/QjCtnIYcaDjh5U11PGh29Dgg==" + }, + "node_modules/@types/lodash": { + "version": "4.14.179", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.179.tgz", + "integrity": "sha512-uwc1x90yCKqGcIOAT6DwOSuxnrAbpkdPsUOZtwrXb4D/6wZs+6qG7QnIawDuZWg0sWpxl+ltIKCaLoMlna678w==" + }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz", + "integrity": "sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA==", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "18.11.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz", + "integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==", + "dev": true + }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==" + }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "dependencies": { + "node-fetch": "2.6.7" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/jose": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.11.1.tgz", + "integrity": "sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oidc-token-hash": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz", + "integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/openid-client": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.3.0.tgz", + "integrity": "sha512-SykPCeZBZ/SxiBH5AWynvFUIDX3//2pgwc/3265alUmGHeCN03+X8uP+pHOVnCXCKfX/XOhO90qttAQ76XcGxA==", + "dependencies": { + "jose": "^4.10.0", + "lru-cache": "^6.0.0", + "object-hash": "^2.0.1", + "oidc-token-hash": "^5.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + }, + "dependencies": { + "@inrupt/solid-client-authn-core": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.12.2.tgz", + "integrity": "sha512-UitzMHfUKVHy5ogYgdRLo82CUkHrFLJfnYH8jxpELEK+EzOg3zDT0vmNyNjpZ9vL9th9BOzy8RBuwWusZuo/wg==", + "requires": { + "@inrupt/solid-common-vocab": "^1.0.0", + "@types/lodash.clonedeep": "^4.5.6", + "@types/uuid": "^8.3.0", + "cross-fetch": "^3.1.5", + "events": "^3.3.0", + "jose": "^4.3.7", + "lodash.clonedeep": "^4.5.0", + "uuid": "^8.3.1" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, + "@inrupt/solid-common-vocab": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@inrupt/solid-common-vocab/-/solid-common-vocab-1.0.0.tgz", + "integrity": "sha512-LcImhJqqPsNl/OlULzEEK2rYevty0eh1zaOLVz3lnydEU1DQkeaJ8fKBxKdp5/QjCtnIYcaDjh5U11PGh29Dgg==" + }, + "@types/lodash": { + "version": "4.14.179", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.179.tgz", + "integrity": "sha512-uwc1x90yCKqGcIOAT6DwOSuxnrAbpkdPsUOZtwrXb4D/6wZs+6qG7QnIawDuZWg0sWpxl+ltIKCaLoMlna678w==" + }, + "@types/lodash.clonedeep": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz", + "integrity": "sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA==", + "requires": { + "@types/lodash": "*" + } + }, + "@types/node": { + "version": "18.11.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz", + "integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==", + "dev": true + }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==" + }, + "cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "requires": { + "node-fetch": "2.6.7" + } + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + }, + "jose": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.11.1.tgz", + "integrity": "sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==" + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "requires": { + "whatwg-url": "^5.0.0" + }, + "dependencies": { + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" + }, + "oidc-token-hash": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz", + "integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==" + }, + "openid-client": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.3.0.tgz", + "integrity": "sha512-SykPCeZBZ/SxiBH5AWynvFUIDX3//2pgwc/3265alUmGHeCN03+X8uP+pHOVnCXCKfX/XOhO90qttAQ76XcGxA==", + "requires": { + "jose": "^4.10.0", + "lru-cache": "^6.0.0", + "object-hash": "^2.0.1", + "oidc-token-hash": "^5.0.1" + } + }, + "uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } +} diff --git a/packages/node/package.json b/packages/node/package.json new file mode 100644 index 0000000..b81078a --- /dev/null +++ b/packages/node/package.json @@ -0,0 +1,40 @@ +{ + "name": "@inrupt/solid-client-authn-node", + "private": true, + "version": "1.12.3", + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index", + "repository": { + "url": "https://github.com/inrupt/solid-client-authn" + }, + "scripts": { + "prepublishOnly": "npm run build", + "clean": "rimraf ./dist && rimraf ./coverage", + "build": "tsc -p tsconfig.json", + "lint:fix": "npm run lint:eslint -- --fix && npm run lint:prettier -- --write", + "lint:check": "npm run lint:eslint && npm run lint:prettier -- --check", + "lint:eslint": "eslint --config .eslintrc.js \"src/\"", + "lint:prettier": "prettier \"{src,e2e}/**/*.{ts,tsx,js,jsx,css}\" \"**/*.{md,mdx,yml}\"", + "licenses:check": "license-checker --production --out license.csv --failOn \"AGPL-1.0-only; AGPL-1.0-or-later; AGPL-3.0-only; AGPL-3.0-or-later; Beerware; CC-BY-NC-1.0; CC-BY-NC-2.0; CC-BY-NC-2.5; CC-BY-NC-3.0; CC-BY-NC-4.0; CC-BY-NC-ND-1.0; CC-BY-NC-ND-2.0; CC-BY-NC-ND-2.5; CC-BY-NC-ND-3.0; CC-BY-NC-ND-4.0; CC-BY-NC-SA-1.0; CC-BY-NC-SA-2.0; CC-BY-NC-SA-2.5; CC-BY-NC-SA-3.0; CC-BY-NC-SA-4.0; CPAL-1.0; EUPL-1.0; EUPL-1.1; EUPL-1.1; GPL-1.0-only; GPL-1.0-or-later; GPL-2.0-only; GPL-2.0-or-later; GPL-3.0; GPL-3.0-only; GPL-3.0-or-later; SISSL; SISSL-1.2; WTFPL\"", + "build-api-docs": "npx typedoc --out docs/api/source/api --readme none", + "build-docs-preview-site": "npm run build-api-docs; cd docs/api; make html" + }, + "devDependencies": { + "@types/node": "^18.0.3", + "@types/uuid": "^8.3.0" + }, + "dependencies": { + "@inrupt/solid-client-authn-core": "^1.12.3", + "cross-fetch": "^3.1.5", + "jose": "^4.3.7", + "openid-client": "^5.1.0", + "uuid": "^9.0.0" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": "^14.0.0 || ^16.0.0" + } +} diff --git a/packages/node/sonar-project.properties b/packages/node/sonar-project.properties new file mode 100644 index 0000000..612cc01 --- /dev/null +++ b/packages/node/sonar-project.properties @@ -0,0 +1,15 @@ +sonar.projectKey=inrupt_solid-client-authn-node +sonar.projectName=solid-client-authn-node +sonar.organization=inrupt + +# Path is relative to the sonar-project.properties file. Defaults to . +sonar.sources=src + +# Typescript tsconfigPath JSON file +sonar.typescript.tsconfigPath=. + +# Comma-delimited list of paths to LCOV coverage report files. Paths may be absolute or relative to the project root. +sonar.javascript.lcov.reportPaths=./coverage/lcov.info + +# Exclude tests from analysis +sonar.exclusions=**/*.test.ts diff --git a/packages/node/src/ClientAuthentication.spec.ts b/packages/node/src/ClientAuthentication.spec.ts new file mode 100644 index 0000000..7e46c2e --- /dev/null +++ b/packages/node/src/ClientAuthentication.spec.ts @@ -0,0 +1,309 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { EventEmitter } from "events"; + +import { ILoginHandler, ILoginOptions } from "@inrupt/solid-client-authn-core"; +import { + mockStorageUtility, + mockIncomingRedirectHandler, +} from "@inrupt/solid-client-authn-core/mocks"; +import { Session } from "./Session"; + +import { mockLoginHandler } from "./login/__mocks__/LoginHandler"; +import { mockLogoutHandler } from "./logout/__mocks__/LogoutHandler"; +import { + mockSessionInfoManager, + SessionCreatorCreateResponse, +} from "./sessionInfo/__mocks__/SessionInfoManager"; + +import ClientAuthentication from "./ClientAuthentication"; + +jest.mock("cross-fetch"); + +describe("ClientAuthentication", () => { + const defaultMockStorage = mockStorageUtility({}); + const defaultMocks = { + loginHandler: mockLoginHandler(), + redirectHandler: mockIncomingRedirectHandler(), + logoutHandler: mockLogoutHandler(defaultMockStorage), + sessionInfoManager: mockSessionInfoManager(defaultMockStorage), + }; + + function getClientAuthentication( + mocks: Partial = defaultMocks + ): ClientAuthentication { + return new ClientAuthentication( + mocks.loginHandler ?? defaultMocks.loginHandler, + mocks.redirectHandler ?? defaultMocks.redirectHandler, + mocks.logoutHandler ?? defaultMocks.logoutHandler, + mocks.sessionInfoManager ?? defaultMocks.sessionInfoManager + ); + } + + describe("login", () => { + const mockEmitter = new EventEmitter(); + it("calls login, and defaults to a DPoP token", async () => { + const clientAuthn = getClientAuthentication(); + await clientAuthn.login( + "mySession", + { + clientId: "coolApp", + clientName: "some client app name", + redirectUrl: "https://coolapp.com/redirect", + oidcIssuer: "https://idp.com", + }, + mockEmitter + ); + expect(defaultMocks.loginHandler.handle).toHaveBeenCalledWith({ + sessionId: "mySession", + clientId: "coolApp", + redirectUrl: "https://coolapp.com/redirect", + oidcIssuer: "https://idp.com", + clientName: "some client app name", + clientSecret: undefined, + handleRedirect: undefined, + tokenType: "DPoP", + eventEmitter: mockEmitter, + refreshToken: undefined, + }); + }); + + it("normalizes the redirect IRI", async () => { + const clientAuthn = getClientAuthentication(); + await clientAuthn.login( + "mySession", + { + clientId: "coolApp", + redirectUrl: "https://coolapp.com", + oidcIssuer: "https://idp.com", + }, + mockEmitter + ); + expect(defaultMocks.loginHandler.handle).toHaveBeenCalledWith( + expect.objectContaining({ + redirectUrl: "https://coolapp.com/", + }) + ); + }); + + it("may return after login if no redirect is required", async () => { + const mockedAuthFetch = jest.fn(); + const mockedLoginHandler: jest.Mocked = { + // jest's Mock types don't seem to align here after an update. + // Not sure what happened; taking the `any` escape since the tests worked. + canHandle: jest.fn((_options: ILoginOptions) => + Promise.resolve(true) + ) as any, + handle: jest.fn((_options: ILoginOptions) => + Promise.resolve({ + fetch: mockedAuthFetch, + webId: "https://my.webid/", + }) + ) as any, + }; + const clientAuthn = getClientAuthentication({ + loginHandler: mockedLoginHandler, + }); + const loginResult = await clientAuthn.login( + "mySession", + { + refreshToken: "some refresh token", + clientId: "some client ID", + clientSecret: "some client secret", + }, + mockEmitter + ); + expect(loginResult).toBeDefined(); + expect(loginResult?.webId).toBe("https://my.webid/"); + expect(clientAuthn.fetch).toBe(mockedAuthFetch); + }); + + it("request a bearer token if specified", async () => { + const clientAuthn = getClientAuthentication(); + await clientAuthn.login( + "mySession", + { + clientId: "coolApp", + redirectUrl: "https://coolapp.com/redirect", + oidcIssuer: "https://idp.com", + tokenType: "Bearer", + }, + mockEmitter + ); + expect(defaultMocks.loginHandler.handle).toHaveBeenCalledWith({ + sessionId: "mySession", + clientId: "coolApp", + redirectUrl: "https://coolapp.com/redirect", + oidcIssuer: "https://idp.com", + clientName: "coolApp", + clientSecret: undefined, + handleRedirect: undefined, + tokenType: "Bearer", + eventEmitter: mockEmitter, + }); + }); + }); + + describe("fetch", () => { + it("calls fetch", async () => { + const mockedFetch = jest.requireMock("cross-fetch"); + const clientAuthn = getClientAuthentication(); + await clientAuthn.fetch("https://html5zombo.com"); + expect(mockedFetch).toHaveBeenCalledWith("https://html5zombo.com"); + }); + }); + + describe("logout", () => { + it("reverts back to un-authenticated fetch on logout", async () => { + const clientAuthn = getClientAuthentication(); + const unauthFetch = clientAuthn.fetch; + const session = new Session(); + const url = + "https://coolapp.com/redirect?state=userId&id_token=idToken&access_token=accessToken"; + await clientAuthn.handleIncomingRedirect(url, session); + + // Calling the redirect handler should give us an authenticated fetch. + expect(clientAuthn.fetch).not.toBe(unauthFetch); + + await clientAuthn.logout("mySession"); + + // Calling logout should revert back to our un-authenticated fetch. + expect(clientAuthn.fetch).toBe(unauthFetch); + }); + }); + + describe("getSessionInfo", () => { + it("gets the session info for a specific session ID", async () => { + const sessionInfo = { + isLoggedIn: "true", + sessionId: "mySession", + webId: "https://pod.com/profile/card#me", + issuer: "https://some.idp", + }; + const clientAuthn = getClientAuthentication({ + sessionInfoManager: mockSessionInfoManager( + mockStorageUtility({ + "solidClientAuthenticationUser:mySession": { ...sessionInfo }, + }) + ), + }); + const session = await clientAuthn.getSessionInfo("mySession"); + // isLoggedIn is stored as a string under the hood, but deserialized as a boolean + expect(session).toEqual({ ...sessionInfo, isLoggedIn: true }); + }); + }); + + describe("getAllSessionInfo", () => { + it("gets all session info instances", async () => { + const clientAuthn = getClientAuthentication(); + await expect(clientAuthn.getAllSessionInfo()).rejects.toThrow( + "Not implemented" + ); + }); + }); + + describe("getSessionIdAll", () => { + it("calls the session manager", async () => { + const sessionInfoManager = mockSessionInfoManager(mockStorageUtility({})); + const sessionManagerGetAllSpy = jest.spyOn( + sessionInfoManager, + "getRegisteredSessionIdAll" + ); + const clientAuthn = getClientAuthentication({ + sessionInfoManager, + }); + await clientAuthn.getSessionIdAll(); + expect(sessionManagerGetAllSpy).toHaveBeenCalled(); + }); + }); + + describe("registerSession", () => { + it("calls the session manager", async () => { + const sessionInfoManager = mockSessionInfoManager(mockStorageUtility({})); + const sessionManagerRegister = jest.spyOn(sessionInfoManager, "register"); + const clientAuthn = getClientAuthentication({ + sessionInfoManager, + }); + await clientAuthn.registerSession("some session"); + expect(sessionManagerRegister).toHaveBeenCalled(); + }); + }); + + describe("clearSessionAll", () => { + it("calls the session manager", async () => { + const sessionInfoManager = mockSessionInfoManager(mockStorageUtility({})); + const sessionManagerClearAll = jest.spyOn(sessionInfoManager, "clearAll"); + const clientAuthn = getClientAuthentication({ + sessionInfoManager, + }); + await clientAuthn.clearSessionAll(); + expect(sessionManagerClearAll).toHaveBeenCalled(); + }); + }); + + describe("handleIncomingRedirect", () => { + it("calls handle redirect", async () => { + const clientAuthn = getClientAuthentication(); + const session = new Session(); + const unauthFetch = clientAuthn.fetch; + const url = + "https://coolapp.com/redirect?state=userId&id_token=idToken&access_token=accessToken"; + const redirectInfo = await clientAuthn.handleIncomingRedirect( + url, + session + ); + expect(redirectInfo).toEqual({ + ...SessionCreatorCreateResponse, + }); + expect(defaultMocks.redirectHandler.handle).toHaveBeenCalledWith( + url, + session + ); + + // Calling the redirect handler should have updated the fetch. + expect(clientAuthn.fetch).not.toBe(unauthFetch); + }); + + it("calls handle redirect with the refresh token handler if one is provided", async () => { + const clientAuthn = getClientAuthentication(); + const unauthFetch = clientAuthn.fetch; + const session = new Session(); + const url = + "https://coolapp.com/redirect?state=userId&id_token=idToken&access_token=accessToken"; + const redirectInfo = await clientAuthn.handleIncomingRedirect( + url, + session + ); + expect(redirectInfo).toEqual({ + ...SessionCreatorCreateResponse, + }); + expect(defaultMocks.redirectHandler.handle).toHaveBeenCalledWith( + url, + session + ); + + // Calling the redirect handler should have updated the fetch. + expect(clientAuthn.fetch).not.toBe(unauthFetch); + }); + }); +}); diff --git a/packages/node/src/ClientAuthentication.ts b/packages/node/src/ClientAuthentication.ts new file mode 100644 index 0000000..7d13111 --- /dev/null +++ b/packages/node/src/ClientAuthentication.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { + ILoginInputOptions, + ILoginHandler, + ILogoutHandler, + IIncomingRedirectHandler, + ISessionInfo, + ISessionInternalInfo, + ISessionInfoManager, +} from "@inrupt/solid-client-authn-core"; +// eslint-disable-next-line no-shadow +import { fetch } from "cross-fetch"; +import { EventEmitter } from "events"; + +/** + * @hidden + */ +export default class ClientAuthentication { + constructor( + private loginHandler: ILoginHandler, + private redirectHandler: IIncomingRedirectHandler, + private logoutHandler: ILogoutHandler, + private sessionInfoManager: ISessionInfoManager + ) {} + + // Define these functions as properties so that they don't get accidentally re-bound. + // Isn't Javascript fun? + login = async ( + sessionId: string, + options: ILoginInputOptions, + eventEmitter: EventEmitter + ): Promise => { + // Keep track of the session ID + await this.sessionInfoManager.register(sessionId); + const loginReturn = await this.loginHandler.handle({ + sessionId, + oidcIssuer: options.oidcIssuer, + redirectUrl: options.redirectUrl + ? new URL(options.redirectUrl).href + : undefined, + clientId: options.clientId, + clientSecret: options.clientSecret, + clientName: options.clientName ?? options.clientId, + refreshToken: options.refreshToken, + handleRedirect: options.handleRedirect, + // Defaults to DPoP + tokenType: options.tokenType ?? "DPoP", + eventEmitter, + }); + + if (loginReturn !== undefined) { + this.fetch = loginReturn.fetch; + return { + isLoggedIn: true, + sessionId, + webId: loginReturn.webId, + }; + } + + // undefined is returned in the case when the login must be completed + // after redirect. + return undefined; + }; + + // By default, our fetch() resolves to the environment fetch() function. + fetch = fetch; + + logout = async (sessionId: string): Promise => { + await this.logoutHandler.handle(sessionId); + + // Restore our fetch() function back to the environment fetch(), effectively + // leaving us with un-authenticated fetches from now on. + this.fetch = fetch; + }; + + getSessionInfo = async ( + sessionId: string + ): Promise<(ISessionInfo & ISessionInternalInfo) | undefined> => { + // TODO complete + return this.sessionInfoManager.get(sessionId); + }; + + getSessionIdAll = async (): Promise => { + return this.sessionInfoManager.getRegisteredSessionIdAll(); + }; + + registerSession = async (sessionId: string): Promise => { + return this.sessionInfoManager.register(sessionId); + }; + + clearSessionAll = async (): Promise => { + return this.sessionInfoManager.clearAll(); + }; + + getAllSessionInfo = async (): Promise => { + return this.sessionInfoManager.getAll(); + }; + + handleIncomingRedirect = async ( + url: string, + eventEmitter: EventEmitter + ): Promise => { + const redirectInfo = await this.redirectHandler.handle(url, eventEmitter); + + this.fetch = redirectInfo.fetch; + + return { + isLoggedIn: redirectInfo.isLoggedIn, + webId: redirectInfo.webId, + sessionId: redirectInfo.sessionId, + }; + }; +} diff --git a/packages/node/src/Session.spec.ts b/packages/node/src/Session.spec.ts new file mode 100644 index 0000000..2ffdc51 --- /dev/null +++ b/packages/node/src/Session.spec.ts @@ -0,0 +1,659 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { + InMemoryStorage, + ISessionInfo, + EVENTS, +} from "@inrupt/solid-client-authn-core"; +import { + mockStorage, + mockStorageUtility, +} from "@inrupt/solid-client-authn-core/mocks"; +import { + mockClientAuthentication, + mockCustomClientAuthentication, +} from "./__mocks__/ClientAuthentication"; +import { Session } from "./Session"; +import { mockSessionInfoManager } from "./sessionInfo/__mocks__/SessionInfoManager"; +import { KEY_REGISTERED_SESSIONS } from "./constant"; +import { + clearSessionFromStorageAll, + getSessionFromStorage, + getSessionIdFromStorageAll, +} from "./multiSession"; + +jest.mock("cross-fetch"); +jest.mock("./dependencies"); + +describe("Session", () => { + describe("constructor", () => { + it("accepts an empty config", async () => { + const mySession = new Session({}); + expect(mySession.info.isLoggedIn).toBe(false); + expect(mySession.info.sessionId).toBeDefined(); + }); + + it("accepts no config", async () => { + const mySession = new Session(); + expect(mySession.info.isLoggedIn).toBe(false); + expect(mySession.info.sessionId).toBeDefined(); + }); + + it("does not generate a session ID if one is provided", () => { + const mySession = new Session({}, "mySession"); + expect(mySession.info.sessionId).toBe("mySession"); + }); + + it("accepts legacy input storage", async () => { + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest.fn(); + const insecureStorage = mockStorage({}); + const secureStorage = mockStorage({}); + + const mySession = new Session({ + insecureStorage, + secureStorage, + }); + expect(mySession).toBeDefined(); + expect( + dependencies.getClientAuthenticationWithDependencies + ).toHaveBeenCalledWith({ + secureStorage, + insecureStorage, + }); + }); + + it("accepts input storage", async () => { + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest.fn(); + const storage = mockStorage({}); + const mySession = new Session({ + storage, + }); + expect(mySession).toBeDefined(); + expect( + dependencies.getClientAuthenticationWithDependencies + ).toHaveBeenCalledWith({ + secureStorage: storage, + insecureStorage: storage, + }); + }); + + it("ignores legacy input storage if new input storage is specified", async () => { + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest.fn(); + const insecureStorage = mockStorage({}); + const secureStorage = mockStorage({}); + const storage = mockStorage({}); + const mySession = new Session({ + insecureStorage, + secureStorage, + storage, + }); + expect(mySession).toBeDefined(); + expect( + dependencies.getClientAuthenticationWithDependencies + ).toHaveBeenCalledWith({ + secureStorage: storage, + insecureStorage: storage, + }); + }); + + it("accepts session info", () => { + const mySession = new Session({ + sessionInfo: { + sessionId: "mySession", + isLoggedIn: false, + webId: "https://some.webid", + }, + }); + expect(mySession.info.isLoggedIn).toBe(false); + expect(mySession.info.sessionId).toBe("mySession"); + expect(mySession.info.webId).toBe("https://some.webid"); + }); + + it("accepts legacy token rotation callback", () => { + const legacyTokenRotationCallback = jest.fn(); + const mySession = new Session({ + onNewRefreshToken: legacyTokenRotationCallback, + }); + mySession.emit(EVENTS.NEW_REFRESH_TOKEN, "some refresh token"); + expect(legacyTokenRotationCallback).toHaveBeenCalledWith( + "some refresh token" + ); + }); + + it("listens on the timeout event", () => { + const mySession = new Session(); + mySession.emit(EVENTS.TIMEOUT_SET, 0); + expect( + (mySession as unknown as { lastTimeoutHandle: number }) + .lastTimeoutHandle + ).toBe(0); + }); + + it("logs the session out on error", async () => { + const mySession = new Session({ + clientAuthentication: mockClientAuthentication(), + }); + // Spy on the private session logout + const spiedLogout = jest.spyOn( + mySession as unknown as { internalLogout: () => Promise }, + "internalLogout" + ); + const logoutEventcallback = jest.fn(); + mySession.onLogout(logoutEventcallback); + mySession.emit(EVENTS.ERROR); + // The internal logout should have been called... + expect(spiedLogout).toHaveBeenCalled(); + // ... but the user-initiated logout signal should not have been sent + expect(logoutEventcallback).not.toHaveBeenCalled(); + }); + + it("logs the session out on expiration", async () => { + const mySession = new Session({ + clientAuthentication: mockClientAuthentication(), + }); + // Spy on the private session logout + const spiedLogout = jest.spyOn( + mySession as unknown as { internalLogout: () => Promise }, + "internalLogout" + ); + const logoutEventcallback = jest.fn(); + mySession.onLogout(logoutEventcallback); + mySession.emit(EVENTS.SESSION_EXPIRED); + // The internal logout should have been called... + expect(spiedLogout).toHaveBeenCalled(); + // ... but the user-initiated logout signal should not have been sent + expect(logoutEventcallback).not.toHaveBeenCalled(); + }); + }); + + describe("login", () => { + it("wraps up ClientAuthentication login", async () => { + const clientAuthentication = mockClientAuthentication(); + const clientAuthnLogin = jest.spyOn(clientAuthentication, "login"); + const mySession = new Session({ clientAuthentication }); + await mySession.login({}); + expect(clientAuthnLogin).toHaveBeenCalled(); + }); + + it("updates the session info with the login return value", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.login = jest + .fn() + .mockResolvedValueOnce({ + isLoggedIn: true, + sessionId: "mySession", + webId: "https://my.webid/", + }); + const mySession = new Session({ clientAuthentication }); + await mySession.login({}); + expect(mySession.info.isLoggedIn).toBe(true); + expect(mySession.info.sessionId).toBe("mySession"); + expect(mySession.info.webId).toBe("https://my.webid/"); + }); + }); + + describe("logout", () => { + it("wraps up ClientAuthentication logout", async () => { + const clientAuthentication = mockClientAuthentication(); + const clientAuthnLogout = jest.spyOn(clientAuthentication, "logout"); + const mySession = new Session({ clientAuthentication }); + await mySession.logout(); + expect(clientAuthnLogout).toHaveBeenCalled(); + }); + + it("clears the timeouts", async () => { + const mySession = new Session({ + clientAuthentication: mockClientAuthentication(), + }); + ( + mySession as unknown as { lastTimeoutHandle: number } + ).lastTimeoutHandle = 12345; + const spiedClearTimeout = jest.spyOn(global, "clearTimeout"); + await mySession.logout(); + expect(spiedClearTimeout).toHaveBeenCalledWith(12345); + }); + }); + + describe("fetch", () => { + it("wraps up ClientAuthentication fetch if logged in", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.login = jest + .fn() + .mockResolvedValueOnce({ + isLoggedIn: true, + sessionId: "mySession", + }); + clientAuthentication.fetch = jest + .fn() + .mockResolvedValueOnce({} as any); + const mySession = new Session({ clientAuthentication }); + await mySession.login({}); + await mySession.fetch("https://some.url"); + expect(clientAuthentication.fetch).toHaveBeenCalled(); + }); + + it("defaults to non-authenticated fetch if not logged in", async () => { + const clientAuthentication = mockClientAuthentication(); + const mockedFetch = jest.requireMock("cross-fetch"); + const mySession = new Session({ clientAuthentication }); + await mySession.fetch("https://some.url"); + expect(mockedFetch).toHaveBeenCalled(); + }); + }); + + describe("handleincomingRedirect", () => { + it("wraps up ClientAuthentication handleIncomingRedirect", async () => { + const clientAuthentication = mockClientAuthentication(); + const clientAuthnHandle = jest.spyOn( + clientAuthentication, + "handleIncomingRedirect" + ); + const mySession = new Session({ clientAuthentication }); + await mySession.handleIncomingRedirect("https://some.url"); + expect(clientAuthnHandle).toHaveBeenCalled(); + }); + + it("updates the session's info if relevant", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.handleIncomingRedirect = jest.fn( + async (_url: string) => { + return { + isLoggedIn: true, + sessionId: "a session ID", + webId: "https://some.webid#them", + }; + } + ); + const mySession = new Session({ clientAuthentication }); + expect(mySession.info.isLoggedIn).toBe(false); + await mySession.handleIncomingRedirect("https://some.url"); + expect(mySession.info.isLoggedIn).toBe(true); + expect(mySession.info.sessionId).toBe("a session ID"); + expect(mySession.info.webId).toBe("https://some.webid#them"); + }); + + it("directly returns the session's info if already logged in", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.handleIncomingRedirect = jest.fn( + async (_url: string) => { + return { + isLoggedIn: true, + sessionId: "a session ID", + webId: "https://some.webid#them", + }; + } + ); + const mySession = new Session({ clientAuthentication }); + await mySession.handleIncomingRedirect("https://some.url"); + expect(mySession.info.isLoggedIn).toBe(true); + await mySession.handleIncomingRedirect("https://some.url"); + // The second request should not hit the wrapped function + expect(clientAuthentication.handleIncomingRedirect).toHaveBeenCalledTimes( + 1 + ); + }); + + it("leaves the session's info unchanged if no session is obtained after redirect", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.handleIncomingRedirect = jest.fn( + async (_url: string) => undefined + ); + const mySession = new Session({ clientAuthentication }, "mySession"); + await mySession.handleIncomingRedirect("https://some.url"); + expect(mySession.info.isLoggedIn).toBe(false); + expect(mySession.info.sessionId).toBe("mySession"); + }); + + function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + + it("prevents from hitting the token endpoint twice with the same auth code", async () => { + const clientAuthentication = mockClientAuthentication(); + const obtainedSession: ISessionInfo = { + isLoggedIn: true, + sessionId: "mySession", + }; + let secondRequestIssued = false; + const blockingRequest = async (): Promise => { + while (!secondRequestIssued) { + // eslint-disable-next-line no-await-in-loop + await sleep(100); + } + return obtainedSession; + }; + // The ClientAuthn's handleIncomingRedirect will only return when the + // second Session's handleIncomingRedirect has been called. + clientAuthentication.handleIncomingRedirect = jest.fn( + async (_url: string) => blockingRequest() + ); + const mySession = new Session({ clientAuthentication }); + const firstTokenRequest = mySession.handleIncomingRedirect( + "https://my.app/?code=someCode&state=arizona" + ); + const secondTokenRequest = mySession.handleIncomingRedirect( + "https://my.app/?code=someCode&state=arizona" + ); + secondRequestIssued = true; + const tokenRequests = await Promise.all([ + firstTokenRequest, + secondTokenRequest, + ]); + // One of the two token requests should not have reached the token endpoint + // because the other was pending. + expect(tokenRequests).toContain(undefined); + }); + }); + + describe("onLogin", () => { + it("calls the registered callback on login", async () => { + const myCallback = jest.fn(); + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.handleIncomingRedirect = jest.fn( + async (_url: string) => { + return { + isLoggedIn: true, + sessionId: "a session ID", + webId: "https://some.webid#them", + }; + } + ); + const mySession = new Session({ clientAuthentication }); + mySession.onLogin(myCallback); + await mySession.handleIncomingRedirect("https://some.url"); + expect(myCallback).toHaveBeenCalled(); + }); + + it("does not call the registered callback if login isn't successful", async () => { + const failCallback = (): void => { + throw new Error( + "Should *NOT* call callback - this means test has failed!" + ); + }; + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.handleIncomingRedirect = jest.fn( + async (_url: string) => { + return { + isLoggedIn: false, + sessionId: "a session ID", + webId: "https://some.webid#them", + }; + } + ); + const mySession = new Session({ clientAuthentication }); + mySession.onLogin(failCallback); + await expect( + mySession.handleIncomingRedirect("https://some.url") + ).resolves.not.toThrow(); + }); + + it("sets the appropriate information before calling the callback", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.handleIncomingRedirect = jest.fn( + async (_url: string) => { + return { + isLoggedIn: true, + sessionId: "a session ID", + webId: "https://some.webid#them", + }; + } + ); + const mySession = new Session({ clientAuthentication }); + const myCallback = jest.fn((): void => { + expect(mySession.info.webId).toBe("https://some.webid#them"); + }); + mySession.onLogin(myCallback); + await mySession.handleIncomingRedirect("https://some.url"); + expect(myCallback).toHaveBeenCalled(); + // Verify that the conditional assertion has been called + expect.assertions(2); + }); + }); + + describe("onLogout", () => { + it("calls the registered callback on logout", async () => { + const myCallback = jest.fn(); + const mySession = new Session({ + clientAuthentication: mockClientAuthentication(), + }); + mySession.onLogout(myCallback); + await mySession.logout(); + expect(myCallback).toHaveBeenCalled(); + }); + }); + + describe("onNewRefreshToken", () => { + it("calls the registered callback on the newREfreshToken event", async () => { + const myCallback = jest.fn(); + const mySession = new Session(); + mySession.onNewRefreshToken(myCallback); + mySession.emit("newRefreshToken", "some new refresh token"); + expect(myCallback).toHaveBeenCalledWith("some new refresh token"); + }); + }); +}); + +describe("getSessionFromStorage", () => { + it("returns a logged in Session if a refresh token is available in storage", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.getSessionInfo = jest + .fn() + .mockResolvedValueOnce({ + webId: "https://my.webid", + isLoggedIn: true, + refreshToken: "some token", + issuer: "https://my.idp", + sessionId: "mySession", + }); + clientAuthentication.login = jest + .fn() + .mockResolvedValueOnce({ + webId: "https://my.webid", + isLoggedIn: true, + sessionId: "mySession", + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + const mySession = await getSessionFromStorage("mySession", mockStorage({})); + expect(mySession?.info).toStrictEqual({ + webId: "https://my.webid", + isLoggedIn: true, + sessionId: "mySession", + }); + }); + + it("returns a logged out Session if no refresh token is available", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.getSessionInfo = jest + .fn() + .mockResolvedValueOnce({ + webId: "https://my.webid", + isLoggedIn: true, + issuer: "https://my.idp", + sessionId: "mySession", + }); + clientAuthentication.logout = jest + .fn() + .mockResolvedValueOnce(); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + const mySession = await getSessionFromStorage("mySession", mockStorage({})); + expect(mySession?.info).toStrictEqual({ + isLoggedIn: false, + sessionId: "mySession", + webId: "https://my.webid", + }); + }); + + it("returns undefined if no session id matches in storage", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.getSessionInfo = jest + .fn() + .mockResolvedValueOnce(undefined); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + const mySession = await getSessionFromStorage("mySession", mockStorage({})); + expect(mySession?.info).toBeUndefined(); + }); + + it("falls back to the environment storage if none is specified", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.getSessionInfo = jest + .fn() + .mockResolvedValueOnce(undefined); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + await getSessionFromStorage("mySession"); + const mockDefaultStorage = new InMemoryStorage(); + expect( + dependencies.getClientAuthenticationWithDependencies + ).toHaveBeenCalledWith({ + insecureStorage: mockDefaultStorage, + secureStorage: mockDefaultStorage, + }); + }); +}); + +describe("getStoredSessionIdAll", () => { + it("returns all the session IDs available in storage", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify([ + "a session", + "another session", + ]), + }); + + const clientAuthentication = mockCustomClientAuthentication({ + sessionInfoManager: mockSessionInfoManager(storage), + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + const sessions = await getSessionIdFromStorageAll(mockStorage({})); + expect(sessions).toStrictEqual(["a session", "another session"]); + }); + + it("falls back to the environment storage if none is specified", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify([ + "a session", + "another session", + ]), + }); + + const clientAuthentication = mockCustomClientAuthentication({ + sessionInfoManager: mockSessionInfoManager(storage), + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + await getSessionIdFromStorageAll(); + const mockDefaultStorage = new InMemoryStorage(); + expect( + dependencies.getClientAuthenticationWithDependencies + ).toHaveBeenCalledWith({ + insecureStorage: mockDefaultStorage, + secureStorage: mockDefaultStorage, + }); + }); +}); + +describe("clearSessionAll", () => { + it("clears all the sessions in storage", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify([ + "a session", + "another session", + ]), + }); + + const clientAuthentication = mockCustomClientAuthentication({ + sessionInfoManager: mockSessionInfoManager(storage), + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + await clearSessionFromStorageAll(storage); + await expect(storage.get(KEY_REGISTERED_SESSIONS)).resolves.toStrictEqual( + JSON.stringify([]) + ); + }); + + it("falls back to the environment storage if none is specified", async () => { + const storage = mockStorageUtility({}); + + const clientAuthentication = mockCustomClientAuthentication({ + sessionInfoManager: mockSessionInfoManager(storage), + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + await clearSessionFromStorageAll(); + const mockDefaultStorage = new InMemoryStorage(); + expect( + dependencies.getClientAuthenticationWithDependencies + ).toHaveBeenCalledWith({ + insecureStorage: mockDefaultStorage, + secureStorage: mockDefaultStorage, + }); + }); +}); diff --git a/packages/node/src/Session.ts b/packages/node/src/Session.ts new file mode 100644 index 0000000..81aa83e --- /dev/null +++ b/packages/node/src/Session.ts @@ -0,0 +1,285 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + */ +import { EventEmitter } from "events"; +import { + ILoginInputOptions, + InMemoryStorage, + ISessionInfo, + IStorage, + EVENTS, +} from "@inrupt/solid-client-authn-core"; +import { v4 } from "uuid"; +// eslint-disable-next-line no-shadow +import { fetch } from "cross-fetch"; +import ClientAuthentication from "./ClientAuthentication"; +import { getClientAuthenticationWithDependencies } from "./dependencies"; + +export interface ISessionOptions { + /** + * A private storage, unreachable to other scripts on the page. Typically in-memory. + * This is deprecated in the NodeJS environment, since there is no issue getting + * a storage both private and persistent. If both `secureStorage` and its intended + * replacement `storage` are set, `secureStorage` will be ignored. + * + * @deprecated + */ + secureStorage: IStorage; + /** + * A storage where non-sensitive information may be stored, potentially longer-lived + * than the secure storage. This is deprecated in the NodeJS environment, since there + * is no issue getting a storage both private and persistent. If both `insecureStorage` + * and its intended replacement `storage` are set, `insecureStorage` will be ignored. + * + * @deprecated + */ + insecureStorage: IStorage; + /** + * A private storage where sensitive information may be stored, such as refresh + * tokens. The `storage` option aims at eventually replacing the legacy `secureStorage` + * and `insecureStorage`, which + * @since X.Y.Z + */ + storage: IStorage; + /** + * Details about the current session + */ + sessionInfo: ISessionInfo; + /** + * An instance of the library core. Typically obtained using `getClientAuthenticationWithDependencies`. + */ + clientAuthentication: ClientAuthentication; + /** + * A callback that gets invoked whenever a new refresh token is obtained. + * @deprecated Prefer calling Session::onNewRefreshToken instead. + */ + onNewRefreshToken?: (newToken: string) => unknown; +} + +/** + * If no external storage is provided, this storage gets used. + */ +export const defaultStorage = new InMemoryStorage(); + +/** + * A {@link Session} object represents a user's session on an application. The session holds state, as it stores information enabling acces to private resources after login for instance. + */ +export class Session extends EventEmitter { + /** + * Information regarding the current session. + */ + public readonly info: ISessionInfo; + + private clientAuthentication: ClientAuthentication; + + private tokenRequestInProgress = false; + + private lastTimeoutHandle = 0; + + /** + * Session object constructor. Typically called as follows: + * + * ```typescript + * const session = new Session( + * { + * clientAuthentication: getClientAuthenticationWithDependencies({}) + * }, + * "mySession" + * ); + * ``` + * @param sessionOptions The options enabling the correct instantiation of + * the session. Either both storages or clientAuthentication are required. For + * more information, see {@link ISessionOptions}. + * @param sessionId A string uniquely identifying the session. + * + */ + constructor( + sessionOptions: Partial = {}, + sessionId: string | undefined = undefined + ) { + super(); + if (sessionOptions.clientAuthentication) { + this.clientAuthentication = sessionOptions.clientAuthentication; + } else if (sessionOptions.storage) { + this.clientAuthentication = getClientAuthenticationWithDependencies({ + secureStorage: sessionOptions.storage, + insecureStorage: sessionOptions.storage, + }); + } else if (sessionOptions.secureStorage && sessionOptions.insecureStorage) { + this.clientAuthentication = getClientAuthenticationWithDependencies({ + secureStorage: sessionOptions.secureStorage, + insecureStorage: sessionOptions.insecureStorage, + }); + } else { + this.clientAuthentication = getClientAuthenticationWithDependencies({ + secureStorage: defaultStorage, + insecureStorage: defaultStorage, + }); + } + + if (sessionOptions.sessionInfo) { + this.info = { + sessionId: sessionOptions.sessionInfo.sessionId, + isLoggedIn: false, + webId: sessionOptions.sessionInfo.webId, + }; + } else { + this.info = { + sessionId: sessionId ?? v4(), + isLoggedIn: false, + }; + } + if (sessionOptions.onNewRefreshToken !== undefined) { + this.onNewRefreshToken(sessionOptions.onNewRefreshToken); + } + // Keeps track of the latest timeout handle in order to clean up on logout + // and not leave open timeouts. + this.on(EVENTS.TIMEOUT_SET, (timeoutHandle: number) => { + this.lastTimeoutHandle = timeoutHandle; + }); + + this.on(EVENTS.ERROR, () => this.internalLogout(false)); + this.on(EVENTS.SESSION_EXPIRED, () => this.internalLogout(false)); + } + + /** + * Triggers the login process. Note that this method will redirect the user away from your app. + * + * @param options Parameter to customize the login behaviour. In particular, two options are mandatory: `options.oidcIssuer`, the user's identity provider, and `options.redirectUrl`, the URL to which the user will be redirected after logging in their identity provider. + * @returns This method should redirect the user away from the app: it does not return anything. The login process is completed by {@linkcode handleIncomingRedirect}. + */ + // Define these functions as properties so that they don't get accidentally re-bound. + // Isn't Javascript fun? + login = async (options: ILoginInputOptions): Promise => { + const loginInfo = await this.clientAuthentication.login( + this.info.sessionId, + { + ...options, + }, + this + ); + if (loginInfo !== undefined) { + this.info.isLoggedIn = loginInfo.isLoggedIn; + this.info.sessionId = loginInfo.sessionId; + this.info.webId = loginInfo.webId; + } + }; + + /** + * Fetches data using available login information. If the user is not logged in, this will behave as a regular `fetch`. The signature of this method is identical to the [canonical `fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). + * + * @param url The URL from which data should be fetched. + * @param init Optional parameters customizing the request, by specifying an HTTP method, headers, a body, etc. Follows the [WHATWG Fetch Standard](https://fetch.spec.whatwg.org/). + */ + fetch: typeof fetch = async (url, init) => { + if (!this.info.isLoggedIn) { + // TODO: why does this.clientAuthentication.fetch return throws + // ""'fetch' called on an object that does not implement interface Window" + // when unauthenticated ? + return fetch(url, init); + } + return this.clientAuthentication.fetch(url, init); + }; + + /** + * Logs the user out of the application. This does not log the user out of the identity provider, and should not redirect the user away. + */ + logout = async (): Promise => this.internalLogout(true); + + private internalLogout = async (emitEvent: boolean): Promise => { + await this.clientAuthentication.logout(this.info.sessionId); + // Clears the timeouts on logout so that Node does not hang. + clearTimeout(this.lastTimeoutHandle); + this.info.isLoggedIn = false; + if (emitEvent) { + this.emit(EVENTS.LOGOUT); + } + }; + + /** + * Completes the login process by processing the information provided by the identity provider through redirect. + * + * @param url The URL of the page handling the redirect, including the query parameters — these contain the information to process the login. + */ + handleIncomingRedirect = async ( + url: string + ): Promise => { + let sessionInfo; + + if (this.info.isLoggedIn) { + sessionInfo = this.info; + } else if (this.tokenRequestInProgress) { + // TODO: PMcB55: Add this logging once we start using LogLevel. + // Log the interesting fact that (we think!) we're already requesting + // the token... + // log.debug(`Handle incoming request called, but we're already requesting our token`); + } else { + try { + this.tokenRequestInProgress = true; + sessionInfo = await this.clientAuthentication.handleIncomingRedirect( + url, + this + ); + + if (sessionInfo) { + this.info.isLoggedIn = sessionInfo.isLoggedIn; + this.info.webId = sessionInfo.webId; + this.info.sessionId = sessionInfo.sessionId; + if (sessionInfo.isLoggedIn) { + // The login event can only be triggered **after** the user has been + // redirected from the IdP with access and ID tokens. + this.emit(EVENTS.LOGIN); + } + } + } finally { + this.tokenRequestInProgress = false; + } + } + return sessionInfo; + }; + + /** + * Register a callback function to be called when a user completes login. + * + * The callback is called when {@link handleIncomingRedirect} completes successfully. + * + * @param callback The function called when a user completes login. + */ + onLogin(callback: () => unknown): void { + this.on(EVENTS.LOGIN, callback); + } + + /** + * Register a callback function to be called when a user logs out: + * + * @param callback The function called when a user completes logout. + */ + onLogout(callback: () => unknown): void { + this.on(EVENTS.LOGOUT, callback); + } + + onNewRefreshToken(callback: (newToken: string) => unknown): void { + this.on(EVENTS.NEW_REFRESH_TOKEN, callback); + } +} diff --git a/packages/node/src/__mocks__/ClientAuthentication.ts b/packages/node/src/__mocks__/ClientAuthentication.ts new file mode 100644 index 0000000..1447415 --- /dev/null +++ b/packages/node/src/__mocks__/ClientAuthentication.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + ILoginHandler, + ILogoutHandler, + IIncomingRedirectHandler, + ISessionInfoManager, + IStorageUtility, +} from "@inrupt/solid-client-authn-core"; +import { + mockStorageUtility, + mockIncomingRedirectHandler, +} from "@inrupt/solid-client-authn-core/mocks"; +import ClientAuthentication from "../ClientAuthentication"; +import { mockLoginHandler } from "../login/__mocks__/LoginHandler"; +import { mockLogoutHandler } from "../logout/__mocks__/LogoutHandler"; +import { mockSessionInfoManager } from "../sessionInfo/__mocks__/SessionInfoManager"; + +type CustomMocks = { + storage: IStorageUtility; + sessionInfoManager: ISessionInfoManager; + loginHandler: ILoginHandler; + redirectHandler: IIncomingRedirectHandler; + logoutHandler: ILogoutHandler; +}; + +export const mockCustomClientAuthentication = ( + mocks: Partial +): ClientAuthentication => { + const storage = mocks.storage ?? mockStorageUtility({}); + return new ClientAuthentication( + mocks.loginHandler ?? mockLoginHandler(), + mocks.redirectHandler ?? mockIncomingRedirectHandler(), + mocks.logoutHandler ?? mockLogoutHandler(storage), + mocks.sessionInfoManager ?? mockSessionInfoManager(storage) + ); +}; + +export const mockClientAuthentication = (): ClientAuthentication => { + return mockCustomClientAuthentication({}); +}; diff --git a/packages/node/src/authenticatedFetch/headers/HeadersUtils.spec.ts b/packages/node/src/authenticatedFetch/headers/HeadersUtils.spec.ts new file mode 100644 index 0000000..84f6d39 --- /dev/null +++ b/packages/node/src/authenticatedFetch/headers/HeadersUtils.spec.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { describe, it, expect } from "@jest/globals"; + +import { Headers as NodeHeaders } from "cross-fetch"; +import { flattenHeaders } from "./HeadersUtils"; + +describe("Headers interoperability function", () => { + it("returns empty object if nothing passed in", () => { + expect(flattenHeaders(undefined)).toStrictEqual({}); + }); + + it("returns Record if Record passed in", () => { + const flatHeaders: Record = { + test: "value", + }; + const result: Record = flattenHeaders(flatHeaders); + expect(result.test).toBe("value"); + }); + + it("transforms an incoming Headers object into a flat headers structure", () => { + const myHeaders = new NodeHeaders(); + myHeaders.append("accept", "application/json"); + myHeaders.append("content-type", "text/turtle"); + // The following needs to be ignored because `node-fetch::Headers` and + // `lib.dom.d.ts::Headers` don't align. It doesn't break the way we + // use them currently, but it's something that must be cleaned up + // at some point. + // eslint-disable-next-line + // @ts-ignore + const flatHeaders = flattenHeaders(myHeaders); + expect(Object.entries(flatHeaders)).toEqual([ + ["accept", "application/json"], + ["content-type", "text/turtle"], + ]); + }); + + it("supports non-iterable headers if they provide a reasonably standard way of browsing them", () => { + const myHeaders: any = {}; + myHeaders.forEach = ( + callback: (value: string, key: string) => void + ): void => { + callback("application/json", "accept"); + callback("text/turtle", "content-type"); + }; + const flatHeaders = flattenHeaders(myHeaders); + expect(Object.entries(flatHeaders)).toEqual([ + ["accept", "application/json"], + ["content-type", "text/turtle"], + ]); + }); +}); diff --git a/packages/node/src/authenticatedFetch/headers/HeadersUtils.ts b/packages/node/src/authenticatedFetch/headers/HeadersUtils.ts new file mode 100644 index 0000000..54ce2cb --- /dev/null +++ b/packages/node/src/authenticatedFetch/headers/HeadersUtils.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * @hidden + * This function feels unnecessarily complicated, but is required in order to + * have Headers according to type definitions in both Node and browser environments. + * This might require a fix upstream to be cleaned up. + * + * @param headersToFlatten A structure containing headers potentially in several formats + */ +export function flattenHeaders( + headersToFlatten: Headers | Record | string[][] | undefined +): Record { + if (typeof headersToFlatten === "undefined") { + return {}; + } + const flatHeaders: Record = {}; + + // If the headers are already a Record, + // they can directly be returned. + if (typeof headersToFlatten.forEach !== "function") { + // FIXME: This will break when passed a string[][] + // (as shown when the type assertions are removed). + return headersToFlatten as Record; + } + + (headersToFlatten as Headers).forEach((value: string, key: string) => { + flatHeaders[key] = value; + }); + return flatHeaders; +} diff --git a/packages/node/src/constant.ts b/packages/node/src/constant.ts new file mode 100644 index 0000000..b20a815 --- /dev/null +++ b/packages/node/src/constant.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { SOLID_CLIENT_AUTHN_KEY_PREFIX } from "@inrupt/solid-client-authn-core"; + +export const KEY_REGISTERED_SESSIONS = `${SOLID_CLIENT_AUTHN_KEY_PREFIX}registeredSessions`; diff --git a/packages/node/src/dependencies.spec.ts b/packages/node/src/dependencies.spec.ts new file mode 100644 index 0000000..04042e7 --- /dev/null +++ b/packages/node/src/dependencies.spec.ts @@ -0,0 +1,203 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { mockStorage } from "@inrupt/solid-client-authn-core"; +import type * as SolidClientAuthnCore from "@inrupt/solid-client-authn-core"; +import { EventEmitter } from "events"; +import { + buildLoginHandler, + buildRedirectHandler, + getClientAuthenticationWithDependencies, +} from "./dependencies"; +import ClientAuthentication from "./ClientAuthentication"; +import StorageUtilityNode from "./storage/StorageUtility"; +import { + mockDefaultIssuerConfig, + mockIssuerConfigFetcher, +} from "./login/oidc/__mocks__/IssuerConfigFetcher"; +import { mockDefaultClientRegistrar } from "./login/oidc/__mocks__/ClientRegistrar"; +import { SessionInfoManager } from "./sessionInfo/SessionInfoManager"; +import { mockDefaultTokenRefresher } from "./login/oidc/refresh/__mocks__/TokenRefresher"; +import GeneralLogoutHandler from "./logout/GeneralLogoutHandler"; +import RefreshTokenOidcHandler from "./login/oidc/oidcHandlers/RefreshTokenOidcHandler"; +import ClientCredentialsOidcHandler from "./login/oidc/oidcHandlers/ClientCredentialsOidcHandler"; +import AuthorizationCodeWithPkceOidcHandler from "./login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler"; + +jest.mock("openid-client"); +jest.mock("@inrupt/solid-client-authn-core", () => { + const actualCoreModule = jest.requireActual( + "@inrupt/solid-client-authn-core" + ) as typeof SolidClientAuthnCore; + return { + ...actualCoreModule, + // This works around the network lookup to the JWKS in order to validate the ID token. + getWebidFromTokenPayload: jest.fn(() => + Promise.resolve("https://my.webid/") + ), + }; +}); + +const setupOidcClientMock = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { Issuer } = jest.requireMock("openid-client") as any; + function clientConstructor() { + // this is untyped, which makes TS complain + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.grant = jest.fn().mockResolvedValueOnce({ + access_token: "some token", + id_token: + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJodHRwczovL215LndlYmlkIiwiaXNzIjoiaHR0cHM6Ly9teS5pZHAvIiwiYXVkIjoiaHR0cHM6Ly9yZXNvdXJjZS5leGFtcGxlLm9yZyIsImV4cCI6MTY2MjI2NjIxNiwiaWF0IjoxNDYyMjY2MjE2fQ.IwumuwJtQw5kUBMMHAaDPJBppfBpRHbiXZw_HlKe6GNVUWUlyQRYV7W7r9OQtHmMsi6GVwOckelA3ErmhrTGVw", + token_type: "Bearer", + expired: () => false, + claims: jest.fn(), + } as any); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.authorizationUrl = jest + .fn() + .mockReturnValue("https://some.issuer/uri_parameters_go_there/"); + } + const mockedIssuer = { + metadata: mockDefaultIssuerConfig(), + Client: clientConstructor, + }; + Issuer.mockReturnValueOnce(mockedIssuer); +}; + +describe("dependencies.node", () => { + it("performs dependency injection in a node environment", () => { + const clientAuthn = getClientAuthenticationWithDependencies({}); + expect(clientAuthn).toBeInstanceOf(ClientAuthentication); + }); +}); + +describe("resolution order", () => { + const mockClientAuthentication = () => { + const storageUtility = new StorageUtilityNode( + mockStorage({}), + mockStorage({}) + ); + + const issuerConfigFetcher = mockIssuerConfigFetcher( + mockDefaultIssuerConfig() + ); + const clientRegistrar = mockDefaultClientRegistrar(); + + const sessionInfoManager = new SessionInfoManager(storageUtility); + + const tokenRefresher = mockDefaultTokenRefresher(); + + const loginHandler = buildLoginHandler( + storageUtility, + tokenRefresher, + issuerConfigFetcher, + clientRegistrar + ); + + const redirectHandler = buildRedirectHandler( + storageUtility, + sessionInfoManager, + issuerConfigFetcher, + clientRegistrar, + tokenRefresher + ); + + return new ClientAuthentication( + loginHandler, + redirectHandler, + new GeneralLogoutHandler(sessionInfoManager), + sessionInfoManager + ); + }; + + // FIXME There are unresolved async operations going on here + it.skip("calls the refresh token handler if a refresh token is present", async () => { + const clientAuthn = mockClientAuthentication(); + const handlerSelectSpy = jest.spyOn( + // The easiest way to test this is to look into the injected dependencies + // (which is why we look up private attributes). + (clientAuthn as any).loginHandler.oidcHandler, + "getProperHandler" + ); + await clientAuthn.login( + "someSession", + { + clientId: "some client ID", + clientSecret: "some client secret", + refreshToken: "some refresh token", + oidcIssuer: "https://some.issuer", + }, + new EventEmitter() + ); + await expect( + handlerSelectSpy.mock.results[0].value + ).resolves.toBeInstanceOf(RefreshTokenOidcHandler); + }); + + // FIXME There are unresolved async operations going on here + it.skip("calls the client credentials handler if client credentials are present, but no refresh token is provided", async () => { + setupOidcClientMock(); + const clientAuthn = mockClientAuthentication(); + const handlerSelectSpy = jest.spyOn( + // The easiest way to test this is to look into the injected dependencies + // (which is why we look up private attributes). + (clientAuthn as any).loginHandler.oidcHandler, + "getProperHandler" + ); + await clientAuthn.login( + "someSession", + { + clientId: "some client ID", + clientSecret: "some client secret", + oidcIssuer: "https://some.issuer", + }, + new EventEmitter() + ); + await expect( + handlerSelectSpy.mock.results[0].value + ).resolves.toBeInstanceOf(ClientCredentialsOidcHandler); + }); + + it("calls the auth code handler if no client secret is present", async () => { + setupOidcClientMock(); + const clientAuthn = mockClientAuthentication(); + const handlerSelectSpy = jest.spyOn( + // The easiest way to test this is to look into the injected dependencies + // (which is why we look up private attributes). + (clientAuthn as any).loginHandler.oidcHandler, + "getProperHandler" + ); + await clientAuthn.login( + "someSession", + { + clientId: "some client ID", + oidcIssuer: "https://some.issuer", + handleRedirect: jest.fn(), + }, + new EventEmitter() + ); + await expect( + handlerSelectSpy.mock.results[0].value + ).resolves.toBeInstanceOf(AuthorizationCodeWithPkceOidcHandler); + }); +}); diff --git a/packages/node/src/dependencies.ts b/packages/node/src/dependencies.ts new file mode 100644 index 0000000..57b1112 --- /dev/null +++ b/packages/node/src/dependencies.ts @@ -0,0 +1,147 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Top Level core document. Responsible for setting up the dependency graph + */ +import { + IStorage, + InMemoryStorage, + ITokenRefresher, + IIssuerConfigFetcher, + IClientRegistrar, + IStorageUtility, + ILoginHandler, + ISessionInfoManager, + IIncomingRedirectHandler, +} from "@inrupt/solid-client-authn-core"; +import StorageUtilityNode from "./storage/StorageUtility"; +import ClientAuthentication from "./ClientAuthentication"; +import OidcLoginHandler from "./login/oidc/OidcLoginHandler"; +import AggregateOidcHandler from "./login/oidc/AggregateOidcHandler"; +import AuthorizationCodeWithPkceOidcHandler from "./login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler"; +import RefreshTokenOidcHandler from "./login/oidc/oidcHandlers/RefreshTokenOidcHandler"; +import IssuerConfigFetcher from "./login/oidc/IssuerConfigFetcher"; +import GeneralLogoutHandler from "./logout/GeneralLogoutHandler"; +import { SessionInfoManager } from "./sessionInfo/SessionInfoManager"; +import { AuthCodeRedirectHandler } from "./login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler"; +import { FallbackRedirectHandler } from "./login/oidc/incomingRedirectHandler/FallbackRedirectHandler"; +import AggregateIncomingRedirectHandler from "./login/oidc/AggregateIncomingRedirectHandler"; +import Redirector from "./login/oidc/Redirector"; +import ClientRegistrar from "./login/oidc/ClientRegistrar"; +import TokenRefresher from "./login/oidc/refresh/TokenRefresher"; +import ClientCredentialsOidcHandler from "./login/oidc/oidcHandlers/ClientCredentialsOidcHandler"; + +export const buildLoginHandler = ( + storageUtility: IStorageUtility, + tokenRefresher: ITokenRefresher, + issuerConfigFetcher: IIssuerConfigFetcher, + clientRegistrar: IClientRegistrar +): ILoginHandler => { + return new OidcLoginHandler( + storageUtility, + new AggregateOidcHandler([ + new RefreshTokenOidcHandler(tokenRefresher, storageUtility), + new ClientCredentialsOidcHandler(tokenRefresher, storageUtility), + new AuthorizationCodeWithPkceOidcHandler( + storageUtility, + new Redirector() + ), + ]), + issuerConfigFetcher, + clientRegistrar + ); +}; + +export const buildRedirectHandler = ( + storageUtility: IStorageUtility, + sessionInfoManager: ISessionInfoManager, + issuerConfigFetcher: IIssuerConfigFetcher, + clientRegistrar: IClientRegistrar, + tokenRefresher: ITokenRefresher +): IIncomingRedirectHandler => { + return new AggregateIncomingRedirectHandler([ + new AuthCodeRedirectHandler( + storageUtility, + sessionInfoManager, + issuerConfigFetcher, + clientRegistrar, + tokenRefresher + ), + // This catch-all class will always be able to handle the + // redirect IRI, so it must be registered last. + new FallbackRedirectHandler(), + ]); +}; + +/** + * + * @param dependencies + * @deprecated This function will be removed from the external API in an upcoming release. + */ +export function getClientAuthenticationWithDependencies(dependencies: { + secureStorage?: IStorage; + insecureStorage?: IStorage; +}): ClientAuthentication { + const inMemoryStorage = new InMemoryStorage(); + const secureStorage = dependencies.secureStorage || inMemoryStorage; + const insecureStorage = dependencies.insecureStorage || inMemoryStorage; + + const storageUtility = new StorageUtilityNode(secureStorage, insecureStorage); + + const issuerConfigFetcher = new IssuerConfigFetcher(storageUtility); + const clientRegistrar = new ClientRegistrar(storageUtility); + + const sessionInfoManager = new SessionInfoManager(storageUtility); + + const tokenRefresher = new TokenRefresher( + storageUtility, + issuerConfigFetcher, + clientRegistrar + ); + + const loginHandler = buildLoginHandler( + storageUtility, + tokenRefresher, + issuerConfigFetcher, + clientRegistrar + ); + + const redirectHandler = buildRedirectHandler( + storageUtility, + sessionInfoManager, + issuerConfigFetcher, + clientRegistrar, + tokenRefresher + ); + + return new ClientAuthentication( + loginHandler, + redirectHandler, + new GeneralLogoutHandler(sessionInfoManager), + sessionInfoManager + ); +} diff --git a/packages/node/src/index.spec.ts b/packages/node/src/index.spec.ts new file mode 100644 index 0000000..b3322f8 --- /dev/null +++ b/packages/node/src/index.spec.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { it, expect } from "@jest/globals"; + +import { Session } from "./index"; + +it("exports the public API from the entrypoint", () => { + expect(Session).toBeDefined(); +}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts new file mode 100644 index 0000000..3cd9481 --- /dev/null +++ b/packages/node/src/index.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export { Session, ISessionOptions } from "./Session"; + +export { + getSessionFromStorage, + getSessionIdFromStorageAll, + clearSessionFromStorageAll, +} from "./multiSession"; + +// Re-export of types defined in the core module and produced/consumed by our API + +export { + ILoginInputOptions, + ISessionInfo, + IStorage, + NotImplementedError, + ConfigurationError, + InMemoryStorage, +} from "@inrupt/solid-client-authn-core"; diff --git a/packages/node/src/login/__mocks__/LoginHandler.ts b/packages/node/src/login/__mocks__/LoginHandler.ts new file mode 100644 index 0000000..db87fcb --- /dev/null +++ b/packages/node/src/login/__mocks__/LoginHandler.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + ILoginOptions, + ILoginHandler, + ISessionInfo, +} from "@inrupt/solid-client-authn-core"; +import { jest } from "@jest/globals"; + +export const LoginHandlerResponse: ISessionInfo = { + isLoggedIn: false, + sessionId: "global", +}; + +export const mockLoginHandler = (): ILoginHandler => { + return { + canHandle: jest.fn((_options: ILoginOptions) => Promise.resolve(true)), + handle: jest.fn((_options: ILoginOptions) => Promise.resolve(undefined)), + }; +}; diff --git a/packages/node/src/login/oidc/AggregateIncomingRedirectHandler.ts b/packages/node/src/login/oidc/AggregateIncomingRedirectHandler.ts new file mode 100644 index 0000000..a1173c1 --- /dev/null +++ b/packages/node/src/login/oidc/AggregateIncomingRedirectHandler.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Responsible for selecting the correct OidcHandler to handle the provided OIDC Options + */ +import { + IIncomingRedirectHandler, + ISessionInfo, + AggregateHandler, +} from "@inrupt/solid-client-authn-core"; +import { EventEmitter } from "events"; + +/** + * @hidden + */ +export default class AggregateIncomingRedirectHandler + extends AggregateHandler< + [string, EventEmitter], + ISessionInfo & { fetch: typeof fetch } + > + implements IIncomingRedirectHandler +{ + constructor(redirectHandlers: IIncomingRedirectHandler[]) { + super(redirectHandlers); + } +} diff --git a/packages/node/src/login/oidc/AggregateOidcHandler.spec.ts b/packages/node/src/login/oidc/AggregateOidcHandler.spec.ts new file mode 100644 index 0000000..6f61e0e --- /dev/null +++ b/packages/node/src/login/oidc/AggregateOidcHandler.spec.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + IOidcHandler, + AggregateHandler, +} from "@inrupt/solid-client-authn-core"; +import { jest, it, describe, expect } from "@jest/globals"; +import AggregateOidcHandler from "./AggregateOidcHandler"; + +jest.mock("@inrupt/solid-client-authn-core"); + +describe("AggregateOidcHandler", () => { + it("should pass injected handlers to its superclass", () => { + // We just test if the parent is called. + // eslint-disable-next-line no-new + new AggregateOidcHandler(["Some handler"] as unknown as IOidcHandler[]); + + expect((AggregateHandler as jest.Mock).mock.calls).toEqual([ + [["Some handler"]], + ]); + }); +}); diff --git a/packages/node/src/login/oidc/AggregateOidcHandler.ts b/packages/node/src/login/oidc/AggregateOidcHandler.ts new file mode 100644 index 0000000..adec632 --- /dev/null +++ b/packages/node/src/login/oidc/AggregateOidcHandler.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Responsible for selecting the correct OidcHandler to handle the provided OIDC Options + */ +import { + IOidcHandler, + IOidcOptions, + AggregateHandler, + LoginResult, +} from "@inrupt/solid-client-authn-core"; + +/** + * @hidden + */ +export default class AggregateOidcHandler + extends AggregateHandler<[IOidcOptions], LoginResult> + implements IOidcHandler +{ + constructor(oidcLoginHandlers: IOidcHandler[]) { + super(oidcLoginHandlers); + } +} diff --git a/packages/node/src/login/oidc/ClientRegistrar.spec.ts b/packages/node/src/login/oidc/ClientRegistrar.spec.ts new file mode 100644 index 0000000..846b322 --- /dev/null +++ b/packages/node/src/login/oidc/ClientRegistrar.spec.ts @@ -0,0 +1,378 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { mockStorageUtility } from "@inrupt/solid-client-authn-core"; +import ClientRegistrar from "./ClientRegistrar"; +import { + IssuerConfigFetcherFetchConfigResponse, + mockDefaultIssuerMetadata, + mockIssuerMetadata, +} from "./__mocks__/IssuerConfigFetcher"; +import { + mockClientConfig, + mockDefaultClientConfig, +} from "./__mocks__/ClientRegistrar"; + +jest.mock("openid-client"); + +/** + * Test for ClientRegistrar + */ +describe("ClientRegistrar", () => { + const defaultMocks = { + storage: mockStorageUtility({}), + }; + function getClientRegistrar( + mocks: Partial = defaultMocks + ): ClientRegistrar { + return new ClientRegistrar(mocks.storage ?? defaultMocks.storage); + } + + describe("getClient", () => { + it("fails if there is not registration endpoint", async () => { + // Sets up the mock-up for DCR + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuerConfig = mockIssuerMetadata({ + registration_endpoint: undefined, + }); + const mockedIssuer = { + metadata: mockedIssuerConfig, + }; + Issuer.mockReturnValue(mockedIssuer); + + // Run the test + const clientRegistrar = getClientRegistrar({ + storage: mockStorageUtility({}), + }); + await expect( + clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + IssuerConfigFetcherFetchConfigResponse + ) + ).rejects.toThrow( + "Dynamic client registration cannot be performed, because issuer does not have a registration endpoint" + ); + }); + + it("retrieves client information from storage if they are present", async () => { + const clientRegistrar = getClientRegistrar({ + storage: mockStorageUtility( + { + "solidClientAuthenticationUser:mySession": { + clientId: "an id", + clientSecret: "a secret", + clientName: "my client name", + idTokenSignedResponseAlg: "ES256", + }, + }, + false + ), + }); + const client = await clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + { + ...IssuerConfigFetcherFetchConfigResponse, + } + ); + expect(client.clientId).toBe("an id"); + expect(client.clientSecret).toBe("a secret"); + expect(client.clientName).toBe("my client name"); + expect(client.idTokenSignedResponseAlg).toBe("ES256"); + }); + + it("negotiates signing alg if not found in storage", async () => { + const clientRegistrar = getClientRegistrar({ + storage: mockStorageUtility( + { + "solidClientAuthenticationUser:mySession": { + clientId: "an id", + clientSecret: "a secret", + clientName: "my client name", + }, + }, + false + ), + }); + const client = await clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + { + ...IssuerConfigFetcherFetchConfigResponse, + } + ); + expect(client.idTokenSignedResponseAlg).toBe("ES256"); + }); + + it("properly performs dynamic registration and saves client information", async () => { + // Sets up the mock-up for DCR + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuer = { + metadata: mockDefaultIssuerMetadata(), + Client: { + register: (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockDefaultClientConfig(), + }), + }, + }; + Issuer.mockReturnValue(mockedIssuer); + const mockStorage = mockStorageUtility({}); + + // Run the test + const clientRegistrar = getClientRegistrar({ + storage: mockStorage, + }); + const client = await clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + { + ...IssuerConfigFetcherFetchConfigResponse, + } + ); + + // Check that the returned value is what we expect + expect(client.clientId).toEqual(mockDefaultClientConfig().client_id); + expect(client.clientSecret).toEqual( + mockDefaultClientConfig().client_secret + ); + expect(client.idTokenSignedResponseAlg).toEqual( + mockDefaultClientConfig().id_token_signed_response_alg + ); + + // Check that the client information have been saved in storage + await expect( + mockStorage.getForUser("mySession", "clientId") + ).resolves.toEqual(mockDefaultClientConfig().client_id); + await expect( + mockStorage.getForUser("mySession", "clientSecret") + ).resolves.toEqual(mockDefaultClientConfig().client_secret); + await expect( + mockStorage.getForUser("mySession", "idTokenSignedResponseAlg") + ).resolves.toEqual( + mockDefaultClientConfig().id_token_signed_response_alg + ); + }); + + it("throws if the issuer doesn't avertise for supported signing algorithms", async () => { + // Sets up the mock-up for DCR + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuer = { + metadata: mockDefaultIssuerMetadata(), + Client: { + register: (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockDefaultClientConfig(), + }), + }, + }; + Issuer.mockReturnValue(mockedIssuer); + const mockStorage = mockStorageUtility({}); + + // Run the test + const clientRegistrar = getClientRegistrar({ + storage: mockStorage, + }); + const issuerConfig = { ...IssuerConfigFetcherFetchConfigResponse }; + delete issuerConfig.idTokenSigningAlgValuesSupported; + await expect( + clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + issuerConfig + ) + ).rejects.toThrow( + "The OIDC issuer discovery profile is missing the 'id_token_signing_alg_values_supported' value, which is mandatory." + ); + }); + + it("throws if no signing algorithm supported by the issuer match the client preferences", async () => { + // Sets up the mock-up for DCR + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuer = { + metadata: mockDefaultIssuerMetadata(), + Client: { + register: (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockDefaultClientConfig(), + }), + }, + }; + Issuer.mockReturnValue(mockedIssuer); + const mockStorage = mockStorageUtility({}); + + // Run the test + const clientRegistrar = getClientRegistrar({ + storage: mockStorage, + }); + await expect( + clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + { + ...IssuerConfigFetcherFetchConfigResponse, + idTokenSigningAlgValuesSupported: ["Some_bogus_algorithm"], + } + ) + ).rejects.toThrow( + 'No signature algorithm match between ["Some_bogus_algorithm"] supported by the Identity Provider and ["ES256","RS256"] preferred by the client.' + ); + }); + + it("retrieves client information from storage after dynamic registration", async () => { + // Sets up the mock-up for DCR + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuer = { + metadata: mockDefaultIssuerMetadata(), + Client: { + register: (jest.fn() as any) + .mockResolvedValueOnce({ + metadata: mockDefaultClientConfig(), + }) + .mockRejectedValue("Cannot register more than once"), + }, + }; + Issuer.mockReturnValue(mockedIssuer); + const mockStorage = mockStorageUtility({}); + + // Run the test + const clientRegistrar = getClientRegistrar({ + storage: mockStorage, + }); + let client = await clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + { + ...IssuerConfigFetcherFetchConfigResponse, + } + ); + // Re-request for the client. If not returned from memory, the mock throws. + client = await clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + { + ...IssuerConfigFetcherFetchConfigResponse, + } + ); + + // Check that the returned value is what we expect + expect(client.clientId).toEqual(mockDefaultClientConfig().client_id); + }); + + it("saves the registered client information for a public client in storage", async () => { + // Sets up the mock-up for DCR + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedClientConfig = mockClientConfig({ + client_secret: undefined, + }); + const mockedIssuer = { + metadata: mockDefaultIssuerMetadata(), + Client: { + register: (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockedClientConfig, + }), + }, + }; + Issuer.mockReturnValue(mockedIssuer); + const mockStorage = mockStorageUtility({}); + + // Run the test + const clientRegistrar = getClientRegistrar({ + storage: mockStorage, + }); + await clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + { + ...IssuerConfigFetcherFetchConfigResponse, + } + ); + await expect( + mockStorage.getForUser("mySession", "clientId") + ).resolves.toEqual(mockDefaultClientConfig().client_id); + await expect( + mockStorage.getForUser("mySession", "clientSecret") + ).resolves.toBeUndefined(); + }); + + it("uses stores the signing algorithm preferred by the client when the registration didn't return the used algorithm", async () => { + // Sets up the mock-up for DCR + const { Issuer } = jest.requireMock("openid-client") as any; + const metadata = mockDefaultClientConfig(); + delete metadata.id_token_signed_response_alg; + const mockedIssuer = { + metadata: mockDefaultIssuerMetadata(), + Client: { + register: (jest.fn() as any).mockResolvedValueOnce({ + metadata, + }), + }, + }; + Issuer.mockReturnValue(mockedIssuer); + const mockStorage = mockStorageUtility({}); + + // Run the test + const clientRegistrar = getClientRegistrar({ + storage: mockStorage, + }); + const client = await clientRegistrar.getClient( + { + sessionId: "mySession", + redirectUrl: "https://example.com", + }, + { + ...IssuerConfigFetcherFetchConfigResponse, + } + ); + + // Check that the returned algorithm value is what we expect + expect(client.idTokenSignedResponseAlg).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + IssuerConfigFetcherFetchConfigResponse.idTokenSigningAlgValuesSupported![0] + ); + + // Check that the expected algorithm information have been saved in storage + await expect( + mockStorage.getForUser("mySession", "idTokenSignedResponseAlg") + ).resolves.toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + IssuerConfigFetcherFetchConfigResponse.idTokenSigningAlgValuesSupported![0] + ); + }); + }); +}); diff --git a/packages/node/src/login/oidc/ClientRegistrar.ts b/packages/node/src/login/oidc/ClientRegistrar.ts new file mode 100644 index 0000000..04cccd8 --- /dev/null +++ b/packages/node/src/login/oidc/ClientRegistrar.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { + IStorageUtility, + IClientRegistrar, + IIssuerConfig, + IClient, + IClientRegistrarOptions, + ConfigurationError, + determineSigningAlg, + PREFERRED_SIGNING_ALG, +} from "@inrupt/solid-client-authn-core"; +import { Client, Issuer } from "openid-client"; +import { configToIssuerMetadata } from "./IssuerConfigFetcher"; + +export function negotiateClientSigningAlg( + issuerConfig: IIssuerConfig, + clientPreference: string[] +): string { + if (!Array.isArray(issuerConfig.idTokenSigningAlgValuesSupported)) { + throw new Error( + "The OIDC issuer discovery profile is missing the 'id_token_signing_alg_values_supported' value, which is mandatory." + ); + } + + const signingAlg = determineSigningAlg( + issuerConfig.idTokenSigningAlgValuesSupported, + clientPreference + ); + + if (signingAlg === null) { + throw new Error( + `No signature algorithm match between ${JSON.stringify( + issuerConfig.idTokenSigningAlgValuesSupported + )} supported by the Identity Provider and ${JSON.stringify( + clientPreference + )} preferred by the client.` + ); + } + + return signingAlg; +} + +/** + * @hidden + */ +export default class ClientRegistrar implements IClientRegistrar { + constructor(private storageUtility: IStorageUtility) {} + + async getClient( + options: IClientRegistrarOptions, + issuerConfig: IIssuerConfig + ): Promise { + // If client secret and/or client id are stored in storage, use those. + const [ + storedClientId, + storedClientSecret, + storedClientName, + storedIdTokenSignedResponseAlg, + ] = await Promise.all([ + this.storageUtility.getForUser(options.sessionId, "clientId"), + this.storageUtility.getForUser(options.sessionId, "clientSecret"), + this.storageUtility.getForUser(options.sessionId, "clientName"), + this.storageUtility.getForUser( + options.sessionId, + "idTokenSignedResponseAlg" + ), + ]); + if (storedClientId) { + return { + clientId: storedClientId, + clientSecret: storedClientSecret, + clientName: storedClientName as string | undefined, + idTokenSignedResponseAlg: + storedIdTokenSignedResponseAlg ?? + negotiateClientSigningAlg(issuerConfig, PREFERRED_SIGNING_ALG), + clientType: "dynamic", + }; + } + + // TODO: It would be more efficient to only issue a single request (see IssuerConfigFetcher) + const issuer = new Issuer(configToIssuerMetadata(issuerConfig)); + + if (issuer.metadata.registration_endpoint === undefined) { + throw new ConfigurationError( + `Dynamic client registration cannot be performed, because issuer does not have a registration endpoint: ${JSON.stringify( + issuer.metadata + )}` + ); + } + + const signingAlg = negotiateClientSigningAlg( + issuerConfig, + PREFERRED_SIGNING_ALG + ); + + // The following is compliant with the example code, but seems to mismatch the + // type annotations. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const registeredClient: Client = await issuer.Client.register({ + redirect_uris: [options.redirectUrl], + client_name: options.clientName, + // See https://openid.net/specs/openid-connect-registration-1_0.html + id_token_signed_response_alg: signingAlg, + grant_types: ["authorization_code", "refresh_token"], + }); + + const infoToSave: Record = { + clientId: registeredClient.metadata.client_id, + idTokenSignedResponseAlg: + registeredClient.metadata.id_token_signed_response_alg ?? signingAlg, + }; + if (registeredClient.metadata.client_secret) { + infoToSave.clientSecret = registeredClient.metadata.client_secret; + } + await this.storageUtility.setForUser(options.sessionId, infoToSave); + return { + clientId: registeredClient.metadata.client_id, + clientSecret: registeredClient.metadata.client_secret, + clientName: registeredClient.metadata.client_name as string | undefined, + idTokenSignedResponseAlg: + registeredClient.metadata.id_token_signed_response_alg ?? signingAlg, + clientType: "dynamic", + }; + } +} diff --git a/packages/node/src/login/oidc/IssuerConfigFetcher.spec.ts b/packages/node/src/login/oidc/IssuerConfigFetcher.spec.ts new file mode 100644 index 0000000..541b33b --- /dev/null +++ b/packages/node/src/login/oidc/IssuerConfigFetcher.spec.ts @@ -0,0 +1,197 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { mockStorageUtility } from "@inrupt/solid-client-authn-core"; +import { jest, it, describe, expect } from "@jest/globals"; +import IssuerConfigFetcher from "./IssuerConfigFetcher"; +import { + mockDefaultIssuerMetadata, + mockIssuerMetadata, +} from "./__mocks__/IssuerConfigFetcher"; + +jest.mock("openid-client"); + +/** + * Test for IssuerConfigFetcher + */ +describe("IssuerConfigFetcher", () => { + const defaultMocks = { + storageUtility: mockStorageUtility({}), + }; + + function getIssuerConfigFetcher( + mocks: Partial = defaultMocks + ): IssuerConfigFetcher { + return new IssuerConfigFetcher( + mocks.storageUtility ?? defaultMocks.storageUtility + ); + } + + it("should return a config based on the fetched config if none was stored in the storage", async () => { + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuerConfig = mockDefaultIssuerMetadata(); + Issuer.discover = (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockedIssuerConfig, + }); + + const configFetcher = getIssuerConfigFetcher({ + storageUtility: mockStorageUtility({}), + }); + + const fetchedConfig = await configFetcher.fetchConfig("https://my.idp/"); + expect(fetchedConfig.issuer).toBe(mockedIssuerConfig.issuer); + expect(fetchedConfig.authorizationEndpoint).toBe( + mockedIssuerConfig.authorization_endpoint + ); + expect(fetchedConfig.claimsSupported).toBe( + mockedIssuerConfig.claims_supported + ); + expect(fetchedConfig.issuer).toBe(mockedIssuerConfig.issuer); + expect(fetchedConfig.jwksUri).toBe(mockedIssuerConfig.jwks_uri); + expect(fetchedConfig.tokenEndpoint).toBe(mockedIssuerConfig.token_endpoint); + expect(fetchedConfig.subjectTypesSupported).toBe( + mockedIssuerConfig.subject_types_supported + ); + }); + + it("throws an error if authorization_endpoint is missing", async () => { + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuerConfig = mockIssuerMetadata({ + authorization_endpoint: undefined, + }); + Issuer.discover = (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockedIssuerConfig, + }); + + const configFetcher = getIssuerConfigFetcher({ + storageUtility: mockStorageUtility({}), + }); + + await expect(configFetcher.fetchConfig("https://my.idp/")).rejects.toThrow( + "Issuer metadata is missing an authorization endpoint" + ); + }); + + it("throws an error if token_endpoint is missing", async () => { + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuerConfig = mockIssuerMetadata({ + token_endpoint: undefined, + }); + Issuer.discover = (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockedIssuerConfig, + }); + + const configFetcher = getIssuerConfigFetcher({ + storageUtility: mockStorageUtility({}), + }); + + await expect(configFetcher.fetchConfig("https://my.idp/")).rejects.toThrow( + "Issuer metadata is missing an token endpoint" + ); + }); + + it("throws an error if jwks_uri is missing", async () => { + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuerConfig = mockIssuerMetadata({ + jwks_uri: undefined, + }); + Issuer.discover = (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockedIssuerConfig, + }); + + const configFetcher = getIssuerConfigFetcher({ + storageUtility: mockStorageUtility({}), + }); + + await expect(configFetcher.fetchConfig("https://my.idp/")).rejects.toThrow( + "Issuer metadata is missing a keyset URI" + ); + }); + + it("throws an error if claims_supported is missing", async () => { + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuerConfig = mockIssuerMetadata({ + claims_supported: undefined, + }); + Issuer.discover = (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockedIssuerConfig, + }); + + const configFetcher = getIssuerConfigFetcher({ + storageUtility: mockStorageUtility({}), + }); + + await expect(configFetcher.fetchConfig("https://my.idp/")).rejects.toThrow( + "Issuer metadata is missing supported claims:" + ); + }); + + it("throws an error if subject_types_supported is missing", async () => { + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuerConfig = mockIssuerMetadata({ + subject_types_supported: undefined, + }); + Issuer.discover = (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockedIssuerConfig, + }); + + const configFetcher = getIssuerConfigFetcher({ + storageUtility: mockStorageUtility({}), + }); + + await expect(configFetcher.fetchConfig("https://my.idp/")).rejects.toThrow( + "Issuer metadata is missing supported subject types:" + ); + }); + + it("defaults scopes_supported to [openid] if omitted", async () => { + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuerConfig = mockIssuerMetadata({ + scopes_supported: undefined, + }); + Issuer.discover = (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockedIssuerConfig, + }); + + const configFetcher = getIssuerConfigFetcher({ + storageUtility: mockStorageUtility({}), + }); + const fetchedConfig = await configFetcher.fetchConfig("https://my.idp/"); + expect(fetchedConfig.scopesSupported).toContain("openid"); + expect(fetchedConfig.scopesSupported).toHaveLength(1); + }); + + it("should return a config including the support for solid-oidc if present in the discovery profile", async () => { + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuerConfig = mockIssuerMetadata({ + scopes_supported: ["webid"], + }); + Issuer.discover = (jest.fn() as any).mockResolvedValueOnce({ + metadata: mockedIssuerConfig, + }); + + const configFetcher = getIssuerConfigFetcher({ + storageUtility: mockStorageUtility({}), + }); + const fetchedConfig = await configFetcher.fetchConfig("https://my.idp/"); + expect(fetchedConfig.scopesSupported).toContain("webid"); + }); +}); diff --git a/packages/node/src/login/oidc/IssuerConfigFetcher.ts b/packages/node/src/login/oidc/IssuerConfigFetcher.ts new file mode 100644 index 0000000..6c746dd --- /dev/null +++ b/packages/node/src/login/oidc/IssuerConfigFetcher.ts @@ -0,0 +1,169 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Responsible for fetching an IDP configuration + */ +import { + IIssuerConfig, + IIssuerConfigFetcher, + IStorageUtility, + ConfigurationError, +} from "@inrupt/solid-client-authn-core"; +import { Issuer, IssuerMetadata } from "openid-client"; + +export const WELL_KNOWN_OPENID_CONFIG = ".well-known/openid-configuration"; + +/** + * Transforms an openid-client IssuerMetadata object into an [[IIssuerConfig]] + * @param metadata the object to transform. + * @returns an [[IIssuerConfig]] initialized from the metadata. + * @internal + */ +export function configFromIssuerMetadata( + metadata: IssuerMetadata +): IIssuerConfig { + // If the fields required as per https://openid.net/specs/openid-connect-discovery-1_0.html are missing, + // throw an error. + if (metadata.authorization_endpoint === undefined) { + throw new ConfigurationError( + `Issuer metadata is missing an authorization endpoint: ${JSON.stringify( + metadata + )}` + ); + } + if (metadata.token_endpoint === undefined) { + throw new ConfigurationError( + `Issuer metadata is missing an token endpoint: ${JSON.stringify( + metadata + )}` + ); + } + if (metadata.jwks_uri === undefined) { + throw new ConfigurationError( + `Issuer metadata is missing a keyset URI: ${JSON.stringify(metadata)}` + ); + } + if (metadata.claims_supported === undefined) { + throw new ConfigurationError( + `Issuer metadata is missing supported claims: ${JSON.stringify(metadata)}` + ); + } + if (metadata.subject_types_supported === undefined) { + throw new ConfigurationError( + `Issuer metadata is missing supported subject types: ${JSON.stringify( + metadata + )}` + ); + } + return { + issuer: metadata.issuer, + authorizationEndpoint: metadata.authorization_endpoint, + subjectTypesSupported: metadata.subject_types_supported as string[], + claimsSupported: metadata.claims_supported as string[], + tokenEndpoint: metadata.token_endpoint, + jwksUri: metadata.jwks_uri, + userinfoEndpoint: metadata.userinfo_endpoint, + registrationEndpoint: metadata.registration_endpoint, + tokenEndpointAuthMethodsSupported: + metadata.token_endpoint_auth_methods_supported, + tokenEndpointAuthSigningAlgValuesSupported: + metadata.token_endpoint_auth_signing_alg_values_supported, + requestObjectSigningAlgValuesSupported: + metadata.request_object_signing_alg_values_supported, + // TODO: add revocation_endpoint, end_session_endpoint, introspection_endpoint_auth_methods_supported, introspection_endpoint_auth_signing_alg_values_supported, revocation_endpoint_auth_methods_supported, revocation_endpoint_auth_signing_alg_values_supported, mtls_endpoint_aliases to IIssuerConfig + // The following properties may be captured as "unkown" entries in the metadata object. + grantTypesSupported: metadata.grant_types_supported as string[] | undefined, + responseTypesSupported: metadata.response_types_supported as + | string[] + | undefined, + idTokenSigningAlgValuesSupported: + metadata.id_token_signing_alg_values_supported as string[] | undefined, + scopesSupported: + metadata.scopes_supported === undefined + ? ["openid"] + : (metadata.scopes_supported as string[]), + }; +} + +/** + * Transforms an [[IIssuerConfig]] into an openid-client IssuerMetadata + * @param config the IIssuerConfig to convert. + * @returns an IssuerMetadata object initialized from the [[IIssuerConfig]]. + */ +export function configToIssuerMetadata(config: IIssuerConfig): IssuerMetadata { + return { + issuer: config.issuer, + authorization_endpoint: config.authorizationEndpoint, + jwks_uri: config.jwksUri, + token_endpoint: config.tokenEndpoint, + registration_endpoint: config.registrationEndpoint, + subject_types_supported: config.subjectTypesSupported, + claims_supported: config.claimsSupported, + token_endpoint_auth_signing_alg_values_supported: + config.tokenEndpointAuthSigningAlgValuesSupported, + userinfo_endpoint: config.userinfoEndpoint, + token_endpoint_auth_methods_supported: + config.tokenEndpointAuthMethodsSupported, + request_object_signing_alg_values_supported: + config.requestObjectSigningAlgValuesSupported, + grant_types_supported: config.grantTypesSupported, + response_types_supported: config.responseTypesSupported, + id_token_signing_alg_values_supported: + config.idTokenSigningAlgValuesSupported, + scopes_supported: config.scopesSupported, + }; +} + +/** + * @hidden + */ +export default class IssuerConfigFetcher implements IIssuerConfigFetcher { + constructor(private storageUtility: IStorageUtility) {} + + // This method needs no state (so can be static), and can be exposed to allow + // callers to know where this implementation puts state it needs. + public static getLocalStorageKey(issuer: string): string { + return `issuerConfig:${issuer}`; + } + + async fetchConfig(issuer: string): Promise { + // TODO: The issuer config discovery happens in multiple places in the current + // codebase, because in openid-client the Client is built based on the Issuer. + // The codebase could be refactored so that issuer discovery only happens once. + const oidcIssuer = await Issuer.discover(issuer); + const issuerConfig: IIssuerConfig = configFromIssuerMetadata( + oidcIssuer.metadata + ); + // Update store with fetched config + await this.storageUtility.set( + IssuerConfigFetcher.getLocalStorageKey(issuer), + JSON.stringify(issuerConfig) + ); + + return issuerConfig; + } +} diff --git a/packages/node/src/login/oidc/OidcLoginHandler.spec.ts b/packages/node/src/login/oidc/OidcLoginHandler.spec.ts new file mode 100644 index 0000000..6cfcf01 --- /dev/null +++ b/packages/node/src/login/oidc/OidcLoginHandler.spec.ts @@ -0,0 +1,352 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { + IIssuerConfigFetcher, + mockStorage, + mockStorageUtility, + StorageUtility, +} from "@inrupt/solid-client-authn-core"; +import { OidcHandlerMock } from "./__mocks__/IOidcHandler"; +import { + IssuerConfigFetcherFetchConfigResponse, + mockDefaultIssuerConfig, + mockIssuerConfigFetcher, +} from "./__mocks__/IssuerConfigFetcher"; +import OidcLoginHandler from "./OidcLoginHandler"; +import { + mockDefaultClient, + mockDefaultClientRegistrar, +} from "./__mocks__/ClientRegistrar"; +import ClientRegistrar from "./ClientRegistrar"; + +describe("OidcLoginHandler", () => { + const defaultMocks = { + storageUtility: mockStorageUtility({}), + oidcHandler: OidcHandlerMock, + issuerConfigFetcher: mockIssuerConfigFetcher(mockDefaultIssuerConfig()), + clientRegistrar: mockDefaultClientRegistrar(), + }; + function getInitialisedHandler( + mocks: Partial = defaultMocks + ): OidcLoginHandler { + return new OidcLoginHandler( + mocks.storageUtility ?? defaultMocks.storageUtility, + mocks.oidcHandler ?? defaultMocks.oidcHandler, + mocks.issuerConfigFetcher ?? defaultMocks.issuerConfigFetcher, + mocks.clientRegistrar ?? defaultMocks.clientRegistrar + ); + } + + describe("canHandle", () => { + it("cannot handle options without an issuer", async () => { + const handler = getInitialisedHandler(); + await expect( + handler.canHandle({ + sessionId: "mySession", + tokenType: "DPoP", + redirectUrl: "https://my.app/redirect", + }) + ).resolves.toBe(false); + }); + + // TODO: Move this to appropriate handlers (auth code, implicit) + // eslint-disable-next-line jest/no-commented-out-tests + // it("cannot handle options without an redirect url", async () => { + // const handler = getInitialisedHandler(); + // await expect( + // handler.canHandle({ + // sessionId: "mySession", + // tokenType: "DPoP", + // oidcIssuer: "https://my.idp/", + // }) + // ).resolves.toEqual(false); + // }); + + it("can handle options with both a redirect url and an issuer", async () => { + const handler = getInitialisedHandler(); + await expect( + handler.canHandle({ + sessionId: "mySession", + tokenType: "DPoP", + oidcIssuer: "https://my.idp/", + redirectUrl: "https://my.app/redirect", + }) + ).resolves.toBe(true); + }); + }); + + describe("handle", () => { + it("throws if config misses an issuer", async () => { + const handler = getInitialisedHandler(); + await expect( + handler.handle({ + sessionId: "mySession", + tokenType: "DPoP", + redirectUrl: "https://my.app/redirect", + }) + ).rejects.toThrow("OidcLoginHandler requires an OIDC issuer"); + }); + + // TODO: Move this to appropriate handlers (auth code, implicit) + // eslint-disable-next-line jest/no-commented-out-tests + // it("throws if config misses a redirect URL", async () => { + // const handler = getInitialisedHandler(); + // await expect( + // handler.handle({ + // sessionId: "mySession", + // tokenType: "DPoP", + // oidcIssuer: "https://my.idp/", + // }) + // ).rejects.toThrow("OidcLoginHandler requires a redirect URL"); + // }); + + it("performs DCR if client ID and secret aren't specified", async () => { + const { oidcHandler } = defaultMocks; + const clientRegistrar = mockDefaultClientRegistrar(); + clientRegistrar.getClient = (jest.fn() as any).mockResolvedValueOnce( + mockDefaultClient() + ); + const handler = getInitialisedHandler({ oidcHandler, clientRegistrar }); + await handler.handle({ + sessionId: "mySession", + oidcIssuer: "https://arbitrary.url", + redirectUrl: "https://app.com/redirect", + tokenType: "DPoP", + }); + expect(clientRegistrar.getClient).toHaveBeenCalled(); + }); + + it("does not perform DCR if client ID and secret are specified, but stores client credentials", async () => { + const { oidcHandler } = defaultMocks; + const mockedStorage = mockStorageUtility({}); + const clientRegistrar = mockDefaultClientRegistrar(); + clientRegistrar.getClient = (jest.fn() as any).mockResolvedValueOnce( + mockDefaultClient() + ); + const handler = getInitialisedHandler({ + oidcHandler, + clientRegistrar, + storageUtility: mockedStorage, + }); + await handler.handle({ + sessionId: "mySession", + oidcIssuer: "https://arbitrary.url", + redirectUrl: "https://app.com/redirect", + clientId: "some pre-registered client id", + clientSecret: "some pre-registered client secret", + clientName: "My App", + tokenType: "DPoP", + }); + expect(clientRegistrar.getClient).not.toHaveBeenCalled(); + await expect( + mockedStorage.getForUser("mySession", "clientId") + ).resolves.toBe("some pre-registered client id"); + await expect( + mockedStorage.getForUser("mySession", "clientSecret") + ).resolves.toBe("some pre-registered client secret"); + await expect( + mockedStorage.getForUser("mySession", "clientName") + ).resolves.toBe("My App"); + }); + + it("should save client WebID if one is provided, and the target IdP supports Solid-OIDC", async () => { + const mockedStorage = new StorageUtility( + mockStorage({}), + mockStorage({}) + ); + + const actualHandler = defaultMocks.oidcHandler; + const handler = getInitialisedHandler({ + oidcHandler: actualHandler, + storageUtility: mockedStorage, + clientRegistrar: new ClientRegistrar(mockedStorage), + issuerConfigFetcher: mockIssuerConfigFetcher({ + ...IssuerConfigFetcherFetchConfigResponse, + scopesSupported: ["webid"], + }), + }); + + await handler.handle({ + sessionId: "mySession", + oidcIssuer: "https://arbitrary.url", + redirectUrl: "https://app.com/redirect", + tokenType: "DPoP", + clientId: "https://my.app/registration#app", + }); + + const calledWith = actualHandler.handle.mock.calls[0][0]; + expect(calledWith.client.clientId).toBe( + "https://my.app/registration#app" + ); + + const storedClientId = await mockedStorage.getForUser( + "mySession", + "clientId" + ); + expect(storedClientId).toBe("https://my.app/registration#app"); + }); + + it("should perform DCR if a client WebID is provided, but the target IdP does not support Solid-OIDC", async () => { + const { oidcHandler } = defaultMocks; + const clientRegistrar = mockDefaultClientRegistrar(); + clientRegistrar.getClient = (jest.fn() as any).mockResolvedValueOnce({ + clientId: "a dynamically registered client id", + clientSecret: "a dynamically registered client secret", + }); + + const mockedEmptyStorage = new StorageUtility( + mockStorage({}), + mockStorage({}) + ); + + const handler = getInitialisedHandler({ + oidcHandler, + storageUtility: mockedEmptyStorage, + clientRegistrar, + issuerConfigFetcher: mockIssuerConfigFetcher({ + ...IssuerConfigFetcherFetchConfigResponse, + }), + }); + + await handler.handle({ + sessionId: "mySession", + oidcIssuer: "https://arbitrary.url", + redirectUrl: "https://app.com/redirect", + tokenType: "DPoP", + clientId: "https://my.app/registration#app", + }); + + const calledWith = oidcHandler.handle.mock.calls[0][0]; + expect(calledWith.client.clientId).toBe( + "a dynamically registered client id" + ); + }); + + it("stores credentials for public clients", async () => { + const { oidcHandler } = defaultMocks; + const mockedStorage = mockStorageUtility({}); + const clientRegistrar = mockDefaultClientRegistrar(); + clientRegistrar.getClient = (jest.fn() as any).mockResolvedValueOnce( + mockDefaultClient() + ); + const handler = getInitialisedHandler({ + oidcHandler, + clientRegistrar, + storageUtility: mockedStorage, + }); + await handler.handle({ + sessionId: "mySession", + oidcIssuer: "https://arbitrary.url", + redirectUrl: "https://app.com/redirect", + clientId: "some pre-registered client id", + tokenType: "DPoP", + }); + expect(clientRegistrar.getClient).not.toHaveBeenCalled(); + await expect( + mockedStorage.getForUser("mySession", "clientId") + ).resolves.toBe("some pre-registered client id"); + }); + + it("uses the refresh token from storage if available", async () => { + const { oidcHandler } = defaultMocks; + const mockedStorage = mockStorageUtility({}); + await mockedStorage.setForUser("mySession", { + refreshToken: "some token", + }); + const clientRegistrar = mockDefaultClientRegistrar(); + clientRegistrar.getClient = (jest.fn() as any).mockResolvedValueOnce( + mockDefaultClient() + ); + const handler = getInitialisedHandler({ + oidcHandler, + clientRegistrar, + storageUtility: mockedStorage, + }); + await handler.handle({ + sessionId: "mySession", + oidcIssuer: "https://arbitrary.url", + redirectUrl: "https://app.com/redirect", + tokenType: "DPoP", + }); + expect(oidcHandler.handle).toHaveBeenCalledWith( + expect.objectContaining({ + refreshToken: "some token", + }) + ); + }); + + it("ignores the refresh token from storage if one is passed in arguments", async () => { + const { oidcHandler } = defaultMocks; + const mockedStorage = mockStorageUtility({}); + await mockedStorage.setForUser("mySession", { + refreshToken: "some token", + }); + const clientRegistrar = mockDefaultClientRegistrar(); + clientRegistrar.getClient = (jest.fn() as any).mockResolvedValueOnce( + mockDefaultClient() + ); + const handler = getInitialisedHandler({ + oidcHandler, + clientRegistrar, + storageUtility: mockedStorage, + }); + await handler.handle({ + sessionId: "mySession", + oidcIssuer: "https://arbitrary.url", + redirectUrl: "https://app.com/redirect", + tokenType: "DPoP", + refreshToken: "some other refresh token", + }); + expect(oidcHandler.handle).toHaveBeenCalledWith( + expect.objectContaining({ + refreshToken: "some other refresh token", + }) + ); + }); + + it("should use the issuer's IRI from the fetched configuration rather than from the input options", async () => { + const actualHandler = defaultMocks.oidcHandler; + const issuerConfig = IssuerConfigFetcherFetchConfigResponse; + issuerConfig.issuer = "https://some.issuer/"; + const handler = getInitialisedHandler({ + issuerConfigFetcher: mockIssuerConfigFetcher( + issuerConfig + ) as jest.Mocked, + oidcHandler: actualHandler, + }); + await handler.handle({ + sessionId: "mySession", + oidcIssuer: "https://some.issuer", + redirectUrl: "https://app.com/redirect", + clientId: "coolApp", + tokenType: "DPoP", + }); + + expect(actualHandler.handle).toHaveBeenCalledWith( + expect.objectContaining({ + issuer: "https://some.issuer/", + }) + ); + }); + }); +}); diff --git a/packages/node/src/login/oidc/OidcLoginHandler.ts b/packages/node/src/login/oidc/OidcLoginHandler.ts new file mode 100644 index 0000000..2260210 --- /dev/null +++ b/packages/node/src/login/oidc/OidcLoginHandler.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Handles Common Oidc login functions (Like fetching the configuration) + */ + +import { + IClientRegistrar, + IIssuerConfigFetcher, + ILoginOptions, + ILoginHandler, + IOidcHandler, + IStorageUtility, + ConfigurationError, + IClient, + IOidcOptions, + LoginResult, + handleRegistration, +} from "@inrupt/solid-client-authn-core"; + +function hasIssuer( + options: ILoginOptions +): options is ILoginOptions & { oidcIssuer: string } { + return typeof options.oidcIssuer === "string"; +} + +// TODO: the following code must be pushed to the handlers that actually need redirection +// function hasRedirectUrl( +// options: ILoginOptions +// ): options is ILoginOptions & { redirectUrl: string } { +// return typeof options.redirectUrl === "string"; +// } + +/** + * @hidden + */ +export default class OidcLoginHandler implements ILoginHandler { + constructor( + private storageUtility: IStorageUtility, + private oidcHandler: IOidcHandler, + private issuerConfigFetcher: IIssuerConfigFetcher, + private clientRegistrar: IClientRegistrar + ) {} + + async canHandle(options: ILoginOptions): Promise { + return hasIssuer(options); + } + + async handle(options: ILoginOptions): Promise { + if (!hasIssuer(options)) { + throw new ConfigurationError( + `OidcLoginHandler requires an OIDC issuer: missing property 'oidcIssuer' in ${JSON.stringify( + options + )}` + ); + } + // TODO: the following code must be pushed to the handlers that actually need redirection + // if (!hasRedirectUrl(options)) { + // throw new ConfigurationError( + // `OidcLoginHandler requires a redirect URL: missing property 'redirectUrl' in ${JSON.stringify( + // options + // )}` + // ); + // } + + const issuerConfig = await this.issuerConfigFetcher.fetchConfig( + options.oidcIssuer + ); + + const clientInfo: IClient = await handleRegistration( + options, + issuerConfig, + this.storageUtility, + this.clientRegistrar + ); + + // Construct OIDC Options + const oidcOptions: IOidcOptions = { + issuer: issuerConfig.issuer, + // TODO: differentiate if DPoP should be true + dpop: options.tokenType.toLowerCase() === "dpop", + // TODO Cleanup to remove the type assertion + redirectUrl: options.redirectUrl as string, + issuerConfiguration: issuerConfig, + client: clientInfo, + sessionId: options.sessionId, + // If the refresh token is available in storage, use it. + refreshToken: + options.refreshToken ?? + (await this.storageUtility.getForUser( + options.sessionId, + "refreshToken" + )), + handleRedirect: options.handleRedirect, + eventEmitter: options.eventEmitter, + }; + // Call proper OIDC Handler + return this.oidcHandler.handle(oidcOptions); + } +} diff --git a/packages/node/src/login/oidc/Redirector.spec.ts b/packages/node/src/login/oidc/Redirector.spec.ts new file mode 100644 index 0000000..1f43ff8 --- /dev/null +++ b/packages/node/src/login/oidc/Redirector.spec.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import Redirector from "./Redirector"; + +/** + * Test for Redirector + */ +describe("Redirector", () => { + describe("Redirect", () => { + it("calls the provided redirect callback", () => { + const redirector = new Redirector(); + const redirectCallback = jest.fn(); + redirector.redirect("https://someUrl.com/redirect", { + handleRedirect: redirectCallback, + }); + expect(redirectCallback).toHaveBeenCalled(); + }); + + it("throws if no handler is provided", () => { + const redirector = new Redirector(); + expect(() => + redirector.redirect("https://someUrl.com/redirect") + ).toThrow(); + }); + }); +}); diff --git a/packages/node/src/login/oidc/Redirector.ts b/packages/node/src/login/oidc/Redirector.ts new file mode 100644 index 0000000..b3d5141 --- /dev/null +++ b/packages/node/src/login/oidc/Redirector.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { + IRedirector, + IRedirectorOptions, +} from "@inrupt/solid-client-authn-core"; + +/** + * @hidden + */ +export default class Redirector implements IRedirector { + redirect(redirectUrl: string, options?: IRedirectorOptions): void { + if (options && options.handleRedirect) { + options.handleRedirect(redirectUrl); + } else { + throw new Error( + "A redirection handler must be provided in the Node environment." + ); + } + } +} diff --git a/packages/node/src/login/oidc/TokenRequester.spec.ts b/packages/node/src/login/oidc/TokenRequester.spec.ts new file mode 100644 index 0000000..787fa59 --- /dev/null +++ b/packages/node/src/login/oidc/TokenRequester.spec.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +// eslint-disable-next-line no-shadow +import { Response as NodeResponse, fetch } from "cross-fetch"; +import { + IIssuerConfig, + mockStorageUtility, +} from "@inrupt/solid-client-authn-core"; +import { + IssuerConfigFetcherMock, + IssuerConfigFetcherFetchConfigResponse, +} from "./__mocks__/IssuerConfigFetcher"; +import TokenRequester from "./TokenRequester"; +import { + ClientRegistrarMock, + PublicClientRegistrarMock, +} from "./__mocks__/ClientRegistrar"; + +jest.mock("cross-fetch"); + +describe("TokenRequester", () => { + const defaultMocks = { + storageUtility: mockStorageUtility({}), + issueConfigFetcher: IssuerConfigFetcherMock, + clientRegistrar: ClientRegistrarMock, + }; + + function getTokenRequester( + mocks: Partial = defaultMocks + ): TokenRequester { + return new TokenRequester( + mocks.storageUtility ?? defaultMocks.storageUtility, + mocks.issueConfigFetcher ?? defaultMocks.issueConfigFetcher, + mocks.clientRegistrar ?? ClientRegistrarMock + ); + } + + const defaultReturnValues: { + storageIdp: string; + issuerConfig: IIssuerConfig; + responseBody: string; + jwt: Record; + } = { + storageIdp: "https://idp.com", + + issuerConfig: { + ...IssuerConfigFetcherFetchConfigResponse, + grantTypesSupported: ["refresh_token"], + }, + + responseBody: JSON.stringify({ + /* eslint-disable camelcase */ + id_token: "abcd", + access_token: "1234", + refresh_token: "!@#$", + /* eslint-enable camelcase */ + }), + + jwt: { + sub: "https://jackson.solid.community/profile/card#me", + }, + }; + + async function setUpMockedReturnValues( + values: Partial + ): Promise { + await defaultMocks.storageUtility.setForUser("global", { + issuer: values.storageIdp ?? defaultReturnValues.storageIdp, + }); + + const issuerConfig = + values.issuerConfig ?? defaultReturnValues.issuerConfig; + defaultMocks.issueConfigFetcher.fetchConfig.mockResolvedValueOnce( + issuerConfig + ); + + const mockedFetch = ( + jest.requireMock("cross-fetch") as jest.Mocked + ).mockResolvedValueOnce( + new NodeResponse(values.responseBody ?? defaultReturnValues.responseBody) + ); + return mockedFetch; + } + + it("the refresh flow isn't implemented", async () => { + await setUpMockedReturnValues({}); + const TokenRefresher = getTokenRequester({ + clientRegistrar: PublicClientRegistrarMock, + }); + /* eslint-disable camelcase */ + await expect( + TokenRefresher.request("global", { + grant_type: "refresh_token", + refresh_token: "thisIsARefreshToken", + }) + ).rejects.toThrow("not implemented"); + /* eslint-enable camelcase */ + }); +}); diff --git a/packages/node/src/login/oidc/TokenRequester.ts b/packages/node/src/login/oidc/TokenRequester.ts new file mode 100644 index 0000000..93cac5c --- /dev/null +++ b/packages/node/src/login/oidc/TokenRequester.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { + IClientRegistrar, + IStorageUtility, + IIssuerConfigFetcher, + NotImplementedError, +} from "@inrupt/solid-client-authn-core"; + +/** + * @hidden + */ +export interface ITokenRequester { + request(localUserId: string, body: Record): Promise; +} + +/** + * @hidden + */ +export default class TokenRequester { + constructor( + private storageUtility: IStorageUtility, + private issuerConfigFetcher: IIssuerConfigFetcher, + private clientRegistrar: IClientRegistrar + ) {} + + async request( + _sessionId: string, + _body: Record + ): Promise { + throw new NotImplementedError("TokenRequester not implemented for Node"); + } +} diff --git a/packages/node/src/login/oidc/__mocks__/ClientRegistrar.ts b/packages/node/src/login/oidc/__mocks__/ClientRegistrar.ts new file mode 100644 index 0000000..f4e1897 --- /dev/null +++ b/packages/node/src/login/oidc/__mocks__/ClientRegistrar.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + IClient, + IClientRegistrar, + IClientRegistrarOptions, + IIssuerConfig, +} from "@inrupt/solid-client-authn-core"; +import { jest } from "@jest/globals"; +import { ClientMetadata } from "openid-client"; + +export const ClientRegistrarResponse: IClient = { + clientId: "abcde", + clientSecret: "12345", + clientType: "dynamic", +}; + +export const PublicClientRegistrarResponse: IClient = { + clientId: "abcde", + clientType: "dynamic", +}; + +export const ClientRegistrarMock: jest.Mocked = { + getClient: jest.fn( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (options: IClientRegistrarOptions, issuerConfig: IIssuerConfig) => + Promise.resolve(ClientRegistrarResponse) + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +export const PublicClientRegistrarMock: jest.Mocked = { + getClient: jest.fn( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (options: IClientRegistrarOptions, issuerConfig: IIssuerConfig) => + Promise.resolve(PublicClientRegistrarResponse) + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +export const mockDefaultClientConfig = (): ClientMetadata => { + return { + client_id: "some client", + client_secret: "some secret", + redirect_uris: ["https://my.app/redirect"], + response_types: ["code"], + id_token_signed_response_alg: "RS256", + }; +}; + +export const mockClientConfig = ( + config: Record +): ClientMetadata => { + return { + ...mockDefaultClientConfig(), + ...config, + }; +}; + +export const mockDefaultClient = (): IClient => { + return { + clientId: "a client id", + clientSecret: "a client secret", + clientType: "dynamic", + }; +}; + +export const mockDefaultClientRegistrar = (): IClientRegistrar => { + return { + getClient: async () => mockDefaultClient(), + }; +}; + +export const mockClientRegistrar = (client: IClient): IClientRegistrar => { + return { + getClient: async () => client, + }; +}; diff --git a/packages/node/src/login/oidc/__mocks__/IOidcHandler.ts b/packages/node/src/login/oidc/__mocks__/IOidcHandler.ts new file mode 100644 index 0000000..d48bd17 --- /dev/null +++ b/packages/node/src/login/oidc/__mocks__/IOidcHandler.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + IOidcHandler, + IOidcOptions, + ISessionInfo, +} from "@inrupt/solid-client-authn-core"; +import { jest } from "@jest/globals"; + +import { SessionCreatorGetSessionResponse } from "../../../sessionInfo/__mocks__/SessionInfoManager"; + +export const OidcHandlerHandleResponse: ISessionInfo = + SessionCreatorGetSessionResponse; + +export const OidcHandlerMock: jest.Mocked = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + canHandle: jest.fn((_options: IOidcOptions) => Promise.resolve(true)), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + handle: jest.fn(async (_options: IOidcOptions) => Promise.resolve(undefined)), + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; diff --git a/packages/node/src/login/oidc/__mocks__/IOidcOptions.ts b/packages/node/src/login/oidc/__mocks__/IOidcOptions.ts new file mode 100644 index 0000000..298cd10 --- /dev/null +++ b/packages/node/src/login/oidc/__mocks__/IOidcOptions.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { IOidcOptions } from "@inrupt/solid-client-authn-core"; +import { jest } from "@jest/globals"; + +export const standardOidcOptions: IOidcOptions = { + sessionId: "mySession", + issuer: "https://example.com", + dpop: true, + redirectUrl: "https://app.example.com", + handleRedirect: jest.fn((url) => url), + // This will be fixed in a different pull request + issuerConfiguration: { + issuer: "https://example.com", + authorizationEndpoint: "https://example.com/auth", + tokenEndpoint: "https://example.com/token", + jwksUri: "https://example.com/jwks", + subjectTypesSupported: [], + claimsSupported: [], + scopesSupported: ["openid"], + }, + client: { + clientId: "coolApp", + clientType: "dynamic", + }, +}; + +export const mockDefaultOidcOptions = (): IOidcOptions => { + return { ...standardOidcOptions }; +}; + +export const mockOidcOptions = ( + overriddenOptions: Partial +): IOidcOptions => { + return { + ...standardOidcOptions, + ...overriddenOptions, + }; +}; diff --git a/packages/node/src/login/oidc/__mocks__/IssuerConfigFetcher.ts b/packages/node/src/login/oidc/__mocks__/IssuerConfigFetcher.ts new file mode 100644 index 0000000..0b90504 --- /dev/null +++ b/packages/node/src/login/oidc/__mocks__/IssuerConfigFetcher.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + IIssuerConfig, + IIssuerConfigFetcher, +} from "@inrupt/solid-client-authn-core"; +import { jest } from "@jest/globals"; +import { IssuerMetadata } from "openid-client"; +import { configFromIssuerMetadata } from "../IssuerConfigFetcher"; + +export const IssuerConfigFetcherFetchConfigResponse: IIssuerConfig = { + issuer: "https://idp.com", + authorizationEndpoint: "https://idp.com/auth", + tokenEndpoint: "https://idp.com/token", + jwksUri: "https://idp.com/jwks", + subjectTypesSupported: [], + claimsSupported: [], + grantTypesSupported: ["refresh_token"], + idTokenSigningAlgValuesSupported: ["ES256", "RS256"], + scopesSupported: ["openid", "offline_access"], +}; + +export const IssuerConfigFetcherMock = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fetchConfig: jest.fn((_issuer: string) => + Promise.resolve(IssuerConfigFetcherFetchConfigResponse) + ), +} as unknown as jest.Mocked; + +// Note that this returns an instance of IssuerMetadata, which is the equivalent +// of our IIssuerConfig for openid-client +export const mockDefaultIssuerMetadata = (): IssuerMetadata => { + return { + issuer: "https://my.idp/", + authorization_endpoint: "https://my.idp/auth", + token_endpoint: "https://my.idp/token", + registration_endpoint: "https://my.idp/register", + jwks_uri: "https://my.idp/jwks", + claims_supported: ["sub"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["ES256", "RS256"], + grant_types_supported: [ + "authorization_code", + "refresh_token", + "client_credentials", + ], + }; +}; + +export const mockIssuerMetadata = ( + config: Partial +): IssuerMetadata => { + return { + ...mockDefaultIssuerMetadata(), + ...config, + }; +}; + +export const mockDefaultIssuerConfig = (): IIssuerConfig => + configFromIssuerMetadata(mockDefaultIssuerMetadata()); +export const mockIssuerConfig = ( + config: Partial +): IIssuerConfig => { + return { + ...configFromIssuerMetadata(mockDefaultIssuerMetadata()), + ...config, + }; +}; + +export function mockIssuerConfigFetcher( + config: IIssuerConfig +): IIssuerConfigFetcher { + return { + fetchConfig: async (): Promise => config, + }; +} diff --git a/packages/node/src/login/oidc/__mocks__/Redirector.ts b/packages/node/src/login/oidc/__mocks__/Redirector.ts new file mode 100644 index 0000000..58afcc1 --- /dev/null +++ b/packages/node/src/login/oidc/__mocks__/Redirector.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { IRedirector } from "@inrupt/solid-client-authn-core"; +import { jest } from "@jest/globals"; + +export const mockedRedirector = jest.fn(); +export const mockRedirector = (): IRedirector => { + return { + redirect: mockedRedirector, + }; +}; diff --git a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.spec.ts b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.spec.ts new file mode 100644 index 0000000..95d5287 --- /dev/null +++ b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.spec.ts @@ -0,0 +1,531 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { + StorageUtilityMock, + mockStorageUtility, + EVENTS, +} from "@inrupt/solid-client-authn-core"; +import { IdTokenClaims, TokenSet } from "openid-client"; +import { JWK } from "jose"; +import { Response as NodeResponse, Headers as NodeHeaders } from "cross-fetch"; +import type * as CrossFetch from "cross-fetch"; +import { EventEmitter } from "events"; +import { AuthCodeRedirectHandler } from "./AuthCodeRedirectHandler"; +import { mockSessionInfoManager } from "../../../sessionInfo/__mocks__/SessionInfoManager"; +import { + mockIssuerConfigFetcher, + mockDefaultIssuerConfig, +} from "../__mocks__/IssuerConfigFetcher"; +import { mockDefaultClientRegistrar } from "../__mocks__/ClientRegistrar"; +import { mockDefaultTokenRefresher } from "../refresh/__mocks__/TokenRefresher"; +import { configToIssuerMetadata } from "../IssuerConfigFetcher"; + +jest.mock("openid-client"); +// The fetch factory in the core module resolves cross-fetch to the environment-specific fetch + +jest.mock("cross-fetch", () => { + return { + ...(jest.requireActual("cross-fetch") as typeof CrossFetch), + default: jest.fn(), + fetch: jest.fn(), + } as typeof CrossFetch; +}); + +jest.mock("@inrupt/solid-client-authn-core", () => { + const actualCoreModule = jest.requireActual( + "@inrupt/solid-client-authn-core" + ) as any; + return { + ...actualCoreModule, + // This works around the network lookup to the JWKS in order to validate the ID token. + getWebidFromTokenPayload: jest.fn(() => + Promise.resolve("https://my.webid/") + ), + }; +}); +jest.useFakeTimers(); + +const mockJwk = (): JWK => { + return { + kty: "EC", + kid: "oOArcXxcwvsaG21jAx_D5CHr4BgVCzCEtlfmNFQtU0s", + alg: "ES256", + crv: "P-256", + x: "0dGe_s-urLhD3mpqYqmSXrqUZApVV5ZNxMJXg7Vp-2A", + y: "-oMe9gGkpfIrnJ0aiSUHMdjqYVm5ZrGCeQmRKoIIfj8", + d: "yR1bCsR7m4hjFCvWo8Jw3OfNR4aiYDAFbBD9nkudJKM", + }; +}; + +const mockWebId = (): string => "https://my.webid/"; + +const mockIdTokenPayload = (): IdTokenClaims => { + return { + sub: "https://my.webid", + iss: "https://my.idp/", + aud: "https://resource.example.org", + exp: 1662266216, + iat: 1462266216, + }; +}; +// The key is the one returned by mockJwk(), and the payload is mockIdTokenPayload() +const mockIdToken = (): string => + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJodHRwczovL215LndlYmlkIiwiaXNzIjoiaHR0cHM6Ly9teS5pZHAvIiwiYXVkIjoiaHR0cHM6Ly9yZXNvdXJjZS5leGFtcGxlLm9yZyIsImV4cCI6MTY2MjI2NjIxNiwiaWF0IjoxNDYyMjY2MjE2fQ.IwumuwJtQw5kUBMMHAaDPJBppfBpRHbiXZw_HlKe6GNVUWUlyQRYV7W7r9OQtHmMsi6GVwOckelA3ErmhrTGVw"; + +type AccessJwt = { + sub: string; + iss: string; + aud: string; + nbf: number; + exp: number; + cnf: { + jkt: string; + }; +}; + +const mockKeyBoundToken = async (): Promise => { + return { + sub: mockWebId(), + iss: mockDefaultIssuerConfig().issuer.toString(), + aud: "https://resource.example.org", + nbf: 1562262611, + exp: 1562266216, + cnf: { + jkt: mockJwk().kid as string, + }, + }; +}; + +const mockBearerAccessToken = (): string => "some token"; + +const mockBearerTokens = (): TokenSet => { + return { + access_token: mockBearerAccessToken(), + id_token: mockIdToken(), + token_type: "Bearer", + expired: () => false, + claims: mockIdTokenPayload, + }; +}; + +const mockDpopTokens = (): TokenSet => { + return { + access_token: JSON.stringify(mockKeyBoundToken()), + id_token: mockIdToken(), + token_type: "DPoP", + expired: () => false, + claims: mockIdTokenPayload, + }; +}; + +const defaultMocks = { + storageUtility: StorageUtilityMock, + sessionInfoManager: mockSessionInfoManager(mockStorageUtility({})), + clientRegistrar: mockDefaultClientRegistrar(), + issuerConfigFetcher: mockIssuerConfigFetcher(mockDefaultIssuerConfig()), + tokenRefresher: mockDefaultTokenRefresher(), +}; + +function getAuthCodeRedirectHandler( + mocks: Partial = defaultMocks +): AuthCodeRedirectHandler { + return new AuthCodeRedirectHandler( + mocks.storageUtility ?? defaultMocks.storageUtility, + mocks.sessionInfoManager ?? defaultMocks.sessionInfoManager, + mocks.issuerConfigFetcher ?? defaultMocks.issuerConfigFetcher, + mocks.clientRegistrar ?? defaultMocks.clientRegistrar, + mocks.tokenRefresher ?? defaultMocks.tokenRefresher + ); +} + +describe("AuthCodeRedirectHandler", () => { + describe("canHandler", () => { + it("Accepts a valid url with the correct query", async () => { + const authCodeRedirectHandler = getAuthCodeRedirectHandler(); + expect( + await authCodeRedirectHandler.canHandle( + "https://coolparty.com/?code=someCode&state=oauth2_state_value" + ) + ).toBe(true); + }); + + it("throws on invalid url", async () => { + const authCodeRedirectHandler = getAuthCodeRedirectHandler(); + await expect(() => + authCodeRedirectHandler.canHandle("beep boop I am a robot") + ).rejects.toThrow( + "[beep boop I am a robot] is not a valid URL, and cannot be used as a redirect URL" + ); + }); + + it("Rejects a valid url with the incorrect query", async () => { + const authCodeRedirectHandler = getAuthCodeRedirectHandler(); + expect( + await authCodeRedirectHandler.canHandle( + "https://coolparty.com/?meep=mop" + ) + ).toBe(false); + }); + + it("rejects a valid url without authorization code", async () => { + const authCodeRedirectHandler = getAuthCodeRedirectHandler(); + expect( + await authCodeRedirectHandler.canHandle( + "https://coolparty.com/?state=someState" + ) + ).toBe(false); + }); + + it("rejects a valid url without state", async () => { + const authCodeRedirectHandler = getAuthCodeRedirectHandler(); + expect( + await authCodeRedirectHandler.canHandle( + "https://coolparty.com/?code=someCode" + ) + ).toBe(false); + }); + }); + + const mockDefaultRedirectStorage = () => + mockStorageUtility({ + "solidClientAuthenticationUser:someState": { + sessionId: "mySession", + }, + "solidClientAuthenticationUser:mySession": { + issuer: "https://my.idp/", + codeVerifier: "some code verifier", + redirectUrl: "https://my.app/redirect", + dpop: "true", + idTokenSignedResponseAlg: "RS256", + }, + }); + + const setupOidcClientMock = (tokenSet?: TokenSet, callback?: unknown) => { + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuer = { + metadata: configToIssuerMetadata(mockDefaultIssuerConfig()), + Client: jest.fn().mockReturnValue({ + callbackParams: jest.fn().mockReturnValue({ + code: "someCode", + state: "someState", + }), + callback: callback ?? jest.fn().mockResolvedValue(tokenSet as never), + metadata: { + client_id: "https://some.client#id", + }, + }), + }; + Issuer.mockReturnValueOnce(mockedIssuer); + return mockedIssuer; + }; + + const setupDefaultOidcClientMock = () => + setupOidcClientMock(mockDpopTokens()); + + describe("handle", () => { + it("throws on non-redirect URL", async () => { + const authCodeRedirectHandler = getAuthCodeRedirectHandler(); + await expect( + authCodeRedirectHandler.handle("https://my.app") + ).rejects.toThrow( + "AuthCodeRedirectHandler cannot handle [https://my.app]: it is missing one of [code, state]." + ); + }); + + it("properly performs DPoP token exchange", async () => { + setupDefaultOidcClientMock(); + const mockedStorage = mockDefaultRedirectStorage(); + + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockSessionInfoManager(mockedStorage), + }); + + const result = await authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ); + + // Check that the returned session is the one we expected + expect(result.sessionId).toBe("mySession"); + expect(result.isLoggedIn).toBe(true); + expect(result.webId).toEqual(mockWebId()); + + // Check that the session information is stored in the provided storage + await expect( + mockedStorage.getForUser("mySession", "webId") + ).resolves.toEqual(mockWebId()); + await expect( + mockedStorage.getForUser("mySession", "isLoggedIn") + ).resolves.toBe("true"); + + // Check that the returned fetch function is authenticated + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + mockedFetch.mockResolvedValueOnce(new NodeResponse()); + await result.fetch("https://some.url"); + const headers = new NodeHeaders(mockedFetch.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toContain("DPoP"); + }); + + it("uses 'none' client authentication if using Solid-OIDC client identifiers", async () => { + const mockedIssuer = setupDefaultOidcClientMock(); + const mockedStorage = mockDefaultRedirectStorage(); + + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockSessionInfoManager(mockedStorage), + clientRegistrar: { + getClient: async () => { + return { + clientId: "https://some.client.identifier", + clientType: "solid-oidc", + }; + }, + }, + }); + + await authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ); + + expect(mockedIssuer.Client).toHaveBeenCalledWith( + expect.objectContaining({ + token_endpoint_auth_method: "none", + }) + ); + }); + + it("properly performs Bearer token exchange", async () => { + setupOidcClientMock(mockBearerTokens()); + const mockedStorage = mockDefaultRedirectStorage(); + await mockedStorage.setForUser("mySession", { + dpop: "false", + }); + + // Run the test + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockSessionInfoManager(mockedStorage), + }); + + const result = await authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ); + + // Check that the returned fetch function is authenticated + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + mockedFetch.mockResolvedValueOnce(new NodeResponse()); + await result.fetch("https://some.url"); + const headers = new NodeHeaders(mockedFetch.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toContain("Bearer"); + }); + + it("cleans up the redirect IRI from the OIDC parameters", async () => { + // This function represents the openid-client callback + const callback = (jest.fn() as any).mockResolvedValueOnce( + mockDpopTokens() + ); + setupOidcClientMock(undefined, callback); + const mockedStorage = mockDefaultRedirectStorage(); + + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockSessionInfoManager(mockedStorage), + }); + + await authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ); + + expect(callback).toHaveBeenCalledWith( + "https://my.app/redirect", + { code: "someCode", state: "someState" }, + // The code verifier comes from the mocked storage. + { code_verifier: "some code verifier", state: "someState" }, + expect.anything() + ); + }); + + it("stores the refresh token if one is returned", async () => { + const mockedTokens = mockDpopTokens(); + mockedTokens.refresh_token = "some refresh token"; + setupOidcClientMock(mockedTokens); + const mockedStorage = mockDefaultRedirectStorage(); + + // Run the test + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockSessionInfoManager(mockedStorage), + }); + + await authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ); + + // Check that the session information is stored in the provided storage + await expect( + mockedStorage.getForUser("mySession", "refreshToken") + ).resolves.toBe("some refresh token"); + }); + + it("stores the DPoP key pair if the refresh token is DPoP-bound", async () => { + const mockedTokens = mockDpopTokens(); + mockedTokens.refresh_token = "some refresh token"; + setupOidcClientMock(mockedTokens); + const mockedStorage = mockDefaultRedirectStorage(); + + // Run the test + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockSessionInfoManager(mockedStorage), + }); + + await authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ); + + // Check that the session information is stored in the provided storage + await expect( + mockedStorage.getForUser("mySession", "privateKey") + ).resolves.toBeDefined(); + await expect( + mockedStorage.getForUser("mySession", "publicKey") + ).resolves.toBeDefined(); + }); + + it("calls the refresh token handler if one is provided", async () => { + const mockedTokens = mockDpopTokens(); + mockedTokens.refresh_token = "some refresh token"; + setupOidcClientMock(mockedTokens); + const mockedStorage = mockDefaultRedirectStorage(); + const mockEmitter = new EventEmitter(); + const mockEmit = jest.spyOn(mockEmitter, "emit"); + + // Run the test + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockSessionInfoManager(mockedStorage), + }); + + await authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState", + mockEmitter + ); + + expect(mockEmit).toHaveBeenCalledWith( + EVENTS.NEW_REFRESH_TOKEN, + "some refresh token" + ); + }); + + it("throws if the IdP does not return an access token", async () => { + const mockedTokens = mockDpopTokens(); + mockedTokens.access_token = undefined; + setupOidcClientMock(mockedTokens); + const mockedStorage = mockDefaultRedirectStorage(); + + // Run the test + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockSessionInfoManager(mockedStorage), + }); + + await expect( + authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ) + ).rejects.toThrow( + `The Identity Provider [${ + mockDefaultIssuerConfig().issuer + }] did not return the expected tokens: missing at least one of 'access_token', 'id_token.` + ); + }); + + it("throws if the IdP does not return an ID token", async () => { + const mockedTokens = mockDpopTokens(); + mockedTokens.id_token = undefined; + setupOidcClientMock(mockedTokens); + const mockedStorage = mockDefaultRedirectStorage(); + + // Run the test + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockSessionInfoManager(mockedStorage), + }); + + await expect( + authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ) + ).rejects.toThrow( + `The Identity Provider [${ + mockDefaultIssuerConfig().issuer + }] did not return the expected tokens: missing at least one of 'access_token', 'id_token.` + ); + }); + + it("throws if the Session manager cannot retrieve the session info", async () => { + setupDefaultOidcClientMock(); + const mockedStorage = mockDefaultRedirectStorage(); + const mockedSessionManager = mockSessionInfoManager(mockedStorage); + mockedSessionManager.get = jest + .fn() + .mockReturnValue(undefined) as typeof mockedSessionManager.get; + + // Run the test + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + sessionInfoManager: mockedSessionManager, + }); + + await expect( + authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ) + ).rejects.toThrow( + "Could not find any session information associated with SessionID [mySession] in our storage" + ); + }); + + it("throws if no session ID matches the request state", async () => { + setupDefaultOidcClientMock(); + const mockedStorage = mockStorageUtility({}); + + // Run the test + const authCodeRedirectHandler = getAuthCodeRedirectHandler({ + storageUtility: mockedStorage, + }); + + await expect( + authCodeRedirectHandler.handle( + "https://my.app/redirect?code=someCode&state=someState" + ) + ).rejects.toThrow( + "No stored session is associated with the state [someState]" + ); + }); + }); +}); diff --git a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts new file mode 100644 index 0000000..b161b30 --- /dev/null +++ b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts @@ -0,0 +1,206 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { + IClient, + IClientRegistrar, + IIssuerConfigFetcher, + IIncomingRedirectHandler, + ISessionInfo, + ISessionInfoManager, + IStorageUtility, + loadOidcContextFromStorage, + saveSessionInfoToStorage, + getSessionIdFromOauthState, + getWebidFromTokenPayload, + KeyPair, + generateDpopKeyPair, + RefreshOptions, + ITokenRefresher, + buildAuthenticatedFetch, + EVENTS, +} from "@inrupt/solid-client-authn-core"; +// eslint-disable-next-line no-shadow +import { URL } from "url"; +import { Issuer } from "openid-client"; +import { KeyObject } from "crypto"; +import { fetch as globalFetch } from "cross-fetch"; + +import { EventEmitter } from "events"; +import { configToIssuerMetadata } from "../IssuerConfigFetcher"; + +/** + * @hidden + * Token endpoint request: https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint + */ +export class AuthCodeRedirectHandler implements IIncomingRedirectHandler { + constructor( + private storageUtility: IStorageUtility, + private sessionInfoManager: ISessionInfoManager, + private issuerConfigFetcher: IIssuerConfigFetcher, + private clientRegistrar: IClientRegistrar, + private tokenRefresher: ITokenRefresher + ) {} + + async canHandle(redirectUrl: string): Promise { + try { + const myUrl = new URL(redirectUrl); + return ( + myUrl.searchParams.get("code") !== null && + myUrl.searchParams.get("state") !== null + ); + } catch (e) { + throw new Error( + `[${redirectUrl}] is not a valid URL, and cannot be used as a redirect URL: ${e}` + ); + } + } + + async handle( + inputRedirectUrl: string, + eventEmitter?: EventEmitter + ): Promise { + if (!(await this.canHandle(inputRedirectUrl))) { + throw new Error( + `AuthCodeRedirectHandler cannot handle [${inputRedirectUrl}]: it is missing one of [code, state].` + ); + } + + const url = new URL(inputRedirectUrl); + // The type assertion is ok, because we checked in canHandle for the presence of a state + const oauthState = url.searchParams.get("state") as string; + url.searchParams.delete("code"); + url.searchParams.delete("state"); + + const sessionId = await getSessionIdFromOauthState( + this.storageUtility, + oauthState + ); + if (sessionId === undefined) { + throw new Error( + `No stored session is associated with the state [${oauthState}]` + ); + } + + const oidcContext = await loadOidcContextFromStorage( + sessionId, + this.storageUtility, + this.issuerConfigFetcher + ); + + const issuer = new Issuer(configToIssuerMetadata(oidcContext.issuerConfig)); + // This should also retrieve the client from storage + const clientInfo: IClient = await this.clientRegistrar.getClient( + { sessionId }, + oidcContext.issuerConfig + ); + const client = new issuer.Client({ + client_id: clientInfo.clientId, + client_secret: clientInfo.clientSecret, + token_endpoint_auth_method: clientInfo.clientSecret + ? "client_secret_basic" + : "none", + id_token_signed_response_alg: clientInfo.idTokenSignedResponseAlg, + }); + + const params = client.callbackParams(inputRedirectUrl); + let dpopKey: KeyPair | undefined; + + if (oidcContext.dpop) { + dpopKey = await generateDpopKeyPair(); + } + const tokenSet = await client.callback( + url.href, + params, + { code_verifier: oidcContext.codeVerifier, state: oauthState }, + // The KeyLike type is dynamically bound to either KeyObject or CryptoKey + // at runtime depending on the environment. Here, we know we are in a NodeJS + // context. + { DPoP: dpopKey?.privateKey as KeyObject } + ); + + if ( + tokenSet.access_token === undefined || + tokenSet.id_token === undefined + ) { + // The error message is left minimal on purpose not to leak the tokens. + throw new Error( + `The Identity Provider [${issuer.metadata.issuer}] did not return the expected tokens: missing at least one of 'access_token', 'id_token.` + ); + } + let refreshOptions: RefreshOptions | undefined; + if (tokenSet.refresh_token !== undefined) { + eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refresh_token); + refreshOptions = { + refreshToken: tokenSet.refresh_token, + sessionId, + tokenRefresher: this.tokenRefresher, + }; + } + const authFetch = await buildAuthenticatedFetch( + globalFetch, + tokenSet.access_token, + { + dpopKey, + refreshOptions, + eventEmitter, + expiresIn: tokenSet.expires_in, + } + ); + + // tokenSet.claims() parses the ID token, validates its signature, and returns + // its payload as a JSON object. + const webid = await getWebidFromTokenPayload( + tokenSet.id_token, + // The JWKS URI is mandatory in the spec, so the non-null assertion is valid. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + issuer.metadata.jwks_uri!, + issuer.metadata.issuer, + client.metadata.client_id + ); + + await saveSessionInfoToStorage( + this.storageUtility, + sessionId, + webid, + "true", + tokenSet.refresh_token, + undefined, + dpopKey + ); + + const sessionInfo = await this.sessionInfoManager.get(sessionId); + if (!sessionInfo) { + throw new Error( + `Could not find any session information associated with SessionID [${sessionId}] in our storage.` + ); + } + + return Object.assign(sessionInfo, { + fetch: authFetch, + }); + } +} diff --git a/packages/node/src/login/oidc/incomingRedirectHandler/FallbackRedirectHandler.spec.ts b/packages/node/src/login/oidc/incomingRedirectHandler/FallbackRedirectHandler.spec.ts new file mode 100644 index 0000000..065b1f2 --- /dev/null +++ b/packages/node/src/login/oidc/incomingRedirectHandler/FallbackRedirectHandler.spec.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { FallbackRedirectHandler } from "./FallbackRedirectHandler"; + +jest.mock("cross-fetch"); + +describe("FallbackRedirectHandler", () => { + describe("canHandle", () => { + it("always accept the given IRI", async () => { + const redirectHandler = new FallbackRedirectHandler(); + expect( + await redirectHandler.canHandle( + "https://coolparty.com/?code=someCode&state=oauth2_state_value" + ) + ).toBe(true); + expect(await redirectHandler.canHandle("https://coolparty.com/")).toBe( + true + ); + expect( + await redirectHandler.canHandle("https://coolparty.com/?test=test") + ).toBe(true); + }); + + it("throws on invalid url", async () => { + const redirectHandler = new FallbackRedirectHandler(); + await expect( + redirectHandler.canHandle("beep boop I am a robot") + ).rejects.toThrow( + "[beep boop I am a robot] is not a valid URL, and cannot be used as a redirect URL" + ); + }); + }); + + describe("handle", () => { + it("returns an unauthenticated session", async () => { + const redirectHandler = new FallbackRedirectHandler(); + const mySession = await redirectHandler.handle("https://my.app"); + expect(mySession.isLoggedIn).toBe(false); + expect(mySession.webId).toBeUndefined(); + }); + }); +}); diff --git a/packages/node/src/login/oidc/incomingRedirectHandler/FallbackRedirectHandler.ts b/packages/node/src/login/oidc/incomingRedirectHandler/FallbackRedirectHandler.ts new file mode 100644 index 0000000..40ab7a4 --- /dev/null +++ b/packages/node/src/login/oidc/incomingRedirectHandler/FallbackRedirectHandler.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { + IIncomingRedirectHandler, + ISessionInfo, +} from "@inrupt/solid-client-authn-core"; +// eslint-disable-next-line no-shadow +import { URL } from "url"; + +import { getUnauthenticatedSession } from "../../../sessionInfo/SessionInfoManager"; + +/** + * This class handles redirect IRIs without any query params, and returns an unauthenticated + * session. It serves as a fallback so that consuming libraries don't have to test + * for the query params themselves, and can always try to use them as a redirect IRI. + * @hidden + */ +export class FallbackRedirectHandler implements IIncomingRedirectHandler { + async canHandle(redirectUrl: string): Promise { + try { + // The next URL object is built for validating it. + // eslint-disable-next-line no-new + new URL(redirectUrl); + return true; + } catch (e) { + throw new Error( + `[${redirectUrl}] is not a valid URL, and cannot be used as a redirect URL: ${e}` + ); + } + } + + async handle( + // The argument is ignored, but must be present to implement the interface + _redirectUrl: string + ): Promise { + return getUnauthenticatedSession(); + } +} diff --git a/packages/node/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.spec.ts b/packages/node/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.spec.ts new file mode 100644 index 0000000..d6f288f --- /dev/null +++ b/packages/node/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.spec.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * Test for AuthorizationCodeWithPkceOidcHandler + */ +import { it, describe, expect } from "@jest/globals"; +import { + mockStorageUtility, + StorageUtilityMock, +} from "@inrupt/solid-client-authn-core"; +// eslint-disable-next-line no-shadow +import { URL } from "url"; +import AuthorizationCodeWithPkceOidcHandler from "./AuthorizationCodeWithPkceOidcHandler"; +import canHandleTests from "./OidcHandlerCanHandleTests"; +import { SessionInfoManagerMock } from "../../../sessionInfo/__mocks__/SessionInfoManager"; +import { + mockDefaultOidcOptions, + mockOidcOptions, +} from "../__mocks__/IOidcOptions"; +import { mockRedirector, mockedRedirector } from "../__mocks__/Redirector"; + +describe("AuthorizationCodeWithPkceOidcHandler", () => { + const defaultMocks = { + sessionCreator: SessionInfoManagerMock, + storageUtility: StorageUtilityMock, + redirector: mockRedirector(), + }; + + function getAuthorizationCodeWithPkceOidcHandler( + mocks: Partial = defaultMocks + ): AuthorizationCodeWithPkceOidcHandler { + return new AuthorizationCodeWithPkceOidcHandler( + mocks.storageUtility ?? defaultMocks.storageUtility, + mocks.redirector ?? defaultMocks.redirector + ); + } + + describe("canHandle", () => { + const authorizationCodeWithPkceOidcHandler = + getAuthorizationCodeWithPkceOidcHandler(); + canHandleTests.authorizationCodeWithPkceOidcHandler.forEach( + (testConfig) => { + // eslint-disable-next-line jest/valid-title + it(testConfig.message, async () => { + const value = await authorizationCodeWithPkceOidcHandler.canHandle( + testConfig.oidcOptions + ); + expect(value).toBe(testConfig.shouldPass); + }); + } + ); + }); + + describe("handle", () => { + it("redirects the user to the specified IdP", async () => { + const authorizationCodeWithPkceOidcHandler = + getAuthorizationCodeWithPkceOidcHandler(); + + await authorizationCodeWithPkceOidcHandler.handle( + mockDefaultOidcOptions() + ); + + const builtUrl = new URL(mockedRedirector.mock.calls[0][0]); + expect(builtUrl.hostname).toEqual( + new URL(mockDefaultOidcOptions().issuer).hostname + ); + }); + + it("sets the specified options in the query params", async () => { + const authorizationCodeWithPkceOidcHandler = + getAuthorizationCodeWithPkceOidcHandler(); + const oidcOptions = mockDefaultOidcOptions(); + + await authorizationCodeWithPkceOidcHandler.handle(oidcOptions); + + const builtUrl = new URL(mockedRedirector.mock.calls[0][0]); + expect(builtUrl.searchParams.get("client_id")).toEqual( + oidcOptions.client.clientId + ); + expect(builtUrl.searchParams.get("response_type")).toBe("code"); + expect(builtUrl.searchParams.get("redirect_uri")).toEqual( + oidcOptions.redirectUrl + ); + expect(builtUrl.searchParams.get("code_challenge")).not.toBeNull(); + expect(builtUrl.searchParams.get("prompt")).toBe("consent"); + expect(builtUrl.searchParams.get("scope")).toBe( + "openid offline_access webid" + ); + }); + + it("saves relevant information in storage", async () => { + const mockedStorage = mockStorageUtility({}); + const authorizationCodeWithPkceOidcHandler = + getAuthorizationCodeWithPkceOidcHandler({ + storageUtility: mockedStorage, + }); + const oidcOptions = mockDefaultOidcOptions(); + + await authorizationCodeWithPkceOidcHandler.handle(oidcOptions); + + await expect( + mockedStorage.getForUser(oidcOptions.sessionId, "codeVerifier") + ).resolves.not.toBeNull(); + await expect( + mockedStorage.getForUser(oidcOptions.sessionId, "issuer") + ).resolves.toEqual(oidcOptions.issuer); + await expect( + mockedStorage.getForUser(oidcOptions.sessionId, "redirectUrl") + ).resolves.toEqual(oidcOptions.redirectUrl); + await expect( + mockedStorage.getForUser(oidcOptions.sessionId, "dpop") + ).resolves.toBe("true"); + }); + + it("serializes the token type boolean appropriately", async () => { + const mockedStorage = mockStorageUtility({}); + const authorizationCodeWithPkceOidcHandler = + getAuthorizationCodeWithPkceOidcHandler({ + storageUtility: mockedStorage, + }); + const oidcOptions = mockOidcOptions({ + dpop: false, + }); + + await authorizationCodeWithPkceOidcHandler.handle(oidcOptions); + + await expect( + mockedStorage.getForUser(oidcOptions.sessionId, "dpop") + ).resolves.toBe("false"); + }); + }); +}); diff --git a/packages/node/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.ts b/packages/node/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.ts new file mode 100644 index 0000000..2afc016 --- /dev/null +++ b/packages/node/src/login/oidc/oidcHandlers/AuthorizationCodeWithPkceOidcHandler.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Handler for the Authorization Code with PKCE Flow + */ +import { + IOidcHandler, + IOidcOptions, + IRedirector, + IStorageUtility, + LoginResult, + DEFAULT_SCOPES, +} from "@inrupt/solid-client-authn-core"; +import { Issuer, generators } from "openid-client"; +import { configToIssuerMetadata } from "../IssuerConfigFetcher"; + +/** + * @hidden + * Authorization code flow spec: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth + * PKCE: https://tools.ietf.org/html/rfc7636 + */ +export default class AuthorizationCodeWithPkceOidcHandler + implements IOidcHandler +{ + constructor( + private storageUtility: IStorageUtility, + private redirector: IRedirector + ) {} + + async canHandle(oidcLoginOptions: IOidcOptions): Promise { + return !!( + oidcLoginOptions.issuerConfiguration.grantTypesSupported && + oidcLoginOptions.issuerConfiguration.grantTypesSupported.indexOf( + "authorization_code" + ) > -1 + ); + } + + async handle(oidcLoginOptions: IOidcOptions): Promise { + const issuer = new Issuer( + configToIssuerMetadata(oidcLoginOptions.issuerConfiguration) + ); + const client = new issuer.Client({ + client_id: oidcLoginOptions.client.clientId, + client_secret: oidcLoginOptions.client.clientSecret, + }); + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + const state = generators.state(); + + const targetUrl = client.authorizationUrl({ + code_challenge: codeChallenge, + state, + response_type: "code", + redirect_uri: oidcLoginOptions.redirectUrl, + code_challenge_method: "S256", + prompt: "consent", + scope: DEFAULT_SCOPES, + }); + + // Stores information to be reused after reload + await Promise.all([ + this.storageUtility.setForUser(state, { + sessionId: oidcLoginOptions.sessionId, + }), + this.storageUtility.setForUser(oidcLoginOptions.sessionId, { + codeVerifier, + issuer: oidcLoginOptions.issuer, + redirectUrl: oidcLoginOptions.redirectUrl, + dpop: oidcLoginOptions.dpop ? "true" : "false", + }), + ]); + + this.redirector.redirect(targetUrl, { + handleRedirect: oidcLoginOptions.handleRedirect, + }); + // The login is only completed AFTER redirect, so there is nothing to return. + return undefined; + } +} diff --git a/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.spec.ts b/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.spec.ts new file mode 100644 index 0000000..e984159 --- /dev/null +++ b/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.spec.ts @@ -0,0 +1,441 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * Test for AuthorizationCodeWithPkceOidcHandler + */ +import { mockStorageUtility } from "@inrupt/solid-client-authn-core"; +import type * as SolidClientAuthnCore from "@inrupt/solid-client-authn-core"; +import { jest, it, describe, expect } from "@jest/globals"; +import { IdTokenClaims, TokenSet } from "openid-client"; +import type * as OpenidClient from "openid-client"; +import { JWK } from "jose"; +// eslint-disable-next-line no-shadow +import { Headers, Response } from "cross-fetch"; +import type * as CrossFetch from "cross-fetch"; + +import { mockDefaultTokenRefresher } from "../refresh/__mocks__/TokenRefresher"; +import { standardOidcOptions } from "../__mocks__/IOidcOptions"; +import ClientCredentialsOidcHandler from "./ClientCredentialsOidcHandler"; + +import { mockDefaultIssuerConfig } from "../__mocks__/IssuerConfigFetcher"; + +jest.mock("openid-client"); + +jest.mock("cross-fetch", () => { + return { + ...(jest.requireActual("cross-fetch") as typeof CrossFetch), + default: jest.fn(), + fetch: jest.fn(), + } as typeof CrossFetch; +}); + +jest.mock("@inrupt/solid-client-authn-core", () => { + const actualCoreModule = jest.requireActual( + "@inrupt/solid-client-authn-core" + ) as typeof SolidClientAuthnCore; + return { + ...actualCoreModule, + // This works around the network lookup to the JWKS in order to validate the ID token. + // getWebidFromTokenPayload: jest.fn(() => + // Promise.resolve("https://my.webid/") + // ), + getWebidFromTokenPayload: jest.fn(), + }; +}); +jest.useFakeTimers(); + +const mockJwk = (): JWK => { + return { + kty: "EC", + kid: "oOArcXxcwvsaG21jAx_D5CHr4BgVCzCEtlfmNFQtU0s", + alg: "ES256", + crv: "P-256", + x: "0dGe_s-urLhD3mpqYqmSXrqUZApVV5ZNxMJXg7Vp-2A", + y: "-oMe9gGkpfIrnJ0aiSUHMdjqYVm5ZrGCeQmRKoIIfj8", + d: "yR1bCsR7m4hjFCvWo8Jw3OfNR4aiYDAFbBD9nkudJKM", + }; +}; + +const mockIdToken = (): string => + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJodHRwczovL215LndlYmlkIiwiaXNzIjoiaHR0cHM6Ly9teS5pZHAvIiwiYXVkIjoiaHR0cHM6Ly9yZXNvdXJjZS5leGFtcGxlLm9yZyIsImV4cCI6MTY2MjI2NjIxNiwiaWF0IjoxNDYyMjY2MjE2fQ.IwumuwJtQw5kUBMMHAaDPJBppfBpRHbiXZw_HlKe6GNVUWUlyQRYV7W7r9OQtHmMsi6GVwOckelA3ErmhrTGVw"; + +type AccessJwt = { + webid: string; + iss: string; + aud: string; + nbf: number; + exp: number; + cnf: { + jkt: string; + }; +}; + +const mockWebId = (): string => "https://my.webid/"; + +const mockKeyBoundToken = (): AccessJwt => { + return { + webid: mockWebId(), + iss: mockDefaultIssuerConfig().issuer.toString(), + aud: "https://resource.example.org", + nbf: 1562262611, + exp: 1562266216, + cnf: { + jkt: mockJwk().kid as string, + }, + }; +}; + +const mockIdTokenPayload = (): IdTokenClaims => { + return { + sub: "https://my.webid/", + iss: "https://my.idp/", + aud: "https://resource.example.org", + exp: 1662266216, + iat: 1462266216, + }; +}; + +const mockDpopTokens = (): TokenSet => { + return { + access_token: JSON.stringify(mockKeyBoundToken()), + id_token: mockIdToken(), + token_type: "DPoP", + expired: () => false, + claims: mockIdTokenPayload, + }; +}; + +const mockBearerTokens = (): TokenSet => { + return { + access_token: "some token", + id_token: mockIdToken(), + token_type: "Bearer", + expired: () => false, + claims: mockIdTokenPayload, + }; +}; + +const setupOidcClientMock = (tokenSet: TokenSet) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { Issuer } = jest.requireMock("openid-client") as jest.Mocked< + typeof OpenidClient + >; + function clientConstructor() { + // this is untyped, which makes TS complain + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.grant = jest.fn().mockResolvedValueOnce(tokenSet); + } + + const mockedIssuer = jest.mocked({ + metadata: mockDefaultIssuerConfig(), + Client: clientConstructor, + // Cast to unknown because only partially mocked + } as unknown as OpenidClient.Issuer); + Issuer.mockReturnValueOnce(mockedIssuer); +}; + +const setupGetWebidMock = (webid: string) => { + const { getWebidFromTokenPayload } = jest.requireMock( + "@inrupt/solid-client-authn-core" + ) as jest.Mocked; + getWebidFromTokenPayload.mockResolvedValueOnce(webid); +}; + +describe("ClientCredentialsOidcHandler", () => { + describe("canHandle", () => { + it("cannot handle if the client ID is missing", async () => { + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + await expect( + clientCredentialsOidcHandler.canHandle({ + ...standardOidcOptions, + client: { + clientId: undefined as unknown as string, + clientType: "static", + }, + }) + ).resolves.toBe(false); + }); + + it("cannot handle if the client secret is missing", async () => { + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + await expect( + clientCredentialsOidcHandler.canHandle({ + ...standardOidcOptions, + client: { + clientId: "some client ID", + clientSecret: undefined, + clientType: "static", + }, + }) + ).resolves.toBe(false); + }); + + it("cannot handle if the client is not statically registered", async () => { + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + await expect( + clientCredentialsOidcHandler.canHandle({ + ...standardOidcOptions, + client: { + clientId: "some client ID", + clientSecret: "some secret", + clientType: "dynamic", + }, + }) + ).resolves.toBe(false); + }); + + it("can handle if both client ID and secret are present for a confidential client", async () => { + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + await expect( + clientCredentialsOidcHandler.canHandle({ + ...standardOidcOptions, + client: { + clientId: "some client ID", + clientSecret: "some client secret", + clientType: "static", + }, + }) + ).resolves.toBe(true); + }); + }); +}); + +describe("handle", () => { + it("throws if the issuer does not return an access token", async () => { + const tokens = mockDpopTokens(); + tokens.access_token = undefined; + setupOidcClientMock(tokens); + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + await expect( + clientCredentialsOidcHandler.handle({ + ...standardOidcOptions, + client: { + clientId: "some client ID", + clientSecret: "some client secret", + clientType: "static", + }, + }) + ).rejects.toThrow( + /Invalid response from Solid Identity Provider \[.+\]: \{.+\} is missing 'access_token'/ + ); + }); + + // Note that this is a temporary fix, and it will eventually be removed from the + // codebase once the client credential use case is properly covered by the authentication + // panel. + it("gets the WebID from the access token if the issuer does not return an ID token", async () => { + const tokens = mockDpopTokens(); + tokens.id_token = undefined; + setupOidcClientMock(tokens); + setupGetWebidMock("https://my.webid/"); + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + + const sessionInfo = await clientCredentialsOidcHandler.handle({ + ...standardOidcOptions, + client: { + clientId: "some client ID", + clientSecret: "some client secret", + clientType: "static", + }, + }); + // The session's WebID should have been picked up from the access token in + // the absence of an ID token. + expect((sessionInfo as SolidClientAuthnCore.ISessionInfo).webId).toBe( + "https://my.webid/" + ); + }); + + // Note that this is a temporary fix, and it will eventually be removed from the + // codebase once the client credential use case is properly covered by the authentication + // panel. This encodes assumptions about the Access Token which have emerged + // in the current implementations, but is out of scope of the Solid-OIDC specification. + it("throws if the Access Token doesn't have the expected shape and the issuer does not return an ID token", async () => { + const tokens = mockDpopTokens(); + tokens.id_token = undefined; + setupOidcClientMock(tokens); + const { getWebidFromTokenPayload } = jest.requireMock( + "@inrupt/solid-client-authn-core" + ) as jest.Mocked; + // Pretend the token validation function throws + getWebidFromTokenPayload.mockRejectedValueOnce(new Error("Bad audience")); + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + + await expect( + clientCredentialsOidcHandler.handle({ + ...standardOidcOptions, + client: { + clientId: "some client ID", + clientSecret: "some client secret", + clientType: "static", + }, + }) + ).rejects.toThrow("Bad audience"); + }); + + it("builds a fetch authenticated with a DPoP token if appropriate", async () => { + const tokens = mockDpopTokens(); + setupOidcClientMock(tokens); + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + const result = await clientCredentialsOidcHandler.handle({ + ...standardOidcOptions, + dpop: true, + client: { + clientId: "some client ID", + clientSecret: "some client secret", + clientType: "static", + }, + }); + + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + mockedFetch.mockResolvedValue( + new Response(undefined, { + status: 200, + }) + ); + await result?.fetch("https://some.pod/resource"); + const headers = new Headers(mockedFetch.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toContain( + `DPoP ${tokens.access_token}` + ); + }); + + it("builds a fetch authenticated with a Bearer token if appropriate", async () => { + const tokens = mockBearerTokens(); + setupOidcClientMock(tokens); + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + const result = await clientCredentialsOidcHandler.handle({ + ...standardOidcOptions, + dpop: false, + client: { + clientId: "some client ID", + clientSecret: "some client secret", + clientType: "static", + }, + }); + + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + mockedFetch.mockResolvedValue( + new Response(undefined, { + status: 200, + }) + ); + await result?.fetch("https://some.pod/resource"); + const headers = new Headers(mockedFetch.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toContain( + `Bearer ${tokens.access_token}` + ); + }); + + it("builds a fetch authenticated handling the refresh flow if appropriate", async () => { + const tokens = mockDpopTokens(); + tokens.refresh_token = "some refresh token"; + setupOidcClientMock(tokens); + const coreModule = jest.requireMock( + "@inrupt/solid-client-authn-core" + ) as typeof SolidClientAuthnCore; + const mockAuthenticatedFetchBuild = jest.spyOn( + coreModule, + "buildAuthenticatedFetch" + ); + const mockedRefresher = mockDefaultTokenRefresher(); + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockedRefresher, + mockStorageUtility({}) + ); + await clientCredentialsOidcHandler.handle({ + ...standardOidcOptions, + dpop: true, + client: { + clientId: "some client ID", + clientSecret: "some client secret", + clientType: "static", + }, + }); + + expect(mockAuthenticatedFetchBuild).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + refreshOptions: { + refreshToken: "some refresh token", + sessionId: "mySession", + tokenRefresher: mockedRefresher, + }, + }) + ); + }); + + it("returns session info with the built fetch", async () => { + const tokens = mockDpopTokens(); + setupOidcClientMock(tokens); + setupGetWebidMock("https://my.webid/"); + const clientCredentialsOidcHandler = new ClientCredentialsOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + const result = await clientCredentialsOidcHandler.handle({ + ...standardOidcOptions, + dpop: true, + client: { + clientId: "some client ID", + clientSecret: "some client secret", + clientType: "static", + }, + }); + + expect(result?.isLoggedIn).toBe(true); + expect(result?.sessionId).toBe(standardOidcOptions.sessionId); + expect(result?.webId).toBe("https://my.webid/"); + }); +}); diff --git a/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts b/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts new file mode 100644 index 0000000..15a237e --- /dev/null +++ b/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts @@ -0,0 +1,154 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Handler for the Client Credentials Flow + */ +import { + IOidcHandler, + IOidcOptions, + LoginResult, + IStorageUtility, + ISessionInfo, + KeyPair, + generateDpopKeyPair, + PREFERRED_SIGNING_ALG, + getWebidFromTokenPayload, + buildAuthenticatedFetch, + ITokenRefresher, + DEFAULT_SCOPES, +} from "@inrupt/solid-client-authn-core"; +import { KeyObject } from "crypto"; +import { Issuer } from "openid-client"; +import { fetch as globalFetch } from "cross-fetch"; +import { configToIssuerMetadata } from "../IssuerConfigFetcher"; + +/** + * @hidden + */ +export default class ClientCredentialsOidcHandler implements IOidcHandler { + constructor( + private tokenRefresher: ITokenRefresher, + private _storageUtility: IStorageUtility + ) {} + + async canHandle(oidcLoginOptions: IOidcOptions): Promise { + return ( + typeof oidcLoginOptions.client.clientId === "string" && + typeof oidcLoginOptions.client.clientSecret === "string" && + oidcLoginOptions.client.clientType === "static" + ); + } + + async handle(oidcLoginOptions: IOidcOptions): Promise { + const issuer = new Issuer( + configToIssuerMetadata(oidcLoginOptions.issuerConfiguration) + ); + const client = new issuer.Client({ + client_id: oidcLoginOptions.client.clientId, + client_secret: oidcLoginOptions.client.clientSecret, + }); + + let dpopKey: KeyPair | undefined; + + if (oidcLoginOptions.dpop) { + dpopKey = await generateDpopKeyPair(); + // The alg property isn't set by exportJWK, so set it manually. + [dpopKey.publicKey.alg] = PREFERRED_SIGNING_ALG; + } + + const tokens = await client.grant( + { + grant_type: "client_credentials", + token_endpoint_auth_method: "client_secret_basic", + scope: DEFAULT_SCOPES, + }, + { + DPoP: + oidcLoginOptions.dpop && dpopKey !== undefined + ? (dpopKey.privateKey as KeyObject) + : undefined, + } + ); + + let webId: string; + if (tokens.access_token === undefined) { + throw new Error( + `Invalid response from Solid Identity Provider [${ + oidcLoginOptions.issuer + }]: ${JSON.stringify(tokens)} is missing 'access_token'.` + ); + } + + if (tokens.id_token === undefined) { + // In the case where no ID token is provided, the access token is used to + // get the authenticated user's WebID. This is only a temporary solution, + // as eventually we want to move away from the Identity Provider issuing + // Access Tokens, but by then panel work for the bot use case support will + // have moved forward. + webId = await getWebidFromTokenPayload( + tokens.access_token, + oidcLoginOptions.issuerConfiguration.jwksUri, + oidcLoginOptions.issuer, + // When validating the Access Token, the audience should always be 'solid' + "solid" + ); + } else { + webId = await getWebidFromTokenPayload( + tokens.id_token, + oidcLoginOptions.issuerConfiguration.jwksUri, + oidcLoginOptions.issuer, + oidcLoginOptions.client.clientId + ); + } + + const authFetch = await buildAuthenticatedFetch( + globalFetch, + tokens.access_token, + { + dpopKey, + refreshOptions: tokens.refresh_token + ? { + refreshToken: tokens.refresh_token, + sessionId: oidcLoginOptions.sessionId, + tokenRefresher: this.tokenRefresher, + } + : undefined, + eventEmitter: oidcLoginOptions.eventEmitter, + } + ); + + const sessionInfo: ISessionInfo = { + isLoggedIn: true, + sessionId: oidcLoginOptions.sessionId, + webId, + }; + + return Object.assign(sessionInfo, { + fetch: authFetch, + }); + } +} diff --git a/packages/node/src/login/oidc/oidcHandlers/OidcHandlerCanHandleTests.ts b/packages/node/src/login/oidc/oidcHandlers/OidcHandlerCanHandleTests.ts new file mode 100644 index 0000000..ff6f336 --- /dev/null +++ b/packages/node/src/login/oidc/oidcHandlers/OidcHandlerCanHandleTests.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { IOidcOptions } from "@inrupt/solid-client-authn-core"; +import { standardOidcOptions } from "../__mocks__/IOidcOptions"; + +const canHandleTests: { + [key: string]: { + oidcOptions: IOidcOptions; + message: string; + shouldPass: boolean; + }[]; +} = { + legacyImplicitFlowOidcHandler: [ + { + message: + "should accept a configuration with many grant types including implicit", + shouldPass: true, + oidcOptions: { + ...standardOidcOptions, + issuerConfiguration: { + ...standardOidcOptions.issuerConfiguration, + grantTypesSupported: ["authorization_code", "implicit", "device"], + }, + }, + }, + { + message: + "should accept a configuration with only the implicit grant type", + shouldPass: true, + oidcOptions: { + ...standardOidcOptions, + issuerConfiguration: { + ...standardOidcOptions.issuerConfiguration, + grantTypesSupported: ["implicit"], + }, + }, + }, + { + message: + "shouldn't accept a configuration that has many grant types not including implicit", + shouldPass: false, + oidcOptions: { + ...standardOidcOptions, + issuerConfiguration: { + ...standardOidcOptions.issuerConfiguration, + grantTypesSupported: ["authorization_code", "device"], + }, + }, + }, + { + message: + "shouldn't accept a configuration that does not include grant types", + shouldPass: false, + oidcOptions: standardOidcOptions, + }, + ], + authorizationCodeWithPkceOidcHandler: [ + { + message: + "should accept a configuration with many grant types including authorization code", + shouldPass: true, + oidcOptions: { + ...standardOidcOptions, + issuerConfiguration: { + ...standardOidcOptions.issuerConfiguration, + grantTypesSupported: ["authorization_code", "implicit", "device"], + }, + }, + }, + { + message: + "should accept a configuration with only the authorization code grant type", + shouldPass: true, + oidcOptions: { + ...standardOidcOptions, + issuerConfiguration: { + ...standardOidcOptions.issuerConfiguration, + grantTypesSupported: ["authorization_code"], + }, + }, + }, + { + message: + "shouldn't accept a configuration that has many grant types not including authorization code", + shouldPass: false, + oidcOptions: { + ...standardOidcOptions, + issuerConfiguration: { + ...standardOidcOptions.issuerConfiguration, + grantTypesSupported: ["implicit", "device"], + }, + }, + }, + { + message: + "shouldn't accept a configuration that does not include grant types", + shouldPass: false, + oidcOptions: standardOidcOptions, + }, + ], +}; + +export default canHandleTests; diff --git a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.spec.ts b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.spec.ts new file mode 100644 index 0000000..d3558ca --- /dev/null +++ b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.spec.ts @@ -0,0 +1,457 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { + generateDpopKeyPair, + mockStorageUtility, + USER_SESSION_PREFIX, +} from "@inrupt/solid-client-authn-core"; +import type * as SolidClientAuthnCore from "@inrupt/solid-client-authn-core"; +import { jwtVerify, exportJWK } from "jose"; +import { EventEmitter } from "events"; +import { Headers as NodeHeaders, Response as NodeResponse } from "cross-fetch"; +import type * as CrossFetch from "cross-fetch"; +import { + mockDefaultOidcOptions, + mockOidcOptions, +} from "../__mocks__/IOidcOptions"; +import RefreshTokenOidcHandler from "./RefreshTokenOidcHandler"; +import { + mockDefaultTokenRefresher, + mockDefaultTokenSet, + mockTokenRefresher, +} from "../refresh/__mocks__/TokenRefresher"; + +jest.mock("cross-fetch", () => { + return { + ...(jest.requireActual("cross-fetch") as typeof CrossFetch), + default: jest.fn(), + fetch: jest.fn(), + } as typeof CrossFetch; +}); + +jest.mock("@inrupt/solid-client-authn-core", () => { + const actualCoreModule = jest.requireActual( + "@inrupt/solid-client-authn-core" + ) as typeof SolidClientAuthnCore; + return { + ...actualCoreModule, + // This works around the network lookup to the JWKS in order to validate the ID token. + getWebidFromTokenPayload: jest.fn(() => + Promise.resolve("https://my.webid/") + ), + }; +}); +jest.useFakeTimers(); + +describe("RefreshTokenOidcHandler", () => { + describe("canHandle", () => { + it("doesn't handle options missing a refresh token", async () => { + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + await expect( + refreshTokenOidcHandler.canHandle( + mockOidcOptions({ + refreshToken: undefined, + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + }) + ) + ).resolves.toBe(false); + }); + + it("doesn't handle options missing a client ID", async () => { + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + await expect( + refreshTokenOidcHandler.canHandle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + // TS would prevent this configuration + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + clientId: undefined, + clientSecret: "some client secret", + clientType: "dynamic", + }, + }) + ) + ).resolves.toBe(false); + }); + }); + + describe("handle", () => { + it("throws if the refresh token is missing", async () => { + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + await expect(() => + refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: undefined, + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + }) + ) + ).rejects.toThrow("missing one of 'refreshToken', 'clientId'"); + }); + + it("uses the refresh token to get an access token", async () => { + const mockedTokenRefresher = mockDefaultTokenRefresher(); + const mockedRefreshFunction = jest.spyOn(mockedTokenRefresher, "refresh"); + + // This builds the fetch function holding the refresh token... + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockedTokenRefresher, + mockStorageUtility({}) + ); + const result = await refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + }) + ); + expect(result?.webId).toBe("https://my.webid/"); + + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + mockedFetch.mockResolvedValue({ + ...new NodeResponse(undefined, { status: 401 }), + url: "https://my.pod/resource", + }); + if (result !== undefined) { + // ... and this should trigger the refresh flow. + await result.fetch("https://some.pod/resource"); + } + expect(mockedRefreshFunction).toHaveBeenCalled(); + }); + + it("returns an authenticated fetch if the credentials are valid", async () => { + // This builds the fetch function holding the refresh token... + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + const result = (await refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + }) + )) as SolidClientAuthnCore.LoginResult; + expect(result).toBeDefined(); + expect(result?.webId).toBe("https://my.webid/"); + + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + mockedFetch.mockResolvedValue({ + ...new NodeResponse(undefined, { status: 200 }), + url: "https://my.pod/resource", + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await result!.fetch("https://some.pod/resource"); + const headers = new NodeHeaders(mockedFetch.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toContain( + "DPoP some refreshed access token" + ); + }); + + it("reuses stored DPoP keys if any when refreshing the access token", async () => { + const dpopKeyPair = await generateDpopKeyPair(); + + // This builds the fetch function holding the refresh token... + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({ + [`${USER_SESSION_PREFIX}:mySession`]: { + publicKey: JSON.stringify(dpopKeyPair.publicKey), + privateKey: JSON.stringify(await exportJWK(dpopKeyPair.privateKey)), + }, + }) + ); + const result = await refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + }) + ); + + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + mockedFetch.mockResolvedValue({ + ...new NodeResponse(undefined, { status: 200 }), + url: "https://my.pod/resource", + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await result!.fetch("https://some.pod/resource"); + const headers = new NodeHeaders(mockedFetch.mock.calls[0][1]?.headers); + const dpopProof = headers.get("DPoP"); + // This checks that the refreshed access token is bound to the initial DPoP key. + await expect( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + jwtVerify(dpopProof!, dpopKeyPair.privateKey) + ).resolves.not.toThrow(); + }); + + it("returns a bearer-authenticated fetch if the credentials are valid", async () => { + // This builds the fetch function holding the refresh token... + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockDefaultTokenRefresher(), + mockStorageUtility({}) + ); + const result = await refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + dpop: false, + }) + ); + expect(result).toBeDefined(); + + const { fetch: mockedFetch } = jest.requireMock( + "cross-fetch" + ) as jest.Mocked; + + mockedFetch.mockResolvedValue({ + ...new NodeResponse(undefined, { status: 200 }), + url: "https://my.pod/resource", + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await result!.fetch("https://some.pod/resource"); + const headers = new NodeHeaders(mockedFetch.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toContain( + "Bearer some refreshed access token" + ); + }); + + it("stores OIDC context in storage so that refreshing the token succeeds later", async () => { + const mockedStorage = mockStorageUtility({}); + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockDefaultTokenRefresher(), + mockedStorage + ); + await refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientName: "some client name", + clientType: "dynamic", + }, + }) + ); + await expect( + mockedStorage.getForUser(mockDefaultOidcOptions().sessionId, "clientId") + ).resolves.toBe("some client id"); + await expect( + mockedStorage.getForUser( + mockDefaultOidcOptions().sessionId, + "clientSecret" + ) + ).resolves.toBe("some client secret"); + await expect( + mockedStorage.getForUser( + mockDefaultOidcOptions().sessionId, + "clientName" + ) + ).resolves.toBe("some client name"); + await expect( + mockedStorage.getForUser(mockDefaultOidcOptions().sessionId, "issuer") + ).resolves.toEqual(mockDefaultOidcOptions().issuer); + }); + }); + + it("supports a public client without a secret", async () => { + const mockedStorage = mockStorageUtility({}); + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockDefaultTokenRefresher(), + mockedStorage + ); + const result = await refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: undefined, + clientName: "some client name", + clientType: "dynamic", + }, + }) + ); + expect(result).toBeDefined(); + }); + + it("throws if the IdP does not return an ID token", async () => { + // This builds the fetch function holding the refresh token... + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockTokenRefresher({ + accessToken: "some access token", + }), + mockStorageUtility({}) + ); + const result = refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + }) + ); + await expect(result).rejects.toThrow( + "The Identity Provider [https://example.com] did not return an ID token on refresh, which prevents us from getting the user's WebID." + ); + }); + + it("uses the rotated refresh token to build the DPoP-authenticated fetch if applicable", async () => { + const tokenSet = mockDefaultTokenSet(); + tokenSet.refreshToken = "some rotated refresh token"; + const mockedTokenRefresher = mockTokenRefresher(tokenSet); + const coreModule = jest.requireMock( + "@inrupt/solid-client-authn-core" + ) as typeof SolidClientAuthnCore; + const mockAuthenticatedFetchBuild = jest.spyOn( + coreModule, + "buildAuthenticatedFetch" + ); + + // This builds the fetch function holding the refresh token... + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockedTokenRefresher, + mockStorageUtility({}) + ); + const result = await refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + }) + ); + expect(result?.webId).toBe("https://my.webid/"); + + expect(mockAuthenticatedFetchBuild).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + refreshOptions: { + refreshToken: "some rotated refresh token", + sessionId: "mySession", + tokenRefresher: mockedTokenRefresher, + }, + }) + ); + }); + + it("calls the token refresher if applicable", async () => { + const tokenSet = mockDefaultTokenSet(); + tokenSet.refreshToken = "some rotated refresh token"; + const mockedTokenRefresher = mockTokenRefresher(tokenSet); + const mockEmitter = new EventEmitter(); + + // This builds the fetch function holding the refresh token... + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + mockedTokenRefresher, + mockStorageUtility({}) + ); + await refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + eventEmitter: mockEmitter, + }) + ); + + expect(mockedTokenRefresher.refresh).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything() + ); + }); + + it("throws if the credentials are incorrect", async () => { + const tokenRefresher = mockTokenRefresher({ + accessToken: "some access token", + }); + tokenRefresher.refresh = jest + .fn() + .mockRejectedValue("Invalid credentials"); + const refreshTokenOidcHandler = new RefreshTokenOidcHandler( + tokenRefresher, + mockStorageUtility({}) + ); + const result = refreshTokenOidcHandler.handle( + mockOidcOptions({ + refreshToken: "some refresh token", + client: { + clientId: "some client id", + clientSecret: "some client secret", + clientType: "dynamic", + }, + }) + ); + await expect(result).rejects.toThrow("Invalid credentials"); + }); +}); diff --git a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts new file mode 100644 index 0000000..c037d70 --- /dev/null +++ b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts @@ -0,0 +1,222 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Handler for the Refresh Token Flow + */ +import { + IOidcHandler, + IOidcOptions, + IStorageUtility, + LoginResult, + saveSessionInfoToStorage, + getWebidFromTokenPayload, + ISessionInfo, + generateDpopKeyPair, + KeyPair, + PREFERRED_SIGNING_ALG, + RefreshOptions, + ITokenRefresher, + TokenEndpointResponse, + buildAuthenticatedFetch, +} from "@inrupt/solid-client-authn-core"; +import { JWK, importJWK } from "jose"; +import { fetch as globalFetch } from "cross-fetch"; +import { EventEmitter } from "events"; +import { KeyObject } from "crypto"; + +function validateOptions( + oidcLoginOptions: IOidcOptions +): oidcLoginOptions is IOidcOptions & { + refreshToken: string; + client: { clientId: string; clientSecret: string }; +} { + return ( + oidcLoginOptions.refreshToken !== undefined && + oidcLoginOptions.client.clientId !== undefined + ); +} + +/** + * Go through the refresh flow to get a new valid access token, and build an + * authenticated fetch with it. + * @param refreshOptions + * @param dpop + */ +async function refreshAccess( + refreshOptions: RefreshOptions, + dpop: boolean, + refreshBindingKey?: KeyPair, + eventEmitter?: EventEmitter +): Promise { + try { + let dpopKey: KeyPair | undefined; + if (dpop) { + dpopKey = refreshBindingKey || (await generateDpopKeyPair()); + // The alg property isn't set by exportJWK, so set it manually. + [dpopKey.publicKey.alg] = PREFERRED_SIGNING_ALG; + } + const tokens = await refreshOptions.tokenRefresher.refresh( + refreshOptions.sessionId, + refreshOptions.refreshToken, + dpopKey + ); + // Rotate the refresh token if applicable + const rotatedRefreshOptions = { + ...refreshOptions, + refreshToken: tokens.refreshToken ?? refreshOptions.refreshToken, + }; + const authFetch = await buildAuthenticatedFetch( + globalFetch, + tokens.accessToken, + { + dpopKey, + refreshOptions: rotatedRefreshOptions, + eventEmitter, + } + ); + return Object.assign(tokens, { + fetch: authFetch, + }); + } catch (e) { + throw new Error(`Invalid refresh credentials: ${e}`); + } +} + +/** + * @hidden + * Refresh token flow spec: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens + */ +export default class RefreshTokenOidcHandler implements IOidcHandler { + constructor( + private tokenRefresher: ITokenRefresher, + private storageUtility: IStorageUtility + ) {} + + async canHandle(oidcLoginOptions: IOidcOptions): Promise { + return validateOptions(oidcLoginOptions); + } + + async handle(oidcLoginOptions: IOidcOptions): Promise { + if (!(await this.canHandle(oidcLoginOptions))) { + throw new Error( + `RefreshTokenOidcHandler cannot handle the provided options, missing one of 'refreshToken', 'clientId' in: ${JSON.stringify( + oidcLoginOptions + )}` + ); + } + const refreshOptions: RefreshOptions = { + // The type assertion is okay, because it is tested for in canHandle. + refreshToken: oidcLoginOptions.refreshToken as string, + sessionId: oidcLoginOptions.sessionId, + tokenRefresher: this.tokenRefresher, + }; + + // This information must be in storage for the refresh flow to succeed. + await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { + issuer: oidcLoginOptions.issuer, + dpop: oidcLoginOptions.dpop ? "true" : "false", + clientId: oidcLoginOptions.client.clientId, + // Note: We assume here that a client secret is present, which is checked for when validating the options. + clientSecret: oidcLoginOptions.client.clientSecret as string, + }); + + // In the case when the refresh token is bound to a DPoP key, said key must + // be used during the refresh grant. + const publicKey = await this.storageUtility.getForUser( + oidcLoginOptions.sessionId, + "publicKey" + ); + const privateKey = await this.storageUtility.getForUser( + oidcLoginOptions.sessionId, + "privateKey" + ); + let keyPair: undefined | KeyPair; + if (publicKey !== undefined && privateKey !== undefined) { + keyPair = { + publicKey: JSON.parse(publicKey) as JWK, + privateKey: (await importJWK( + JSON.parse(privateKey), + PREFERRED_SIGNING_ALG[0] + )) as KeyObject, + }; + } + + const accessInfo = await refreshAccess( + refreshOptions, + oidcLoginOptions.dpop, + keyPair + ); + + const sessionInfo: ISessionInfo = { + isLoggedIn: true, + sessionId: oidcLoginOptions.sessionId, + }; + + if (accessInfo.idToken === undefined) { + throw new Error( + `The Identity Provider [${oidcLoginOptions.issuer}] did not return an ID token on refresh, which prevents us from getting the user's WebID.` + ); + } + sessionInfo.webId = await getWebidFromTokenPayload( + accessInfo.idToken, + oidcLoginOptions.issuerConfiguration.jwksUri, + oidcLoginOptions.issuer, + oidcLoginOptions.client.clientId + ); + + await saveSessionInfoToStorage( + this.storageUtility, + oidcLoginOptions.sessionId, + undefined, + "true", + accessInfo.refreshToken ?? refreshOptions.refreshToken, + undefined, + keyPair + ); + + await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { + issuer: oidcLoginOptions.issuer, + dpop: oidcLoginOptions.dpop ? "true" : "false", + clientId: oidcLoginOptions.client.clientId, + }); + + if (oidcLoginOptions.client.clientSecret) { + await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { + clientSecret: oidcLoginOptions.client.clientSecret, + }); + } + if (oidcLoginOptions.client.clientName) { + await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { + clientName: oidcLoginOptions.client.clientName, + }); + } + + return Object.assign(sessionInfo, { + fetch: accessInfo.fetch, + }); + } +} diff --git a/packages/node/src/login/oidc/refresh/TokenRefresher.spec.ts b/packages/node/src/login/oidc/refresh/TokenRefresher.spec.ts new file mode 100644 index 0000000..9404600 --- /dev/null +++ b/packages/node/src/login/oidc/refresh/TokenRefresher.spec.ts @@ -0,0 +1,427 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { + mockStorageUtility, + StorageUtilityMock, + EVENTS, + KeyPair, +} from "@inrupt/solid-client-authn-core"; +import { JWK, importJWK } from "jose"; +import { IdTokenClaims, TokenSet } from "openid-client"; +import { EventEmitter } from "events"; +import { KeyObject } from "crypto"; +import TokenRefresher from "./TokenRefresher"; +import { + mockClientRegistrar, + mockDefaultClientRegistrar, +} from "../__mocks__/ClientRegistrar"; +import { + mockDefaultIssuerConfig, + mockIssuerConfigFetcher, +} from "../__mocks__/IssuerConfigFetcher"; +import { negotiateClientSigningAlg } from "../ClientRegistrar"; + +jest.mock("openid-client"); +jest.mock("../ClientRegistrar"); + +const mockJwk = (): JWK => { + return { + kty: "EC", + kid: "oOArcXxcwvsaG21jAx_D5CHr4BgVCzCEtlfmNFQtU0s", + alg: "ES256", + crv: "P-256", + x: "0dGe_s-urLhD3mpqYqmSXrqUZApVV5ZNxMJXg7Vp-2A", + y: "-oMe9gGkpfIrnJ0aiSUHMdjqYVm5ZrGCeQmRKoIIfj8", + d: "yR1bCsR7m4hjFCvWo8Jw3OfNR4aiYDAFbBD9nkudJKM", + }; +}; + +const mockKeyPair = async (): Promise => { + return { + privateKey: (await importJWK(mockJwk())) as KeyObject, + // Use the same JWK for public and private key out of convenience, don't do + // this in real life. + publicKey: mockJwk(), + }; +}; + +const mockIdToken = (): string => + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJodHRwczovL215LndlYmlkIiwiaXNzIjoiaHR0cHM6Ly9teS5pZHAvIiwiYXVkIjoiaHR0cHM6Ly9yZXNvdXJjZS5leGFtcGxlLm9yZyIsImV4cCI6MTY2MjI2NjIxNiwiaWF0IjoxNDYyMjY2MjE2fQ.IwumuwJtQw5kUBMMHAaDPJBppfBpRHbiXZw_HlKe6GNVUWUlyQRYV7W7r9OQtHmMsi6GVwOckelA3ErmhrTGVw"; + +type AccessJwt = { + sub: string; + iss: string; + aud: string; + nbf: number; + exp: number; + cnf: { + jkt: string; + }; +}; + +const mockWebId = (): string => "https://my.webid/"; + +const mockKeyBoundToken = (): AccessJwt => { + return { + sub: mockWebId(), + iss: mockDefaultIssuerConfig().issuer.toString(), + aud: "https://resource.example.org", + nbf: 1562262611, + exp: 1562266216, + cnf: { + jkt: mockJwk().kid as string, + }, + }; +}; + +const mockIdTokenPayload = (): IdTokenClaims => { + return { + sub: "https://my.webid", + iss: "https://my.idp/", + aud: "https://resource.example.org", + exp: 1662266216, + iat: 1462266216, + }; +}; + +const mockDpopTokens = (): TokenSet => { + return { + access_token: JSON.stringify(mockKeyBoundToken()), + id_token: mockIdToken(), + token_type: "DPoP", + expired: () => false, + claims: mockIdTokenPayload, + }; +}; + +const setupOidcClientMock = (tokenSet: TokenSet) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { Issuer } = jest.requireMock("openid-client") as any; + const mockedIssuer = { + metadata: mockDefaultIssuerConfig(), + Client: jest.fn().mockReturnValue({ + refresh: jest.fn().mockResolvedValueOnce(tokenSet as never), + }), + }; + Issuer.mockReturnValueOnce(mockedIssuer); + return mockedIssuer; +}; + +const setupDefaultOidcClientMock = () => setupOidcClientMock(mockDpopTokens()); + +const mockDefaultStorageContent = { + "solidClientAuthenticationUser:mySession": { + issuer: "https://my.idp", + codeVerifier: "some code verifier", + redirectUrl: "https://my.app/redirect", + idTokenSignedResponseAlg: "ES256", + dpop: "true", + }, +}; + +const mockRefresherDefaultStorageUtility = () => + mockStorageUtility(mockDefaultStorageContent); + +describe("TokenRefresher", () => { + const defaultMocks = { + storageUtility: StorageUtilityMock, + issuerConfigFetcher: mockIssuerConfigFetcher(mockDefaultIssuerConfig()), + clientRegistrar: mockDefaultClientRegistrar(), + }; + + function getTokenRefresher( + mocks: Partial = defaultMocks + ): TokenRefresher { + return new TokenRefresher( + mocks.storageUtility ?? defaultMocks.storageUtility, + mocks.issuerConfigFetcher ?? defaultMocks.issuerConfigFetcher, + mocks.clientRegistrar ?? defaultMocks.clientRegistrar + ); + } + + it("throws if no OIDC issuer can be retrieved from storage", async () => { + const mockedStorage = mockStorageUtility({ + "solidClientAuthenticationUser:mySession": { + codeVerifier: "some code verifier", + redirectUrl: "https://my.app/redirect", + dpop: "true", + }, + }); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + await expect( + refresher.refresh("mySession", "some refresh token") + ).rejects.toThrow( + "Failed to retrieve OIDC context from storage associated with session [mySession]" + ); + }); + + it("throws if the token type cannot be retrieved from storage", async () => { + const mockedStorage = mockStorageUtility({ + "solidClientAuthenticationUser:mySession": { + issuer: "https://my.idp", + }, + }); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + await expect( + refresher.refresh("mySession", "some refresh token") + ).rejects.toThrow( + "Failed to retrieve OIDC context from storage associated with session [mySession]" + ); + }); + + it("throws if a refresh token isn't provided", async () => { + setupDefaultOidcClientMock(); + const mockedModule = jest.requireMock("../ClientRegistrar") as { + negotiateClientSigningAlg: typeof negotiateClientSigningAlg; + }; + mockedModule.negotiateClientSigningAlg = jest + .fn(negotiateClientSigningAlg) + .mockReturnValue("ES256"); + + const mockedStorage = mockRefresherDefaultStorageUtility(); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + await expect(refresher.refresh("mySession")).rejects.toThrow( + "Session [mySession] has no refresh token to allow it to refresh its access token." + ); + }); + + it("throws if a DPoP token is expected, but no DPoP key is provided", async () => { + setupDefaultOidcClientMock(); + const mockedModule = jest.requireMock("../ClientRegistrar") as { + negotiateClientSigningAlg: typeof negotiateClientSigningAlg; + }; + mockedModule.negotiateClientSigningAlg = jest + .fn(negotiateClientSigningAlg) + .mockReturnValue("ES256"); + const mockedStorage = mockRefresherDefaultStorageUtility(); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + await expect( + refresher.refresh("mySession", "some refresh token") + ).rejects.toThrow( + "For session [mySession], the key bound to the DPoP access token must be provided to refresh said access token." + ); + }); + + it("does not negotiate the signing algorithm if it is already set for the client", async () => { + setupDefaultOidcClientMock(); + const mockedModule = jest.requireMock("../ClientRegistrar") as { + negotiateClientSigningAlg: typeof negotiateClientSigningAlg; + }; + mockedModule.negotiateClientSigningAlg = + jest.fn(); + const refresher = getTokenRefresher({ + storageUtility: mockRefresherDefaultStorageUtility(), + clientRegistrar: mockClientRegistrar({ + clientId: "some client ID", + clientSecret: "some client secret", + idTokenSignedResponseAlg: "ES256", + clientType: "static", + }), + }); + + await refresher.refresh( + "mySession", + "some refresh token", + await mockKeyPair() + ); + + expect(mockedModule.negotiateClientSigningAlg).not.toHaveBeenCalled(); + }); + + it("uses 'none' authentication if using Solid-OIDC client identifiers", async () => { + const mockedIssuer = setupDefaultOidcClientMock(); + const refresher = getTokenRefresher({ + clientRegistrar: mockClientRegistrar({ + clientId: "https://some.client.identifier", + clientType: "solid-oidc", + idTokenSignedResponseAlg: "ES256", + }), + }); + + await refresher.refresh( + "mySession", + "some refresh token", + await mockKeyPair() + ); + + expect(mockedIssuer.Client).toHaveBeenCalledWith( + expect.objectContaining({ + token_endpoint_auth_method: "none", + }) + ); + }); + + it("refreshes a DPoP token properly", async () => { + setupDefaultOidcClientMock(); + const mockedStorage = mockRefresherDefaultStorageUtility(); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + const refreshedTokens = await refresher.refresh( + "mySession", + "some refresh token", + await mockKeyPair() + ); + + expect(refreshedTokens.accessToken).toEqual(mockDpopTokens().access_token); + }); + + it("refreshes a bearer token properly", async () => { + setupDefaultOidcClientMock(); + const mockedStorage = mockStorageUtility({ + "solidClientAuthenticationUser:mySession": { + issuer: "https://my.idp", + codeVerifier: "some code verifier", + redirectUrl: "https://my.app/redirect", + dpop: "false", + }, + }); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + const refreshedTokens = await refresher.refresh( + "mySession", + "some refresh token" + ); + + expect(refreshedTokens.accessToken).toEqual(mockDpopTokens().access_token); + }); + + it("stores the refresh token if one is returned", async () => { + const mockedTokens = mockDpopTokens(); + mockedTokens.refresh_token = "some new refresh token"; + setupOidcClientMock(mockedTokens); + + const mockedStorage = mockRefresherDefaultStorageUtility(); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + const refreshedTokens = await refresher.refresh( + "mySession", + "some old refresh token", + await mockKeyPair() + ); + expect(refreshedTokens.refreshToken).toBe("some new refresh token"); + + // Check that the session information is stored in the provided storage + await expect( + mockedStorage.getForUser("mySession", "refreshToken") + ).resolves.toBe("some new refresh token"); + }); + + it("calls the refresh token rotation handler if one is provided", async () => { + const mockedTokens = mockDpopTokens(); + mockedTokens.refresh_token = "some new refresh token"; + setupOidcClientMock(mockedTokens); + const mockedStorage = mockRefresherDefaultStorageUtility(); + const mockEmitter = new EventEmitter(); + const mockEmit = jest.spyOn(mockEmitter, "emit"); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + const refreshedTokens = await refresher.refresh( + "mySession", + "some old refresh token", + await mockKeyPair(), + mockEmitter + ); + + expect(refreshedTokens.refreshToken).toBe("some new refresh token"); + expect(mockEmit).toHaveBeenCalledWith( + EVENTS.NEW_REFRESH_TOKEN, + "some new refresh token" + ); + }); + + it("throws if the IdP does not return an access token", async () => { + const mockedTokens = mockDpopTokens(); + mockedTokens.access_token = undefined; + setupOidcClientMock(mockedTokens); + + const mockedStorage = mockRefresherDefaultStorageUtility(); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + await expect( + refresher.refresh( + "mySession", + "some old refresh token", + await mockKeyPair() + ) + ).rejects.toThrow( + `The Identity Provider [${ + mockDefaultIssuerConfig().issuer + }] did not return an access token on refresh` + ); + }); + + it("throws if the IdP returns an unknown token type", async () => { + const mockedTokens = mockDpopTokens(); + mockedTokens.token_type = "Some unknown token type"; + setupOidcClientMock(mockedTokens); + + const mockedStorage = mockRefresherDefaultStorageUtility(); + + const refresher = getTokenRefresher({ + storageUtility: mockedStorage, + }); + + await expect( + refresher.refresh( + "mySession", + "some old refresh token", + await mockKeyPair() + ) + ).rejects.toThrow( + `The Identity Provider [${ + mockDefaultIssuerConfig().issuer + }] returned an unknown token type: [Some unknown token type]` + ); + }); +}); diff --git a/packages/node/src/login/oidc/refresh/TokenRefresher.ts b/packages/node/src/login/oidc/refresh/TokenRefresher.ts new file mode 100644 index 0000000..95669b7 --- /dev/null +++ b/packages/node/src/login/oidc/refresh/TokenRefresher.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { + IClient, + IClientRegistrar, + IIssuerConfigFetcher, + IStorageUtility, + loadOidcContextFromStorage, + PREFERRED_SIGNING_ALG, + KeyPair, + ITokenRefresher, + TokenEndpointResponse, + EVENTS, +} from "@inrupt/solid-client-authn-core"; +import { Issuer, IssuerMetadata, TokenSet } from "openid-client"; +import { KeyObject } from "crypto"; +import { EventEmitter } from "events"; +import { configToIssuerMetadata } from "../IssuerConfigFetcher"; +import { negotiateClientSigningAlg } from "../ClientRegistrar"; + +// Some identifiers are not in camelcase on purpose, as they are named using the +// official names from the OIDC/OAuth2 specifications. +/* eslint-disable camelcase */ + +const tokenSetToTokenEndpointResponse = ( + tokenSet: TokenSet, + issuerMetadata: IssuerMetadata +): TokenEndpointResponse => { + if (tokenSet.access_token === undefined) { + // The error message is left minimal on purpose not to leak the tokens. + throw new Error( + `The Identity Provider [${issuerMetadata.issuer}] did not return an access token on refresh.` + ); + } + + if (tokenSet.token_type !== "Bearer" && tokenSet.token_type !== "DPoP") { + throw new Error( + `The Identity Provider [${issuerMetadata.issuer}] returned an unknown token type: [${tokenSet.token_type}].` + ); + } + return { + accessToken: tokenSet.access_token, + tokenType: tokenSet.token_type, + idToken: tokenSet.id_token, + refreshToken: tokenSet.refresh_token, + expiresAt: tokenSet.expires_at, + }; +}; + +/** + * @hidden + */ +export default class TokenRefresher implements ITokenRefresher { + constructor( + private storageUtility: IStorageUtility, + private issuerConfigFetcher: IIssuerConfigFetcher, + private clientRegistrar: IClientRegistrar + ) {} + + async refresh( + sessionId: string, + refreshToken?: string, + dpopKey?: KeyPair, + eventEmitter?: EventEmitter + ): Promise { + const oidcContext = await loadOidcContextFromStorage( + sessionId, + this.storageUtility, + this.issuerConfigFetcher + ); + + const issuer = new Issuer(configToIssuerMetadata(oidcContext.issuerConfig)); + // This should also retrieve the client from storage + const clientInfo: IClient = await this.clientRegistrar.getClient( + { sessionId }, + oidcContext.issuerConfig + ); + if (clientInfo.idTokenSignedResponseAlg === undefined) { + clientInfo.idTokenSignedResponseAlg = negotiateClientSigningAlg( + oidcContext.issuerConfig, + PREFERRED_SIGNING_ALG + ); + } + const client = new issuer.Client({ + client_id: clientInfo.clientId, + client_secret: clientInfo.clientSecret, + token_endpoint_auth_method: clientInfo.clientSecret + ? "client_secret_basic" + : "none", + id_token_signed_response_alg: clientInfo.idTokenSignedResponseAlg, + }); + + if (refreshToken === undefined) { + // TODO: in a next PR, look up storage for a refresh token + throw new Error( + `Session [${sessionId}] has no refresh token to allow it to refresh its access token.` + ); + } + + if (oidcContext.dpop && dpopKey === undefined) { + throw new Error( + `For session [${sessionId}], the key bound to the DPoP access token must be provided to refresh said access token.` + ); + } + + const tokenSet = tokenSetToTokenEndpointResponse( + await client.refresh(refreshToken, { + // openid-client does not support yet jose@3.x, and expects + // type definitions that are no longer present. However, the JWK + // type that we pass here is compatible with the API, hence the type + // assertion. + DPoP: dpopKey ? (dpopKey.privateKey as KeyObject) : undefined, + }), + issuer.metadata + ); + + if (tokenSet.refreshToken !== undefined) { + eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refreshToken); + await this.storageUtility.setForUser(sessionId, { + refreshToken: tokenSet.refreshToken, + }); + } + return tokenSet; + } +} diff --git a/packages/node/src/login/oidc/refresh/__mocks__/TokenRefresher.ts b/packages/node/src/login/oidc/refresh/__mocks__/TokenRefresher.ts new file mode 100644 index 0000000..93c2214 --- /dev/null +++ b/packages/node/src/login/oidc/refresh/__mocks__/TokenRefresher.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest } from "@jest/globals"; +import { + ITokenRefresher, + TokenEndpointResponse, +} from "@inrupt/solid-client-authn-core"; + +// Some identifiers are in camelcase on purpose. +/* eslint-disable camelcase */ + +export const mockTokenRefresher = ( + tokenSet: TokenEndpointResponse +): ITokenRefresher => { + return { + refresh: jest.fn().mockResolvedValue(tokenSet), + }; +}; + +const mockIdTokenPayload = () => { + return { + sub: "https://my.webid", + iss: "https://my.idp/", + aud: "https://resource.example.org", + exp: 1662266216, + iat: 1462266216, + }; +}; + +export const mockDefaultTokenSet = (): TokenEndpointResponse => { + return { + accessToken: "some refreshed access token", + idToken: JSON.stringify(mockIdTokenPayload()), + }; +}; + +export const mockDefaultTokenRefresher = (): ITokenRefresher => + mockTokenRefresher(mockDefaultTokenSet()); diff --git a/packages/node/src/logout/GeneralLogoutHandler.spec.ts b/packages/node/src/logout/GeneralLogoutHandler.spec.ts new file mode 100644 index 0000000..82aaacb --- /dev/null +++ b/packages/node/src/logout/GeneralLogoutHandler.spec.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { it, describe, expect } from "@jest/globals"; +import { mockStorageUtility } from "@inrupt/solid-client-authn-core"; +import LogoutHandler from "./GeneralLogoutHandler"; +import { mockSessionInfoManager } from "../sessionInfo/__mocks__/SessionInfoManager"; + +describe("GeneralLogoutHandler", () => { + const defaultMocks = { + sessionManager: mockSessionInfoManager(mockStorageUtility({})), + }; + function getInitialisedHandler( + mocks: Partial = defaultMocks + ): LogoutHandler { + return new LogoutHandler( + mocks.sessionManager ?? defaultMocks.sessionManager + ); + } + + describe("canHandle", () => { + it("should always be able to handle logout", async () => { + const logoutHandler = getInitialisedHandler(); + await expect(logoutHandler.canHandle()).resolves.toBe(true); + }); + }); + + describe("handle", () => { + it("should clear the local storage (both secure and not secure) when logging out", async () => { + const nonEmptyStorage = mockStorageUtility({ + someUser: { someKey: "someValue" }, + }); + await nonEmptyStorage.setForUser( + "someUser", + { someKey: "someValue" }, + { secure: true } + ); + const logoutHandler = getInitialisedHandler({ + sessionManager: mockSessionInfoManager(nonEmptyStorage), + }); + await logoutHandler.handle("someUser"); + await expect( + nonEmptyStorage.getForUser("someUser", "someKey", { secure: true }) + ).resolves.toBeUndefined(); + await expect( + nonEmptyStorage.getForUser("someUser", "someKey", { secure: false }) + ).resolves.toBeUndefined(); + // This test is only necessary until the key is stored safely + await expect( + nonEmptyStorage.get("clientKey", { secure: false }) + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/node/src/logout/GeneralLogoutHandler.ts b/packages/node/src/logout/GeneralLogoutHandler.ts new file mode 100644 index 0000000..d512beb --- /dev/null +++ b/packages/node/src/logout/GeneralLogoutHandler.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { + ILogoutHandler, + ISessionInfoManager, +} from "@inrupt/solid-client-authn-core"; + +/** + * @hidden + */ +export default class GeneralLogoutHandler implements ILogoutHandler { + constructor(private sessionInfoManager: ISessionInfoManager) {} + + async canHandle(): Promise { + return true; + } + + async handle(userId: string): Promise { + await this.sessionInfoManager.clear(userId); + } +} diff --git a/packages/node/src/logout/__mocks__/LogoutHandler.ts b/packages/node/src/logout/__mocks__/LogoutHandler.ts new file mode 100644 index 0000000..c2e7198 --- /dev/null +++ b/packages/node/src/logout/__mocks__/LogoutHandler.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + ILogoutHandler, + IStorageUtility, +} from "@inrupt/solid-client-authn-core"; +import { jest } from "@jest/globals"; +import { clear } from "../../sessionInfo/SessionInfoManager"; + +export const mockLogoutHandler = ( + storageUtility: IStorageUtility +): ILogoutHandler => { + return { + canHandle: jest.fn(async (_localUserId: string) => Promise.resolve(true)), + handle: jest.fn(async (localUserId: string) => { + return clear(localUserId, storageUtility); + }), + }; +}; diff --git a/packages/node/src/multiSession.spec.ts b/packages/node/src/multiSession.spec.ts new file mode 100644 index 0000000..7809c80 --- /dev/null +++ b/packages/node/src/multiSession.spec.ts @@ -0,0 +1,235 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { + InMemoryStorage, + // FIXME: use @inrupt/solid-client-authn-core/mocks instead: + mockStorage, + mockStorageUtility, +} from "@inrupt/solid-client-authn-core"; +import { + mockClientAuthentication, + mockCustomClientAuthentication, +} from "./__mocks__/ClientAuthentication"; +import { KEY_REGISTERED_SESSIONS } from "./constant"; +import { + clearSessionFromStorageAll, + getSessionFromStorage, + getSessionIdFromStorageAll, +} from "./multiSession"; +import { mockSessionInfoManager } from "./sessionInfo/__mocks__/SessionInfoManager"; + +jest.mock("./dependencies"); + +describe("getSessionFromStorage", () => { + it("returns a logged in Session if a refresh token is available in storage", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.getSessionInfo = jest + .fn() + .mockResolvedValue({ + webId: "https://my.webid", + isLoggedIn: true, + refreshToken: "some token", + issuer: "https://my.idp", + sessionId: "mySession", + }); + clientAuthentication.login = jest + .fn() + .mockResolvedValue({ + webId: "https://my.webid", + isLoggedIn: true, + sessionId: "mySession", + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + const mySession = await getSessionFromStorage("mySession", mockStorage({})); + expect(mySession?.info).toStrictEqual({ + webId: "https://my.webid", + isLoggedIn: true, + sessionId: "mySession", + }); + }); + + it("returns a logged out Session if no refresh token is available", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.getSessionInfo = jest + .fn() + .mockResolvedValueOnce({ + webId: "https://my.webid", + isLoggedIn: true, + issuer: "https://my.idp", + sessionId: "mySession", + }); + clientAuthentication.logout = jest + .fn() + .mockResolvedValueOnce(); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + const mySession = await getSessionFromStorage("mySession", mockStorage({})); + expect(mySession?.info).toStrictEqual({ + isLoggedIn: false, + sessionId: "mySession", + webId: "https://my.webid", + }); + }); + + it("returns undefined if no session id matches in storage", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.getSessionInfo = jest + .fn() + .mockResolvedValueOnce(undefined); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + const mySession = await getSessionFromStorage("mySession", mockStorage({})); + expect(mySession?.info).toBeUndefined(); + }); + + it("falls back to the environment storage if none is specified", async () => { + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.getSessionInfo = jest + .fn() + .mockResolvedValueOnce(undefined); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + await getSessionFromStorage("mySession"); + const mockDefaultStorage = new InMemoryStorage(); + expect( + dependencies.getClientAuthenticationWithDependencies + ).toHaveBeenCalledWith({ + insecureStorage: mockDefaultStorage, + secureStorage: mockDefaultStorage, + }); + }); +}); + +describe("getStoredSessionIdAll", () => { + it("returns all the session IDs available in storage", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify([ + "a session", + "another session", + ]), + }); + + const clientAuthentication = mockCustomClientAuthentication({ + sessionInfoManager: mockSessionInfoManager(storage), + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + const sessions = await getSessionIdFromStorageAll(mockStorage({})); + expect(sessions).toStrictEqual(["a session", "another session"]); + }); + + it("falls back to the environment storage if none is specified", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify([ + "a session", + "another session", + ]), + }); + + const clientAuthentication = mockCustomClientAuthentication({ + sessionInfoManager: mockSessionInfoManager(storage), + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + await getSessionIdFromStorageAll(); + const mockDefaultStorage = new InMemoryStorage(); + expect( + dependencies.getClientAuthenticationWithDependencies + ).toHaveBeenCalledWith({ + insecureStorage: mockDefaultStorage, + secureStorage: mockDefaultStorage, + }); + }); +}); + +describe("clearSessionAll", () => { + it("clears all the sessions in storage", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify([ + "a session", + "another session", + ]), + }); + + const clientAuthentication = mockCustomClientAuthentication({ + sessionInfoManager: mockSessionInfoManager(storage), + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + await clearSessionFromStorageAll(storage); + await expect(storage.get(KEY_REGISTERED_SESSIONS)).resolves.toStrictEqual( + JSON.stringify([]) + ); + }); + + it("falls back to the environment storage if none is specified", async () => { + const storage = mockStorageUtility({}); + + const clientAuthentication = mockCustomClientAuthentication({ + sessionInfoManager: mockSessionInfoManager(storage), + }); + // Mocking the type definitions of the entire DI framework is a bit too + // involved at this time, so settling for `any`: + const dependencies = jest.requireMock("./dependencies") as any; + dependencies.getClientAuthenticationWithDependencies = jest + .fn() + .mockReturnValue(clientAuthentication); + await clearSessionFromStorageAll(); + const mockDefaultStorage = new InMemoryStorage(); + expect( + dependencies.getClientAuthenticationWithDependencies + ).toHaveBeenCalledWith({ + insecureStorage: mockDefaultStorage, + secureStorage: mockDefaultStorage, + }); + }); +}); diff --git a/packages/node/src/multiSession.ts b/packages/node/src/multiSession.ts new file mode 100644 index 0000000..bbc2148 --- /dev/null +++ b/packages/node/src/multiSession.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { IStorage } from "@inrupt/solid-client-authn-core"; +import ClientAuthentication from "./ClientAuthentication"; +import { getClientAuthenticationWithDependencies } from "./dependencies"; +import { defaultStorage, Session } from "./Session"; + +/** + * Retrieve a Session from the given storage based on its session ID. If possible, + * the Session is logged in before it is returned, so that `session.fetch` may + * access private Resource without any additional interaction. + * + * If no storage is provided, a default in-memory storage will be used. It is + * instanciated once on load, and is shared across all the sessions. Since it + * is only available in memory, the storage is lost when the code stops running. + * + * A Session is available in storage as soon as it logged in once, and it is removed + * from storage on logout. + * + * @param sessionId The ID of the Session to retrieve + * @param storage The storage where the Session can be found + * @returns A session object, authenticated if possible, or undefined if no Session + * in storage matches the given ID. + */ +export async function getSessionFromStorage( + sessionId: string, + storage?: IStorage, + onNewRefreshToken?: (newToken: string) => unknown +): Promise { + const clientAuth: ClientAuthentication = storage + ? getClientAuthenticationWithDependencies({ + secureStorage: storage, + insecureStorage: storage, + }) + : getClientAuthenticationWithDependencies({ + secureStorage: defaultStorage, + insecureStorage: defaultStorage, + }); + const sessionInfo = await clientAuth.getSessionInfo(sessionId); + if (sessionInfo === undefined) { + return undefined; + } + const session = new Session({ + sessionInfo, + clientAuthentication: clientAuth, + onNewRefreshToken, + }); + if (sessionInfo.refreshToken) { + await session.login({ + oidcIssuer: sessionInfo.issuer, + }); + } + return session; +} + +/** + * Retrieve the IDs for all the Sessions available in the given storage. Note that + * it is only the Session IDs that are returned, and not Session object. Given a + * Session ID, one may use [[getSessionFromStorage]] to get the actual Session + * object, while being conscious that logging in a Session required an HTTP + * interaction, so doing it in batch for a large number of sessions may result + * in performance issues. + * + * If no storage is provided, a default in-memory storage will be used. It is + * instanciated once on load, and is shared across all the sessions. Since it + * is only available in memory, the storage is lost when the code stops running. + * + * A Session is available in storage as soon as it logged in once, and it is removed + * from storage on logout. + * + * @param storage The storage where the Session can be found + * @returns An array of Session IDs + */ +export async function getSessionIdFromStorageAll( + storage?: IStorage +): Promise { + const clientAuth: ClientAuthentication = storage + ? getClientAuthenticationWithDependencies({ + secureStorage: storage, + insecureStorage: storage, + }) + : getClientAuthenticationWithDependencies({ + secureStorage: defaultStorage, + insecureStorage: defaultStorage, + }); + return clientAuth.getSessionIdAll(); +} + +/** + * Clear the given storage from any existing Session ID. In order to remove an + * individual Session from storage, rather than going through this batch deletion, + * one may simply log the Session out calling `session.logout`. + * + * If no storage is provided, a default in-memory storage will be used. It is + * instanciated once on load, and is shared across all the sessions. Since it + * is only available in memory, the storage is lost when the code stops running. + * + * A Session is available in storage as soon as it logged in once, and it is removed + * from storage on logout. + * + * @param storage The storage where the Session can be found + */ +export async function clearSessionFromStorageAll( + storage?: IStorage +): Promise { + const clientAuth: ClientAuthentication = storage + ? getClientAuthenticationWithDependencies({ + secureStorage: storage, + insecureStorage: storage, + }) + : getClientAuthenticationWithDependencies({ + secureStorage: defaultStorage, + insecureStorage: defaultStorage, + }); + return clientAuth.clearSessionAll(); +} diff --git a/packages/node/src/sessionInfo/SessionInfoManager.spec.ts b/packages/node/src/sessionInfo/SessionInfoManager.spec.ts new file mode 100644 index 0000000..92ee78b --- /dev/null +++ b/packages/node/src/sessionInfo/SessionInfoManager.spec.ts @@ -0,0 +1,317 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import { mockStorageUtility } from "@inrupt/solid-client-authn-core"; +import { UuidGeneratorMock } from "../util/__mocks__/UuidGenerator"; +import { mockLogoutHandler } from "../logout/__mocks__/LogoutHandler"; +import { SessionInfoManager } from "./SessionInfoManager"; +import { KEY_REGISTERED_SESSIONS } from "../constant"; + +describe("SessionInfoManager", () => { + const defaultMockStorage = mockStorageUtility({}); + const defaultMocks = { + uuidGenerator: UuidGeneratorMock, + logoutHandler: mockLogoutHandler(defaultMockStorage), + storageUtility: defaultMockStorage, + }; + + function getSessionInfoManager( + mocks: Partial = defaultMocks + ): SessionInfoManager { + const sessionManager = new SessionInfoManager( + mocks.storageUtility ?? defaultMocks.storageUtility + ); + return sessionManager; + } + + describe("update", () => { + it("is not implemented yet", async () => { + const sessionManager = getSessionInfoManager({ + storageUtility: mockStorageUtility({}), + }); + await expect(async () => + sessionManager.update("commanderCool", {}) + ).rejects.toThrow("Not Implemented"); + }); + }); + + describe("get", () => { + it("retrieves a session from specified storage", async () => { + const sessionId = "commanderCool"; + + const webId = "https://zoomies.com/commanderCool#me"; + + const storageMock = mockStorageUtility({ + [`solidClientAuthenticationUser:${sessionId}`]: { + webId, + isLoggedIn: "true", + issuer: "https://some.idp/", + }, + }); + + const sessionManager = getSessionInfoManager({ + storageUtility: storageMock, + }); + const session = await sessionManager.get(sessionId); + expect(session).toMatchObject({ + sessionId, + webId, + isLoggedIn: true, + }); + }); + + it("returns undefined if the specified storage does not contain the user", async () => { + const sessionManager = getSessionInfoManager({ + storageUtility: mockStorageUtility({}), + }); + const session = await sessionManager.get("commanderCool"); + expect(session).toBeUndefined(); + }); + + it("retrieves a session internal info from specified storage", async () => { + const sessionId = "commanderCool"; + + const webId = "https://zoomies.com/commanderCool#me"; + + const storageMock = mockStorageUtility({ + [`solidClientAuthenticationUser:${sessionId}`]: { + webId, + isLoggedIn: "true", + refreshToken: "some token", + issuer: "https://my.idp/", + }, + }); + + const sessionManager = getSessionInfoManager({ + storageUtility: storageMock, + }); + const session = await sessionManager.get(sessionId); + expect(session).toMatchObject({ + sessionId, + webId, + isLoggedIn: true, + refreshToken: "some token", + issuer: "https://my.idp/", + }); + }); + }); + + describe("clear", () => { + it("clears oidc data", async () => { + const storage = mockStorageUtility({}, true); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + const mockClear = jest.spyOn(storage, "deleteAllUserData"); + await sessionManager.clear("Value of sessionId doesn't matter"); + expect(mockClear).toHaveBeenCalled(); + }); + + it("clears local secure storage from user data", async () => { + const mockStorage = mockStorageUtility( + { + mySession: { + key: "value", + }, + }, + true + ); + const sessionManager = getSessionInfoManager({ + storageUtility: mockStorage, + }); + await sessionManager.clear("mySession"); + expect( + await mockStorage.getForUser("mySession", "key", { secure: true }) + ).toBeUndefined(); + }); + + it("clears local unsecure storage from user data", async () => { + const mockStorage = mockStorageUtility( + { + mySession: { + key: "value", + }, + }, + false + ); + const sessionManager = getSessionInfoManager({ + storageUtility: mockStorage, + }); + await sessionManager.clear("mySession"); + expect( + await mockStorage.getForUser("mySession", "key", { secure: false }) + ).toBeUndefined(); + }); + + it("clears the session registration", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify([ + "a session", + "another session", + ]), + }); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + await sessionManager.clear("a session"); + await expect(storage.get(KEY_REGISTERED_SESSIONS)).resolves.toStrictEqual( + JSON.stringify(["another session"]) + ); + }); + }); + + describe("getAll", () => { + it("is not implemented", async () => { + const sessionManager = getSessionInfoManager({ + storageUtility: mockStorageUtility({}), + }); + await expect(sessionManager.getAll).rejects.toThrow("Not implemented"); + }); + }); + + describe("register", () => { + it("adds a new entry in the registered sessions list", async () => { + const storage = mockStorageUtility({}); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + await sessionManager.register("someSession"); + + await expect(storage.get(KEY_REGISTERED_SESSIONS)).resolves.toBe( + JSON.stringify(["someSession"]) + ); + }); + + it("does not overwrite registered sessions already in storage", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify(["some existing session"]), + }); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + await sessionManager.register("some session"); + + await expect(storage.get(KEY_REGISTERED_SESSIONS)).resolves.toBe( + JSON.stringify(["some existing session", "some session"]) + ); + }); + + it("does not register an already registered session", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify(["some session"]), + }); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + await sessionManager.register("some session"); + + await expect(storage.get(KEY_REGISTERED_SESSIONS)).resolves.toBe( + JSON.stringify(["some session"]) + ); + }); + + it("does not overwrite registered sessions", async () => { + const storage = mockStorageUtility({}); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + await sessionManager.register("some session"); + await sessionManager.register("some other session"); + + await expect(storage.get(KEY_REGISTERED_SESSIONS)).resolves.toBe( + JSON.stringify(["some session", "some other session"]) + ); + }); + }); + + describe("getRegisteredSessionIdAll", () => { + it("returns a list of all registered session IDs", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify([ + "a session", + "another session", + ]), + }); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + + await expect( + sessionManager.getRegisteredSessionIdAll() + ).resolves.toStrictEqual(["a session", "another session"]); + }); + + it("returns an empty list if no session IDs are registered", async () => { + const sessionManager = getSessionInfoManager({ + storageUtility: mockStorageUtility({}), + }); + await expect( + sessionManager.getRegisteredSessionIdAll() + ).resolves.toStrictEqual([]); + }); + }); + + describe("clearAll", () => { + it("clears all sessions registrations", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify([ + "a session", + "another session", + ]), + }); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + await sessionManager.clearAll(); + await expect(storage.get(KEY_REGISTERED_SESSIONS)).resolves.toStrictEqual( + JSON.stringify([]) + ); + }); + + it("clears all sessions information", async () => { + const storage = mockStorageUtility({ + [KEY_REGISTERED_SESSIONS]: JSON.stringify(["a session"]), + "solidClientAuthenticationUser:a session": { + "some user info": "a value", + }, + }); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + await sessionManager.clearAll(); + await expect( + storage.getForUser("a session", "some user info") + ).resolves.toBeUndefined(); + }); + + it("does not fail if no session information arae available", async () => { + const storage = mockStorageUtility({}); + const sessionManager = getSessionInfoManager({ + storageUtility: storage, + }); + await sessionManager.clearAll(); + await expect( + storage.getForUser("a session", "some user info") + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/node/src/sessionInfo/SessionInfoManager.ts b/packages/node/src/sessionInfo/SessionInfoManager.ts new file mode 100644 index 0000000..72a46e2 --- /dev/null +++ b/packages/node/src/sessionInfo/SessionInfoManager.ts @@ -0,0 +1,213 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +import { + ISessionInfo, + ISessionInternalInfo, + ISessionInfoManager, + ISessionInfoManagerOptions, + IStorageUtility, +} from "@inrupt/solid-client-authn-core"; +import { v4 } from "uuid"; +// eslint-disable-next-line no-shadow +import { fetch } from "cross-fetch"; +import { KEY_REGISTERED_SESSIONS } from "../constant"; + +export function getUnauthenticatedSession(): ISessionInfo & { + fetch: typeof fetch; +} { + return { + isLoggedIn: false, + sessionId: v4(), + fetch, + }; +} + +/** + * @param sessionId + * @param storage + * @hidden + */ +export async function clear( + sessionId: string, + storage: IStorageUtility +): Promise { + await Promise.all([ + storage.deleteAllUserData(sessionId, { secure: false }), + storage.deleteAllUserData(sessionId, { secure: true }), + // FIXME: This is needed until the DPoP key is stored safely + storage.delete("clientKey", { secure: false }), + ]); + // TODO: Clear OIDC storage if need be. + // await clearOidcPersistentStorage(); +} + +/** + * @hidden + */ +export class SessionInfoManager implements ISessionInfoManager { + constructor(private storageUtility: IStorageUtility) {} + + // eslint-disable-next-line class-methods-use-this + update( + _sessionId: string, + _options: ISessionInfoManagerOptions + ): Promise { + // const localUserId: string = options.localUserId || this.uuidGenerator.v4(); + // if (options.loggedIn) { + // return { + // sessionId, + // loggedIn: true, + // webId: options.webId as string, + // neededAction: options.neededAction || { actionType: "inaction" }, + // state: options.state, + // logout: async (): Promise => { + // // TODO: handle if webid isn't here + // return this.logoutHandler.handle(localUserId); + // }, + // fetch: (url: RequestInfo, init?: RequestInit): Promise => { + // // TODO: handle if webid isn't here + // return this.authenticatedFetcher.handle( + // { + // localUserId, + // type: "dpop" + // }, + // url, + // init + // ); + // } + // }; + // } else { + // return { + // localUserId, + // loggedIn: false, + // neededAction: options.neededAction || { actionType: "inaction" } + // }; + // } + throw new Error("Not Implemented"); + } + + async get( + sessionId: string + ): Promise<(ISessionInfo & ISessionInternalInfo) | undefined> { + const webId = await this.storageUtility.getForUser(sessionId, "webId"); + const isLoggedIn = await this.storageUtility.getForUser( + sessionId, + "isLoggedIn" + ); + const refreshToken = await this.storageUtility.getForUser( + sessionId, + "refreshToken" + ); + const issuer = await this.storageUtility.getForUser(sessionId, "issuer"); + + if (issuer !== undefined) { + return { + sessionId, + webId, + isLoggedIn: isLoggedIn === "true", + refreshToken, + issuer, + }; + } + + return undefined; + } + + // eslint-disable-next-line class-methods-use-this + async getAll(): Promise<(ISessionInfo & ISessionInternalInfo)[]> { + throw new Error("Not implemented"); + } + + /** + * This function removes all session-related information from storage. + * @param sessionId the session identifier + * @param storage the storage where session info is stored + * @hidden + */ + async clear(sessionId: string): Promise { + const rawSessions = await this.storageUtility.get(KEY_REGISTERED_SESSIONS); + if (rawSessions !== undefined) { + const sessions: string[] = JSON.parse(rawSessions); + await this.storageUtility.set( + KEY_REGISTERED_SESSIONS, + JSON.stringify( + sessions.filter((storedSessionId) => storedSessionId !== sessionId) + ) + ); + } + return clear(sessionId, this.storageUtility); + } + + /** + * Registers a new session, so that its ID can be retrieved. + * @param sessionId + */ + async register(sessionId: string): Promise { + const rawSessions = await this.storageUtility.get(KEY_REGISTERED_SESSIONS); + if (rawSessions === undefined) { + return this.storageUtility.set( + KEY_REGISTERED_SESSIONS, + JSON.stringify([sessionId]) + ); + } + const sessions: string[] = JSON.parse(rawSessions); + if (!sessions.includes(sessionId)) { + sessions.push(sessionId); + return this.storageUtility.set( + KEY_REGISTERED_SESSIONS, + JSON.stringify(sessions) + ); + } + return Promise.resolve(); + } + + /** + * Returns all the registered session IDs. Differs from getAll, which also + * returns additional session information. + */ + async getRegisteredSessionIdAll(): Promise { + return this.storageUtility.get(KEY_REGISTERED_SESSIONS).then((data) => { + if (data === undefined) { + return []; + } + return JSON.parse(data); + }); + } + + /** + * Deletes all information about all sessions, including their registrations. + */ + async clearAll(): Promise { + const rawSessions = await this.storageUtility.get(KEY_REGISTERED_SESSIONS); + if (rawSessions === undefined) { + return Promise.resolve(); + } + const sessions: string[] = JSON.parse(rawSessions); + await Promise.all(sessions.map((sessionId) => this.clear(sessionId))); + return this.storageUtility.set(KEY_REGISTERED_SESSIONS, JSON.stringify([])); + } +} diff --git a/packages/node/src/sessionInfo/__mocks__/SessionInfoManager.ts b/packages/node/src/sessionInfo/__mocks__/SessionInfoManager.ts new file mode 100644 index 0000000..e886700 --- /dev/null +++ b/packages/node/src/sessionInfo/__mocks__/SessionInfoManager.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + ISessionInfo, + ISessionInfoManager, + ISessionInfoManagerOptions, + IStorageUtility, +} from "@inrupt/solid-client-authn-core"; +import { jest } from "@jest/globals"; +import { SessionInfoManager } from "../SessionInfoManager"; + +export const SessionCreatorCreateResponse: ISessionInfo = { + sessionId: "global", + isLoggedIn: true, + webId: "https://pod.com/profile/card#me", +}; +export const SessionCreatorGetSessionResponse: ISessionInfo = + SessionCreatorCreateResponse; + +export const SessionInfoManagerMock: jest.Mocked = { + update: jest.fn( + async (_sessionId: string, _options: ISessionInfoManagerOptions) => {} + ), + get: jest.fn(async (_sessionId: string) => + Promise.resolve(SessionCreatorCreateResponse) + ), + getAll: jest.fn(async () => Promise.resolve([SessionCreatorCreateResponse])), + clear: jest.fn(async (_sessionId: string) => Promise.resolve()), + register: jest.fn(async (_sessionId: string) => Promise.resolve()), + clearAll: jest.fn(async () => Promise.resolve()), + getRegisteredSessionIdAll: jest.fn(async () => Promise.resolve([])), + // A Jest update seemes to have caused some troubles aligning mock types. + // Since the tests worked, I'm setting it to `any`: + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +export function mockSessionInfoManager( + storageUtility: IStorageUtility +): ISessionInfoManager { + return new SessionInfoManager(storageUtility); +} diff --git a/packages/node/src/storage/StorageUtility.ts b/packages/node/src/storage/StorageUtility.ts new file mode 100644 index 0000000..b7797df --- /dev/null +++ b/packages/node/src/storage/StorageUtility.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * A helper class that will validate items taken from local storage + */ +import { IStorage, StorageUtility } from "@inrupt/solid-client-authn-core"; + +/** + * This class in a no-value-added extension of StorageUtility from the core module. + * The reason it has to be declared is for TSyringe to find the decorators in the + * same modules as where the dependency container is declared (in this case, + * the browser module, with the dependancy container in dependencies.ts). + * @hidden + */ +export default class StorageUtilityNode extends StorageUtility { + constructor(secureStorage: IStorage, insecureStorage: IStorage) { + super(secureStorage, insecureStorage); + } +} diff --git a/packages/node/src/util/UuidGenerator.spec.ts b/packages/node/src/util/UuidGenerator.spec.ts new file mode 100644 index 0000000..304f560 --- /dev/null +++ b/packages/node/src/util/UuidGenerator.spec.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest, it, describe, expect } from "@jest/globals"; +import UuidGenerator from "./UuidGenerator"; + +jest.mock("uuid"); + +describe("UuidGenerator", () => { + it("should simply wrap the `uuid` module", () => { + const uuidMock: { v4: jest.Mock } = jest.requireMock("uuid") as any; + uuidMock.v4.mockReturnValueOnce("some uuid"); + + const generator = new UuidGenerator(); + expect(generator.v4()).toBe("some uuid"); + }); +}); diff --git a/packages/node/src/util/UuidGenerator.ts b/packages/node/src/util/UuidGenerator.ts new file mode 100644 index 0000000..a039624 --- /dev/null +++ b/packages/node/src/util/UuidGenerator.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * @hidden + * @packageDocumentation + */ + +/** + * A wrapper class for uuid + */ +import { v4 } from "uuid"; + +/** + * @hidden + */ +export interface IUuidGenerator { + v4(): string; +} + +/** + * @hidden + */ +export default class UuidGenerator { + // eslint-disable-next-line class-methods-use-this + v4(): string { + return v4(); + } +} diff --git a/packages/node/src/util/__mocks__/UuidGenerator.ts b/packages/node/src/util/__mocks__/UuidGenerator.ts new file mode 100644 index 0000000..9d75cbb --- /dev/null +++ b/packages/node/src/util/__mocks__/UuidGenerator.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { jest } from "@jest/globals"; +import { IUuidGenerator } from "../UuidGenerator"; + +export const UuidGeneratorMockResponse = "fee3fa53-a6a9-475c-a0da-b1343a4fff76"; + +export const UuidGeneratorMock: jest.Mocked = { + v4: jest.fn(() => UuidGeneratorMockResponse), + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; diff --git a/packages/node/src/util/urlPath.spec.ts b/packages/node/src/util/urlPath.spec.ts new file mode 100644 index 0000000..cd7a729 --- /dev/null +++ b/packages/node/src/util/urlPath.spec.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { describe, it, expect } from "@jest/globals"; + +import { appendToUrlPathname } from "./urlPath"; + +describe("urlPath", () => { + it("should remove one slash if empty path", () => { + expect(appendToUrlPathname("https://ex.com/", "/test")).toBe( + "https://ex.com/test" + ); + + expect( + appendToUrlPathname("https://ex.com/", "////only-remove-one-slash") + ).toBe("https://ex.com////only-remove-one-slash"); + }); + + it("should remove one slash if non-empty path", () => { + expect(appendToUrlPathname("https://ex.com/a/", "/test")).toBe( + "https://ex.com/a/test" + ); + + expect( + appendToUrlPathname("https://ex.com/a/", "////only-remove-one-slash") + ).toBe("https://ex.com/a////only-remove-one-slash"); + }); + + it("should add a slash before appending slash", () => { + expect(appendToUrlPathname("https://ex.com", "test")).toBe( + "https://ex.com/test" + ); + + expect(appendToUrlPathname("https://ex.com/a", "test")).toBe( + "https://ex.com/a/test" + ); + }); + + it("should throw a helpful error if URL is invalid", () => { + // Regular expression here simply says "match against 1st string, followed + // anywhere later by the second, followed anywhere later by the third". + expect(() => appendToUrlPathname("not an iri", "test ending")).toThrow( + /test ending.*not an iri.*Invalid URL/ + ); + }); +}); diff --git a/packages/node/src/util/urlPath.ts b/packages/node/src/util/urlPath.ts new file mode 100644 index 0000000..d134d2c --- /dev/null +++ b/packages/node/src/util/urlPath.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2022 Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// eslint-disable-next-line no-shadow +import { URL } from "url"; + +/** + * @hidden + * @packageDocumentation + */ + +/** + * Utility that appends the specified value to end of the specified URL's path. + * Note: if we are passed an invalid URL we return a more helpful error that + * tells us both input values, and that we were attempting an append operation. + * + * @param url the URL to whose path we append the specified value + * @param append the value to append to the URL's path + */ +export function appendToUrlPathname(url: string, append: string): string { + try { + const parsedUrl = new URL(url); + const path = parsedUrl.pathname; + parsedUrl.pathname = `${path}${path.endsWith("/") ? "" : "/"}${ + append.startsWith("/") ? append.substring(1) : append + }`; + + return parsedUrl.toString(); + } catch (error) { + throw new Error( + `Failed to append [${append}] to the URL path of [${url}]. Error: ${error}` + ); + } +} diff --git a/packages/node/tsconfig.eslint.json b/packages/node/tsconfig.eslint.json new file mode 100644 index 0000000..81df810 --- /dev/null +++ b/packages/node/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + + "include": ["src/**/*", "e2e/*.ts"], + "exclude": [] +} diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json new file mode 100644 index 0000000..b203a26 --- /dev/null +++ b/packages/node/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.build.json", + + "compilerOptions": { + "lib": ["es2018"], + "outDir": "./dist", + "skipLibCheck": true, + }, + + "typedocOptions": { + "out": "website/docs/api/node", + "entryPoints": ["./src/index.ts"], + "entryDocument": "index.md" + }, + + "include": ["src/**/*"], + "exclude": ["src/**/*.spec.ts", "**/__mocks__/*"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..564988b --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,40 @@ + +{ + "include": ["packages/*/src/**/*.ts", "e2e/*/**/*.ts", "./jest.setup.ts"], + "compilerOptions": { + "module": "commonjs", + "strict": true, + "declaration": true, + "noImplicitAny": true, + "removeComments": true, + "noLib": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "esModuleInterop": true, + "target": "es2018", + "sourceMap": true, + "lib": ["es2018", "dom"], + "moduleResolution": "node", + "outDir": "./dist", + // This is required to transform native ESM from our dependencies using ts-jest. + "allowJs": true + }, + "exclude": ["node_modules", "dist"], + + // We don't provide an 'out' value here, each sub-package should provide its + // own. + "typedocOptions": { + "mode": "modules", + "exclude": [ + // Re-exported functions are already documented in their own modules: + "./packages/*/src/index.ts", + "./packages/*/src/index.browser.ts", + "./e2e/**/*.ts" + ], + "excludeNotExported": true, + "excludePrivate": true, + "stripInternal": true, + "theme": "markdown", + "readme": "none" + }, +} From ff5c6885dbaad159dc705d0da691edac992cb661 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Wed, 7 Dec 2022 15:57:34 +1100 Subject: [PATCH 06/17] feat: working session refresh --- README.md | 12 + .../src/auth/AuthCodeRedirectHandler.ts | 245 ------------- .../src/auth/RefreshTokenOidcHandler.ts | 224 ------------ .../solidauth/src/auth/TokenRefresher.ts | 150 -------- extensions/solidauth/src/auth/fetchFactory.ts | 341 ------------------ .../src/auth/solidAuthenticationProvider.ts | 153 ++------ extensions/solidfs/src/solidFS.ts | 3 +- .../src/authenticatedFetch/fetchFactory.ts | 115 +----- .../AuthCodeRedirectHandler.ts | 39 +- packages/solid-vscode-auth/lib/index.ts | 3 + 10 files changed, 90 insertions(+), 1195 deletions(-) delete mode 100644 extensions/solidauth/src/auth/AuthCodeRedirectHandler.ts delete mode 100644 extensions/solidauth/src/auth/RefreshTokenOidcHandler.ts delete mode 100644 extensions/solidauth/src/auth/TokenRefresher.ts delete mode 100644 extensions/solidauth/src/auth/fetchFactory.ts diff --git a/README.md b/README.md index 6e00f43..f8db912 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,15 @@ code ./extensions/solidfs/ ``` and then press `fn`+`F5` in the new vscode window that is opened. + +## authn dependencies + +We have had to customise the authentication libraries to handle session management in vscode. The following 2 files +have been modified compared to the source code for the authn libraries + +core/src/authenticatedFetch/fetchFactory - removed token refreshing functionality +node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler - +ensure refresh token and access_token are saved to storage + +In each case comments starting with "===" have been added to indicate where the files deviate from the original authn +libraries diff --git a/extensions/solidauth/src/auth/AuthCodeRedirectHandler.ts b/extensions/solidauth/src/auth/AuthCodeRedirectHandler.ts deleted file mode 100644 index c6ae4e3..0000000 --- a/extensions/solidauth/src/auth/AuthCodeRedirectHandler.ts +++ /dev/null @@ -1,245 +0,0 @@ -// -// Copyright 2022 Inrupt Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal in -// the Software without restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -// Software, and to permit persons to whom the Software is furnished to do so, -// subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -// -import type { - IIncomingRedirectHandler, - IStorageUtility, - ISessionInfoManager, - IIssuerConfigFetcher, - IClientRegistrar, - ITokenRefresher, - ISessionInfo, - IClient, - KeyPair, - RefreshOptions, -} from "@inrupt/solid-client-authn-core"; -import { - getSessionIdFromOauthState, - loadOidcContextFromStorage, - generateDpopKeyPair, - EVENTS, - getWebidFromTokenPayload, - saveSessionInfoToStorage, -} from "@inrupt/solid-client-authn-core"; -import { - buildAuthenticatedFetch, -} from "./fetchFactory"; -import { configToIssuerMetadata } from "@inrupt/solid-client-authn-node/dist/login/oidc/IssuerConfigFetcher"; -import type { KeyObject } from "crypto"; -import { fetch as globalFetch } from "cross-fetch"; -import type { EventEmitter } from "events"; -import { Issuer } from "openid-client"; - -export default class AuthCodeRedirectHandler - implements IIncomingRedirectHandler -{ - // eslint-disable-next-line no-useless-constructor - constructor( - private storageUtility: IStorageUtility, - private sessionInfoManager: ISessionInfoManager, - private issuerConfigFetcher: IIssuerConfigFetcher, - private clientRegistrar: IClientRegistrar, - private tokenRefresher: ITokenRefresher - ) {} - - // eslint-disable-next-line class-methods-use-this - async canHandle(redirectUrl: string): Promise { - try { - const myUrl = new URL(redirectUrl); - return ( - myUrl.searchParams.get("code") !== null && - myUrl.searchParams.get("state") !== null - ); - } catch (e) { - throw new Error( - `[${redirectUrl}] is not a valid URL, and cannot be used as a redirect URL: ${e}` - ); - } - } - - async handle( - inputRedirectUrl: string, - eventEmitter?: EventEmitter - ): Promise { - if (!(await this.canHandle(inputRedirectUrl))) { - throw new Error( - `AuthCodeRedirectHandler cannot handle [${inputRedirectUrl}]: it is missing one of [code, state].` - ); - } - - const url = new URL(inputRedirectUrl); - // The type assertion is ok, because we checked in canHandle for the presence of a state - const oauthState = url.searchParams.get("state") as string; - url.searchParams.delete("code"); - url.searchParams.delete("state"); - - const sessionId = await getSessionIdFromOauthState( - this.storageUtility, - oauthState - ); - if (sessionId === undefined) { - throw new Error( - `No stored session is associated with the state [${oauthState}]` - ); - } - - const oidcContext = await loadOidcContextFromStorage( - sessionId, - this.storageUtility, - this.issuerConfigFetcher - ); - - const issuer = new Issuer(configToIssuerMetadata(oidcContext.issuerConfig)); - // This should also retrieve the client from storage - const clientInfo: IClient = await this.clientRegistrar.getClient( - { sessionId }, - oidcContext.issuerConfig - ); - const client = new issuer.Client({ - client_id: clientInfo.clientId, - client_secret: clientInfo.clientSecret, - token_endpoint_auth_method: clientInfo.clientSecret - ? "client_secret_basic" - : "none", - id_token_signed_response_alg: clientInfo.idTokenSignedResponseAlg, - }); - - const params = client.callbackParams(inputRedirectUrl); - let dpopKey: KeyPair | undefined; - - if (oidcContext.dpop) { - dpopKey = await generateDpopKeyPair(); - } - const tokenSet = await client.callback( - url.href, - params, - { code_verifier: oidcContext.codeVerifier, state: oauthState }, - // The KeyLike type is dynamically bound to either KeyObject or CryptoKey - // at runtime depending on the environment. Here, we know we are in a NodeJS - // context. - { DPoP: dpopKey?.privateKey as KeyObject } - ); - - if ( - tokenSet.access_token === undefined || - tokenSet.id_token === undefined - ) { - // The error message is left minimal on purpose not to leak the tokens. - throw new Error( - `The Identity Provider [${issuer.metadata.issuer}] did not return the expected tokens: missing at least one of 'access_token', 'id_token.` - ); - } - let refreshOptions: RefreshOptions | undefined; - if (tokenSet.refresh_token !== undefined) { - eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refresh_token); - refreshOptions = { - refreshToken: tokenSet.refresh_token, - sessionId, - tokenRefresher: this.tokenRefresher, - }; - } - - const authFetch = await buildAuthenticatedFetch( - globalFetch, - tokenSet.access_token, - { - dpopKey, - refreshOptions, - eventEmitter, - expiresIn: tokenSet.expires_in, - } - ); - - // tokenSet.claims() parses the ID token, validates its signature, and returns - // its payload as a JSON object. - const webid = await getWebidFromTokenPayload( - tokenSet.id_token, - // The JWKS URI is mandatory in the spec, so the non-null assertion is valid. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - issuer.metadata.jwks_uri!, - issuer.metadata.issuer, - client.metadata.client_id - ); - - await saveSessionInfoToStorage( - this.storageUtility, - sessionId, - webid, - "true", - tokenSet.refresh_token, - undefined, - dpopKey - ); - - console.log("setting", tokenSet.access_token, "for", sessionId); - await this.storageUtility.setForUser( - sessionId, - { access_token: tokenSet.access_token }, - { secure: true } - ); - - if (typeof tokenSet.expires_at === "number") { - const eat = tokenSet.expires_at.toString(); - - console.log("setting expires at", eat); - - await this.storageUtility.setForUser( - sessionId, - { expires_at: tokenSet.expires_at.toString() }, - { secure: true } - ); - } else if (typeof tokenSet.expires_in === "number") { - await this.storageUtility.setForUser( - sessionId, - { expires_at: Math.floor(tokenSet.expires_in + (Date.now() / 1000)).toString() }, - { secure: true } - ); - } - - if (typeof tokenSet.expires_in === "number") { - await this.storageUtility.setForUser( - sessionId, - { expires_in: tokenSet.expires_in.toString() }, - { secure: true } - ); - } - - if (typeof tokenSet.refresh_token === "string") { - await this.storageUtility.setForUser( - sessionId, - { refresh_token: tokenSet.refresh_token }, - { secure: true } - ); - } - - // console.log('the token set is', tokenSet) - - const sessionInfo = await this.sessionInfoManager.get(sessionId); - if (!sessionInfo) { - throw new Error( - `Could not find any session information associated with SessionID [${sessionId}] in our storage.` - ); - } - - return Object.assign(sessionInfo, { - fetch: authFetch, - }); - } -} diff --git a/extensions/solidauth/src/auth/RefreshTokenOidcHandler.ts b/extensions/solidauth/src/auth/RefreshTokenOidcHandler.ts deleted file mode 100644 index 0756f15..0000000 --- a/extensions/solidauth/src/auth/RefreshTokenOidcHandler.ts +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright 2022 Inrupt Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, - * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A - * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -/** - * @hidden - * @packageDocumentation - */ - -/** - * Handler for the Refresh Token Flow - */ - import { - IOidcHandler, - IOidcOptions, - IStorageUtility, - LoginResult, - saveSessionInfoToStorage, - getWebidFromTokenPayload, - ISessionInfo, - generateDpopKeyPair, - KeyPair, - PREFERRED_SIGNING_ALG, - RefreshOptions, - ITokenRefresher, - TokenEndpointResponse, -} from "@inrupt/solid-client-authn-core"; -import { JWK, importJWK } from "jose"; -import { fetch as globalFetch } from "cross-fetch"; -import { EventEmitter } from "events"; -import { KeyObject } from "crypto"; -import { - buildAuthenticatedFetch, -} from "./fetchFactory"; - -function validateOptions( - oidcLoginOptions: IOidcOptions -): oidcLoginOptions is IOidcOptions & { - refreshToken: string; - client: { clientId: string; clientSecret: string }; -} { - return ( - oidcLoginOptions.refreshToken !== undefined && - oidcLoginOptions.client.clientId !== undefined - ); -} - -/** - * Go through the refresh flow to get a new valid access token, and build an - * authenticated fetch with it. - * @param refreshOptions - * @param dpop - */ -async function refreshAccess( - refreshOptions: RefreshOptions, - dpop: boolean, - refreshBindingKey?: KeyPair, - eventEmitter?: EventEmitter -): Promise { - try { - let dpopKey: KeyPair | undefined; - if (dpop) { - dpopKey = refreshBindingKey || (await generateDpopKeyPair()); - // The alg property isn't set by exportJWK, so set it manually. - [dpopKey.publicKey.alg] = PREFERRED_SIGNING_ALG; - } - const tokens = await refreshOptions.tokenRefresher.refresh( - refreshOptions.sessionId, - refreshOptions.refreshToken, - dpopKey - ); - // Rotate the refresh token if applicable - const rotatedRefreshOptions = { - ...refreshOptions, - refreshToken: tokens.refreshToken ?? refreshOptions.refreshToken, - }; - const authFetch = await buildAuthenticatedFetch( - globalFetch, - tokens.accessToken, - { - dpopKey, - refreshOptions: rotatedRefreshOptions, - eventEmitter, - } - ); - return Object.assign(tokens, { - fetch: authFetch, - }); - } catch (e) { - throw new Error(`Invalid refresh credentials: ${e}`); - } -} - -/** - * @hidden - * Refresh token flow spec: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens - */ -export default class RefreshTokenOidcHandler implements IOidcHandler { - constructor( - private tokenRefresher: ITokenRefresher, - private storageUtility: IStorageUtility - ) {} - - async canHandle(oidcLoginOptions: IOidcOptions): Promise { - return validateOptions(oidcLoginOptions); - } - - async handle(oidcLoginOptions: IOidcOptions): Promise { - if (!(await this.canHandle(oidcLoginOptions))) { - throw new Error( - `RefreshTokenOidcHandler cannot handle the provided options, missing one of 'refreshToken', 'clientId' in: ${JSON.stringify( - oidcLoginOptions - )}` - ); - } - const refreshOptions: RefreshOptions = { - // The type assertion is okay, because it is tested for in canHandle. - refreshToken: oidcLoginOptions.refreshToken as string, - sessionId: oidcLoginOptions.sessionId, - tokenRefresher: this.tokenRefresher, - }; - - // This information must be in storage for the refresh flow to succeed. - await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { - issuer: oidcLoginOptions.issuer, - dpop: oidcLoginOptions.dpop ? "true" : "false", - clientId: oidcLoginOptions.client.clientId, - // Note: We assume here that a client secret is present, which is checked for when validating the options. - clientSecret: oidcLoginOptions.client.clientSecret as string, - }); - - // In the case when the refresh token is bound to a DPoP key, said key must - // be used during the refresh grant. - const publicKey = await this.storageUtility.getForUser( - oidcLoginOptions.sessionId, - "publicKey" - ); - const privateKey = await this.storageUtility.getForUser( - oidcLoginOptions.sessionId, - "privateKey" - ); - let keyPair: undefined | KeyPair; - if (publicKey !== undefined && privateKey !== undefined) { - keyPair = { - publicKey: JSON.parse(publicKey) as JWK, - privateKey: (await importJWK( - JSON.parse(privateKey), - PREFERRED_SIGNING_ALG[0] - )) as KeyObject, - }; - } - - const accessInfo = await refreshAccess( - refreshOptions, - oidcLoginOptions.dpop, - keyPair - ); - - const sessionInfo: ISessionInfo = { - isLoggedIn: true, - sessionId: oidcLoginOptions.sessionId, - }; - - if (accessInfo.idToken === undefined) { - throw new Error( - `The Identity Provider [${oidcLoginOptions.issuer}] did not return an ID token on refresh, which prevents us from getting the user's WebID.` - ); - } - sessionInfo.webId = await getWebidFromTokenPayload( - accessInfo.idToken, - oidcLoginOptions.issuerConfiguration.jwksUri, - oidcLoginOptions.issuer, - oidcLoginOptions.client.clientId - ); - - await saveSessionInfoToStorage( - this.storageUtility, - oidcLoginOptions.sessionId, - undefined, - "true", - accessInfo.refreshToken ?? refreshOptions.refreshToken, - undefined, - keyPair - ); - - await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { - issuer: oidcLoginOptions.issuer, - dpop: oidcLoginOptions.dpop ? "true" : "false", - clientId: oidcLoginOptions.client.clientId, - }); - - if (oidcLoginOptions.client.clientSecret) { - await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { - clientSecret: oidcLoginOptions.client.clientSecret, - }); - } - if (oidcLoginOptions.client.clientName) { - await this.storageUtility.setForUser(oidcLoginOptions.sessionId, { - clientName: oidcLoginOptions.client.clientName, - }); - } - - return Object.assign(sessionInfo, { - fetch: accessInfo.fetch, - }); - } -} diff --git a/extensions/solidauth/src/auth/TokenRefresher.ts b/extensions/solidauth/src/auth/TokenRefresher.ts deleted file mode 100644 index c899e2f..0000000 --- a/extensions/solidauth/src/auth/TokenRefresher.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright 2022 Inrupt Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, - * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A - * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -/** - * @hidden - * @packageDocumentation - */ - - import { - IClient, - IClientRegistrar, - IIssuerConfigFetcher, - IStorageUtility, - loadOidcContextFromStorage, - PREFERRED_SIGNING_ALG, - KeyPair, - ITokenRefresher, - TokenEndpointResponse, - EVENTS, -} from "@inrupt/solid-client-authn-core"; -import { Issuer, IssuerMetadata, TokenSet } from "openid-client"; -import { KeyObject } from "crypto"; -import { EventEmitter } from "events"; -import { configToIssuerMetadata } from "../IssuerConfigFetcher"; -import { negotiateClientSigningAlg } from "../ClientRegistrar"; - -// Some identifiers are not in camelcase on purpose, as they are named using the -// official names from the OIDC/OAuth2 specifications. -/* eslint-disable camelcase */ - -const tokenSetToTokenEndpointResponse = ( - tokenSet: TokenSet, - issuerMetadata: IssuerMetadata -): TokenEndpointResponse => { - if (tokenSet.access_token === undefined) { - // The error message is left minimal on purpose not to leak the tokens. - throw new Error( - `The Identity Provider [${issuerMetadata.issuer}] did not return an access token on refresh.` - ); - } - - if (tokenSet.token_type !== "Bearer" && tokenSet.token_type !== "DPoP") { - throw new Error( - `The Identity Provider [${issuerMetadata.issuer}] returned an unknown token type: [${tokenSet.token_type}].` - ); - } - return { - accessToken: tokenSet.access_token, - tokenType: tokenSet.token_type, - idToken: tokenSet.id_token, - refreshToken: tokenSet.refresh_token, - expiresAt: tokenSet.expires_at, - }; -}; - -/** - * @hidden - */ -export default class TokenRefresher implements ITokenRefresher { - constructor( - private storageUtility: IStorageUtility, - private issuerConfigFetcher: IIssuerConfigFetcher, - private clientRegistrar: IClientRegistrar - ) {} - - async refresh( - sessionId: string, - refreshToken?: string, - dpopKey?: KeyPair, - eventEmitter?: EventEmitter - ): Promise { - const oidcContext = await loadOidcContextFromStorage( - sessionId, - this.storageUtility, - this.issuerConfigFetcher - ); - - const issuer = new Issuer(configToIssuerMetadata(oidcContext.issuerConfig)); - // This should also retrieve the client from storage - const clientInfo: IClient = await this.clientRegistrar.getClient( - { sessionId }, - oidcContext.issuerConfig - ); - if (clientInfo.idTokenSignedResponseAlg === undefined) { - clientInfo.idTokenSignedResponseAlg = negotiateClientSigningAlg( - oidcContext.issuerConfig, - PREFERRED_SIGNING_ALG - ); - } - const client = new issuer.Client({ - client_id: clientInfo.clientId, - client_secret: clientInfo.clientSecret, - token_endpoint_auth_method: clientInfo.clientSecret - ? "client_secret_basic" - : "none", - id_token_signed_response_alg: clientInfo.idTokenSignedResponseAlg, - }); - - if (refreshToken === undefined) { - // TODO: in a next PR, look up storage for a refresh token - throw new Error( - `Session [${sessionId}] has no refresh token to allow it to refresh its access token.` - ); - } - - if (oidcContext.dpop && dpopKey === undefined) { - throw new Error( - `For session [${sessionId}], the key bound to the DPoP access token must be provided to refresh said access token.` - ); - } - - const tokenSet = tokenSetToTokenEndpointResponse( - await client.refresh(refreshToken, { - // openid-client does not support yet jose@3.x, and expects - // type definitions that are no longer present. However, the JWK - // type that we pass here is compatible with the API, hence the type - // assertion. - DPoP: dpopKey ? (dpopKey.privateKey as KeyObject) : undefined, - }), - issuer.metadata - ); - - if (tokenSet.refreshToken !== undefined) { - eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refreshToken); - await this.storageUtility.setForUser(sessionId, { - refreshToken: tokenSet.refreshToken, - }); - } - return tokenSet; - } -} - \ No newline at end of file diff --git a/extensions/solidauth/src/auth/fetchFactory.ts b/extensions/solidauth/src/auth/fetchFactory.ts deleted file mode 100644 index 16f05e9..0000000 --- a/extensions/solidauth/src/auth/fetchFactory.ts +++ /dev/null @@ -1,341 +0,0 @@ -// -// Copyright 2022 Inrupt Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal in -// the Software without restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -// Software, and to permit persons to whom the Software is furnished to do so, -// subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -// -import type { fetch as crossFetch } from "cross-fetch"; -import { Headers as CrossFetchHeaders } from "cross-fetch"; -import type { EventEmitter } from "events"; -import { EVENTS } from "@inrupt/solid-client-authn-core"; -import type { KeyPair } from "@inrupt/solid-client-authn-core/dist/authenticatedFetch/dpopUtils"; -import type { ITokenRefresher } from "@inrupt/solid-client-authn-core/dist/login/oidc/refresh/ITokenRefresher"; -import { SignJWT } from "jose"; -import { v4 } from "uuid"; -/* eslint-disable import/no-unresolved */ -const REFRESH_BEFORE_EXPIRATION_SECONDS = 20; -/* eslint-enable import/no-unresolved */ - -export type RefreshOptions = { - sessionId: string; - refreshToken: string; - tokenRefresher: ITokenRefresher; -}; - -/** - * Normalizes a URL in order to generate the DPoP token based on a consistent scheme. - * - * @param audience The URL to normalize. - * @returns The normalized URL as a string. - * @hidden - */ -function normalizeHTU(audience: string): string { - const audienceUrl = new URL(audience); - return new URL(audienceUrl.pathname, audienceUrl.origin).toString(); -} - -/** - * Creates a DPoP header according to https://tools.ietf.org/html/draft-fett-oauth-dpop-04, - * based on the target URL and method, using the provided key. - * - * @param audience Target URL. - * @param method HTTP method allowed. - * @param key Key used to sign the token. - * @returns A JWT that can be used as a DPoP Authorization header. - */ -export async function createDpopHeader( - audience: string, - method: string, - dpopKey: KeyPair -): Promise { - return new SignJWT({ - htu: normalizeHTU(audience), - htm: method.toUpperCase(), - jti: v4(), - }) - .setProtectedHeader({ - alg: "ES256", - jwk: dpopKey.publicKey, - typ: "dpop+jwt", - }) - .setIssuedAt() - .sign(dpopKey.privateKey, {}); -} - -/** - * If expires_in isn't specified for the access token, we assume its lifetime is - * 10 minutes. - */ -export const DEFAULT_EXPIRATION_TIME_SECONDS = 600; - -function isExpectedAuthError(statusCode: number): boolean { - // As per https://tools.ietf.org/html/rfc7235#section-3.1 and https://tools.ietf.org/html/rfc7235#section-3.1, - // a response failing because the provided credentials aren't accepted by the - // server can get a 401 or a 403 response. - return [401, 403].includes(statusCode); -} - -export type DpopHeaderPayload = { - htu: string; - htm: string; - jti: string; -}; - -async function buildDpopFetchOptions( - targetUrl: string, - authToken: string, - dpopKey: KeyPair, - defaultOptions?: RequestInit -): Promise { - const headers = new CrossFetchHeaders(defaultOptions?.headers); - // Any pre-existing Authorization header should be overridden. - headers.set("Authorization", `DPoP ${authToken}`); - headers.set( - "DPoP", - await createDpopHeader(targetUrl, defaultOptions?.method ?? "get", dpopKey) - ); - return { - ...defaultOptions, - headers, - }; -} - -async function buildAuthenticatedHeaders( - targetUrl: string, - authToken: string, - dpopKey?: KeyPair, - defaultOptions?: RequestInit -): Promise { - if (dpopKey !== undefined) { - return buildDpopFetchOptions(targetUrl, authToken, dpopKey, defaultOptions); - } - const headers = new CrossFetchHeaders(defaultOptions?.headers); - // Any pre-existing Authorization header should be overriden. - headers.set("Authorization", `Bearer ${authToken}`); - return { - ...defaultOptions, - headers, - }; -} - -async function makeAuthenticatedRequest( - unauthFetch: typeof crossFetch, - accessToken: string, - url: RequestInfo | URL, - defaultRequestInit?: RequestInit, - dpopKey?: KeyPair -) { - return unauthFetch( - url, - await buildAuthenticatedHeaders( - url.toString(), - accessToken, - dpopKey, - defaultRequestInit - ) - ); -} - -export async function refreshAccessToken( - refreshOptions: RefreshOptions, - dpopKey?: KeyPair, - eventEmitter?: EventEmitter -): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> { - const tokenSet = await refreshOptions.tokenRefresher.refresh( - refreshOptions.sessionId, - refreshOptions.refreshToken, - dpopKey - ); - eventEmitter?.emit( - EVENTS.SESSION_EXTENDED, - tokenSet.expiresIn ?? DEFAULT_EXPIRATION_TIME_SECONDS - ); - if (typeof tokenSet.refreshToken === "string") { - eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refreshToken); - } - return { - accessToken: tokenSet.accessToken, - refreshToken: tokenSet.refreshToken, - expiresIn: tokenSet.expiresIn, - }; -} - -/** - * - * @param expiresIn Delay until the access token expires. - * @returns a delay until the access token should be refreshed. - */ -const computeRefreshDelay = (expiresIn?: number): number => { - if (expiresIn !== undefined) { - return expiresIn - REFRESH_BEFORE_EXPIRATION_SECONDS > 0 - ? // We want to refresh the token 5 seconds before they actually expire. - expiresIn - REFRESH_BEFORE_EXPIRATION_SECONDS - : expiresIn; - } - return DEFAULT_EXPIRATION_TIME_SECONDS; -}; - -/** - * @param unauthFetch a regular fetch function, compliant with the WHATWG spec. - * @param authToken an access token, either a Bearer token or a DPoP one. - * @param options The option object may contain two objects: the DPoP key token - * is bound to if applicable, and options to customise token renewal behaviour. - * - * @returns A fetch function that adds an appropriate Authorization header with - * the provided token, and adds a DPoP header if applicable. - */ -export async function buildAuthenticatedFetch( - unauthFetch: typeof crossFetch, - accessToken: string, - options?: { - dpopKey?: KeyPair; - refreshOptions?: RefreshOptions; - expiresIn?: number; - eventEmitter?: EventEmitter; - } -): Promise { - const currentAccessToken = accessToken; - const currentRefreshOptions: RefreshOptions | undefined = - options?.refreshOptions; - // Setup the refresh timeout outside of the authenticated fetch, so that - // an idle app will not get logged out if it doesn't issue a fetch before - // the first expiration date. - if (currentRefreshOptions !== undefined) { - // TODO: Handle this logic inside the solidAuthenticationProvider & make sure that the refresh token - // is saved to secretStorage so that we can continue refreshing if a token is restored after a brief - // period of downtime. - // const proactivelyRefreshToken = async () => { - // try { - // const { - // accessToken: refreshedAccessToken, - // refreshToken, - // expiresIn, - // } = await refreshAccessToken( - // currentRefreshOptions, - // // If currentRefreshOptions is defined, options is necessarily defined too. - // // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - // options!.dpopKey, - // // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - // options!.eventEmitter - // ); - // // Update the tokens in the closure if appropriate. - // /** * BEGIN CUSTOM CODE */ - // if (currentAccessToken !== refreshedAccessToken) { - // options?.eventEmitter?.emit("access_token", refreshedAccessToken); - // } - // /** * END CUSTOM CODE */ - // currentAccessToken = refreshedAccessToken; - // if (refreshToken !== undefined) { - // currentRefreshOptions.refreshToken = refreshToken; - // } - // // Each time the access token is refreshed, we must plan fo the next - // // refresh iteration. - // clearTimeout(latestTimeout); - // latestTimeout = setTimeout( - // proactivelyRefreshToken, - // computeRefreshDelay(expiresIn) * 1000 - // ); - // // If currentRefreshOptions is defined, options is necessarily defined too. - // // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - // options!.eventEmitter?.emit(EVENTS.TIMEOUT_SET, latestTimeout); - // } catch (e) { - // // It is possible that an underlying library throws an error on refresh flow failure. - // // If we used a log framework, the error could be logged at the `debug` level, - // // but otherwise the failure of the refresh flow should not blow up in the user's - // // face, so we just swallow the error. - // if (e instanceof OidcProviderError) { - // // The OIDC provider refused to refresh the access token and returned an error instead. - // /* istanbul ignore next 100% coverage would require testing that nothing - // happens here if the emitter is undefined, which is more cumbersome - // than what it's worth. */ - // options?.eventEmitter?.emit( - // EVENTS.ERROR, - // e.error, - // e.errorDescription - // ); - // /* istanbul ignore next 100% coverage would require testing that nothing - // happens here if the emitter is undefined, which is more cumbersome - // than what it's worth. */ - // options?.eventEmitter?.emit(EVENTS.SESSION_EXPIRED); - // } - // if ( - // e instanceof InvalidResponseError && - // e.missingFields.includes("access_token") - // ) { - // // In this case, the OIDC provider returned a non-standard response, but - // // did not specify that it was an error. We cannot refresh nonetheless. - // /* istanbul ignore next 100% coverage would require testing that nothing - // happens here if the emitter is undefined, which is more cumbersome - // than what it's worth. */ - // options?.eventEmitter?.emit(EVENTS.SESSION_EXPIRED); - // } - // } - // }; - // latestTimeout = setTimeout( - // proactivelyRefreshToken, - // // If currentRefreshOptions is defined, options is necessarily defined too. - // // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - // computeRefreshDelay(options!.expiresIn) * 1000 - // ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - // options!.eventEmitter?.emit(EVENTS.TIMEOUT_SET, latestTimeout); - } else if (options !== undefined && options.eventEmitter !== undefined) { - // If no refresh options are provided, the session expires when the access token does. - const expirationTimeout = setTimeout(() => { - // The event emitter is always defined in our code, and it would be tedious - // to test for conditions when it is not. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options.eventEmitter!.emit(EVENTS.SESSION_EXPIRED); - }, computeRefreshDelay(options.expiresIn) * 1000); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options.eventEmitter!.emit(EVENTS.TIMEOUT_SET, expirationTimeout); - } - return async (url, requestInit?): Promise => { - let response = await makeAuthenticatedRequest( - unauthFetch, - currentAccessToken, - url, - requestInit, - options?.dpopKey - ); - - const failedButNotExpectedAuthError = - !response.ok && !isExpectedAuthError(response.status); - if (response.ok || failedButNotExpectedAuthError) { - // If there hasn't been a redirection, or if there has been a non-auth related - // issue, it should be handled at the application level - return response; - } - const hasBeenRedirected = response.url !== url; - if (hasBeenRedirected && options?.dpopKey !== undefined) { - // If the request failed for auth reasons, and has been redirected, we should - // replay it generating a DPoP header for the rediration target IRI. This - // doesn't apply to Bearer tokens, as the Bearer tokens aren't specific - // to a given resource and method, while the DPoP header (associated to a - // DPoP token) is. - response = await makeAuthenticatedRequest( - unauthFetch, - currentAccessToken, - // Replace the original target IRI (`url`) by the redirection target - response.url, - requestInit, - options.dpopKey - ); - } - return response; - }; -} diff --git a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts index d43e9f3..c4975e3 100644 --- a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts +++ b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts @@ -38,8 +38,7 @@ import type { ExtensionContext, } from "vscode"; import { EventEmitter } from "vscode"; -import { EventEmitter as EE } from "stream"; -import { EVENTS, IStorageUtility } from "@inrupt/solid-client-authn-core"; +import { IStorageUtility } from "@inrupt/solid-client-authn-core"; import { StorageUtility } from "@inrupt/solid-client-authn-core"; // TODO: Finish this based on https://www.eliostruyf.com/create-authentication-provider-visual-studio-code/ @@ -48,11 +47,9 @@ import { StorageUtility } from "@inrupt/solid-client-authn-core"; // TODO: Allow users to store a list of idp providers. import { importJWK } from "jose"; -import AuthCodeRedirectHandler from "./AuthCodeRedirectHandler"; +// import AuthCodeRedirectHandler from "./AuthCodeRedirectHandler"; import { ISecretStorage } from "../storage"; -import { refreshAccessToken } from "./fetchFactory"; - -// TODO: Introduce +const DEFAULT_EXPIRATION_TIME_SECONDS = 600; // Get the time left on a NodeJS timeout (in milliseconds) function getTimeLeft(timeout: any): number { @@ -156,28 +153,21 @@ export class SolidAuthenticationProvider console.log("pre await sessions"); await (this.sessions = this.sessions?.then(async (sessions) => { - console.log("pre await login"); session = await this.login(); - console.log("pos await login"); if (session) { // eslint-disable-next-line no-param-reassign sessions[session.id] = session; } // Trigger refresh flow as appropriate and set timeout where appropriate - console.log("pre handle refresh"); try { // DO NOT AWAIT THIS - it should be a valid session // immediately upon log in and it causes blocking (that we should debug) this.handleRefresh(); } catch (e) { - console.log("handle refresh errored with", e); } - console.log("post handle refresh"); return sessions; })); - console.log("sessions resolved", session); - if (session) { return session; } @@ -186,9 +176,7 @@ export class SolidAuthenticationProvider } async removeAllSessions() { - console.log("about to call clear session from storage all"); await clearSessionFromStorageAll(this.storage); - console.log("after calling clear session from storage all"); const sessions = await this.sessions; const sessionList = sessions ? Object.values(sessions) : []; @@ -266,105 +254,34 @@ export class SolidAuthenticationProvider const currentHandler = (s2 as any).clientAuthentication.redirectHandler .handleables[0]; - console.log('the redirecthandleables are ', (s2 as any).clientAuthentication.redirectHandler.handleables) - - // TODO: See if we need to be handling redirects as part of the refresh flow (I don't *think* we do). - // const redirectUrl = await this.storage.getForUser(sessionId, 'redirectUrl', { secure: true, errorIfNull: true }) - const refreshToken = (await this.storage.getForUser( sessionId, - "refresh_token", + "refreshToken", { secure: true, errorIfNull: true } )); - console.log('about to update with refreshToken', refreshToken) - if (!refreshToken) { throw new Error('refresh token is undefined'); } - console.log( - await this.storage.getForUser(sessionId, "expires_at"), - await this.storage.getForUser(sessionId, "expires_in"), - await this.storage.getForUser(sessionId, "refresh_token"), - await this.storage.getForUser(sessionId, "access_token") - ) - - console.log('pre refresh token -') - const result = await currentHandler.tokenRefresher.refresh( sessionId, refreshToken, dpopKey ); - console.log('post refresh token', result) - - // const emitter = new EE(); - - // emitter.on(EVENTS.NEW_REFRESH_TOKEN, (t) => { - // console.log('new refresh token event called', t) - // }) - - // emitter.on(EVENTS.SESSION_EXTENDED, async (t) => { - // console.log('session extension', t) - // await this.storage.setForUser( - // sessionId, - // { expires_in: t.toString() }, - // { secure: true } - // ); - // await this.storage.setForUser( - // sessionId, - // { expires_at: Math.floor(t + (Date.now() / 1000)).toString() }, - // { secure: true } - // ); - // }) - - - - // TODO: Use refreshAccess from RefreshTokenOidcHandler here! - - // const result = await refreshAccessToken( - // { - // refreshToken: refreshToken, - // sessionId, - // tokenRefresher: currentHandler.tokenRefresher, - // }, - // dpopKey, - // // s2 - // emitter - // ); - - console.log( - await this.storage.getForUser(sessionId, "expires_at"), - await this.storage.getForUser(sessionId, "expires_in") - ) - - console.log('refresh result', result) - - if (typeof result.expiresIn === "number") { - await this.storage.setForUser( - sessionId, - { expires_in: result.expiresIn.toString() }, - { secure: true } - ); - await this.storage.setForUser( - sessionId, - { expires_at: Math.floor(result.expiresIn + (Date.now() / 1000)).toString() }, - { secure: true } - ); - } else { - // await this.storage.deleteForUser(sessionId, "expires_in"); - // await this.storage.deleteForUser(sessionId, "expires_at"); - } + const expiresAt = (result.expiresIn ?? DEFAULT_EXPIRATION_TIME_SECONDS) + Math.floor(Date.now() / 1000) - // await this.storage.setForUser(sessionId, { 'access_token': result.accessToken }, { secure: true }) + await this.storage.setForUser( + sessionId, + { expires_at: expiresAt.toString() }, + { secure: true } + ); if (typeof result.refreshToken === "string") { - console.log('-'.repeat(50), 'setting refresh token', result.refreshToken, '-'.repeat(50)) await this.storage.setForUser( sessionId, - { refresh_token: result.refreshToken }, + { refreshToken: result.refreshToken }, { secure: true } ); } @@ -377,24 +294,10 @@ export class SolidAuthenticationProvider ); } - // const s3 = new Session({ - // storage: this.storage, - // sessionInfo: { - // sessionId, - // isLoggedIn: true, - // webId: session.id - // } - // }); - - // const newAuthenticationSession = await toAuthenticationSession(s3, this.storage); - - // TODO: Implement try/catch and delete session on reject - console.log("getting authentication session from storage"); const newAuthenticationSession = await toAuthenticationSessionFromStorage( sessionId, this.storage ); - console.log("authentication session retrieved from storage"); this.sessions = this.sessions?.then((sessions) => { if (sessions && sessionId in sessions) { @@ -416,20 +319,17 @@ export class SolidAuthenticationProvider private runningRefresh = false; public async handleRefresh() { - console.log("handle refresh called"); if (this.runningRefresh) return; this.runningRefresh = true; // When we do this operation we update any sessions that // are set to expire in the next 2 minutes - const REFRESH_EXPIRY_BEFORE = Date.now() + 1200 * 1000; + const REFRESH_EXPIRY_BEFORE = Date.now() + 120 * 1000; let toRefresh: string[]; do { - console.log("about to get expiries"); // eslint-disable-next-line no-await-in-loop const expiries = await this.getAllExpiries(); - console.log("expiries retrieved"); toRefresh = []; @@ -474,7 +374,7 @@ export class SolidAuthenticationProvider if (typeof nextExpiry === "number") { console.log("updating timeout for", (nextExpiry * 1000) - Date.now()); - this.updateTimeout(nextExpiry - Date.now()); + this.updateTimeout((nextExpiry * 1000) - Date.now()); } // Refreshes all necessary tokens @@ -521,7 +421,7 @@ export class SolidAuthenticationProvider public updateTimeout(endsIn: number): void { // 30 seconds to be safe - const REFRESH_BEFORE_EXPIRATION = 3000 * 1000; + const REFRESH_BEFORE_EXPIRATION = 30 * 1000; const newEndsIn = endsIn - REFRESH_BEFORE_EXPIRATION; @@ -616,22 +516,27 @@ export class SolidAuthenticationProvider // TODO: Give this the right storage let session = new Session({ storage: this.storage, + onNewRefreshToken() { + + }, // secureStorage: this.storage.secureStorage, // insecureStorage: this.storage.insecureStorage, // clientAuthentication }); + session.emit + // Monkey patch the AuthCodeRedirectHandler with our custom one that saves the access_token to secret storage - const currentHandler = (session as any).clientAuthentication - .redirectHandler.handleables[0]; - (session as any).clientAuthentication.redirectHandler.handleables[0] = - new AuthCodeRedirectHandler( - currentHandler.storageUtility, - currentHandler.sessionInfoManager, - currentHandler.issuerConfigFetcher, - currentHandler.clientRegistrar, - currentHandler.tokenRefresher - ); + // const currentHandler = (session as any).clientAuthentication + // .redirectHandler.handleables[0]; + // (session as any).clientAuthentication.redirectHandler.handleables[0] = + // new AuthCodeRedirectHandler( + // currentHandler.storageUtility, + // currentHandler.sessionInfoManager, + // currentHandler.issuerConfigFetcher, + // currentHandler.clientRegistrar, + // currentHandler.tokenRefresher + // ); // TODO: See if it is plausible for this to occur after redirect call is made const handleRedirect = async (url: string) => { diff --git a/extensions/solidfs/src/solidFS.ts b/extensions/solidfs/src/solidFS.ts index 5a30e3a..0e07d61 100644 --- a/extensions/solidfs/src/solidFS.ts +++ b/extensions/solidfs/src/solidFS.ts @@ -31,6 +31,7 @@ import { import type { VscodeSolidSession } from "@inrupt/solid-vscode-auth"; const BasicContainer = DF.namedNode("http://www.w3.org/ns/ldp#BasicContainer"); +// TODO: Make sure this is properly used const Container = DF.namedNode("http://www.w3.org/ns/ldp#Container"); // TODO: Work out why we are *first* getting non-existant file errors @@ -278,7 +279,7 @@ export class SolidFS implements vscode.FileSystemProvider { const i = uri.path.lastIndexOf("/") + 1; // TODO: Predict this based on file type - const data = await ((await this.session)?.fetch ?? fetch)( + const data = await ((await this.session)?.fetch ?? (globalThis as any).fetch)( `${this.root}${uri.path.slice(1, i)}`, { method: "HEAD" } ); diff --git a/packages/core/src/authenticatedFetch/fetchFactory.ts b/packages/core/src/authenticatedFetch/fetchFactory.ts index 6c64b89..ac00c37 100644 --- a/packages/core/src/authenticatedFetch/fetchFactory.ts +++ b/packages/core/src/authenticatedFetch/fetchFactory.ts @@ -22,11 +22,9 @@ // eslint-disable-next-line no-shadow import { fetch, Headers } from "cross-fetch"; import { EventEmitter } from "events"; -import { REFRESH_BEFORE_EXPIRATION_SECONDS, EVENTS } from "../constant"; +import { EVENTS } from "../constant"; import { ITokenRefresher } from "../login/oidc/refresh/ITokenRefresher"; import { createDpopHeader, KeyPair } from "./dpopUtils"; -import { OidcProviderError } from "../errors/OidcProviderError"; -import { InvalidResponseError } from "../errors/InvalidResponseError"; export type RefreshOptions = { sessionId: string; @@ -132,21 +130,6 @@ async function refreshAccessToken( }; } -/** - * - * @param expiresIn Delay until the access token expires. - * @returns a delay until the access token should be refreshed. - */ -const computeRefreshDelay = (expiresIn?: number): number => { - if (expiresIn !== undefined) { - return expiresIn - REFRESH_BEFORE_EXPIRATION_SECONDS > 0 - ? // We want to refresh the token 5 seconds before they actually expire. - expiresIn - REFRESH_BEFORE_EXPIRATION_SECONDS - : expiresIn; - } - return DEFAULT_EXPIRATION_TIME_SECONDS; -}; - /** * @param unauthFetch a regular fetch function, compliant with the WHATWG spec. * @param authToken an access token, either a Bearer token or a DPoP one. @@ -166,99 +149,13 @@ export async function buildAuthenticatedFetch( eventEmitter?: EventEmitter; } ): Promise { - let currentAccessToken = accessToken; - let latestTimeout: Parameters[0]; - const currentRefreshOptions: RefreshOptions | undefined = - options?.refreshOptions; - // Setup the refresh timeout outside of the authenticated fetch, so that - // an idle app will not get logged out if it doesn't issue a fetch before - // the first expiration date. - if (currentRefreshOptions !== undefined) { - const proactivelyRefreshToken = async () => { - try { - const { - accessToken: refreshedAccessToken, - refreshToken, - expiresIn, - } = await refreshAccessToken( - currentRefreshOptions, - // If currentRefreshOptions is defined, options is necessarily defined too. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options!.dpopKey, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options!.eventEmitter - ); - // Update the tokens in the closure if appropriate. - currentAccessToken = refreshedAccessToken; - if (refreshToken !== undefined) { - currentRefreshOptions.refreshToken = refreshToken; - } - // Each time the access token is refreshed, we must plan fo the next - // refresh iteration. - clearTimeout(latestTimeout); - latestTimeout = setTimeout( - proactivelyRefreshToken, - computeRefreshDelay(expiresIn) * 1000 - ); - // If currentRefreshOptions is defined, options is necessarily defined too. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options!.eventEmitter?.emit(EVENTS.TIMEOUT_SET, latestTimeout); - } catch (e) { - // It is possible that an underlying library throws an error on refresh flow failure. - // If we used a log framework, the error could be logged at the `debug` level, - // but otherwise the failure of the refresh flow should not blow up in the user's - // face, so we just swallow the error. - if (e instanceof OidcProviderError) { - // The OIDC provider refused to refresh the access token and returned an error instead. - /* istanbul ignore next 100% coverage would require testing that nothing - happens here if the emitter is undefined, which is more cumbersome - than what it's worth. */ - options?.eventEmitter?.emit( - EVENTS.ERROR, - e.error, - e.errorDescription - ); - /* istanbul ignore next 100% coverage would require testing that nothing - happens here if the emitter is undefined, which is more cumbersome - than what it's worth. */ - options?.eventEmitter?.emit(EVENTS.SESSION_EXPIRED); - } - if ( - e instanceof InvalidResponseError && - e.missingFields.includes("access_token") - ) { - // In this case, the OIDC provider returned a non-standard response, but - // did not specify that it was an error. We cannot refresh nonetheless. - /* istanbul ignore next 100% coverage would require testing that nothing - happens here if the emitter is undefined, which is more cumbersome - than what it's worth. */ - options?.eventEmitter?.emit(EVENTS.SESSION_EXPIRED); - } - } - }; - latestTimeout = setTimeout( - proactivelyRefreshToken, - // If currentRefreshOptions is defined, options is necessarily defined too. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - computeRefreshDelay(options!.expiresIn) * 1000 - ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options!.eventEmitter?.emit(EVENTS.TIMEOUT_SET, latestTimeout); - } else if (options !== undefined && options.eventEmitter !== undefined) { - // If no refresh options are provided, the session expires when the access token does. - const expirationTimeout = setTimeout(() => { - // The event emitter is always defined in our code, and it would be tedious - // to test for conditions when it is not. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options.eventEmitter!.emit(EVENTS.SESSION_EXPIRED); - }, computeRefreshDelay(options.expiresIn) * 1000); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options.eventEmitter!.emit(EVENTS.TIMEOUT_SET, expirationTimeout); - } + + /* === CODE REMOVE FROM HERE === */ + return async (url, requestInit?): Promise => { let response = await makeAuthenticatedRequest( unauthFetch, - currentAccessToken, + accessToken, // === Rename url, requestInit, options?.dpopKey @@ -280,7 +177,7 @@ export async function buildAuthenticatedFetch( // DPoP token) is. response = await makeAuthenticatedRequest( unauthFetch, - currentAccessToken, + accessToken, // === Rename // Replace the original target IRI (`url`) by the redirection target response.url, requestInit, diff --git a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts index b161b30..a8bfefb 100644 --- a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts +++ b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts @@ -153,13 +153,50 @@ export class AuthCodeRedirectHandler implements IIncomingRedirectHandler { } let refreshOptions: RefreshOptions | undefined; if (tokenSet.refresh_token !== undefined) { - eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refresh_token); + eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refresh_token); + /* === BEGIN CUSTOM ADDITION === */ + await this.storageUtility.setForUser( + sessionId, + { refreshToken: tokenSet.refresh_token }, + { secure: true } + ); + /* === END CUSTOM ADDITION === */ refreshOptions = { refreshToken: tokenSet.refresh_token, sessionId, tokenRefresher: this.tokenRefresher, }; } + /* === BEGIN CUSTOM ADDITION === */ + await this.storageUtility.setForUser( + sessionId, + { access_token: tokenSet.access_token }, + { secure: true } + ); + + if (typeof tokenSet.expires_at === "number") { + await this.storageUtility.setForUser( + sessionId, + { expires_at: tokenSet.expires_at.toString() }, + { secure: true } + ); + } else if (typeof tokenSet.expires_in === "number") { + await this.storageUtility.setForUser( + sessionId, + { expires_at: Math.floor(tokenSet.expires_in + (Date.now() / 1000)).toString() }, + { secure: true } + ); + } + + if (typeof tokenSet.expires_in === "number") { + await this.storageUtility.setForUser( + sessionId, + { expires_in: tokenSet.expires_in.toString() }, + { secure: true } + ); + } + /* === END CUSTOM ADDITION === */ + const authFetch = await buildAuthenticatedFetch( globalFetch, tokenSet.access_token, diff --git a/packages/solid-vscode-auth/lib/index.ts b/packages/solid-vscode-auth/lib/index.ts index 8d799f3..3bd7700 100644 --- a/packages/solid-vscode-auth/lib/index.ts +++ b/packages/solid-vscode-auth/lib/index.ts @@ -70,6 +70,8 @@ export async function getSolidFetch( // TODO: Remove race conditions here (although they are unlikely to occur on any reasonable timeout scenarios) vscode.authentication.onDidChangeSessions((sessions) => { + console.log('on did change sessions called', sessions) + if (sessions.provider.id === SOLID_AUTHENTICATION_PROVIDER_ID) { const newSession = vscode.authentication.getSession( SOLID_AUTHENTICATION_PROVIDER_ID, @@ -80,6 +82,7 @@ export async function getSolidFetch( Promise.all([session, newSession]).then(async ([old, news]) => { if (old?.id === news?.id) { definedSession = (await newSession) || definedSession; + console.log('session updated', definedSession) } }); } From 433e3d93f81bcd9616eb1280a9a6e53285a836c9 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 8 Dec 2022 19:16:02 +1100 Subject: [PATCH 07/17] chore: fix recursive timeout update bug --- .../src/auth/solidAuthenticationProvider.ts | 35 ++++++++++++++----- packages/solid-vscode-auth/lib/index.ts | 27 +++++++------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts index c4975e3..91704e7 100644 --- a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts +++ b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts @@ -305,12 +305,16 @@ export class SolidAuthenticationProvider sessions[sessionId] = newAuthenticationSession; } + console.log('about to fire session change') + this.sessionChangeEmitter.fire({ changed: [newAuthenticationSession], added: [], removed: [], }); + console.log('post fire session change') + return sessions; }); } @@ -364,6 +368,11 @@ export class SolidAuthenticationProvider // Make sure the sessions are resolved await this.sessions; + + if (toRefresh.length > 0) { + console.log('breaking with toRefresh greater than 0', toRefresh) + } + } while (toRefresh.length > 0); this.runningRefresh = false; @@ -427,17 +436,27 @@ export class SolidAuthenticationProvider console.log("updating timeout for", newEndsIn / 1000, "seconds from now"); - if ( - typeof this.refreshTokenTimeout !== "undefined" && - getTimeLeft(this.refreshTokenTimeout) < newEndsIn - ) { - // We do not need to update the timeout in this case - return; - } + // if ( + // typeof this.refreshTokenTimeout !== "undefined" && + // getTimeLeft(this.refreshTokenTimeout) < newEndsIn && + + // ) { + // // We do not need to update the timeout in this case + // return; + // } console.log("setting timeout for", newEndsIn); + + const currentTimer = this.refreshTokenTimeout ? getTimeLeft(this.refreshTokenTimeout) : Infinity + + // Only include the value of the current timer if it has not already timed out + let nextEndsIn = currentTimer > 0 ? Math.min(currentTimer, newEndsIn) : newEndsIn; + + // Make sure that timeout is still called if the period has passed + nextEndsIn = Math.max(nextEndsIn, 0); clearTimeout(this.refreshTokenTimeout); + this.refreshTokenTimeout = setTimeout(async () => { await this.handleRefresh(); @@ -445,7 +464,7 @@ export class SolidAuthenticationProvider if (typeof nextExpiry === "number") { this.updateTimeout((nextExpiry * 1000) - Date.now()); } - }, newEndsIn); + }, nextEndsIn); } /** diff --git a/packages/solid-vscode-auth/lib/index.ts b/packages/solid-vscode-auth/lib/index.ts index 3bd7700..8973ec4 100644 --- a/packages/solid-vscode-auth/lib/index.ts +++ b/packages/solid-vscode-auth/lib/index.ts @@ -69,27 +69,26 @@ export async function getSolidFetch( let definedSession = session; // TODO: Remove race conditions here (although they are unlikely to occur on any reasonable timeout scenarios) - vscode.authentication.onDidChangeSessions((sessions) => { - console.log('on did change sessions called', sessions) - + vscode.authentication.onDidChangeSessions(async (sessions) => { + console.log('on did change sessions fired') if (sessions.provider.id === SOLID_AUTHENTICATION_PROVIDER_ID) { - const newSession = vscode.authentication.getSession( + console.log('ids match') + const newSession = await vscode.authentication.getSession( SOLID_AUTHENTICATION_PROVIDER_ID, - scopes, + // Use the defined session scopes to ensure + // that the same WebId is used + definedSession.scopes, { ...options, createIfNone: false } ); + console.log('new session retrieved', newSession) - Promise.all([session, newSession]).then(async ([old, news]) => { - if (old?.id === news?.id) { - definedSession = (await newSession) || definedSession; - console.log('session updated', definedSession) - } - }); + if (definedSession.id === newSession?.id) { + console.log('session updated') + definedSession = newSession + } } }); - console.log("creating fetch function"); - const f = async ( input: RequestInfo | URL, init?: RequestInit | undefined @@ -103,8 +102,6 @@ export async function getSolidFetch( return (await buildAuthenticatedFetchFromAccessToken(token))(input, init); }; - console.log("fetch function created"); - return { fetch: f, account: definedSession.account, From c12974abf9e8b25a50236d0e27e92dc5407e7762 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 8 Dec 2022 20:22:26 +1100 Subject: [PATCH 08/17] chore: update solidauth readme to reference @inrupt/solid-vscode-auth --- extensions/solidauth/README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/extensions/solidauth/README.md b/extensions/solidauth/README.md index 1cb5b6b..6adf4d6 100644 --- a/extensions/solidauth/README.md +++ b/extensions/solidauth/README.md @@ -2,7 +2,26 @@ Provides Authentication for the Solid Ecosystem. -## Features +## Using the authentication provider `@inrupt/solid-vscode-auth` + +We currently recommend using `@inrupt/solid-vscode-auth` to get a solid authentication session and build a fetch function. It's usage is as follows: + +```ts +import { getSolidFetch } from "@inrupt/solid-vscode-auth"; +import { getSolidDataset } from "@inrupt/solid-client"; + +function loginAndFetch() { + // Get the existing login session if the user is logged into a + // solid Pod provider, or triggers the login flow otherwise + const { fetch, account } = getSolidFetch([], { createIfNone: true }); + const webid = account.id; + + // Fetching the dataset of the WebId + const dataset = await getSolidDataset(webid, { fetch }); +} +``` + +## Using the authentication provider directly (not currently recommended) This extension should be used via the `vscode.authentication` API. To get a Solid Session for your extension - do the following From 656dc2afff2f61cd4982e801dc5b39070175b43e Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 8 Dec 2022 21:41:21 +1100 Subject: [PATCH 09/17] chore: use expires_in where available --- .../solidauth/src/auth/solidAuthenticationProvider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts index 91704e7..2ae21fa 100644 --- a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts +++ b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts @@ -270,7 +270,11 @@ export class SolidAuthenticationProvider dpopKey ); - const expiresAt = (result.expiresIn ?? DEFAULT_EXPIRATION_TIME_SECONDS) + Math.floor(Date.now() / 1000) + const expiresAt = ( + result.expiresIn ?? + await this.storage.getForUser(sessionId, 'expires_in', { errorIfNull: false }) ?? + DEFAULT_EXPIRATION_TIME_SECONDS + ) + Math.floor(Date.now() / 1000) await this.storage.setForUser( sessionId, From 865bf2d17d489ab66c33cf0fa21fb276c2d6aa0b Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 8 Dec 2022 22:34:53 +1100 Subject: [PATCH 10/17] chore: add newline to .eslintignore --- .eslintignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintignore b/.eslintignore index fe17425..581edad 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1 @@ -**/dist \ No newline at end of file +**/dist From 2249f723fe2f54b06ae206dbf7c5c13e7c26aa33 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 8 Dec 2022 22:53:03 +1100 Subject: [PATCH 11/17] chore: fix lint errors --- .eslintignore | 2 + .../src/auth/solidAuthenticationProvider.ts | 143 ++++-------------- .../solidauth/src/storage/secretStorage.ts | 8 +- extensions/solidfs/src/solidFS.ts | 39 +---- .../src/authenticatedFetch/fetchFactory.ts | 1 - .../AuthCodeRedirectHandler.ts | 8 +- .../__tests__/solid-vscode-auth.test.ts | 7 - packages/solid-vscode-auth/lib/index.ts | 14 +- 8 files changed, 44 insertions(+), 178 deletions(-) delete mode 100644 packages/solid-vscode-auth/__tests__/solid-vscode-auth.test.ts diff --git a/.eslintignore b/.eslintignore index 581edad..9f85ca7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,3 @@ **/dist +packages/core +packages/node diff --git a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts index 2ae21fa..f01567b 100644 --- a/extensions/solidauth/src/auth/solidAuthenticationProvider.ts +++ b/extensions/solidauth/src/auth/solidAuthenticationProvider.ts @@ -38,8 +38,8 @@ import type { ExtensionContext, } from "vscode"; import { EventEmitter } from "vscode"; -import { IStorageUtility } from "@inrupt/solid-client-authn-core"; import { StorageUtility } from "@inrupt/solid-client-authn-core"; +import type { IStorageUtility } from "@inrupt/solid-client-authn-core"; // TODO: Finish this based on https://www.eliostruyf.com/create-authentication-provider-visual-studio-code/ // TODO: Use this to get name of idp provider @@ -49,6 +49,7 @@ import { StorageUtility } from "@inrupt/solid-client-authn-core"; import { importJWK } from "jose"; // import AuthCodeRedirectHandler from "./AuthCodeRedirectHandler"; import { ISecretStorage } from "../storage"; + const DEFAULT_EXPIRATION_TIME_SECONDS = 600; // Get the time left on a NodeJS timeout (in milliseconds) @@ -65,7 +66,6 @@ export class SolidAuthenticationProvider private sessionChangeEmitter = new EventEmitter(); - // private _disposable: Disposable; private storage: StorageUtility; private sessions?: Promise>; @@ -159,12 +159,9 @@ export class SolidAuthenticationProvider sessions[session.id] = session; } // Trigger refresh flow as appropriate and set timeout where appropriate - try { - // DO NOT AWAIT THIS - it should be a valid session - // immediately upon log in and it causes blocking (that we should debug) - this.handleRefresh(); - } catch (e) { - } + // DO NOT AWAIT THIS - it should be a valid session + // immediately upon log in and it causes blocking (that we should debug) + this.handleRefresh().catch(() => {}); return sessions; })); @@ -215,15 +212,6 @@ export class SolidAuthenticationProvider } } - // async removeSession(sessionId: string): Promise { - - // // await (this.sessions as any)?.session.logout(); - // // delete this.sessions; - - // // await clearSessionFromStorageAll(this.storage); - // // await clearSessionFromStorage() - // } - public async runRefresh(sessionId: string) { // Now we run the refresh process for the given session const session = (await this.sessions)?.[sessionId]; @@ -247,21 +235,20 @@ export class SolidAuthenticationProvider isLoggedIn: true, webId: session.id, }, - }); // Monkey patch the AuthCodeRedirectHandler with our custom one that saves the access_token to secret storage const currentHandler = (s2 as any).clientAuthentication.redirectHandler .handleables[0]; - const refreshToken = (await this.storage.getForUser( + const refreshToken = await this.storage.getForUser( sessionId, "refreshToken", { secure: true, errorIfNull: true } - )); + ); if (!refreshToken) { - throw new Error('refresh token is undefined'); + throw new Error("refresh token is undefined"); } const result = await currentHandler.tokenRefresher.refresh( @@ -270,11 +257,12 @@ export class SolidAuthenticationProvider dpopKey ); - const expiresAt = ( - result.expiresIn ?? - await this.storage.getForUser(sessionId, 'expires_in', { errorIfNull: false }) ?? - DEFAULT_EXPIRATION_TIME_SECONDS - ) + Math.floor(Date.now() / 1000) + const expiresAt = + (result.expiresIn ?? + (await this.storage.getForUser(sessionId, "expires_in", { + errorIfNull: false, + })) ?? + DEFAULT_EXPIRATION_TIME_SECONDS) + Math.floor(Date.now() / 1000); await this.storage.setForUser( sessionId, @@ -309,7 +297,7 @@ export class SolidAuthenticationProvider sessions[sessionId] = newAuthenticationSession; } - console.log('about to fire session change') + console.log("about to fire session change"); this.sessionChangeEmitter.fire({ changed: [newAuthenticationSession], @@ -317,7 +305,7 @@ export class SolidAuthenticationProvider removed: [], }); - console.log('post fire session change') + console.log("post fire session change"); return sessions; }); @@ -346,48 +334,26 @@ export class SolidAuthenticationProvider typeof sessionId === "string" && expiries[sessionId] * 1000 < REFRESH_EXPIRY_BEFORE ) { - console.log( - "refreshing sessionId", - expiries[sessionId] * 1000, - Date.now(), - REFRESH_EXPIRY_BEFORE - ); toRefresh.push(sessionId); - } else { - console.log( - "to early to refresh", - expiries[sessionId] * 1000, - Date.now(), - REFRESH_EXPIRY_BEFORE - ); } } - console.log("toRefresh", toRefresh); - // eslint-disable-next-line no-await-in-loop await Promise.all( toRefresh.map((sessionId) => this.runRefresh(sessionId)) ); - + // Make sure the sessions are resolved + // eslint-disable-next-line no-await-in-loop await this.sessions; - - if (toRefresh.length > 0) { - console.log('breaking with toRefresh greater than 0', toRefresh) - } - } while (toRefresh.length > 0); this.runningRefresh = false; const nextExpiry = await this.getNextExpiry(); - console.log("next expiry is", nextExpiry); - if (typeof nextExpiry === "number") { - console.log("updating timeout for", (nextExpiry * 1000) - Date.now()); - this.updateTimeout((nextExpiry * 1000) - Date.now()); + this.updateTimeout(nextExpiry * 1000 - Date.now()); } // Refreshes all necessary tokens @@ -396,7 +362,6 @@ export class SolidAuthenticationProvider // Get all expiries (in seconds since 1970-01-01T00:00:00Z) public async getAllExpiries(): Promise> { const sessions = await this.sessions; - console.log("awaited sessions", sessions); const expiries: Record = {}; for (const sessionId of sessions ? Object.keys(sessions) : []) { @@ -409,13 +374,6 @@ export class SolidAuthenticationProvider { secure: true } ); - // console.log( - // 'the user info is', - // JSON.parse((await this.storage.get(`solidClientAuthenticationUser:${sessionId}`))!) - // ) - - console.log("expires at", expiresAt); - if (typeof expiresAt === "string") { expiries[sessionId] = parseInt(expiresAt, 10); } @@ -438,23 +396,13 @@ export class SolidAuthenticationProvider const newEndsIn = endsIn - REFRESH_BEFORE_EXPIRATION; - console.log("updating timeout for", newEndsIn / 1000, "seconds from now"); - - // if ( - // typeof this.refreshTokenTimeout !== "undefined" && - // getTimeLeft(this.refreshTokenTimeout) < newEndsIn && - - // ) { - // // We do not need to update the timeout in this case - // return; - // } - - console.log("setting timeout for", newEndsIn); - - const currentTimer = this.refreshTokenTimeout ? getTimeLeft(this.refreshTokenTimeout) : Infinity + const currentTimer = this.refreshTokenTimeout + ? getTimeLeft(this.refreshTokenTimeout) + : Infinity; // Only include the value of the current timer if it has not already timed out - let nextEndsIn = currentTimer > 0 ? Math.min(currentTimer, newEndsIn) : newEndsIn; + let nextEndsIn = + currentTimer > 0 ? Math.min(currentTimer, newEndsIn) : newEndsIn; // Make sure that timeout is still called if the period has passed nextEndsIn = Math.max(nextEndsIn, 0); @@ -466,7 +414,7 @@ export class SolidAuthenticationProvider const nextExpiry = await this.getNextExpiry(); if (typeof nextExpiry === "number") { - this.updateTimeout((nextExpiry * 1000) - Date.now()); + this.updateTimeout(nextExpiry * 1000 - Date.now()); } }, nextEndsIn); } @@ -495,7 +443,6 @@ export class SolidAuthenticationProvider "https://solidcommunity.net/", "https://solidweb.me/", "https://pod.playground.solidlab.be/", - // "https://openid.release-ap-1-standalone.inrupt.com/", "https://trinpod.us/gmxLogin", "http://localhost:3000/", "Other", @@ -531,36 +478,13 @@ export class SolidAuthenticationProvider return; } - progress.report({ - message: `Preparing to log in with ${oidcIssuer}`, - // increment: 20 - }); + progress.report({ message: `Preparing to log in with ${oidcIssuer}` }); // TODO: Give this the right storage let session = new Session({ storage: this.storage, - onNewRefreshToken() { - - }, - // secureStorage: this.storage.secureStorage, - // insecureStorage: this.storage.insecureStorage, - // clientAuthentication }); - session.emit - - // Monkey patch the AuthCodeRedirectHandler with our custom one that saves the access_token to secret storage - // const currentHandler = (session as any).clientAuthentication - // .redirectHandler.handleables[0]; - // (session as any).clientAuthentication.redirectHandler.handleables[0] = - // new AuthCodeRedirectHandler( - // currentHandler.storageUtility, - // currentHandler.sessionInfoManager, - // currentHandler.issuerConfigFetcher, - // currentHandler.clientRegistrar, - // currentHandler.tokenRefresher - // ); - // TODO: See if it is plausible for this to occur after redirect call is made const handleRedirect = async (url: string) => { if (!token.isCancellationRequested) { @@ -600,9 +524,7 @@ export class SolidAuthenticationProvider }); }); - progress.report({ - message: `Completing login`, - }); + progress.report({ message: `Completing login` }); await session.handleIncomingRedirect(uri); } catch (e) { @@ -664,16 +586,7 @@ export class SolidAuthenticationProvider { secure: true } ); - // TODO: At this point we should be hooking into whichever handler has the updated access_ - // session.onNewRefreshToken(() => { - - // }) - - // Listen in for the custom event indicating a new access token - // TODO: Do removed on logout style events - // session.on("access_token", async (access_token: string) => { - // await this.storage.setForUser(session.info.sessionId, { access_token }, { secure: true }) - + // TODO: See if we need an added event // this.sessionChangeEmitter.fire({ // changed: [ // await toAuthenticationSession(session, this.storage), diff --git a/extensions/solidauth/src/storage/secretStorage.ts b/extensions/solidauth/src/storage/secretStorage.ts index 12d477f..c152672 100644 --- a/extensions/solidauth/src/storage/secretStorage.ts +++ b/extensions/solidauth/src/storage/secretStorage.ts @@ -28,16 +28,10 @@ export class ISecretStorage implements IStorage { ) {} async get(key: string): Promise { - const result = await this.secrets.get(this.prefix + key); - // console.log('getting', key, result) - return result; + return this.secrets.get(this.prefix + key); } async set(key: string, value: string): Promise { - // console.log('setting', key, value) - if (key.includes('solidClientAuthenticationUser:')) { - console.log('-'.repeat(50), 'refresh token being set is', JSON.parse(value)['refresh_token']) - } return this.secrets.store(this.prefix + key, value); } diff --git a/extensions/solidfs/src/solidFS.ts b/extensions/solidfs/src/solidFS.ts index 0e07d61..e976f4d 100644 --- a/extensions/solidfs/src/solidFS.ts +++ b/extensions/solidfs/src/solidFS.ts @@ -31,7 +31,7 @@ import { import type { VscodeSolidSession } from "@inrupt/solid-vscode-auth"; const BasicContainer = DF.namedNode("http://www.w3.org/ns/ldp#BasicContainer"); -// TODO: Make sure this is properly used +// TODO: Make sure this is properly used const Container = DF.namedNode("http://www.w3.org/ns/ldp#Container"); // TODO: Work out why we are *first* getting non-existant file errors @@ -101,8 +101,6 @@ export class SolidFS implements vscode.FileSystemProvider { } async stat(uri: vscode.Uri): Promise { - console.log("stat called on", uri); - // TODO: See if we should be looking up parent dir instead? if (!(uri.path in this.stats)) { const fileType = await new Promise( @@ -141,12 +139,8 @@ export class SolidFS implements vscode.FileSystemProvider { ); if (fileType !== undefined) { - console.log("setting filetype", fileType, "for", uri.path); this.stats[uri.path] = fileType; } - - // const race = await Promise.race([ file, dir ]) - // race.url.endsWith('/') } if (uri.path in this.stats) { @@ -161,8 +155,6 @@ export class SolidFS implements vscode.FileSystemProvider { }; } - console.log("abotu to throw stat error for", uri); - throw vscode.FileSystemError.FileNotFound(uri); // if @@ -179,7 +171,6 @@ export class SolidFS implements vscode.FileSystemProvider { // in the container metadata. // TODO: See if we can just determine this based on trailing slash async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { - console.log("read directory called on", uri); const source = `${this.root}${ uri.path.length > 1 ? `${uri.path.slice(1)}/` : "" }`; @@ -187,8 +178,6 @@ export class SolidFS implements vscode.FileSystemProvider { try { const session = await this.session; - console.log("fetch object is", session, typeof session?.fetch); - const bindings = await this.engine.queryBindings( ` SELECT * WHERE { <${source}> ?o }`, @@ -221,10 +210,9 @@ export class SolidFS implements vscode.FileSystemProvider { }) .toArray(); - console.log("returning ", res); return res; } catch (e) { - // TODO: Properly log this + // TODO: Properly log this (or perhaps throw an error) console.error("error reading directory", e); } @@ -232,7 +220,6 @@ export class SolidFS implements vscode.FileSystemProvider { } async createDirectory(uri: vscode.Uri): Promise { - // console.log("create directory called on", uri); await createContainerAt(`${this.root}${uri.path.slice(1)}/`, { fetch: (await this.session)?.fetch, }); @@ -240,7 +227,6 @@ export class SolidFS implements vscode.FileSystemProvider { await this.engine.invalidateHttpCache(); this.fireSoon({ type: vscode.FileChangeType.Created, uri }); - // throw new Error('Method not implemented.'); } async readFile(uri: vscode.Uri): Promise { @@ -279,10 +265,9 @@ export class SolidFS implements vscode.FileSystemProvider { const i = uri.path.lastIndexOf("/") + 1; // TODO: Predict this based on file type - const data = await ((await this.session)?.fetch ?? (globalThis as any).fetch)( - `${this.root}${uri.path.slice(1, i)}`, - { method: "HEAD" } - ); + const data = await ( + (await this.session)?.fetch ?? (globalThis as any).fetch + )(`${this.root}${uri.path.slice(1, i)}`, { method: "HEAD" }); let contentType; if (data.status !== 200) { const ext = uri.path.slice(uri.path.lastIndexOf(".") + 1); @@ -295,11 +280,6 @@ export class SolidFS implements vscode.FileSystemProvider { } const buf = Buffer.from(content); - console.log(buf.toString("utf8")); - - console.log("saving in container", `${this.root}${uri.path.slice(1, i)}`); - console.log("with slug", uri.path.slice(i)); - console.log("with content type", contentType); await overwriteFile(`${this.root}${uri.path.slice(1)}`, buf, { fetch: (await this.session)?.fetch, @@ -326,9 +306,6 @@ export class SolidFS implements vscode.FileSystemProvider { uri: vscode.Uri, options: { readonly recursive: boolean } ): Promise { - console.log("delete called on", uri); - // TODO: Handle recursive - const stat = await this.stat(uri); if (stat.type === vscode.FileType.File) { @@ -345,10 +322,6 @@ export class SolidFS implements vscode.FileSystemProvider { await this.engine.invalidateHttpCache(); // TODO: Get this working this.fireSoon({ type: vscode.FileChangeType.Deleted, uri }); - - return; - - throw new Error("Method not implemented."); } rename( @@ -356,7 +329,6 @@ export class SolidFS implements vscode.FileSystemProvider { newUri: vscode.Uri, options: { readonly overwrite: boolean } ): void | Thenable { - console.log("rename file called on", oldUri, newUri, options); throw new Error("Method not implemented."); } @@ -365,7 +337,6 @@ export class SolidFS implements vscode.FileSystemProvider { destination: vscode.Uri, options: { readonly overwrite: boolean } ): void | Thenable { - console.log("cope called on", source, destination, options); throw new Error("Method not implemented."); } diff --git a/packages/core/src/authenticatedFetch/fetchFactory.ts b/packages/core/src/authenticatedFetch/fetchFactory.ts index ac00c37..65f6811 100644 --- a/packages/core/src/authenticatedFetch/fetchFactory.ts +++ b/packages/core/src/authenticatedFetch/fetchFactory.ts @@ -149,7 +149,6 @@ export async function buildAuthenticatedFetch( eventEmitter?: EventEmitter; } ): Promise { - /* === CODE REMOVE FROM HERE === */ return async (url, requestInit?): Promise => { diff --git a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts index a8bfefb..7b0c92a 100644 --- a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts +++ b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts @@ -153,7 +153,7 @@ export class AuthCodeRedirectHandler implements IIncomingRedirectHandler { } let refreshOptions: RefreshOptions | undefined; if (tokenSet.refresh_token !== undefined) { - eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refresh_token); + eventEmitter?.emit(EVENTS.NEW_REFRESH_TOKEN, tokenSet.refresh_token); /* === BEGIN CUSTOM ADDITION === */ await this.storageUtility.setForUser( sessionId, @@ -183,7 +183,11 @@ export class AuthCodeRedirectHandler implements IIncomingRedirectHandler { } else if (typeof tokenSet.expires_in === "number") { await this.storageUtility.setForUser( sessionId, - { expires_at: Math.floor(tokenSet.expires_in + (Date.now() / 1000)).toString() }, + { + expires_at: Math.floor( + tokenSet.expires_in + Date.now() / 1000 + ).toString(), + }, { secure: true } ); } diff --git a/packages/solid-vscode-auth/__tests__/solid-vscode-auth.test.ts b/packages/solid-vscode-auth/__tests__/solid-vscode-auth.test.ts deleted file mode 100644 index ff13702..0000000 --- a/packages/solid-vscode-auth/__tests__/solid-vscode-auth.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -const solidVscodeAuth = require('..'); -const assert = require('assert').strict; - -assert.strictEqual(solidVscodeAuth(), 'Hello from solidVscodeAuth'); -console.info("solidVscodeAuth tests passed"); diff --git a/packages/solid-vscode-auth/lib/index.ts b/packages/solid-vscode-auth/lib/index.ts index 8973ec4..1ca4730 100644 --- a/packages/solid-vscode-auth/lib/index.ts +++ b/packages/solid-vscode-auth/lib/index.ts @@ -52,27 +52,19 @@ export async function getSolidFetch( scopes: readonly string[], options?: vscode.AuthenticationGetSessionOptions ): Promise { - console.log("get solid fetch started"); - const session = await vscode.authentication.getSession( SOLID_AUTHENTICATION_PROVIDER_ID, scopes, options ); - console.log("session retrieved"); - if (!session) return; - console.log("session not empty"); - let definedSession = session; // TODO: Remove race conditions here (although they are unlikely to occur on any reasonable timeout scenarios) - vscode.authentication.onDidChangeSessions(async (sessions) => { - console.log('on did change sessions fired') + vscode.authentication.onDidChangeSessions(async (sessions) => { if (sessions.provider.id === SOLID_AUTHENTICATION_PROVIDER_ID) { - console.log('ids match') const newSession = await vscode.authentication.getSession( SOLID_AUTHENTICATION_PROVIDER_ID, // Use the defined session scopes to ensure @@ -80,11 +72,9 @@ export async function getSolidFetch( definedSession.scopes, { ...options, createIfNone: false } ); - console.log('new session retrieved', newSession) if (definedSession.id === newSession?.id) { - console.log('session updated') - definedSession = newSession + definedSession = newSession; } } }); From 649aa8a9332d5cbe2a13a67d9007ebff93a9bef3 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 8 Dec 2022 23:39:43 +1100 Subject: [PATCH 12/17] chore: add installation warning --- .../.init-default-profile-extensions | 0 .vscode-test/extensions/extensions.json | 1 + README.md | 20 +++++++++++++++++++ package.json | 2 ++ 4 files changed, 23 insertions(+) create mode 100644 .vscode-test/extensions/.init-default-profile-extensions create mode 100644 .vscode-test/extensions/extensions.json diff --git a/.vscode-test/extensions/.init-default-profile-extensions b/.vscode-test/extensions/.init-default-profile-extensions new file mode 100644 index 0000000..e69de29 diff --git a/.vscode-test/extensions/extensions.json b/.vscode-test/extensions/extensions.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.vscode-test/extensions/extensions.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/README.md b/README.md index f8db912..d6e4f73 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,16 @@ code ./extensions/solidfs/ and then press `fn`+`F5` in the new vscode window that is opened. +*or* + +```shell +gh repo clone inrupt/vscode-extension-solidfs +cd ./vscode-extension-solidfs +npm run predev:solidfs +``` + +and then press `fn`+`F5` in the new vscode window that is opened. + ## authn dependencies We have had to customise the authentication libraries to handle session management in vscode. The following 2 files @@ -61,3 +71,13 @@ ensure refresh token and access_token are saved to storage In each case comments starting with "===" have been added to indicate where the files deviate from the original authn libraries + +## Installation warning + +*Note* there is the following deprecation warning when installing the extension in the command line + +```bash +(node:57198) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead. +``` + +It occurs due to the use of `cross-fetch` in a nested dependency which uses a deprecated version of `node-fetch` and in turn `whatwg-url`. diff --git a/package.json b/package.json index 28d7efe..104dd38 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "solidauth:link": "lerna run solidauth:link", "solidauth:install": "lerna run solidauth:install", "test:extensions": "lerna run test --concurrency 1 --stream", + "predev:all": "npm install && npm run build && npm run package && npm run solidauth:install", + "predev:solidfs": "npm run predev:all && code ./extensions/solidfs", "test:packages": "jest --coverage --verbose", "test": "npm run test:extensions" }, From 20c0ef30377a31cf677cabfbbd30559ab9ae56da Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 8 Dec 2022 23:42:08 +1100 Subject: [PATCH 13/17] chore: run lint:fix --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d6e4f73..088ed09 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ code ./extensions/solidfs/ and then press `fn`+`F5` in the new vscode window that is opened. -*or* +_or_ ```shell gh repo clone inrupt/vscode-extension-solidfs @@ -74,7 +74,7 @@ libraries ## Installation warning -*Note* there is the following deprecation warning when installing the extension in the command line +_Note_ there is the following deprecation warning when installing the extension in the command line ```bash (node:57198) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead. From 2938aab919781d1cbaec87b1b3d7f8a745794c4e Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 8 Dec 2022 23:49:35 +1100 Subject: [PATCH 14/17] chore: remove .vscode-test/ files --- .gitignore | 1 + .vscode-test/extensions/.init-default-profile-extensions | 0 .vscode-test/extensions/extensions.json | 1 - 3 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .vscode-test/extensions/.init-default-profile-extensions delete mode 100644 .vscode-test/extensions/extensions.json diff --git a/.gitignore b/.gitignore index d97bcd2..c3c9bde 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ license.csv dist/ +.vscode-test diff --git a/.vscode-test/extensions/.init-default-profile-extensions b/.vscode-test/extensions/.init-default-profile-extensions deleted file mode 100644 index e69de29..0000000 diff --git a/.vscode-test/extensions/extensions.json b/.vscode-test/extensions/extensions.json deleted file mode 100644 index 0637a08..0000000 --- a/.vscode-test/extensions/extensions.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file From 6243b374cc7ee0bbfc72d0771cf9b646545ea967 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Fri, 9 Dec 2022 10:37:16 +1100 Subject: [PATCH 15/17] chore: .gitignore .vscode-test/ files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c3c9bde..e09d8b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ node_modules/ license.csv dist/ -.vscode-test +.vscode-test/ From 51770ffa98be3286812c183eedc3b7bfc877bf63 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Fri, 9 Dec 2022 10:55:26 +1100 Subject: [PATCH 16/17] chore: add postinstall script so reusable-lint works --- package-lock.json | 1 + package.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9fdfd6b..f5c0965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "root", + "hasInstallScript": true, "workspaces": [ "packages/*", "extensions/*" diff --git a/package.json b/package.json index 104dd38..1bfcd57 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "solidauth:link": "lerna run solidauth:link", "solidauth:install": "lerna run solidauth:install", "test:extensions": "lerna run test --concurrency 1 --stream", - "predev:all": "npm install && npm run build && npm run package && npm run solidauth:install", + "postinstall": "lerna bootstrap --ci && npm run build", + "predev:all": "npm install && npm run package && npm run solidauth:install", "predev:solidfs": "npm run predev:all && code ./extensions/solidfs", "test:packages": "jest --coverage --verbose", "test": "npm run test:extensions" From 0af326769905a92a8aab14b2e7c2161985c385ce Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 15 Dec 2022 13:17:25 +1100 Subject: [PATCH 17/17] WIP: refactor to use solid-bashlib --- extensions/solidfs/.vscode/launch.json | 2 - extensions/solidfs/package.json | 4 +- extensions/solidfs/src/extension.ts | 38 +- extensions/solidfs/src/solidFS.ts | 107 +- package-lock.json | 1395 ++++++++++++++++++++++-- package.json | 1 + 6 files changed, 1374 insertions(+), 173 deletions(-) diff --git a/extensions/solidfs/.vscode/launch.json b/extensions/solidfs/.vscode/launch.json index 1cd32c6..8594af5 100644 --- a/extensions/solidfs/.vscode/launch.json +++ b/extensions/solidfs/.vscode/launch.json @@ -12,7 +12,6 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}", "--extensions-dir=${workspaceFolder}/.vscode-test/extensions/", - // "--install-extension=${workspaceFolder}/.vscode-test/extensions/solidauth-0.0.1.vsix" ], "outFiles": [ "${workspaceFolder}/dist/**/*.js" @@ -26,7 +25,6 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}", "--extensions-dir=${workspaceFolder}/.vscode-test/extensions/", - // "--install-extension=${workspaceFolder}/.vscode-test/extensions/solidauth-0.0.1.vsix", "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" ], "outFiles": [ diff --git a/extensions/solidfs/package.json b/extensions/solidfs/package.json index 762643c..e2293bc 100644 --- a/extensions/solidfs/package.json +++ b/extensions/solidfs/package.json @@ -72,7 +72,9 @@ "@inrupt/solid-vscode-auth": "^0.0.0", "@rdfjs/types": "^1.1.0", "http-link-header": "^1.1.0", - "n3": "^1.16.3" + "md5": "^2.3.0", + "n3": "^1.16.3", + "solid-bashlib": "^0.2.1" }, "devDependencies": { "solidauth": "^0.0.1" diff --git a/extensions/solidfs/src/extension.ts b/extensions/solidfs/src/extension.ts index 7a760c1..52e0e66 100644 --- a/extensions/solidfs/src/extension.ts +++ b/extensions/solidfs/src/extension.ts @@ -21,6 +21,7 @@ import { QueryEngine } from "@comunica/query-sparql-solid"; import * as vscode from "vscode"; import { getSolidFetch } from "@inrupt/solid-vscode-auth"; +import md5 = require('md5'); import LinkHeader = require("http-link-header"); import { SolidFS } from "./solidFS"; // TODO: Investigate https://stackoverflow.com/questions/61959354/vscode-extension-add-custom-command-to-right-click-menu-in-file-explorer @@ -72,7 +73,7 @@ function initFileSystem(context: vscode.ExtensionContext, engine: QueryEngine) { // TODO: Refactor this context.subscriptions.push( vscode.workspace.registerFileSystemProvider( - `solidfs-${hashCode(webId)}-${hashCode(root)}`, + `solidfs-${md5(webId)}-${md5(root)}`, new SolidFS({ session, root, engine }), { isCaseSensitive: true } ) @@ -105,22 +106,6 @@ function initFileSystem(context: vscode.ExtensionContext, engine: QueryEngine) { console.log("end inti file system"); } -// See https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript -function hashCode(str: string) { - let hash = 0; - let i; - let chr; - if (str.length === 0) return hash; - for (i = 0; i < str.length; i += 1) { - chr = str.charCodeAt(i); - // eslint-disable-next-line no-bitwise - hash = (hash << 5) - hash + chr; - // eslint-disable-next-line no-bitwise - hash |= 0; // Convert to 32bit integer - } - return hash; -} - // This method is called when your extension is activated // Your extension is activated the very first time the command is executed export async function activate(context: vscode.ExtensionContext) { @@ -182,7 +167,7 @@ export async function activate(context: vscode.ExtensionContext) { try { context.subscriptions.push( vscode.workspace.registerFileSystemProvider( - `solidfs-${hashCode(webId)}-${hashCode(podRoot)}`, + `solidfs-${md5(webId)}-${md5(podRoot)}`, new SolidFS({ session, root: podRoot, engine }), { isCaseSensitive: true } ) @@ -200,7 +185,7 @@ export async function activate(context: vscode.ExtensionContext) { null, { uri: vscode.Uri.parse( - `solidfs-${hashCode(webId)}-${hashCode(podRoot)}:/` + `solidfs-${md5(webId)}-${md5(podRoot)}:/` ), // name: new URL(webId).pathname.split("/").find((x) => x !== ""), name: session.account.label, @@ -211,10 +196,6 @@ export async function activate(context: vscode.ExtensionContext) { } } ); - - // The code you place here will be executed every time your command is executed - // Display a message box to the user - // vscode.window.showInformationMessage('Hello World from solidFS!'); } ); @@ -227,9 +208,16 @@ export async function activate(context: vscode.ExtensionContext) { console.log( "update workspace state", - await context.workspaceState.get("solidfs", undefined) + context.workspaceState.get("solidfs", undefined) ); - }) + }), + + + vscode.commands.registerCommand("solidfs.toggleMetadata", async () => { + await context.workspaceState.update("solidfs:showMetadata", !context.workspaceState.get("solidfs:showMetadata")) + + // TODO: Trigger a refresh of the workspaces + }), ); } diff --git a/extensions/solidfs/src/solidFS.ts b/extensions/solidfs/src/solidFS.ts index e976f4d..c3a914d 100644 --- a/extensions/solidfs/src/solidFS.ts +++ b/extensions/solidfs/src/solidFS.ts @@ -29,6 +29,7 @@ import { overwriteFile, } from "@inrupt/solid-client"; import type { VscodeSolidSession } from "@inrupt/solid-vscode-auth"; +import { copy, listPermissions, list, makeDirectory, remove, } from 'solid-bashlib'; const BasicContainer = DF.namedNode("http://www.w3.org/ns/ldp#BasicContainer"); // TODO: Make sure this is properly used @@ -72,6 +73,29 @@ export class SolidFS implements vscode.FileSystemProvider { private stats: Record = { "/": true }; + private _fetch?: typeof globalThis.fetch; + + private all: boolean = false; + + get fetch(): typeof globalThis.fetch { + if (this._fetch) + return this._fetch; + + if (typeof this.session === 'undefined') + // TODO: See if we should be using cross-fetch here + return globalThis.fetch; + + if ('fetch' in this.session) + return this.session.fetch; + + return (...args: Parameters) => { + return Promise.resolve(this.session).then(sess => { + this._fetch = sess!.fetch; + return sess!.fetch(...args) + }) + }; + } + constructor(options: { session: | VscodeSolidSession @@ -175,6 +199,18 @@ export class SolidFS implements vscode.FileSystemProvider { uri.path.length > 1 ? `${uri.path.slice(1)}/` : "" }`; + // TODO: Assess performance impact of this + return (await list(source, { fetch: this.fetch, all: this.all, verbose: false })).map(src => [ + src.url.slice(this.root.length - 1, src.url.length - Number(src.isDir)), + src.isDir ? vscode.FileType.Directory : vscode.FileType.File, + ]); + + // sources.map(src => { + // src. + // }) + + return []; + try { const session = await this.session; @@ -220,11 +256,15 @@ export class SolidFS implements vscode.FileSystemProvider { } async createDirectory(uri: vscode.Uri): Promise { - await createContainerAt(`${this.root}${uri.path.slice(1)}/`, { - fetch: (await this.session)?.fetch, + await makeDirectory(`${this.root}${uri.path.slice(1)}/`, { + fetch: this.fetch, }); - // TODO: Don't be as aggressive - just invalidate the parent - await this.engine.invalidateHttpCache(); + + // await createContainerAt(`${this.root}${uri.path.slice(1)}/`, { + // fetch: (await this.session)?.fetch, + // }); + // // TODO: Don't be as aggressive - just invalidate the parent + // await this.engine.invalidateHttpCache(); this.fireSoon({ type: vscode.FileChangeType.Created, uri }); } @@ -279,9 +319,7 @@ export class SolidFS implements vscode.FileSystemProvider { contentType = data.headers.get("Content-Type") ?? undefined; } - const buf = Buffer.from(content); - - await overwriteFile(`${this.root}${uri.path.slice(1)}`, buf, { + await overwriteFile(`${this.root}${uri.path.slice(1)}`, Buffer.from(content), { fetch: (await this.session)?.fetch, contentType, }); @@ -302,42 +340,53 @@ export class SolidFS implements vscode.FileSystemProvider { // throw new Error('Method not implemented.'); } + async vscodeUriToString(uri: vscode.Uri) { + const stat = await this.stat(uri); + return `${this.root}${uri.path.slice(1)}${stat.type === vscode.FileType.File ? '' : '/'}` + } + async delete( uri: vscode.Uri, options: { readonly recursive: boolean } ): Promise { - const stat = await this.stat(uri); + await remove(await this.vscodeUriToString(uri), { fetch: this.fetch, recursive: options.recursive }) - if (stat.type === vscode.FileType.File) { - await deleteFile(`${this.root}${uri.path.slice(1)}`, { - fetch: (await this.session)?.fetch, - }); - } else { - await deleteContainer(`${this.root}${uri.path.slice(1)}/`, { - fetch: (await this.session)?.fetch, - }); - } + // const stat = await this.stat(uri); - // TODO: Don't be as aggressive - just invalidate the parent - await this.engine.invalidateHttpCache(); - // TODO: Get this working + // remove + + // if (stat.type === vscode.FileType.File) { + // await deleteFile(`${this.root}${uri.path.slice(1)}`, { + // fetch: (await this.session)?.fetch, + // }); + // } else { + // await deleteContainer(`${this.root}${uri.path.slice(1)}/`, { + // fetch: (await this.session)?.fetch, + // }); + // } + + // // TODO: Don't be as aggressive - just invalidate the parent + // await this.engine.invalidateHttpCache(); + // // TODO: Get this working this.fireSoon({ type: vscode.FileChangeType.Deleted, uri }); } - rename( - oldUri: vscode.Uri, - newUri: vscode.Uri, - options: { readonly overwrite: boolean } - ): void | Thenable { - throw new Error("Method not implemented."); + async rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { readonly overwrite: boolean }): Promise { + await this.copy(oldUri, newUri, options); + await this.delete(oldUri, { recursive: true }); } - copy?( + async copy( source: vscode.Uri, destination: vscode.Uri, options: { readonly overwrite: boolean } - ): void | Thenable { - throw new Error("Method not implemented."); + ): Promise { + await copy( + await this.vscodeUriToString(source), + await this.vscodeUriToString(destination), + // TODO: double check the default override case is correct + { fetch: this.fetch, noOverride: options.overwrite !== false } + ); } private bufferedEvents: vscode.FileChangeEvent[] = []; diff --git a/package-lock.json b/package-lock.json index f5c0965..381073e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@rushstack/eslint-patch": "^1.2.0", "@types/glob": "^8.0.0", "@types/jest": "^29.2.3", + "@types/md5": "^2.3.2", "@types/mocha": "^10.0.0", "@types/node": "^18.11.9", "@types/vscode": "^1.73.1", @@ -84,7 +85,9 @@ "@inrupt/solid-vscode-auth": "^0.0.0", "@rdfjs/types": "^1.1.0", "http-link-header": "^1.1.0", - "n3": "^1.16.3" + "md5": "^2.3.0", + "n3": "^1.16.3", + "solid-bashlib": "^0.2.1" }, "devDependencies": { "solidauth": "^0.0.1" @@ -7039,6 +7042,12 @@ "version": "5.1.1", "license": "MIT" }, + "node_modules/@types/md5": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.2.tgz", + "integrity": "sha512-v+JFDu96+UYJ3/UWzB0mEglIS//MZXgRaJ4ubUPwOM0gvLc/kcQ3TWNYwENEK7/EcXGQVrW8h/XqednSjBd/Og==", + "dev": true + }, "node_modules/@types/minimatch": { "version": "3.0.5", "dev": true, @@ -7691,6 +7700,18 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.8.1", "dev": true, @@ -7845,7 +7866,6 @@ }, "node_modules/ansi-escapes": { "version": "4.3.2", - "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -7859,7 +7879,6 @@ }, "node_modules/ansi-escapes/node_modules/type-fest": { "version": "0.21.3", - "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -7944,6 +7963,11 @@ "node": ">=0.10.0" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, "node_modules/array-ify": { "version": "1.0.0", "dev": true, @@ -8359,7 +8383,6 @@ }, "node_modules/bl": { "version": "4.1.0", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -8372,6 +8395,50 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/boolbase": { "version": "1.0.0", "dev": true, @@ -8578,7 +8645,6 @@ }, "node_modules/buffer": { "version": "5.7.1", - "dev": true, "funding": [ { "type": "github", @@ -8643,6 +8709,14 @@ "node": ">=10" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacache": { "version": "16.1.3", "dev": true, @@ -8709,7 +8783,6 @@ }, "node_modules/call-bind": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.1", @@ -8802,9 +8875,16 @@ }, "node_modules/chardet": { "version": "0.7.0", - "dev": true, "license": "MIT" }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/cheerio": { "version": "1.0.0-rc.12", "dev": true, @@ -8923,9 +9003,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-columns": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-columns/-/cli-columns-4.0.0.tgz", + "integrity": "sha512-XW2Vg+w+L9on9wtwKpyzluIPCWXjaBahI7mTcYjx+BVIYD9c3yqcv/yKC7CmdCZat4rq2yiE1UMSJC5ivKfMtQ==", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", - "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -8934,9 +9025,24 @@ "node": ">=8" } }, + "node_modules/cli-select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cli-select/-/cli-select-1.1.2.tgz", + "integrity": "sha512-PSvWb8G0PPmBNDcz/uM2LkZN3Nn5JmhUl465tTfynQAXjKzFpmHbxStM6X/+awKp5DJuAaHMzzMPefT0suGm1w==", + "dependencies": { + "ansi-escapes": "^3.2.0" + } + }, + "node_modules/cli-select/node_modules/ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/cli-spinners": { "version": "2.6.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8947,7 +9053,6 @@ }, "node_modules/cli-table": { "version": "0.3.11", - "dev": true, "dependencies": { "colors": "1.0.3" }, @@ -8957,7 +9062,6 @@ }, "node_modules/cli-width": { "version": "3.0.0", - "dev": true, "license": "ISC", "engines": { "node": ">= 10" @@ -8975,7 +9079,6 @@ }, "node_modules/clone": { "version": "1.0.4", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8" @@ -9086,7 +9189,6 @@ }, "node_modules/colors": { "version": "1.0.3", - "dev": true, "license": "MIT", "engines": { "node": ">=0.1.90" @@ -9258,6 +9360,25 @@ "dev": true, "license": "ISC" }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/conventional-changelog-angular": { "version": "5.0.13", "dev": true, @@ -9389,6 +9510,61 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-session": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.0.0.tgz", + "integrity": "sha512-hKvgoThbw00zQOleSlUr2qpvuNweoqBtxrmx0UFosx6AGi9lYtLoA+RbsvknrEX8Pr6MDbdWAb2j6SnMn+lPsg==", + "dependencies": { + "cookies": "0.8.0", + "debug": "3.2.7", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/cookies": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", + "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cookies/node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/copyfiles": { "version": "2.4.1", "dev": true, @@ -9546,6 +9722,14 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, "node_modules/crypto-js": { "version": "4.1.1", "license": "MIT" @@ -9721,7 +9905,6 @@ }, "node_modules/defaults": { "version": "1.0.4", - "dev": true, "license": "MIT", "dependencies": { "clone": "^1.0.2" @@ -9886,6 +10069,15 @@ "dev": true, "license": "MIT" }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-indent": { "version": "6.1.0", "dev": true, @@ -10071,6 +10263,11 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, "node_modules/ejs": { "version": "3.1.8", "dev": true, @@ -10109,6 +10306,14 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding": { "version": "0.1.13", "license": "MIT", @@ -10285,6 +10490,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "dev": true, @@ -10731,6 +10941,14 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "license": "MIT", @@ -10802,9 +11020,70 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/external-editor": { "version": "3.1.0", - "dev": true, "license": "MIT", "dependencies": { "chardet": "^0.7.0", @@ -10938,7 +11217,6 @@ }, "node_modules/figures": { "version": "3.2.0", - "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" @@ -10952,7 +11230,6 @@ }, "node_modules/figures/node_modules/escape-string-regexp": { "version": "1.0.5", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.0" @@ -11007,6 +11284,36 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/find-cache-dir": { "version": "3.3.2", "dev": true, @@ -11116,6 +11423,19 @@ "node": ">= 14.17" } }, + "node_modules/form-urlencoded": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-6.1.0.tgz", + "integrity": "sha512-lc1Qd9nnEewXKoiPjIA1n38M5STbyY6krgoegsg7SsAt2b98HZKe25KaJvKFBwQaOcmh8FP7JbXVC7gocZw+XQ==" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fp-and-or": { "version": "0.1.3", "dev": true, @@ -11124,6 +11444,14 @@ "node": ">=10" } }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "dev": true, @@ -11230,7 +11558,6 @@ }, "node_modules/function-bind": { "version": "1.1.1", - "dev": true, "license": "MIT" }, "node_modules/function.prototype.name": { @@ -11293,7 +11620,6 @@ }, "node_modules/get-intrinsic": { "version": "1.1.3", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.1", @@ -11430,6 +11756,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/getmac": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/getmac/-/getmac-5.20.0.tgz", + "integrity": "sha512-O9T855fb+Hx9dsTJHNv72ZUuA6Y18+BO/0ypPXf6s/tunzXqhc3kbQkNAl+9HVKVlwkWmglHS4LMoJ9YbymKYQ==", + "dependencies": { + "@types/node": "^16.4.7" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/getmac/node_modules/@types/node": { + "version": "16.18.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.9.tgz", + "integrity": "sha512-nhrqXYxiQ+5B/tPorWum37VgAiefi/wmfJ1QZKGKKecC8/3HqcTTJD0O+VABSPwtseMMF7NCPVT9uGgwn0YqsQ==" + }, "node_modules/git-raw-commits": { "version": "2.0.11", "dev": true, @@ -11727,7 +12072,6 @@ }, "node_modules/has": { "version": "1.0.3", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.1" @@ -11764,7 +12108,6 @@ }, "node_modules/has-symbols": { "version": "1.0.3", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11868,6 +12211,29 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-link-header": { "version": "1.1.0", "license": "MIT", @@ -11941,7 +12307,6 @@ }, "node_modules/iconv-lite": { "version": "0.4.24", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -12134,7 +12499,6 @@ }, "node_modules/inquirer": { "version": "8.2.5", - "dev": true, "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", @@ -12191,6 +12555,14 @@ "dev": true, "license": "MIT" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -12249,6 +12621,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-callable": { "version": "1.2.7", "dev": true, @@ -12380,7 +12757,6 @@ }, "node_modules/is-interactive": { "version": "1.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12576,7 +12952,6 @@ }, "node_modules/is-unicode-supported": { "version": "0.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -13576,6 +13951,22 @@ "dev": true, "license": "MIT" }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keytar": { "version": "7.9.0", "dev": true, @@ -14137,7 +14528,6 @@ }, "node_modules/lodash": { "version": "4.17.21", - "dev": true, "license": "MIT" }, "node_modules/lodash.clonedeep": { @@ -14167,7 +14557,6 @@ }, "node_modules/log-symbols": { "version": "4.1.0", - "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -14325,11 +14714,29 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/mdurl": { "version": "1.0.1", "dev": true, "license": "MIT" }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/meow": { "version": "8.1.2", "dev": true, @@ -14483,6 +14890,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, "node_modules/merge-stream": { "version": "2.0.0", "dev": true, @@ -14496,6 +14908,14 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/microdata-rdf-streaming-parser": { "version": "2.0.1", "license": "MIT", @@ -14556,7 +14976,6 @@ }, "node_modules/mime": { "version": "1.6.0", - "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -14567,7 +14986,6 @@ }, "node_modules/mime-db": { "version": "1.52.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -14575,7 +14993,6 @@ }, "node_modules/mime-types": { "version": "2.1.35", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -14586,7 +15003,6 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -14939,7 +15355,6 @@ }, "node_modules/mute-stream": { "version": "0.0.8", - "dev": true, "license": "ISC" }, "node_modules/n3": { @@ -15019,7 +15434,6 @@ }, "node_modules/negotiator": { "version": "0.6.3", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -16232,6 +16646,25 @@ "node": "^10.13.0 || >=12.0.0" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "dev": true, @@ -16249,7 +16682,6 @@ }, "node_modules/onetime": { "version": "5.1.2", - "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -16317,7 +16749,6 @@ }, "node_modules/ora": { "version": "5.4.1", - "dev": true, "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -16347,7 +16778,6 @@ }, "node_modules/os-tmpdir": { "version": "1.0.2", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16678,6 +17108,14 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -16707,6 +17145,11 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, "node_modules/path-type": { "version": "4.0.0", "dev": true, @@ -17053,6 +17496,18 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "dev": true, @@ -17118,7 +17573,6 @@ }, "node_modules/qs": { "version": "6.11.0", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.0.4" @@ -17171,6 +17625,28 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/rc": { "version": "1.2.8", "dev": true, @@ -18042,7 +18518,6 @@ }, "node_modules/restore-cursor": { "version": "3.1.0", - "dev": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -18151,7 +18626,6 @@ }, "node_modules/run-async": { "version": "2.4.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -18181,7 +18655,6 @@ }, "node_modules/rxjs": { "version": "7.5.7", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -18227,7 +18700,6 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "devOptional": true, "license": "MIT" }, "node_modules/sass": { @@ -18336,6 +18808,55 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/serialize-javascript": { "version": "6.0.0", "dev": true, @@ -18344,15 +18865,39 @@ "randombytes": "^2.1.0" } }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "dev": true, "license": "ISC" }, + "node_modules/set-cookie-parser": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz", + "integrity": "sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==" + }, "node_modules/setimmediate": { "version": "1.0.5", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, "node_modules/shallow-clone": { "version": "3.0.1", "dev": true, @@ -18385,7 +18930,6 @@ }, "node_modules/side-channel": { "version": "1.0.4", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.0", @@ -18398,7 +18942,6 @@ }, "node_modules/signal-exit": { "version": "3.0.7", - "dev": true, "license": "ISC" }, "node_modules/simple-concat": { @@ -18511,6 +19054,46 @@ "node": ">= 10" } }, + "node_modules/solid-bashlib": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/solid-bashlib/-/solid-bashlib-0.2.1.tgz", + "integrity": "sha512-keaB1VQKHdT7hmGuwNP3hYttdfNFvhLOnld2TgbtwOPO37Hkl7GJx3SVxcHHzSNdXHWGBMSGR9TnRLQOh560Dw==", + "dependencies": { + "@comunica/query-sparql": "^2.1.0", + "@inrupt/solid-client": "^1.21.0", + "@inrupt/solid-client-authn-node": "^1.11.7", + "chalk": "^4.1.2", + "cli-columns": "^4.0.0", + "cli-select": "^1.1.2", + "cli-table": "^0.3.11", + "commander": "^9.0.0", + "cookie-session": "^2.0.0", + "cross-fetch": "^3.1.5", + "express": "^4.17.3", + "form-urlencoded": "^6.0.6", + "getmac": "^5.20.0", + "http-link-header": "^1.0.4", + "inquirer": "^8.2.4", + "jose": "^4.7.0", + "jwt-decode": "^3.1.2", + "md5": "^2.3.0", + "mime-types": "^2.1.35", + "open": "^8.4.0", + "set-cookie-parser": "^2.4.8", + "tiny-queue": "^0.2.1" + }, + "bin": { + "solid-tools": "bin/solid.js" + } + }, + "node_modules/solid-bashlib/node_modules/commander": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", + "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/solid-node-interactive-auth": { "version": "1.1.1", "license": "MIT", @@ -18878,6 +19461,14 @@ "node": ">=8" } }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stream-to-string": { "version": "1.2.0", "license": "MIT", @@ -19240,7 +19831,6 @@ }, "node_modules/through": { "version": "2.3.8", - "dev": true, "license": "MIT" }, "node_modules/through2": { @@ -19251,9 +19841,13 @@ "readable-stream": "3" } }, + "node_modules/tiny-queue": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tiny-queue/-/tiny-queue-0.2.1.tgz", + "integrity": "sha512-EijGsv7kzd9I9g0ByCl6h42BWNGUZrlCSejfrb3AKeHC33SGbASu1VDf5O3rRiiUOhAC9CHdZxFPbZu0HmR70A==" + }, "node_modules/tmp": { "version": "0.0.33", - "dev": true, "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" @@ -19286,6 +19880,14 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tr46": { "version": "0.0.3", "license": "MIT" @@ -19477,9 +20079,16 @@ }, "node_modules/tslib": { "version": "2.4.1", - "dev": true, "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsutils": { "version": "3.21.0", "dev": true, @@ -19548,6 +20157,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-rest-client": { "version": "1.8.9", "dev": true, @@ -19704,6 +20325,14 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/untildify": { "version": "4.0.0", "dev": true, @@ -19877,6 +20506,14 @@ "dev": true, "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "license": "MIT", @@ -19937,6 +20574,14 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vsce": { "version": "2.14.0", "dev": true, @@ -20099,7 +20744,6 @@ }, "node_modules/wcwidth": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "defaults": "^1.0.3" @@ -26018,6 +26662,12 @@ "@types/lru-cache": { "version": "5.1.1" }, + "@types/md5": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.2.tgz", + "integrity": "sha512-v+JFDu96+UYJ3/UWzB0mEglIS//MZXgRaJ4ubUPwOM0gvLc/kcQ3TWNYwENEK7/EcXGQVrW8h/XqednSjBd/Og==", + "dev": true + }, "@types/minimatch": { "version": "3.0.5", "dev": true @@ -26489,6 +27139,15 @@ "event-target-shim": "^5.0.0" } }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, "acorn": { "version": "8.8.1", "dev": true @@ -26588,14 +27247,12 @@ }, "ansi-escapes": { "version": "4.3.2", - "dev": true, "requires": { "type-fest": "^0.21.3" }, "dependencies": { "type-fest": { - "version": "0.21.3", - "dev": true + "version": "0.21.3" } } }, @@ -26646,6 +27303,11 @@ "version": "1.0.2", "dev": true }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, "array-ify": { "version": "1.0.0", "dev": true @@ -26918,7 +27580,6 @@ }, "bl": { "version": "4.1.0", - "dev": true, "requires": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -26929,6 +27590,45 @@ "version": "3.4.7", "dev": true }, + "body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "boolbase": { "version": "1.0.0", "dev": true @@ -27045,7 +27745,6 @@ }, "buffer": { "version": "5.7.1", - "dev": true, "requires": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -27078,6 +27777,11 @@ "version": "7.0.1", "dev": true }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, "cacache": { "version": "16.1.3", "dev": true, @@ -27127,7 +27831,6 @@ }, "call-bind": { "version": "1.0.2", - "dev": true, "requires": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -27176,8 +27879,12 @@ "dev": true }, "chardet": { - "version": "0.7.0", - "dev": true + "version": "0.7.0" + }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==" }, "cheerio": { "version": "1.0.0-rc.12", @@ -27251,27 +27958,47 @@ "version": "3.0.0", "dev": true }, + "cli-columns": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-columns/-/cli-columns-4.0.0.tgz", + "integrity": "sha512-XW2Vg+w+L9on9wtwKpyzluIPCWXjaBahI7mTcYjx+BVIYD9c3yqcv/yKC7CmdCZat4rq2yiE1UMSJC5ivKfMtQ==", + "requires": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + } + }, "cli-cursor": { "version": "3.1.0", - "dev": true, "requires": { "restore-cursor": "^3.1.0" } }, + "cli-select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cli-select/-/cli-select-1.1.2.tgz", + "integrity": "sha512-PSvWb8G0PPmBNDcz/uM2LkZN3Nn5JmhUl465tTfynQAXjKzFpmHbxStM6X/+awKp5DJuAaHMzzMPefT0suGm1w==", + "requires": { + "ansi-escapes": "^3.2.0" + }, + "dependencies": { + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==" + } + } + }, "cli-spinners": { - "version": "2.6.1", - "dev": true + "version": "2.6.1" }, "cli-table": { "version": "0.3.11", - "dev": true, "requires": { "colors": "1.0.3" } }, "cli-width": { - "version": "3.0.0", - "dev": true + "version": "3.0.0" }, "cliui": { "version": "7.0.4", @@ -27283,8 +28010,7 @@ } }, "clone": { - "version": "1.0.4", - "dev": true + "version": "1.0.4" }, "clone-deep": { "version": "4.0.1", @@ -27362,8 +28088,7 @@ "dev": true }, "colors": { - "version": "1.0.3", - "dev": true + "version": "1.0.3" }, "colorspace": { "version": "1.1.4", @@ -27493,6 +28218,19 @@ "version": "1.1.0", "dev": true }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "requires": { + "safe-buffer": "5.2.1" + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, "conventional-changelog-angular": { "version": "5.0.13", "dev": true, @@ -27584,6 +28322,53 @@ "version": "2.0.0", "dev": true }, + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + }, + "cookie-session": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.0.0.tgz", + "integrity": "sha512-hKvgoThbw00zQOleSlUr2qpvuNweoqBtxrmx0UFosx6AGi9lYtLoA+RbsvknrEX8Pr6MDbdWAb2j6SnMn+lPsg==", + "requires": { + "cookies": "0.8.0", + "debug": "3.2.7", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "cookies": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", + "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "requires": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, "copyfiles": { "version": "2.4.1", "dev": true, @@ -27702,6 +28487,11 @@ "which": "^2.0.1" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==" + }, "crypto-js": { "version": "4.1.1" }, @@ -27798,7 +28588,6 @@ }, "defaults": { "version": "1.0.4", - "dev": true, "requires": { "clone": "^1.0.2" } @@ -27905,6 +28694,11 @@ "version": "0.1.4", "dev": true }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, "detect-indent": { "version": "6.1.0", "dev": true @@ -28024,6 +28818,11 @@ "version": "0.2.0", "dev": true }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, "ejs": { "version": "3.1.8", "dev": true, @@ -28045,6 +28844,11 @@ "enabled": { "version": "2.0.0" }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, "encoding": { "version": "0.1.13", "optional": true, @@ -28162,6 +28966,11 @@ "version": "4.0.0", "dev": true }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, "escape-string-regexp": { "version": "4.0.0", "dev": true @@ -28432,6 +29241,11 @@ "version": "2.0.3", "dev": true }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, "event-target-shim": { "version": "5.0.1" }, @@ -28476,9 +29290,66 @@ "jest-util": "^29.3.1" } }, + "express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "external-editor": { "version": "3.1.0", - "dev": true, "requires": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -28576,14 +29447,12 @@ }, "figures": { "version": "3.2.0", - "dev": true, "requires": { "escape-string-regexp": "^1.0.5" }, "dependencies": { "escape-string-regexp": { - "version": "1.0.5", - "dev": true + "version": "1.0.5" } } }, @@ -28624,6 +29493,35 @@ "to-regex-range": "^5.0.1" } }, + "finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "find-cache-dir": { "version": "3.3.2", "dev": true, @@ -28686,10 +29584,25 @@ "version": "2.1.3", "dev": true }, + "form-urlencoded": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-6.1.0.tgz", + "integrity": "sha512-lc1Qd9nnEewXKoiPjIA1n38M5STbyY6krgoegsg7SsAt2b98HZKe25KaJvKFBwQaOcmh8FP7JbXVC7gocZw+XQ==" + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, "fp-and-or": { "version": "0.1.3", "dev": true }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, "fs-constants": { "version": "1.0.0", "dev": true @@ -28761,8 +29674,7 @@ } }, "function-bind": { - "version": "1.1.1", - "dev": true + "version": "1.1.1" }, "function.prototype.name": { "version": "1.1.5", @@ -28801,7 +29713,6 @@ }, "get-intrinsic": { "version": "1.1.3", - "dev": true, "requires": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -28889,6 +29800,21 @@ "get-intrinsic": "^1.1.1" } }, + "getmac": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/getmac/-/getmac-5.20.0.tgz", + "integrity": "sha512-O9T855fb+Hx9dsTJHNv72ZUuA6Y18+BO/0ypPXf6s/tunzXqhc3kbQkNAl+9HVKVlwkWmglHS4LMoJ9YbymKYQ==", + "requires": { + "@types/node": "^16.4.7" + }, + "dependencies": { + "@types/node": { + "version": "16.18.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.9.tgz", + "integrity": "sha512-nhrqXYxiQ+5B/tPorWum37VgAiefi/wmfJ1QZKGKKecC8/3HqcTTJD0O+VABSPwtseMMF7NCPVT9uGgwn0YqsQ==" + } + } + }, "git-raw-commits": { "version": "2.0.11", "dev": true, @@ -29089,7 +30015,6 @@ }, "has": { "version": "1.0.3", - "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -29109,8 +30034,7 @@ } }, "has-symbols": { - "version": "1.0.3", - "dev": true + "version": "1.0.3" }, "has-tostringtag": { "version": "1.0.0", @@ -29171,6 +30095,25 @@ "version": "4.1.0", "dev": true }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, "http-link-header": { "version": "1.1.0" }, @@ -29218,7 +30161,6 @@ }, "iconv-lite": { "version": "0.4.24", - "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -29337,7 +30279,6 @@ }, "inquirer": { "version": "8.2.5", - "dev": true, "requires": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", @@ -29380,6 +30321,11 @@ "version": "2.0.0", "dev": true }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, "is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -29416,6 +30362,11 @@ "has-tostringtag": "^1.0.0" } }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "is-callable": { "version": "1.2.7", "dev": true @@ -29486,8 +30437,7 @@ } }, "is-interactive": { - "version": "1.0.0", - "dev": true + "version": "1.0.0" }, "is-lambda": { "version": "1.0.1", @@ -29592,8 +30542,7 @@ "dev": true }, "is-unicode-supported": { - "version": "0.1.0", - "dev": true + "version": "0.1.0" }, "is-weakref": { "version": "1.0.2", @@ -30273,6 +31222,19 @@ "version": "5.4.1", "dev": true }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, + "keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "requires": { + "tsscmp": "1.0.6" + } + }, "keytar": { "version": "7.9.0", "dev": true, @@ -30655,8 +31617,7 @@ } }, "lodash": { - "version": "4.17.21", - "dev": true + "version": "4.17.21" }, "lodash.clonedeep": { "version": "4.5.0" @@ -30681,7 +31642,6 @@ }, "log-symbols": { "version": "4.1.0", - "dev": true, "requires": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -30788,10 +31748,25 @@ } } }, + "md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "mdurl": { "version": "1.0.1", "dev": true }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + }, "meow": { "version": "8.1.2", "dev": true, @@ -30893,6 +31868,11 @@ } } }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, "merge-stream": { "version": "2.0.0", "dev": true @@ -30901,6 +31881,11 @@ "version": "1.4.1", "dev": true }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + }, "microdata-rdf-streaming-parser": { "version": "2.0.1", "requires": { @@ -30938,23 +31923,19 @@ } }, "mime": { - "version": "1.6.0", - "dev": true + "version": "1.6.0" }, "mime-db": { - "version": "1.52.0", - "dev": true + "version": "1.52.0" }, "mime-types": { "version": "2.1.35", - "dev": true, "requires": { "mime-db": "1.52.0" } }, "mimic-fn": { - "version": "2.1.0", - "dev": true + "version": "2.1.0" }, "mimic-response": { "version": "3.1.0", @@ -31183,8 +32164,7 @@ } }, "mute-stream": { - "version": "0.0.8", - "dev": true + "version": "0.0.8" }, "n3": { "version": "1.16.3", @@ -31231,8 +32211,7 @@ "version": "1.0.1" }, "negotiator": { - "version": "0.6.3", - "dev": true + "version": "0.6.3" }, "neo-async": { "version": "2.6.2", @@ -32048,6 +33027,19 @@ "oidc-token-hash": { "version": "5.0.1" }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "once": { "version": "1.4.0", "dev": true, @@ -32063,7 +33055,6 @@ }, "onetime": { "version": "5.1.2", - "dev": true, "requires": { "mimic-fn": "^2.1.0" } @@ -32107,7 +33098,6 @@ }, "ora": { "version": "5.4.1", - "dev": true, "requires": { "bl": "^4.1.0", "chalk": "^4.1.0", @@ -32125,8 +33115,7 @@ "dev": true }, "os-tmpdir": { - "version": "1.0.2", - "dev": true + "version": "1.0.2" }, "osenv": { "version": "0.1.5", @@ -32331,6 +33320,11 @@ "parse5": "^7.0.0" } }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, "path-exists": { "version": "4.0.0", "dev": true @@ -32347,6 +33341,11 @@ "version": "1.0.7", "dev": true }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, "path-type": { "version": "4.0.0", "dev": true @@ -32554,6 +33553,15 @@ "version": "2.0.1", "dev": true }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, "proxy-from-env": { "version": "1.1.0", "dev": true @@ -32598,7 +33606,6 @@ }, "qs": { "version": "6.11.0", - "dev": true, "requires": { "side-channel": "^1.0.4" } @@ -32623,6 +33630,22 @@ "safe-buffer": "^5.1.0" } }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, "rc": { "version": "1.2.8", "dev": true, @@ -33228,7 +34251,6 @@ }, "restore-cursor": { "version": "3.1.0", - "dev": true, "requires": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -33299,8 +34321,7 @@ } }, "run-async": { - "version": "2.4.1", - "dev": true + "version": "2.4.1" }, "run-parallel": { "version": "1.2.0", @@ -33311,7 +34332,6 @@ }, "rxjs": { "version": "7.5.7", - "dev": true, "requires": { "tslib": "^2.1.0" } @@ -33332,8 +34352,7 @@ "version": "2.4.1" }, "safer-buffer": { - "version": "2.1.2", - "devOptional": true + "version": "2.1.2" }, "sass": { "version": "1.56.1", @@ -33399,6 +34418,53 @@ "version": "1.1.4", "dev": true }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "serialize-javascript": { "version": "6.0.0", "dev": true, @@ -33406,13 +34472,34 @@ "randombytes": "^2.1.0" } }, + "serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + } + }, "set-blocking": { "version": "2.0.0", "dev": true }, + "set-cookie-parser": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz", + "integrity": "sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==" + }, "setimmediate": { "version": "1.0.5" }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, "shallow-clone": { "version": "3.0.1", "dev": true, @@ -33433,7 +34520,6 @@ }, "side-channel": { "version": "1.0.4", - "dev": true, "requires": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -33441,8 +34527,7 @@ } }, "signal-exit": { - "version": "3.0.7", - "dev": true + "version": "3.0.7" }, "simple-concat": { "version": "1.0.1", @@ -33501,6 +34586,42 @@ "socks": "^2.6.2" } }, + "solid-bashlib": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/solid-bashlib/-/solid-bashlib-0.2.1.tgz", + "integrity": "sha512-keaB1VQKHdT7hmGuwNP3hYttdfNFvhLOnld2TgbtwOPO37Hkl7GJx3SVxcHHzSNdXHWGBMSGR9TnRLQOh560Dw==", + "requires": { + "@comunica/query-sparql": "^2.1.0", + "@inrupt/solid-client": "^1.21.0", + "@inrupt/solid-client-authn-node": "^1.11.7", + "chalk": "^4.1.2", + "cli-columns": "^4.0.0", + "cli-select": "^1.1.2", + "cli-table": "^0.3.11", + "commander": "^9.0.0", + "cookie-session": "^2.0.0", + "cross-fetch": "^3.1.5", + "express": "^4.17.3", + "form-urlencoded": "^6.0.6", + "getmac": "^5.20.0", + "http-link-header": "^1.0.4", + "inquirer": "^8.2.4", + "jose": "^4.7.0", + "jwt-decode": "^3.1.2", + "md5": "^2.3.0", + "mime-types": "^2.1.35", + "open": "^8.4.0", + "set-cookie-parser": "^2.4.8", + "tiny-queue": "^0.2.1" + }, + "dependencies": { + "commander": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", + "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==" + } + } + }, "solid-node-interactive-auth": { "version": "1.1.1", "requires": { @@ -33536,7 +34657,9 @@ "@inrupt/solid-vscode-auth": "^0.0.0", "@rdfjs/types": "^1.1.0", "http-link-header": "^1.1.0", + "md5": "^2.3.0", "n3": "^1.16.3", + "solid-bashlib": "^0.2.1", "solidauth": "^0.0.1" } }, @@ -33781,6 +34904,11 @@ } } }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, "stream-to-string": { "version": "1.2.0", "requires": { @@ -34012,8 +35140,7 @@ "dev": true }, "through": { - "version": "2.3.8", - "dev": true + "version": "2.3.8" }, "through2": { "version": "4.0.2", @@ -34022,9 +35149,13 @@ "readable-stream": "3" } }, + "tiny-queue": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tiny-queue/-/tiny-queue-0.2.1.tgz", + "integrity": "sha512-EijGsv7kzd9I9g0ByCl6h42BWNGUZrlCSejfrb3AKeHC33SGbASu1VDf5O3rRiiUOhAC9CHdZxFPbZu0HmR70A==" + }, "tmp": { "version": "0.0.33", - "dev": true, "requires": { "os-tmpdir": "~1.0.2" } @@ -34044,6 +35175,11 @@ "is-number": "^7.0.0" } }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, "tr46": { "version": "0.0.3" }, @@ -34153,8 +35289,12 @@ } }, "tslib": { - "version": "2.4.1", - "dev": true + "version": "2.4.1" + }, + "tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==" }, "tsutils": { "version": "3.21.0", @@ -34195,6 +35335,15 @@ "version": "0.20.2", "dev": true }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, "typed-rest-client": { "version": "1.8.9", "dev": true, @@ -34291,6 +35440,11 @@ "version": "2.0.0", "dev": true }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, "untildify": { "version": "4.0.0", "dev": true @@ -34416,6 +35570,11 @@ "version": "1.0.3", "dev": true }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, "uuid": { "version": "8.3.2" }, @@ -34462,6 +35621,11 @@ "builtins": "^5.0.0" } }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, "vsce": { "version": "2.14.0", "dev": true, @@ -34576,7 +35740,6 @@ }, "wcwidth": { "version": "1.0.1", - "dev": true, "requires": { "defaults": "^1.0.3" } diff --git a/package.json b/package.json index 1bfcd57..92221d6 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@rushstack/eslint-patch": "^1.2.0", "@types/glob": "^8.0.0", "@types/jest": "^29.2.3", + "@types/md5": "^2.3.2", "@types/mocha": "^10.0.0", "@types/node": "^18.11.9", "@types/vscode": "^1.73.1",