From 1d65143e89d4d64663805467a1587850690eeb59 Mon Sep 17 00:00:00 2001 From: jaxoncreed Date: Tue, 4 May 2021 07:17:43 -0400 Subject: [PATCH] feat: Add identity provider (#455) * Add identity provider handler as a dependency * Temp Identity * Figured out how to get koa to work * Hooked up idp to networking * Feat/idp architecture refactor (#430) * Logs in with solid oidc * Refactored Provider * Attempt to hook up dependencies * Partial wiring of oidc provider components * IdP networking now works with architecture * Interaction Handlers Set Up * fix: Rename & adapt to CSS * Included Login Interaction * Refactored architecture to bind Interaction Policy to HttpHandlers Co-authored-by: Matthieu Bosquet * fix: Rebase on master * fix: DI after rebase * Reimplemented Routing * Renamed modules and removed ProviderFactory (#450) * refactor: Solid IdP DI * refactor: IdP interaction handler DI * refactor: IdP interaction waterfall * refactor: Remove unnecessary legacy URL parse * fix: Add legacy parse back in * feat: adapter & fix: handlers * Removed adapter factory * fix: refactor IdP * fix: refactor IdP * fix: refactor IdP * feat: Add IdP to file storage config * fix: Unintended commit * fix: Components ignore * feat: Basic resource store adapter * Partially complete idp routing * Set up initial routing injection graph * Clean up ResourceStorageAdapter * Refactored configuration architecture * Hooked up Login UIs (#518) * feat: Use template path & run fileserver * feat: Use util function to read resource * Fixed DI JSON-LD context * fixed rendering * WebId validator * Set up persistent storage for loing and register * Fixed ejs template routing * Refactored StorageAdapters * NSS login successful * Forgot password infrastructure * Can send email (#557) * Can send email * fix: IdP crashes if interaction ID doesn't exist (#587) * feat: Require an issuer registration token * fix: Issuer registration token typo in error * fix: Remove dummy IdP storage adapter * fix: Remove unused library lodash * fix: Remove unused library lru-cache * Production ready keystore * Ruben comments before clownface removal * Removed clownface * Change key value store * Completed Ruben's comments * Added comments to each class * Fixed errors on login * Ruben feedback * Refactored out getPostRenderHandler * Identity provider tests (#622) * corrected tests lacking on promises * Added files for all idp tests * Added unfinished tests for all added files * ErrorHandlingWaterfallHandler * RenderEjsHandler and RouterHandler tests * GetPostRouterHandler and BasicOnErrorHandler tests * Corrected tests for updates to Idp * fix: missing export * fix: audience claim * Client Id Support (#630) * Added client_id for the auth challenge * Update src/identity/storage/ClientWebIdFetchingStorageAdapterFactory.ts Co-authored-by: Matthieu Bosquet Co-authored-by: Matthieu Bosquet * fix: Rebase fixes * Several minor Idp changes/refactors (#656) * fix: Minor changes * refactor: Split EmailPasswordInteractionPolicy * refactor: Remove ErrorHandlingWaterfallHandler * refactor: Clean up dependencies * fix: Add dummy IdentityProviderHandler to fix integration tests * Replace KeyValueStore with KeyValueStorage (#663) * feat: Create WrappedExpiringStorage * refactor: Update ResourceStoreEmailPasswordStore to use KeyValueStorage * refactor: Update KeyGeneratingIdpConfigurationGenerator to use KeyValueStorage * refactor: Update ResourceStoreStorageAdapterFactory to use ExpiringStorage * refactor: Removed KeyValueStore * refactor: Simplify EmailPassword handlers (#664) * refactor: Order index.ts * test: Add EmailPasswordForgotPasswordHandler unit tests * test: Add EmailPasswordGetResetPasswordHandler unit tests * test: Add EmailPasswordLoginHandler unit tests * test: Add EmailPasswordRegistrationHandler unit tests * test: Add EmailPasswordResetPasswordHandler unit tests * test: Remove unnecessary test file * feat: Basic instructions for using the IdP * fix: IdP instructions and add example WebID * fix: IdP registration copy * fix: IdP instruction editorial * Update README.md Co-authored-by: Joachim Van Herwegen * Update README.md Co-authored-by: Joachim Van Herwegen * test: Add KeyGeneratingIdpConfigurationGenerator unit tests * test: Add KeyValueEmailPasswordStore unit tests * test: Create IdP integration test * test: Add EmailPasswordInteractionPolicy unit tests * test: Add BasicIssuerReferenceWebIdOwnershipValidator unit tests * test: Add ChooseInitialInteractionHandler unit tests Also fixes the config warning. * test: Add EjsTemplateRenderer unit tests * test: Add EmailSender unit tests * test: Add FormDataUtil unit tests * test: Add IdpRouteController unit tests * test: Add OidcInteractionCompleter unit tests * refactor: Simplify ClientWebIdFetchingStorageAdapterFactory * test: Add ClientWebIdFetchingStorageAdapterFactory unit tests * refactor: Fix ejs html warnings * test: Add step to test logging in again Included are updates to handle cookies more correctly. * feat: Add IdpConfirmHttpHandler This way there's a handler for the confirm page. * test: Add ExpiringStorageAdapterFactory unit tests * test: Add IdentityProviderFactory unit tests * test: Add IdentityProviderHttpHandler unit tests * refactor: Minor refactors * refactor: Use jose instead of node-jose * refactor: Use jose instead of node-jose Reduces the number of dependencies since other libraries also depend on jose. * Update src/identity/configuration/KeyGeneratingIdpConfigurationGenerator.ts Co-authored-by: Matthieu Bosquet * refactor: Use interfaces instead of abstract classes * refactor: Make WebIdOwnershipValidator an AsyncHandler * refactor: Make TemplateRenderer an AsyncHandler * fix: Fix typing issue * fix: Convert JWK to plain object for node 15 * feat: Update CI configuration --ignore-scripts was removed because it also stopped dependency scripts, which was a requirement for bcrypt. 15.0 was removed since that version doesn't run the required scripts after install. 14.0 was removed since the somehow it caused the solid-authn client to do the wrong calls. * test: Run integration tests on Node 14.2 This is the lowest 14.x version where the IdP integration tests succeed. * feat: Use ErrorResponseWriter for handling oidc errors * test: Mock Date in OidcInteractionCompleter tests * fix: Correctly generate new identifiers Previously there could be double slashes if the base URL ended in slash. * fix: Correctly handle storagePathName in ExpiringStorageAdapterFactory * fix: Fix issue with new CliRunner test in rebase * fix: Handle unknown errors more consistently * feat: Make idp parameter dynamic * feat: Add more logging * refactor: Link css instead of injecting * fix: Fix redis integration tests with idp * refactor: Shorten idp class names * refactor: Remove e-mail configuration from default config * feat: Store JsonResourceStorage data in a single container * feat: Make sure expired data gets removed at some point * feat: Only accept strings as keys in KeyValueStorage * fix: Various minor fixes based on review Co-authored-by: Matthieu Bosquet Co-authored-by: Joachim Van Herwegen --- .componentsignore | 4 + .github/workflows/ci.yml | 15 +- README.md | 53 + config/config-default.json | 5 +- config/config-file.json | 5 +- config/config-path-routing.json | 5 +- config/config-rdf-to-sparql-endpoint.json | 5 +- config/config-sparql-endpoint.json | 5 +- config/presets/cli-params.json | 4 + config/presets/http.json | 13 +- config/presets/identity/email-sender.json | 10 + .../presets/identity/identity-provider.json | 84 + .../presets/identity/interaction-policy.json | 224 ++ config/presets/pod-dynamic.json | 5 +- config/presets/storage-wrapper.json | 5 +- jest.config.js | 4 + package-lock.json | 1911 ++++++++++- package.json | 20 +- src/identity/IdentityProviderFactory.ts | 89 + src/identity/IdentityProviderHttpHandler.ts | 80 + .../configuration/ConfigurationFactory.ts | 9 + .../configuration/KeyConfigurationFactory.ts | 133 + .../interaction/InteractionHttpHandler.ts | 9 + src/identity/interaction/InteractionPolicy.ts | 9 + .../interaction/SessionHttpHandler.ts | 24 + .../AccountInteractionPolicy.ts | 37 + .../email-password/EmailPasswordUtil.ts | 44 + .../handler/ForgotPasswordHandler.ts | 112 + .../email-password/handler/LoginHandler.ts | 60 + .../handler/RegistrationHandler.ts | 84 + .../handler/ResetPasswordHandler.ts | 77 + .../handler/ResetPasswordRenderHandler.ts | 12 + .../handler/ResetPasswordViewHandler.ts | 36 + .../email-password/storage/AccountStore.ts | 57 + .../storage/BaseAccountStore.ts | 121 + .../interaction/util/BaseEmailSender.ts | 40 + .../interaction/util/EjsTemplateRenderer.ts | 25 + src/identity/interaction/util/EmailSender.ts | 13 + src/identity/interaction/util/FormDataUtil.ts | 17 + .../interaction/util/IdpInteractionError.ts | 18 + .../interaction/util/IdpRenderHandler.ts | 14 + .../interaction/util/IdpRouteController.ts | 46 + .../util/InitialInteractionHandler.ts | 48 + .../interaction/util/InteractionCompleter.ts | 28 + .../util/IssuerOwnershipValidator.ts | 45 + .../interaction/util/OwnershipValidator.ts | 7 + .../interaction/util/TemplateRenderer.ts | 6 + src/identity/storage/AdapterFactory.ts | 9 + .../storage/ExpiringAdapterFactory.ts | 129 + .../storage/WrappedFetchAdapterFactory.ts | 121 + src/identity/util/FetchUtil.ts | 30 + src/index.ts | 58 +- src/init/AppRunner.ts | 4 + src/init/ConfigPodInitializer.ts | 7 +- src/pods/ConfigPodManager.ts | 6 +- src/pods/settings/PodSettings.ts | 2 +- src/server/BaseHttpServerFactory.ts | 2 +- src/server/util/RenderEjsHandler.ts | 26 + src/server/util/RenderHandler.ts | 9 + src/server/util/RouterHandler.ts | 46 + src/storage/keyvalue/ExpiringStorage.ts | 19 + src/storage/keyvalue/JsonResourceStorage.ts | 70 +- src/storage/keyvalue/KeyValueStorage.ts | 2 - src/storage/keyvalue/MemoryMapStorage.ts | 18 +- .../keyvalue/ResourceIdentifierStorage.ts | 39 - .../keyvalue/WrappedExpiringStorage.ts | 119 + src/storage/routing/BaseUrlRouterRule.ts | 6 +- src/util/ContentTypes.ts | 2 + src/util/PathUtil.ts | 2 +- src/util/Vocabularies.ts | 6 + src/util/errors/ConfigurationError.ts | 8 + src/util/handlers/WaterfallHandler.ts | 2 +- src/util/locking/GreedyReadWriteLocker.ts | 30 +- src/util/locking/RedisResourceLocker.ts | 2 +- .../email-password-interaction/confirm.ejs | 29 + .../email-password-interaction/emailSent.ejs | 41 + .../idp/email-password-interaction/error.ejs | 23 + .../forgotPassword.ejs | 44 + .../idp/email-password-interaction/login.ejs | 54 + .../idp/email-password-interaction/main.css | 2948 +++++++++++++++++ .../email-password-interaction/message.ejs | 23 + .../email-password-interaction/register.ejs | 62 + .../resetPassword.ejs | 45 + .../resetPasswordEmail.ejs | 2 + .../resetPasswordEmailTemplate.ejs | 28 + test/assets/idp/noPropsTestHtml.ejs | 1 + test/assets/idp/testHtml.ejs | 1 + test/integration/Identity.test.ts | 262 ++ test/integration/IdentityTestState.ts | 149 + test/integration/PodCreation.test.ts | 2 +- .../RedisResourceLockerIntegration.test.ts | 2 +- test/integration/ServerFetch.test.ts | 8 +- test/integration/WebSocketsProtocol.test.ts | 2 +- test/integration/config/run-with-redlock.json | 11 +- .../config/server-dynamic-unsafe.json | 4 + test/integration/config/server-memory.json | 18 +- .../integration/config/server-middleware.json | 4 + .../config/server-subdomains-unsafe.json | 4 + .../config/server-without-auth.json | 4 + .../identity/IdentityProviderFactory.test.ts | 103 + .../IdentityProviderHttpHandler.test.ts | 99 + .../KeyConfigurationFactory.test.ts | 93 + .../interaction/SessionHttpHandler.test.ts | 50 + .../AccountInteractionPolicy.test.ts | 20 + .../email-password/EmailPasswordUtil.test.ts | 65 + .../handler/ForgotPasswordHandler.test.ts | 91 + .../handler/LoginHandler.test.ts | 68 + .../handler/RegistrationHandler.test.ts | 96 + .../handler/ResetPasswordHandler.test.ts | 96 + .../handler/ResetPasswordViewHandler.test.ts | 48 + .../email-password/handler/Util.ts | 14 + .../storage/BaseAccountStore.test.ts | 93 + .../interaction/util/BaseEmailSender.test.ts | 55 + .../util/EjsTemplateRenderer.test.ts | 19 + .../interaction/util/FormDataUtil.test.ts | 23 + .../util/IdpRouteController.test.ts | 93 + .../util/InitialInteractionHandler.test.ts | 62 + .../util/InteractionCompleter.test.ts | 52 + .../util/IssuerOwnershipValidator.test.ts | 70 + .../storage/ExpiringAdapterFactory.test.ts | 119 + .../WrappedFetchAdapterFactory.test.ts | 142 + test/unit/identity/util/FetchUtil.test.ts | 44 + test/unit/init/AppRunner.test.ts | 8 + test/unit/init/ConfigPodInitializer.test.ts | 11 +- .../unit/ldp/http/BasicResponseWriter.test.ts | 4 +- test/unit/pods/ConfigPodManager.test.ts | 9 +- .../unit/server/util/RenderEjsHandler.test.ts | 77 + test/unit/server/util/RouterHandler.test.ts | 93 + .../keyvalue/JsonResourceStorage.test.ts | 29 +- .../storage/keyvalue/MemoryMapStorage.test.ts | 9 +- .../ResourceIdentifierStorage.test.ts | 43 - .../keyvalue/WrappedExpiringStorage.test.ts | 155 + .../storage/routing/BaseUrlRouterRule.test.ts | 12 +- .../locking/GreedyReadWriteLocker.test.ts | 20 +- .../util/locking/RedisResourceLocker.test.ts | 6 +- test/util/Util.ts | 1 + 136 files changed, 9940 insertions(+), 374 deletions(-) create mode 100644 config/presets/identity/email-sender.json create mode 100644 config/presets/identity/identity-provider.json create mode 100644 config/presets/identity/interaction-policy.json create mode 100644 src/identity/IdentityProviderFactory.ts create mode 100644 src/identity/IdentityProviderHttpHandler.ts create mode 100644 src/identity/configuration/ConfigurationFactory.ts create mode 100644 src/identity/configuration/KeyConfigurationFactory.ts create mode 100644 src/identity/interaction/InteractionHttpHandler.ts create mode 100644 src/identity/interaction/InteractionPolicy.ts create mode 100644 src/identity/interaction/SessionHttpHandler.ts create mode 100644 src/identity/interaction/email-password/AccountInteractionPolicy.ts create mode 100644 src/identity/interaction/email-password/EmailPasswordUtil.ts create mode 100644 src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts create mode 100644 src/identity/interaction/email-password/handler/LoginHandler.ts create mode 100644 src/identity/interaction/email-password/handler/RegistrationHandler.ts create mode 100644 src/identity/interaction/email-password/handler/ResetPasswordHandler.ts create mode 100644 src/identity/interaction/email-password/handler/ResetPasswordRenderHandler.ts create mode 100644 src/identity/interaction/email-password/handler/ResetPasswordViewHandler.ts create mode 100644 src/identity/interaction/email-password/storage/AccountStore.ts create mode 100644 src/identity/interaction/email-password/storage/BaseAccountStore.ts create mode 100644 src/identity/interaction/util/BaseEmailSender.ts create mode 100644 src/identity/interaction/util/EjsTemplateRenderer.ts create mode 100644 src/identity/interaction/util/EmailSender.ts create mode 100644 src/identity/interaction/util/FormDataUtil.ts create mode 100644 src/identity/interaction/util/IdpInteractionError.ts create mode 100644 src/identity/interaction/util/IdpRenderHandler.ts create mode 100644 src/identity/interaction/util/IdpRouteController.ts create mode 100644 src/identity/interaction/util/InitialInteractionHandler.ts create mode 100644 src/identity/interaction/util/InteractionCompleter.ts create mode 100644 src/identity/interaction/util/IssuerOwnershipValidator.ts create mode 100644 src/identity/interaction/util/OwnershipValidator.ts create mode 100644 src/identity/interaction/util/TemplateRenderer.ts create mode 100644 src/identity/storage/AdapterFactory.ts create mode 100644 src/identity/storage/ExpiringAdapterFactory.ts create mode 100644 src/identity/storage/WrappedFetchAdapterFactory.ts create mode 100644 src/identity/util/FetchUtil.ts create mode 100644 src/server/util/RenderEjsHandler.ts create mode 100644 src/server/util/RenderHandler.ts create mode 100644 src/server/util/RouterHandler.ts create mode 100644 src/storage/keyvalue/ExpiringStorage.ts delete mode 100644 src/storage/keyvalue/ResourceIdentifierStorage.ts create mode 100644 src/storage/keyvalue/WrappedExpiringStorage.ts create mode 100644 src/util/errors/ConfigurationError.ts create mode 100644 templates/idp/email-password-interaction/confirm.ejs create mode 100644 templates/idp/email-password-interaction/emailSent.ejs create mode 100644 templates/idp/email-password-interaction/error.ejs create mode 100644 templates/idp/email-password-interaction/forgotPassword.ejs create mode 100644 templates/idp/email-password-interaction/login.ejs create mode 100644 templates/idp/email-password-interaction/main.css create mode 100644 templates/idp/email-password-interaction/message.ejs create mode 100644 templates/idp/email-password-interaction/register.ejs create mode 100644 templates/idp/email-password-interaction/resetPassword.ejs create mode 100644 templates/idp/email-password-interaction/resetPasswordEmail.ejs create mode 100644 templates/idp/email-password-interaction/resetPasswordEmailTemplate.ejs create mode 100644 test/assets/idp/noPropsTestHtml.ejs create mode 100644 test/assets/idp/testHtml.ejs create mode 100644 test/integration/Identity.test.ts create mode 100644 test/integration/IdentityTestState.ts create mode 100644 test/unit/identity/IdentityProviderFactory.test.ts create mode 100644 test/unit/identity/IdentityProviderHttpHandler.test.ts create mode 100644 test/unit/identity/configuration/KeyConfigurationFactory.test.ts create mode 100644 test/unit/identity/interaction/SessionHttpHandler.test.ts create mode 100644 test/unit/identity/interaction/email-password/AccountInteractionPolicy.test.ts create mode 100644 test/unit/identity/interaction/email-password/EmailPasswordUtil.test.ts create mode 100644 test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts create mode 100644 test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts create mode 100644 test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts create mode 100644 test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts create mode 100644 test/unit/identity/interaction/email-password/handler/ResetPasswordViewHandler.test.ts create mode 100644 test/unit/identity/interaction/email-password/handler/Util.ts create mode 100644 test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts create mode 100644 test/unit/identity/interaction/util/BaseEmailSender.test.ts create mode 100644 test/unit/identity/interaction/util/EjsTemplateRenderer.test.ts create mode 100644 test/unit/identity/interaction/util/FormDataUtil.test.ts create mode 100644 test/unit/identity/interaction/util/IdpRouteController.test.ts create mode 100644 test/unit/identity/interaction/util/InitialInteractionHandler.test.ts create mode 100644 test/unit/identity/interaction/util/InteractionCompleter.test.ts create mode 100644 test/unit/identity/interaction/util/IssuerOwnershipValidator.test.ts create mode 100644 test/unit/identity/storage/ExpiringAdapterFactory.test.ts create mode 100644 test/unit/identity/storage/WrappedFetchAdapterFactory.test.ts create mode 100644 test/unit/identity/util/FetchUtil.test.ts create mode 100644 test/unit/server/util/RenderEjsHandler.test.ts create mode 100644 test/unit/server/util/RouterHandler.test.ts delete mode 100644 test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts create mode 100644 test/unit/storage/keyvalue/WrappedExpiringStorage.test.ts diff --git a/.componentsignore b/.componentsignore index 5bcc29553..2e9c3c76b 100644 --- a/.componentsignore +++ b/.componentsignore @@ -1,6 +1,10 @@ [ + "Adapter", "BasicRepresentation", + "Configuration", "Error", "EventEmitter", + "LRUCache", + "Provider", "ValuePreferencesArg" ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c6dc10e6..0447d1a4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,9 +28,8 @@ jobs: node-version: - '12.17' - '12.x' - - '14.0' + - '14.2' - '14.x' - - '15.0' - '15.x' - '16.0' - '16.x' @@ -55,10 +54,8 @@ jobs: node-version: ${{ matrix.node-version }} - name: Check out repository uses: actions/checkout@v2 - - name: Install dependencies - run: npm ci --ignore-scripts - - name: Run build scripts - run: npm run build + - name: Install dependencies and run build scripts + run: npm ci - name: Validate components files run: npm run validate - name: Type-check tests @@ -95,10 +92,8 @@ jobs: run: git config --global core.autocrlf input - name: Check out repository uses: actions/checkout@v2 - - name: Install dependencies - run: npm ci --ignore-scripts - - name: Run build scripts - run: npm run build + - name: Install dependencies and run build scripts + run: npm ci - name: Run unit tests run: npm run test:unit - name: Run integration tests diff --git a/README.md b/README.md index ea2cc3567..5e57673d3 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Additional recipes for configuring and deploying the server can be found at [sol | `--rootFilePath, -f` | `"./"` | Folder to start the server in when using a file-based config. | | `--sparqlEndpoint, -s` | | Endpoint to call when using a SPARQL-based config. | | `--podConfigJson` | `"./pod-config.json"` | JSON file to store pod configuration when using a dynamic config. | +| `--idpTemplateFolder` | `"templates/idp"` | Folder containing the templates used for IDP interactions. | ### Installing and running locally @@ -203,3 +204,55 @@ Common usage: ```shell docker run --rm -v ~/solid-config:/config -p 3000:3000 -it css:latest -c /config/my-config.json ``` + +## Using the identity provider + +1. Launch the Community Solid Server: + ```bash + git clone git@github.com:solid/community-server.git + cd community-server + npm ci + npm start + ``` +2. To use the identity provider, you need a compatible client application. + + You can use for example `@inrupt/solid-client-authn-js`: + + ```bash + git clone https://github.com/inrupt/solid-client-authn-js + cd solid-client-authn-js + npm ci + cd packages/node/example/demoClientApp/ + npm ci + npm start + ``` + + Go to `http://localhost:3001`. +3. Use the base URL of your running CSS instance to as Identity provider, for + example `http://localhost:3000`, to fill the form. Click the `login` button. +4. Follow the instructions to register/login/... + + A WebID hosted in your pod will be required to complete registration. + + In your running community server, you could create `http://localhost:3000/profile/card` + with the following content: + ```turtle + PREFIX : <#> + PREFIX solid: + + :me solid:oidcIssuer . + ``` + + When registering, follow the on screen instructions and add the OIDC issuer + registration token to your WebID, which you can do for example by PATCHing + `http://localhost:3000/profile/card` with: + ```turtle + PREFIX : <#> + PREFIX solid: + INSERT DATA { + :me solid:oidcIssuerRegistrationToken "IDP_TOKEN" . + } + ``` +5. Once logged in, you are redirected to your client app, running for example on + `http://localhost:3001/`. +6. You're now authenticated and can fetch public and private resources. diff --git a/config/config-default.json b/config/config-default.json index 1a3133a00..1e74517d4 100644 --- a/config/config-default.json +++ b/config/config-default.json @@ -20,7 +20,10 @@ "files-scs:config/presets/static.json", "files-scs:config/presets/storage/backend/storage-memory.json", "files-scs:config/presets/storage-wrapper.json", - "files-scs:config/presets/cli-params.json" + "files-scs:config/presets/cli-params.json", + "files-scs:config/presets/identity/identity-provider.json", + "files-scs:config/presets/identity/interaction-policy.json", + "files-scs:config/presets/identity/email-sender.json" ], "@graph": [ { diff --git a/config/config-file.json b/config/config-file.json index 754a7b340..c033ec720 100644 --- a/config/config-file.json +++ b/config/config-file.json @@ -20,7 +20,10 @@ "files-scs:config/presets/static.json", "files-scs:config/presets/storage/backend/storage-filesystem.json", "files-scs:config/presets/storage-wrapper.json", - "files-scs:config/presets/cli-params.json" + "files-scs:config/presets/cli-params.json", + "files-scs:config/presets/identity/identity-provider.json", + "files-scs:config/presets/identity/interaction-policy.json", + "files-scs:config/presets/identity/email-sender.json" ], "@graph": [ { diff --git a/config/config-path-routing.json b/config/config-path-routing.json index ad18b0630..26c7aa36c 100644 --- a/config/config-path-routing.json +++ b/config/config-path-routing.json @@ -23,7 +23,10 @@ "files-scs:config/presets/storage/backend/storage-sparql-endpoint.json", "files-scs:config/presets/storage/routing/regex-routing.json", "files-scs:config/presets/storage-wrapper.json", - "files-scs:config/presets/cli-params.json" + "files-scs:config/presets/cli-params.json", + "files-scs:config/presets/identity/identity-provider.json", + "files-scs:config/presets/identity/interaction-policy.json", + "files-scs:config/presets/identity/email-sender.json" ], "@graph": [ { diff --git a/config/config-rdf-to-sparql-endpoint.json b/config/config-rdf-to-sparql-endpoint.json index 8368b5b07..bc59f7fce 100644 --- a/config/config-rdf-to-sparql-endpoint.json +++ b/config/config-rdf-to-sparql-endpoint.json @@ -22,7 +22,10 @@ "files-scs:config/presets/storage/backend/storage-sparql-endpoint.json", "files-scs:config/presets/storage/routing/quad-type-routing.json", "files-scs:config/presets/storage-wrapper.json", - "files-scs:config/presets/cli-params.json" + "files-scs:config/presets/cli-params.json", + "files-scs:config/presets/identity/identity-provider.json", + "files-scs:config/presets/identity/interaction-policy.json", + "files-scs:config/presets/identity/email-sender.json" ], "@graph": [ { diff --git a/config/config-sparql-endpoint.json b/config/config-sparql-endpoint.json index 94ca7d054..28f4d4eb8 100644 --- a/config/config-sparql-endpoint.json +++ b/config/config-sparql-endpoint.json @@ -20,7 +20,10 @@ "files-scs:config/presets/static.json", "files-scs:config/presets/storage/backend/storage-sparql-endpoint.json", "files-scs:config/presets/storage-wrapper.json", - "files-scs:config/presets/cli-params.json" + "files-scs:config/presets/cli-params.json", + "files-scs:config/presets/identity/identity-provider.json", + "files-scs:config/presets/identity/interaction-policy.json", + "files-scs:config/presets/identity/email-sender.json" ], "@graph": [ { diff --git a/config/presets/cli-params.json b/config/presets/cli-params.json index 689ec3726..66d72b07f 100644 --- a/config/presets/cli-params.json +++ b/config/presets/cli-params.json @@ -25,6 +25,10 @@ "comment": "Path to the JSON file used to store configuration for dynamic pods.", "@id": "urn:solid-server:default:variable:podConfigJson", "@type": "Variable" + }, + { + "@id": "urn:solid-server:default:variable:idpTemplateFolder", + "@type": "Variable" } ] } diff --git a/config/presets/http.json b/config/presets/http.json index a29783db7..da0549a4e 100644 --- a/config/presets/http.json +++ b/config/presets/http.json @@ -29,7 +29,7 @@ "@type": "WaterfallHandler", "handlers": [ { - "@id": "urn:solid-server:default:FaviconHandler" + "@id": "urn:solid-server:default:CommonAssetHandler" }, { "@id": "urn:solid-server:default:StaticAssetHandler" @@ -37,6 +37,9 @@ { "@id": "urn:solid-server:default:PodManagerHandler" }, + { + "@id": "urn:solid-server:default:IdentityProviderHandler" + }, { "@id": "urn:solid-server:default:LdpHandler" } @@ -45,13 +48,17 @@ ] }, { - "@id": "urn:solid-server:default:FaviconHandler", + "@id": "urn:solid-server:default:CommonAssetHandler", "@type": "StaticAssetHandler", - "comment": "Serves the favicon", + "comment": "Serves the favicon and the idp css", "assets": [ { "StaticAssetHandler:_assets_key": "/favicon.ico", "StaticAssetHandler:_assets_value": "$PACKAGE_ROOT/templates/root/favicon.ico" + }, + { + "StaticAssetHandler:_assets_key": "/idp/style.css", + "StaticAssetHandler:_assets_value": "$PACKAGE_ROOT/templates/views/email-password-interaction/main.css" } ] } diff --git a/config/presets/identity/email-sender.json b/config/presets/identity/email-sender.json new file mode 100644 index 000000000..bac810fdc --- /dev/null +++ b/config/presets/identity/email-sender.json @@ -0,0 +1,10 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "The default configuration does not contain credentials for an email client. In production systems, you likely want to set up your own.", + "@id": "urn:solid-server:default:EmailSender", + "@type": "UnsupportedAsyncHandler" + } + ] +} diff --git a/config/presets/identity/identity-provider.json b/config/presets/identity/identity-provider.json new file mode 100644 index 000000000..bedd8898f --- /dev/null +++ b/config/presets/identity/identity-provider.json @@ -0,0 +1,84 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:default:IdpStorage", + "@type": "JsonResourceStorage", + "source": { + "@id": "urn:solid-server:default:ResourceStore" + }, + "baseUrl": { + "@id": "urn:solid-server:default:variable:baseUrl" + }, + "container": "/idp/data/" + }, + { + "@id": "urn:solid-server:default:ExpiringIdpStorage", + "@type": "WrappedExpiringStorage", + "source": { + "@id": "urn:solid-server:default:IdpStorage" + } + }, + { + "@id": "urn:solid-server:default:IdpAdapterFactory", + "@type": "WrappedFetchAdapterFactory", + "source": { + "@type": "ExpiringAdapterFactory", + "args_storageName": "/idp/oidc", + "args_storage": { + "@id": "urn:solid-server:default:ExpiringIdpStorage" + } + } + }, + { + "@id": "urn:solid-server:default:IdpConfigurationFactory", + "@type": "KeyConfigurationFactory", + "adapterFactory": { + "@id": "urn:solid-server:default:IdpAdapterFactory" + }, + "baseUrl": { + "@id": "urn:solid-server:default:variable:baseUrl" + }, + "idpPath": "/idp", + "storage": { + "@id": "urn:solid-server:default:IdpStorage" + } + }, + { + "@id": "urn:solid-server:default:IdentityProviderFactory", + "@type": "IdentityProviderFactory", + "issuer": { + "@id": "urn:solid-server:default:variable:baseUrl" + }, + "configurationFactory": { + "@id": "urn:solid-server:default:IdpConfigurationFactory" + }, + "errorResponseWriter": { + "@type": "ErrorResponseWriter" + } + }, + { + "@id": "urn:solid-server:default:IdentityProviderHttpHandler", + "@type": "IdentityProviderHttpHandler", + "providerFactory": { + "@id": "urn:solid-server:default:IdentityProviderFactory" + }, + "interactionPolicy": { + "@id": "urn:solid-server:auth:password:AccountInteractionPolicy" + }, + "interactionHttpHandler": { + "@id": "urn:solid-server:auth:password:InteractionHttpHandler" + }, + "errorResponseWriter": { + "@type": "ErrorResponseWriter" + } + }, + { + "@id": "urn:solid-server:default:IdentityProviderHandler", + "@type": "RouterHandler", + "allowedMethods": [ "GET", "POST", "PUT", "DELETE", "OPTIONS" ], + "allowedPathNames": [ "^/idp/.*", "^/\\.well-known/openid-configuration" ], + "handler": { "@id": "urn:solid-server:default:IdentityProviderHttpHandler" } + } + ] +} diff --git a/config/presets/identity/interaction-policy.json b/config/presets/identity/interaction-policy.json new file mode 100644 index 000000000..c47543d63 --- /dev/null +++ b/config/presets/identity/interaction-policy.json @@ -0,0 +1,224 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "The storage adapter that persists usernames, passwords, etc.", + "@id": "urn:solid-server:auth:password:AccountStore", + "@type": "BaseAccountStore", + "args_storageName": "/idp/email-password-db", + "args_saltRounds": 10, + "args_storage": { + "@id": "urn:solid-server:default:IdpStorage" + } + }, + { + "comment": "Responsible for completing an OIDC interaction after login or registration", + "@id": "urn:solid-server:auth:password:InteractionCompleter", + "@type": "InteractionCompleter" + }, + { + "comment": "Renders the login page", + "@id": "urn:solid-server:auth:password:LoginRenderHandler", + "@type": "RenderEjsHandler", + "templatePath": { + "@id": "urn:solid-server:default:variable:idpTemplateFolder" + }, + "templateFile": "./email-password-interaction/login.ejs" + }, + { + "comment": "Renders the register page", + "@id": "urn:solid-server:auth:password:RegisterRenderHandler", + "@type": "RenderEjsHandler", + "templatePath": { + "@id": "urn:solid-server:default:variable:idpTemplateFolder" + }, + "templateFile": "./email-password-interaction/register.ejs" + }, + { + "comment": "Renders the forgot password page", + "@id": "urn:solid-server:auth:password:ForgotPasswordRenderHandler", + "@type": "RenderEjsHandler", + "templatePath": { + "@id": "urn:solid-server:default:variable:idpTemplateFolder" + }, + "templateFile": "./email-password-interaction/forgotPassword.ejs" + }, + { + "comment": "Renders the reset password page", + "@id": "urn:solid-server:auth:password:ResetPasswordRenderHandler", + "@type": "RenderEjsHandler", + "templatePath": { + "@id": "urn:solid-server:default:variable:idpTemplateFolder" + }, + "templateFile": "./email-password-interaction/resetPassword.ejs" + }, + { + "comment": "Renders the Email Sent message page", + "@id": "urn:solid-server:auth:password:EmailSentRenderHandler", + "@type": "RenderEjsHandler", + "templatePath": { + "@id": "urn:solid-server:default:variable:idpTemplateFolder" + }, + "templateFile": "./email-password-interaction/emailSent.ejs" + }, + { + "comment": "Renders a generic page that says a message", + "@id": "urn:solid-server:auth:password:MessageRenderHandler", + "@type": "RenderEjsHandler", + "templatePath": { + "@id": "urn:solid-server:default:variable:idpTemplateFolder" + }, + "templateFile": "./email-password-interaction/message.ejs" + }, + { + "comment": "Http handler to take care of all routing on for the email password interaction", + "@id": "urn:solid-server:auth:password:InteractionHttpHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": "Handles the initial route when the user is directed from their app to the IdP", + "@type": "RouterHandler", + "allowedMethods": [ "GET" ], + "allowedPathNames": [ "^/idp/interaction/[-_A-Za-z0-9]+/?$" ], + "handler": { + "@type": "InitialInteractionHandler", + "renderHandlerMap": [ + { + "InitialInteractionHandler:_renderHandlerMap_key": "consent", + "InitialInteractionHandler:_renderHandlerMap_value": { + "@type": "RenderEjsHandler", + "templatePath": { + "@id": "urn:solid-server:default:variable:idpTemplateFolder" + }, + "templateFile": "./email-password-interaction/confirm.ejs" + } + } + ], + "renderHandlerMap_default": { + "@id": "urn:solid-server:auth:password:LoginRenderHandler" + } + } + }, + { + "comment": "Handles all functionality on the register page", + "@type": "IdpRouteController", + "pathName": "^/idp/interaction/[-_A-Za-z0-9]+/register/?$", + "postHandler": { + "@type": "RegistrationHandler", + "args_ownershipValidator": { + "@type": "IssuerOwnershipValidator", + "issuer": { + "@id": "urn:solid-server:default:variable:baseUrl" + } + }, + "args_accountStore": { + "@id": "urn:solid-server:auth:password:AccountStore" + }, + "args_interactionCompleter": { + "@id": "urn:solid-server:auth:password:InteractionCompleter" + } + }, + "renderHandler": { + "@id": "urn:solid-server:auth:password:RegisterRenderHandler" + } + }, + { + "comment": "Handles all functionality on the Login Page", + "@type": "IdpRouteController", + "pathName": "^/idp/interaction/[-_A-Za-z0-9]+/login/?$", + "postHandler": { + "@type": "LoginHandler", + "args_accountStore": { + "@id": "urn:solid-server:auth:password:AccountStore" + }, + "args_interactionCompleter": { + "@id": "urn:solid-server:auth:password:InteractionCompleter" + } + }, + "renderHandler": { + "@id": "urn:solid-server:auth:password:LoginRenderHandler" + } + }, + { + "comment": "Handles confirm requests", + "@type": "RouterHandler", + "allowedMethods": [ "POST" ], + "allowedPathNames": [ "^/idp/interaction/[-_A-Za-z0-9]+/confirm/?$" ], + "handler": { + "@type": "SessionHttpHandler", + "interactionCompleter": { + "@id": "urn:solid-server:auth:password:InteractionCompleter" + } + } + }, + { + "comment": "Handles all functionality on the forgot password page", + "@type": "IdpRouteController", + "pathName": "^/idp/interaction/[-_A-Za-z0-9]+/forgotpassword/?$", + "postHandler": { + "@type": "ForgotPasswordHandler", + "args_messageRenderHandler": { + "@id": "urn:solid-server:auth:password:EmailSentRenderHandler" + }, + "args_accountStore": { + "@id": "urn:solid-server:auth:password:AccountStore" + }, + "args_baseUrl": { + "@id": "urn:solid-server:default:variable:baseUrl" + }, + "args_idpPath": "/idp", + "args_emailTemplateRenderer": { + "@type": "EjsTemplateRenderer", + "templatePath": { + "@id": "urn:solid-server:default:variable:idpTemplateFolder" + }, + "templateFile": "./email-password-interaction/resetPasswordEmail.ejs" + }, + "args_emailSender": { + "@id": "urn:solid-server:default:EmailSender" + } + }, + "renderHandler": { + "@id": "urn:solid-server:auth:password:ForgotPasswordRenderHandler" + } + }, + { + "comment": "Renders the reset password page", + "@type": "RouterHandler", + "allowedMethods": [ "GET" ], + "allowedPathNames": [ "^/idp/resetpassword/?$" ], + "handler": { + "@type": "ResetPasswordViewHandler", + "renderHandler": { + "@id": "urn:solid-server:auth:password:ResetPasswordRenderHandler" + } + } + }, + { + "comment": "Handles the reset password page submission", + "@type": "RouterHandler", + "allowedMethods": [ "POST" ], + "allowedPathNames": [ "^/idp/resetpassword/?$" ], + "handler": { + "@type": "ResetPasswordHandler", + "args_accountStore": { + "@id": "urn:solid-server:auth:password:AccountStore" + }, + "args_renderHandler": { + "@id": "urn:solid-server:auth:password:ResetPasswordRenderHandler" + }, + "args_messageRenderHandler": { + "@id": "urn:solid-server:auth:password:MessageRenderHandler" + } + } + } + ] + }, + { + "comment": "Sets up the email password interaction policy", + "@id": "urn:solid-server:auth:password:AccountInteractionPolicy", + "@type": "AccountInteractionPolicy", + "idpPath": "/idp" + } + ] +} diff --git a/config/presets/pod-dynamic.json b/config/presets/pod-dynamic.json index cb6c55b26..74d1cba0c 100644 --- a/config/presets/pod-dynamic.json +++ b/config/presets/pod-dynamic.json @@ -17,10 +17,7 @@ }, { "@id": "urn:solid-server:default:PodRoutingStorage", - "@type": "ResourceIdentifierStorage", - "source": { - "@type": "MemoryMapStorage" - } + "@type": "MemoryMapStorage" }, { diff --git a/config/presets/storage-wrapper.json b/config/presets/storage-wrapper.json index 496d398c2..e9735e8d0 100644 --- a/config/presets/storage-wrapper.json +++ b/config/presets/storage-wrapper.json @@ -57,10 +57,7 @@ "@type": "SingleThreadedResourceLocker" }, "storage": { - "@type": "ResourceIdentifierStorage", - "source": { - "@type": "MemoryMapStorage" - } + "@type": "MemoryMapStorage" }, "suffixes_count": "count", "suffixes_read": "read", diff --git a/jest.config.js b/jest.config.js index 82c47dda8..9c3e0e50f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -21,6 +21,10 @@ module.exports = { '/node_modules/', '/test/', ], + // See https://github.com/matthieubosquet/ts-dpop/issues/13 + moduleNameMapper: { + '^jose/(.*)$': '/node_modules/jose/dist/node/cjs/$1', + }, // Slower machines had problems calling the WebSocket integration callbacks on time testTimeout: 15000, }; diff --git a/package-lock.json b/package-lock.json index ea381f315..859c7045a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -719,6 +719,70 @@ "integrity": "sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==", "dev": true }, + "@inrupt/solid-client-authn-core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.7.2.tgz", + "integrity": "sha512-McLxf4h5DAZMsfVkJxqtRyvjDAmN6V2NQjDpxUkpER3EqiRPpne1qkEpj5NKagEE5T0I02VUx+mbfn/xbblWaQ==", + "dev": true, + "requires": { + "@inrupt/solid-common-vocab": "^0.5.3", + "@types/lodash.clonedeep": "^4.5.6", + "@types/uuid": "^8.3.0", + "ajv": "^6.12.6", + "cross-fetch": "^3.0.6", + "lodash.clonedeep": "^4.5.0", + "uuid": "^8.3.1" + } + }, + "@inrupt/solid-client-authn-node": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-node/-/solid-client-authn-node-1.7.2.tgz", + "integrity": "sha512-25hM34j/r3MtfC6mGl/E8BoeLoLD1/Fq2slZM+GsYClNM53bDEeGnur4wCMqIKROvPZS2wOgKoODzArwVLX5sA==", + "dev": true, + "requires": { + "@inrupt/solid-client-authn-core": "^1.7.2", + "@types/node": "^14.14.14", + "@types/uuid": "^8.3.0", + "cross-fetch": "^3.0.6", + "jose": "^2.0.3", + "openid-client": "^4.2.2", + "reflect-metadata": "^0.1.13", + "tsyringe": "^4.4.0", + "uuid": "^8.3.2" + }, + "dependencies": { + "@types/node": { + "version": "14.14.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.35.tgz", + "integrity": "sha512-Lt+wj8NVPx0zUmUwumiVXapmaLUcAk3yPuHCFVXras9k5VT9TdhJqKqGVUQCD60OTMCl0qxJ57OiTL0Mic3Iag==", + "dev": true + }, + "jose": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.4.tgz", + "integrity": "sha512-EArN9f6aq1LT/fIGGsfghOnNXn4noD+3dG5lL/ljY3LcRjw1u9w+4ahu/4ahsN6N0kRLyyW6zqdoYk7LNx3+YQ==", + "dev": true, + "requires": { + "@panva/asn1.js": "^1.0.0" + } + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } + } + }, + "@inrupt/solid-common-vocab": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@inrupt/solid-common-vocab/-/solid-common-vocab-0.5.3.tgz", + "integrity": "sha512-/BKKIInQaP/D6tCFOvViN2Duv2RLAbXCNpQUtQjkz3t6cbmxPBTwGuvwDLan7R+yLJhbiJmJP4yNYEw/5Zc+Rg==", + "dev": true, + "requires": { + "@types/rdf-js": "4.0.0" + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -979,6 +1043,53 @@ } } }, + "@koa/cors": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-3.1.0.tgz", + "integrity": "sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==", + "requires": { + "vary": "^1.1.2" + } + }, + "@mapbox/node-pre-gyp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.0.tgz", + "integrity": "sha512-mEaiD1CURETR/dBIiJAwz0M0Q0mH3gCW4pPMaIlNt97mdzYUVeqGcTJSamgJpS6Tg4tBHDrOJpjdh5fJTLnyNQ==", + "requires": { + "detect-libc": "^1.0.3", + "http-proxy-agent": "^4.0.1", + "mkdirp": "^1.0.4", + "node-fetch": "^2.6.1", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "rimraf": "^3.0.2", + "semver": "^7.3.4", + "tar": "^6.1.0" + }, + "dependencies": { + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, + "nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "requires": { + "abbrev": "1" + } + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "@microsoft/tsdoc": { "version": "0.12.21", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.12.21.tgz", @@ -1050,6 +1161,11 @@ "fastq": "^1.6.0" } }, + "@panva/asn1.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", + "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" + }, "@rdfjs/data-model": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.2.0.tgz", @@ -1058,11 +1174,122 @@ "@types/rdf-js": "*" } }, + "@rdfjs/dataset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rdfjs/dataset/-/dataset-1.0.1.tgz", + "integrity": "sha512-k/c6g4K881QX7LE3eskg6t1j31zDe+CKwTEiKkSCFk6M25gUJ/BReT/FrLdKmPjhXp+YOgHj97AtEphzTeKVeA==", + "requires": { + "@rdfjs/data-model": "^1.1.1" + } + }, + "@rdfjs/fetch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@rdfjs/fetch/-/fetch-2.1.0.tgz", + "integrity": "sha512-1bhXqGfbQQKHrmuZOmUUQmCpDNQC25fksYoGXUvlQ80kWuk8r/PdcdUmzApCp7HSyHFUjmgH89Pkym/9WXyDkQ==", + "requires": { + "@rdfjs/dataset": "^1.0.1", + "@rdfjs/fetch-lite": "^2.1.0", + "@rdfjs/formats-common": "^2.0.1" + } + }, + "@rdfjs/fetch-lite": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@rdfjs/fetch-lite/-/fetch-lite-2.1.0.tgz", + "integrity": "sha512-7P2+QBjSJ/oeN2CRySIrG0IHat7UDqf7KfpZ9IP/MhWN3AneTpuURlOVAKcP/tedsSdlf2ihnzO38C6uAd7ppQ==", + "requires": { + "isstream": "^0.1.2", + "nodeify-fetch": "^2.2.1", + "readable-stream": "^3.3.0" + } + }, + "@rdfjs/formats-common": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@rdfjs/formats-common/-/formats-common-2.1.0.tgz", + "integrity": "sha512-DVsQsMwSf+bNelIocDe35Wq/POkC+puXYd0BRwP76A3tzYKjIHwBHQFfq7wXMUaBe3jQq80x4DaFpxPaI7sPKA==", + "requires": { + "@rdfjs/parser-jsonld": "^1.1.1", + "@rdfjs/parser-n3": "^1.1.2", + "@rdfjs/serializer-jsonld": "^1.2.0", + "@rdfjs/serializer-ntriples": "^1.0.1", + "@rdfjs/sink-map": "^1.0.0", + "rdfxml-streaming-parser": "^1.2.0" + } + }, + "@rdfjs/parser-jsonld": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rdfjs/parser-jsonld/-/parser-jsonld-1.2.1.tgz", + "integrity": "sha512-m8WQBacXaU2qPgf+rPEqit4EiEjBxpohvDEG4aF7YvFAT6uEto3lHu7ilKcMKpLfMKmbXp4v6KzJXm9yvockNw==", + "requires": { + "@rdfjs/data-model": "^1.0.1", + "@rdfjs/sink": "^1.0.2", + "concat-stream": "^2.0.0", + "jsonld": "^1.8.1", + "readable-stream": "^3.6.0" + }, + "dependencies": { + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + } + } + }, + "@rdfjs/parser-n3": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rdfjs/parser-n3/-/parser-n3-1.1.4.tgz", + "integrity": "sha512-PUKSNlfD2Zq3GcQZuOF2ndfrLbc+N96FUe2gNIzARlR2er0BcOHBHEFUJvVGg1Xmsg3hVKwfg0nwn1JZ7qKKMw==", + "requires": { + "@rdfjs/data-model": "^1.0.1", + "@rdfjs/sink": "^1.0.2", + "n3": "^1.3.5", + "readable-stream": "^3.6.0", + "readable-to-readable": "^0.1.0" + } + }, + "@rdfjs/serializer-jsonld": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rdfjs/serializer-jsonld/-/serializer-jsonld-1.2.2.tgz", + "integrity": "sha512-BXcmi2qZlpkrJcVms9g1JSFWXeiOhgJyFsEa6UuN0CPst6WRNY5z6djp0o6rfDgfmgb5FrXFwBgAIDunI5VZoQ==", + "requires": { + "@rdfjs/sink": "^1.0.2", + "readable-stream": "^3.6.0" + } + }, + "@rdfjs/serializer-ntriples": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rdfjs/serializer-ntriples/-/serializer-ntriples-1.0.3.tgz", + "integrity": "sha512-XXFgzNJyYrix0YgysqYowKw40hCJ+zeVqA/CGgO3y5XyKY+NL/VJJELMn7cTwjJteiLVCgRNAvaUVn4CjJ2PCg==", + "requires": { + "@rdfjs/sink": "^1.0.3", + "@rdfjs/to-ntriples": "^1.0.2", + "readable-to-readable": "^0.1.0" + } + }, + "@rdfjs/sink": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rdfjs/sink/-/sink-1.0.3.tgz", + "integrity": "sha512-2KfYa8mAnptRNeogxhQqkWNXqfYVWO04jQThtXKepySrIwYmz83+WlevQtA4VDLFe+kFd2TwgL29ekPe5XVUfA==" + }, + "@rdfjs/sink-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rdfjs/sink-map/-/sink-map-1.0.1.tgz", + "integrity": "sha512-PRp5TjULHe2oRcupR80SClZ/l50wnSuX2Pl+TlkcRazt1w7AT86kLmQYFbDfjqGM7uDwSyD6evLJxXBDf5UuvQ==" + }, + "@rdfjs/to-ntriples": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-1.0.2.tgz", + "integrity": "sha512-ngw5XAaGHjgGiwWWBPGlfdCclHftonmbje5lMys4G2j4NvfExraPIuRZgjSnd5lg4dnulRVUll8tRbgKO+7EDA==" + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", - "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", - "dev": true + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==" }, "@sinonjs/commons": { "version": "1.8.1", @@ -1122,17 +1349,29 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", - "dev": true, "requires": { "defer-to-connect": "^1.0.1" } }, + "@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==" + }, "@tsconfig/node12": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.7.tgz", "integrity": "sha512-dgasobK/Y0wVMswcipr3k0HpevxFJLijN03A8mYfEPvWvOs14v0ZlYTR4kIgMx8g4+fTyTFv8/jLCIfRqLDJ4A==", "dev": true }, + "@types/accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==", + "requires": { + "@types/node": "*" + } + }, "@types/arrayify-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/arrayify-stream/-/arrayify-stream-1.0.0.tgz", @@ -1187,22 +1426,87 @@ "@babel/types": "^7.3.0" } }, + "@types/bcrypt": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-3.0.0.tgz", + "integrity": "sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ==" + }, "@types/bluebird": { "version": "3.5.33", "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.33.tgz", "integrity": "sha512-ndEo1xvnYeHxm7I/5sF6tBvnsA4Tdi3zj1keRKRs12SP+2ye2A27NDJ1B6PqkfMbGAcT+mqQVqbZRIrhfOp5PQ==" }, + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/cacheable-request": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.1.tgz", + "integrity": "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==", + "dev": true, + "requires": { + "@types/http-cache-semantics": "*", + "@types/keyv": "*", + "@types/node": "*", + "@types/responselike": "*" + } + }, + "@types/cheerio": { + "version": "0.22.27", + "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.27.tgz", + "integrity": "sha512-UpmYZewEWNEE6Ya24RzAQ2X2OYwz32AaLyzYinpM8qqFGRyYufqKSvxPjjZkvS+h16bajfXl7VojrAxWzG/+mA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.34", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", + "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg==" + }, "@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "@types/cookies": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.6.tgz", + "integrity": "sha512-FK4U5Qyn7/Sc5ih233OuHO0qAkOpEcD/eG6584yEiLKizTFRny86qHLe/rej3HFQrkBuUjF4whFliAdODbVN/w==", + "requires": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, "@types/cors": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.9.tgz", "integrity": "sha512-zurD1ibz21BRlAOIKP8yhrxlqKx6L9VCwkB5kMiP6nZAhoF5MvC7qS1qPA7nRcr1GJolfkQC7/EAL4hdYejLtg==" }, + "@types/ejs": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.0.6.tgz", + "integrity": "sha512-fj1hi+ZSW0xPLrJJD+YNwIh9GZbyaIepG26E/gXvp8nCa2pYokxUYO1sK9qjGxp2g8ryZYuon7wmjpwE2cyASQ==", + "dev": true + }, "@types/end-of-stream": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@types/end-of-stream/-/end-of-stream-1.4.0.tgz", @@ -1211,6 +1515,27 @@ "@types/node": "*" } }, + "@types/express": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.11.tgz", + "integrity": "sha512-no+R6rW60JEc59977wIxreQVsIEOAYwgCqldrA/vkpCnbD7MqTefO97lmoBe4WE0F156bC4uLSP1XHDOySnChg==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz", + "integrity": "sha512-DJOSHzX7pCiSElWaGR8kCprwibCB/3yW6vcT8VG3P0SJjnv19gnWG/AZMfM60Xj/YJIp/YCaDHyvzsFVeniARA==", + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, "@types/glob": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz", @@ -1230,6 +1555,22 @@ "@types/node": "*" } }, + "@types/http-assert": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz", + "integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==" + }, + "@types/http-cache-semantics": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", + "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==", + "dev": true + }, + "@types/http-errors": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.0.tgz", + "integrity": "sha512-2aoSC4UUbHDj2uCsCxcG/vRMXey/m17bC7UwitVm5hn22nI8O8Y9iDpA76Orc+DWkQ4zZrOKEshCqR/jSuXAHA==" + }, "@types/http-link-header": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/http-link-header/-/http-link-header-1.0.2.tgz", @@ -1284,12 +1625,69 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/keygrip": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz", + "integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==" + }, + "@types/keyv": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz", + "integrity": "sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/koa": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.13.1.tgz", + "integrity": "sha512-Qbno7FWom9nNqu0yHZ6A0+RWt4mrYBhw3wpBAQ3+IuzGcLlfeYkzZrnMq5wsxulN2np8M4KKeUpTodsOsSad5Q==", + "requires": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "@types/koa-compose": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.5.tgz", + "integrity": "sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==", + "requires": { + "@types/koa": "*" + } + }, + "@types/lodash": { + "version": "4.14.168", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz", + "integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==", + "dev": true + }, + "@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==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, "@types/lru-cache": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz", "integrity": "sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==", "dev": true }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + }, "@types/mime-types": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.0.tgz", @@ -1320,6 +1718,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.6.tgz", "integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==" }, + "@types/nodemailer": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.0.tgz", + "integrity": "sha512-KY7bFWB0MahRZvVW4CuW83qcCDny59pJJ0MQ5ifvfcjNwPlIT0vW4uARO4u1gtkYnWdhSvURegecY/tzcukJcA==", + "requires": { + "@types/node": "*" + } + }, "@types/normalize-package-data": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", @@ -1356,6 +1762,16 @@ "resolved": "https://registry.npmjs.org/@types/punycode/-/punycode-2.1.0.tgz", "integrity": "sha512-PG5aLpW6PJOeV2fHRslP4IOMWn+G+Uq8CfnyJ+PDS8ndCbU+soO+fB3NKCKo0p/Jh2Y4aPaiQZsrOXFdzpcA6g==" }, + "@types/qs": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.6.tgz", + "integrity": "sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==" + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, "@types/rdf-js": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/rdf-js/-/rdf-js-4.0.0.tgz", @@ -1364,6 +1780,41 @@ "@types/node": "*" } }, + "@types/rdfjs__fetch": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/rdfjs__fetch/-/rdfjs__fetch-2.0.2.tgz", + "integrity": "sha512-HNjGH7ameGnPmu8xAlHXUxZ+EYHzRjNYyl30QxLbHXiQtMDEMGcgzIuFXVLTz5xgXQ7ZtZVZPK6HX/KcFttuNA==", + "requires": { + "@types/rdf-js": "*", + "@types/rdfjs__fetch-lite": "*" + } + }, + "@types/rdfjs__fetch-lite": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/rdfjs__fetch-lite/-/rdfjs__fetch-lite-2.0.2.tgz", + "integrity": "sha512-rtLBHX9FidyLLKTJXBx5krJ+iBLuDS3R7Biqmpy02Nibc227vpErdDTJ4gn14jncsua2d2xckgz3G6ShP8foBw==", + "requires": { + "@types/rdf-js": "*", + "@types/rdfjs__formats-common": "*" + } + }, + "@types/rdfjs__formats-common": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/rdfjs__formats-common/-/rdfjs__formats-common-2.0.1.tgz", + "integrity": "sha512-Gnh6t8dmdbchz1Ka7PiRIpTm61LU+RJfk5Ketog4K8cUFj5HE+Wmu6PuOvArp00QpQuLsD1PWth/+Ht9tGu2Zw==", + "requires": { + "@types/rdf-js": "*", + "@types/rdfjs__sink-map": "*" + } + }, + "@types/rdfjs__sink-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/rdfjs__sink-map/-/rdfjs__sink-map-1.0.1.tgz", + "integrity": "sha512-KQLVYnsavXJZZmAOU+EpIc636bX97vw/y4tveCEcrdH1p+nYO1Ar+RrIeOjQiGtIUs4F6sTIiMngvD9w6rmX8w==", + "requires": { + "@types/rdf-js": "*" + } + }, "@types/redis": { "version": "2.8.28", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.28.tgz", @@ -1380,6 +1831,15 @@ "@types/bluebird": "*" } }, + "@types/responselike": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", + "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/rimraf": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.0.tgz", @@ -1395,6 +1855,24 @@ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.4.tgz", "integrity": "sha512-+nVsLKlcUCeMzD2ufHEYuJ9a2ovstb6Dp52A5VsoKxDXgvE051XgHI/33I1EymwkRGQkwnA0LkhnUzituGs4EQ==" }, + "@types/serve-static": { + "version": "1.13.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz", + "integrity": "sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==", + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/set-cookie-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.0.tgz", + "integrity": "sha512-w7BFUq81sy7H/0jN0K5cax8MwRN6NOSURpY4YuO4+mOgoicxCZ33BUYz+gyF/sUf7uDl2We2yGJfppxzEXoAXQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/sparqljs": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/sparqljs/-/sparqljs-3.1.0.tgz", @@ -1436,6 +1914,11 @@ "@types/superagent": "*" } }, + "@types/url-join": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/url-join/-/url-join-4.0.0.tgz", + "integrity": "sha512-awrJu8yML4E/xTwr2EMatC+HBnHGoDxc2+ImA9QyeUELI1S7dOCIZcyjki1rkwoA8P2D2NVgLAJLjnclkdLtAw==" + }, "@types/uuid": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz", @@ -1644,14 +2127,12 @@ "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", - "dev": true, "requires": { "mime-types": "~2.1.24", "negotiator": "0.6.2" @@ -1685,11 +2166,43 @@ "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", "dev": true }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1797,6 +2310,11 @@ } } }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + }, "anymatch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", @@ -1807,6 +2325,49 @@ "picomatch": "^2.0.4" } }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1899,7 +2460,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, "requires": { "safer-buffer": "~2.1.0" } @@ -1907,8 +2467,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "assign-symbols": { "version": "1.0.0", @@ -1940,8 +2499,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "at-least-node": { "version": "1.0.0", @@ -1958,14 +2516,12 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", - "dev": true + "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" }, "babel-eslint": { "version": "10.1.0", @@ -2072,8 +2628,7 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "base": { "version": "0.11.2", @@ -2130,11 +2685,19 @@ } } }, + "bcrypt": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.1.tgz", + "integrity": "sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw==", + "requires": { + "@mapbox/node-pre-gyp": "^1.0.0", + "node-addon-api": "^3.1.0" + } + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dev": true, "requires": { "tweetnacl": "^0.14.3" } @@ -2150,6 +2713,12 @@ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, "boxen": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", @@ -2182,7 +2751,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2224,8 +2792,12 @@ "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", - "dev": true + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" }, "cache-base": { "version": "1.0.1", @@ -2244,11 +2816,25 @@ "unset-value": "^1.0.0" } }, + "cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "requires": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + } + }, + "cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true + }, "cacheable-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", - "dev": true, "requires": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", @@ -2263,7 +2849,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, "requires": { "pump": "^3.0.0" } @@ -2271,8 +2856,7 @@ "lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" } } }, @@ -2326,8 +2910,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "chalk": { "version": "4.1.0", @@ -2345,6 +2928,98 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, + "cheerio": { + "version": "1.0.0-rc.5", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.5.tgz", + "integrity": "sha512-yoqps/VCaZgN4pfXtenwHROTp8NG6/Hlt4Jpz2FEP0ZJQ+ZUkVDd0hAPDNKhj3nakpfPt/CNs57yEtxD1bXQiw==", + "dev": true, + "requires": { + "cheerio-select-tmp": "^0.1.0", + "dom-serializer": "~1.2.0", + "domhandler": "^4.0.0", + "entities": "~2.1.0", + "htmlparser2": "^6.0.0", + "parse5": "^6.0.0", + "parse5-htmlparser2-tree-adapter": "^6.0.0" + }, + "dependencies": { + "dom-serializer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.2.0.tgz", + "integrity": "sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "entities": "^2.0.0" + } + }, + "domhandler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz", + "integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==", + "dev": true, + "requires": { + "domelementtype": "^2.1.0" + }, + "dependencies": { + "domelementtype": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz", + "integrity": "sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w==", + "dev": true + } + } + }, + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + } + } + }, + "cheerio-select-tmp": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/cheerio-select-tmp/-/cheerio-select-tmp-0.1.1.tgz", + "integrity": "sha512-YYs5JvbpU19VYJyj+F7oYrIE2BOll1/hRU7rEy/5+v9BzkSo3bK81iAeeQEMI92vRIxz677m72UmJUiVwwgjfQ==", + "dev": true, + "requires": { + "css-select": "^3.1.2", + "css-what": "^4.0.0", + "domelementtype": "^2.1.0", + "domhandler": "^4.0.0", + "domutils": "^2.4.4" + }, + "dependencies": { + "domelementtype": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz", + "integrity": "sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w==", + "dev": true + }, + "domhandler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz", + "integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==", + "dev": true, + "requires": { + "domelementtype": "^2.1.0" + } + }, + "domutils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.5.0.tgz", + "integrity": "sha512-Ho16rzNMOFk2fPwChGh3D2D9OEHAfG19HgmRR2l+WLSsIstNsAYBzePH412bL0y5T44ejABIVfTHQ8nqi/tBCg==", + "dev": true, + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0" + } + } + } + }, "chokidar": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.3.tgz", @@ -2361,6 +3036,11 @@ "readdirp": "~3.5.0" } }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + }, "ci-info": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", @@ -2413,6 +3093,12 @@ } } }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, "cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -2433,7 +3119,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", - "dev": true, "requires": { "mimic-response": "^1.0.0" } @@ -2441,8 +3126,12 @@ "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", - "dev": true + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, "collect-v8-coverage": { "version": "1.0.1", @@ -2509,7 +3198,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -2837,8 +3525,47 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } }, "configstore": { "version": "5.0.1", @@ -2854,12 +3581,37 @@ "xdg-basedir": "^4.0.0" } }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + }, "contains-path": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", "integrity": "sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=", "dev": true }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "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==" + }, "convert-source-map": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", @@ -2883,6 +3635,22 @@ "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", "dev": true }, + "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==" + } + } + }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -2952,6 +3720,53 @@ "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", "dev": true }, + "css-select": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-3.1.2.tgz", + "integrity": "sha512-qmss1EihSuBNWNNhHjxzxSfJoFBM/lERB/Q4EnsJQQC62R2evJDW481091oAdOr9uh46/0n4nrg0It5cAnj1RA==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^4.0.0", + "domhandler": "^4.0.0", + "domutils": "^2.4.3", + "nth-check": "^2.0.0" + }, + "dependencies": { + "domelementtype": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz", + "integrity": "sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w==", + "dev": true + }, + "domhandler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz", + "integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==", + "dev": true, + "requires": { + "domelementtype": "^2.1.0" + } + }, + "domutils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.5.0.tgz", + "integrity": "sha512-Ho16rzNMOFk2fPwChGh3D2D9OEHAfG19HgmRR2l+WLSsIstNsAYBzePH412bL0y5T44ejABIVfTHQ8nqi/tBCg==", + "dev": true, + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0" + } + } + } + }, + "css-what": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-4.0.0.tgz", + "integrity": "sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A==", + "dev": true + }, "cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", @@ -2994,7 +3809,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -3058,11 +3872,15 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", - "dev": true, "requires": { "mimic-response": "^1.0.0" } }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=" + }, "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -3084,8 +3902,7 @@ "defer-to-connect": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", - "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", - "dev": true + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" }, "define-properties": { "version": "1.1.3", @@ -3140,8 +3957,12 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, "denque": { "version": "1.5.0", @@ -3151,8 +3972,17 @@ "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", - "dev": true + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=" }, "detect-newline": { "version": "3.1.0", @@ -3246,19 +4076,30 @@ "duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", - "dev": true + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "dev": true, "requires": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "ejs": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz", + "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==", + "requires": { + "jake": "^10.6.1" + } + }, "emittery": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.7.2.tgz", @@ -3275,6 +4116,11 @@ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -3355,6 +4201,11 @@ "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", "dev": true }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4105,8 +4956,7 @@ "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "extend-shallow": { "version": "3.0.2", @@ -4197,8 +5047,7 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { "version": "3.1.3", @@ -4222,8 +5071,7 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-levenshtein": { "version": "2.0.6", @@ -4300,6 +5148,14 @@ "flat-cache": "^2.0.1" } }, + "filelist": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz", + "integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==", + "requires": { + "minimatch": "^3.0.4" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -4375,14 +5231,12 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -4407,8 +5261,7 @@ "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", - "dev": true + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, "fs-extra": { "version": "9.1.0", @@ -4422,11 +5275,18 @@ "universalify": "^2.0.0" } }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "requires": { + "minipass": "^3.0.0" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "2.1.3", @@ -4447,6 +5307,54 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4491,7 +5399,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", - "dev": true, "requires": { "pump": "^3.0.0" } @@ -4506,7 +5413,6 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -4755,7 +5661,6 @@ "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -4810,7 +5715,6 @@ "version": "9.6.0", "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", - "dev": true, "requires": { "@sindresorhus/is": "^0.14.0", "@szmarczak/http-timer": "^1.1.2", @@ -4860,14 +5764,12 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "dev": true, "requires": { "ajv": "^6.12.3", "har-schema": "^2.0.0" @@ -4900,6 +5802,11 @@ "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", "dev": true }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" + }, "has-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", @@ -5024,28 +5931,121 @@ } } }, + "http-assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.4.1.tgz", + "integrity": "sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==", + "requires": { + "deep-equal": "~1.0.1", + "http-errors": "~1.7.2" + }, + "dependencies": { + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + } + } + }, "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", - "dev": true + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" + }, + "http-errors": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz", + "integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "dependencies": { + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + } + } }, "http-link-header": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.0.3.tgz", "integrity": "sha512-nARK1wSKoBBrtcoESlHBx36c1Ln/gnbNQi1eB6MeTUefJIT3NvUOsV15bClga0k38f0q/kN5xxrGSDS3EFnm9w==" }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, + "http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "requires": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "dependencies": { + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true + } + } + }, "human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -5085,7 +6085,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -5166,7 +6165,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -5345,6 +6343,11 @@ "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true }, + "is-generator-function": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.8.tgz", + "integrity": "sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ==" + }, "is-glob": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", @@ -5447,8 +6450,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-windows": { "version": "1.0.2", @@ -5492,8 +6494,7 @@ "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, "istanbul-lib-coverage": { "version": "3.0.0", @@ -5576,6 +6577,60 @@ "istanbul-lib-report": "^3.0.0" } }, + "jake": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz", + "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==", + "requires": { + "async": "0.9.x", + "chalk": "^2.4.2", + "filelist": "^1.0.1", + "minimatch": "^3.0.4" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=" + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "jest": { "version": "26.6.1", "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.1.tgz", @@ -6230,8 +7285,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jsdom": { "version": "16.4.0", @@ -6289,8 +7343,7 @@ "json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", - "dev": true + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" }, "json-parse-better-errors": { "version": "1.0.2", @@ -6307,14 +7360,12 @@ "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -6325,8 +7376,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json5": { "version": "1.0.1", @@ -6347,6 +7397,25 @@ "universalify": "^2.0.0" } }, + "jsonld": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/jsonld/-/jsonld-1.8.1.tgz", + "integrity": "sha512-f0rusl5v8aPKS3jApT5fhYsdTC/JpyK1PoJ+ZtYYtZXoyb1J0Z///mJqLwrfL/g4NueFSqPymDYIi1CcSk7b8Q==", + "requires": { + "canonicalize": "^1.0.1", + "rdf-canonize": "^1.0.2", + "request": "^2.88.0", + "semver": "^5.6.0", + "xmldom": "0.1.19" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, "jsonld-context-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.1.1.tgz", @@ -6399,7 +7468,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -6417,11 +7485,18 @@ "object.assign": "^4.1.1" } }, + "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" + } + }, "keyv": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", - "dev": true, "requires": { "json-buffer": "3.0.0" } @@ -6438,6 +7513,75 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, + "koa": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.1.tgz", + "integrity": "sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==", + "requires": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.8.0", + "debug": "~3.1.0", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^1.2.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "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==" + } + } + }, + "koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" + }, + "koa-convert": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz", + "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=", + "requires": { + "co": "^4.6.0", + "koa-compose": "^3.0.0" + }, + "dependencies": { + "koa-compose": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", + "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", + "requires": { + "any-promise": "^1.1.0" + } + } + } + }, "kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -6518,6 +7662,12 @@ "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", "dev": true }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -6607,8 +7757,7 @@ "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", - "dev": true + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==" }, "lru-cache": { "version": "6.0.0", @@ -6699,8 +7848,7 @@ "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", - "dev": true + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, "meow": { "version": "7.1.1", @@ -6825,8 +7973,7 @@ "mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" }, "min-indent": { "version": "1.0.1", @@ -6838,7 +7985,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -6859,6 +8005,23 @@ "kind-of": "^6.0.3" } }, + "minipass": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz", + "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==", + "requires": { + "yallist": "^4.0.0" + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + } + }, "mixin-deep": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", @@ -6909,6 +8072,11 @@ "readable-stream": "^3.6.0" } }, + "nanoid": { + "version": "3.1.22", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz", + "integrity": "sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==" + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -6937,8 +8105,7 @@ "negotiator": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", - "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", - "dev": true + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, "neo-async": { "version": "2.6.2", @@ -6956,11 +8123,21 @@ "resolved": "https://registry.npmjs.org/nmspc/-/nmspc-0.2.4.tgz", "integrity": "sha512-8/QG1B93zCXqkCQfOnQ1Ihh54+jWgVddqS3i3/OPUyVc4ChNtcU5H7I65jQ9z1WqM3ywV/bacmy+c14IvzccGg==" }, + "node-addon-api": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz", + "integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw==" + }, "node-fetch": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" }, + "node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7017,6 +8194,22 @@ } } }, + "nodeify-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/nodeify-fetch/-/nodeify-fetch-2.2.1.tgz", + "integrity": "sha512-NAvl/0QTipKEgf9Jo179TXJ02hQ3bM0BMcn6S5aBeL51MUX46GuQW430tHWERoMpC7YLk1oBXToMyX9yDxowqQ==", + "requires": { + "concat-stream": "^1.6.0", + "cross-fetch": "^3.0.4", + "readable-error": "^1.0.0", + "readable-stream": "^3.5.0" + } + }, + "nodemailer": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.5.0.tgz", + "integrity": "sha512-Tm4RPrrIZbnqDKAvX+/4M+zovEReiKlEXWDzG4iwtpL9X34MJY+D5LnQPH/+eghe8DLlAVshHAJZAZWBGhkguw==" + }, "nodemon": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz", @@ -7121,8 +8314,7 @@ "normalize-url": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", - "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", - "dev": true + "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==" }, "npm-run-path": { "version": "2.0.2", @@ -7141,6 +8333,31 @@ } } }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "nth-check": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.0.tgz", + "integrity": "sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==", + "dev": true, + "requires": { + "boolbase": "^1.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, "nwsapi": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", @@ -7150,8 +8367,7 @@ "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "dev": true + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, "object-assign": { "version": "4.1.1", @@ -7189,6 +8405,11 @@ } } }, + "object-hash": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.1.1.tgz", + "integrity": "sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ==" + }, "object-inspect": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", @@ -7266,6 +8487,68 @@ "has": "^1.0.3" } }, + "oidc-provider": { + "version": "6.31.1", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-6.31.1.tgz", + "integrity": "sha512-YYfcJKGrobdaW2v7bx5crd4yfxFTKAoOTHdQs/gsu6fwzc/yD/qjforQX2VgbTX+ySK8nm7EaAwJuJJSFRo1MA==", + "requires": { + "@koa/cors": "^3.1.0", + "@types/koa": "^2.11.4", + "debug": "^4.1.1", + "ejs": "^3.1.5", + "got": "^9.6.0", + "jose": "^2.0.4", + "jsesc": "^3.0.1", + "koa": "^2.13.0", + "koa-compose": "^4.1.0", + "lru-cache": "^6.0.0", + "nanoid": "^3.1.10", + "object-hash": "^2.0.3", + "oidc-token-hash": "^5.0.1", + "raw-body": "^2.4.1" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "jose": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.4.tgz", + "integrity": "sha512-EArN9f6aq1LT/fIGGsfghOnNXn4noD+3dG5lL/ljY3LcRjw1u9w+4ahu/4ahsN6N0kRLyyW6zqdoYk7LNx3+YQ==", + "requires": { + "@panva/asn1.js": "^1.0.0" + } + }, + "jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "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==" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7317,12 +8600,158 @@ } } }, + "only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=" + }, "opencollective-postinstall": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", "dev": true }, + "openid-client": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.5.1.tgz", + "integrity": "sha512-IRjbyA8iIr0JJyhFJNo2oHgH+uS7WnlysvFtfR38YeNsebPyGlhX5d1Rv1Zk9G15cmPHktRO9AnQXYmLhAm+xg==", + "dev": true, + "requires": { + "aggregate-error": "^3.1.0", + "got": "^11.8.0", + "jose": "^2.0.4", + "lru-cache": "^6.0.0", + "make-error": "^1.3.6", + "object-hash": "^2.0.1", + "oidc-token-hash": "^5.0.1" + }, + "dependencies": { + "@sindresorhus/is": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.0.tgz", + "integrity": "sha512-FyD2meJpDPjyNQejSjvnhpgI/azsQkA4lGbuu5BQZfjvJ9cbRZXzeWL2HceCekW4lixO9JPesIIQkSoLjeJHNQ==", + "dev": true + }, + "@szmarczak/http-timer": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz", + "integrity": "sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==", + "dev": true, + "requires": { + "defer-to-connect": "^2.0.0" + } + }, + "cacheable-request": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.1.tgz", + "integrity": "sha512-lt0mJ6YAnsrBErpTMWeu5kl/tg9xMAWjavYTN6VQXM1A/teBITuNcccXsCxF0tDQQJf9DfAaX5O4e0zp0KlfZw==", + "dev": true, + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^2.0.0" + } + }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "requires": { + "mimic-response": "^3.1.0" + } + }, + "defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true + }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "got": { + "version": "11.8.2", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz", + "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==", + "dev": true, + "requires": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.1", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + } + }, + "jose": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.4.tgz", + "integrity": "sha512-EArN9f6aq1LT/fIGGsfghOnNXn4noD+3dG5lL/ljY3LcRjw1u9w+4ahu/4ahsN6N0kRLyyW6zqdoYk7LNx3+YQ==", + "dev": true, + "requires": { + "@panva/asn1.js": "^1.0.0" + } + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "keyv": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz", + "integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true + }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true + }, + "p-cancelable": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.0.tgz", + "integrity": "sha512-HAZyB3ZodPo+BDpb4/Iu7Jv4P6cSazBz9ZM0ChhEXp70scx834aWCEjQRwgt41UzzejUAPdbqqONfRWTPYrPAQ==", + "dev": true + }, + "responselike": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", + "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", + "dev": true, + "requires": { + "lowercase-keys": "^2.0.0" + } + } + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -7340,8 +8769,7 @@ "p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", - "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", - "dev": true + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==" }, "p-each-series": { "version": "2.1.0", @@ -7434,11 +8862,27 @@ "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", "dev": true }, + "parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "requires": { + "parse5": "^6.0.1" + }, + "dependencies": { + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + } + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, "pascalcase": { "version": "0.1.1", @@ -7455,8 +8899,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { "version": "3.1.1", @@ -7479,8 +8922,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", - "dev": true + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "picomatch": { "version": "2.2.2", @@ -7593,8 +9035,7 @@ "prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", - "dev": true + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=" }, "pretty-format": { "version": "26.6.1", @@ -7656,8 +9097,7 @@ "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", - "dev": true + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, "pstree.remy": { "version": "1.1.8", @@ -7691,8 +9131,7 @@ "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", - "dev": true + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" }, "queue-microtask": { "version": "1.2.2", @@ -7717,6 +9156,36 @@ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "dev": true }, + "raw-body": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", + "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.3", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "dependencies": { + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + } + } + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -7729,6 +9198,22 @@ "strip-json-comments": "~2.0.1" } }, + "rdf-canonize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rdf-canonize/-/rdf-canonize-1.2.0.tgz", + "integrity": "sha512-MQdcRDz4+82nUrEb3hNQangBDpmep15uMmnWclGi/1KS0bNVc8oHpoNI0PFLHZsvwgwRzH31bO1JAScqUAstvw==", + "requires": { + "node-forge": "^0.10.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, "rdf-data-factory": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-1.0.4.tgz", @@ -8168,6 +9653,43 @@ "type-fest": "^0.8.1" } }, + "readable-error": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/readable-error/-/readable-error-1.0.0.tgz", + "integrity": "sha512-CLnInu5bUphmFiZ3pD/BC6+Cg4/BzK6ZMvWfd0b2QMzYo159Z/f/nVFQ9L5IeMrqUxy0EFsp3XJ+BRfLfY13IQ==", + "requires": { + "readable-stream": "^2.3.3" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -8183,6 +9705,14 @@ "resolved": "https://registry.npmjs.org/readable-stream-node-to-web/-/readable-stream-node-to-web-1.0.1.tgz", "integrity": "sha1-i3YU+qFGXr+g2pucpjA/onBzt88=" }, + "readable-to-readable": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/readable-to-readable/-/readable-to-readable-0.1.3.tgz", + "integrity": "sha512-G+0kz01xJM/uTuItKcqC73cifW8S6CZ7tp77NLN87lE5mrSU+GC8geoSAlfmp0NocmXckQ7W8s8ns73HYsIA3w==", + "requires": { + "readable-stream": "^3.6.0" + } + }, "readdirp": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", @@ -8248,6 +9778,12 @@ "bluebird": "^3.7.2" } }, + "reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", + "dev": true + }, "regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -8336,7 +9872,6 @@ "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "dev": true, "requires": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -8363,8 +9898,7 @@ "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" } } }, @@ -8420,6 +9954,12 @@ "path-parse": "^1.0.5" } }, + "resolve-alpn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.0.0.tgz", + "integrity": "sha512-rTuiIEqFmGxne4IovivKSDzld2lWW9QCjqv80SYjPgf+gS35eaCAjaP54CCwGAwBtnCsvNLYtqxe1Nw+i6JEmA==", + "dev": true + }, "resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -8453,7 +9993,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", - "dev": true, "requires": { "lowercase-keys": "^1.0.0" } @@ -8474,7 +10013,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "requires": { "glob": "^7.1.3" } @@ -8508,8 +10046,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sane": { "version": "4.1.0", @@ -8712,7 +10249,12 @@ "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + }, + "set-cookie-parser": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.4.8.tgz", + "integrity": "sha512-edRH8mBKEWNVIVMKejNnuJxleqYE/ZSdcT8/Nem9/mmosx12pctd80s2Oy00KNZzrogMZS5mauK2/ymL1bvlvg==", "dev": true }, "set-value": { @@ -8738,6 +10280,11 @@ } } }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8816,8 +10363,7 @@ "signal-exit": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", - "dev": true + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" }, "simple-swizzle": { "version": "0.2.2", @@ -9144,7 +10690,6 @@ "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "dev": true, "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -9200,6 +10745,11 @@ } } }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, "stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", @@ -9512,6 +11062,26 @@ } } }, + "tar": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz", + "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==", + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^3.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + } + } + }, "term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -9638,8 +11208,7 @@ "to-readable-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", - "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", - "dev": true + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==" }, "to-regex": { "version": "3.0.2", @@ -9673,6 +11242,11 @@ "is-number": "^7.0.0" } }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, "touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -9686,7 +11260,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, "requires": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -9772,6 +11345,11 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==" + }, "tsutils": { "version": "3.17.1", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz", @@ -9781,11 +11359,19 @@ "tslib": "^1.8.1" } }, + "tsyringe": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.4.0.tgz", + "integrity": "sha512-SlMApe1lhIq546CDp7bF+IdF4RB6d+9C5T7B0AS0P/Bm+Qpizj/gEmZzvw9J/KlXPEt4qHTbi1TRvX3rCPSdTg==", + "dev": true, + "requires": { + "tslib": "^1.9.3" + } + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -9793,8 +11379,7 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, "type-check": { "version": "0.4.0", @@ -9821,12 +11406,16 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, "requires": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, "typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -9936,6 +11525,11 @@ "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", "dev": true }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, "unset-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", @@ -10013,7 +11607,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz", "integrity": "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==", - "dev": true, "requires": { "punycode": "^2.1.0" } @@ -10024,11 +11617,15 @@ "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", "dev": true }, + "url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" + }, "url-parse-lax": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", - "dev": true, "requires": { "prepend-http": "^2.0.0" } @@ -10099,7 +11696,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -10214,6 +11810,43 @@ "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=", "dev": true }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "requires": { + "string-width": "^1.0.2 || 2" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, "widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -10347,6 +11980,11 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "xmldom": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.19.tgz", + "integrity": "sha1-Yx/Ad3bv2EEYvyUXGzftTQdaCrw=" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -10386,6 +12024,11 @@ "version": "20.2.3", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.3.tgz", "integrity": "sha512-emOFRT9WVHw03QSvN5qor9QQT9+sw5vwxfYweivSMHTcAXPefwVae2FjO7JJjj8hCE4CzPOPeFM83VwT29HCww==" + }, + "ylru": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.2.1.tgz", + "integrity": "sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==" } } } diff --git a/package.json b/package.json index ed505f3a6..41fb19804 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "community-solid-server": "./bin/server.js" + "community-solid-server": "bin/server.js" }, "repository": "git@github.com:solid/community-server.git", "bugs": { @@ -75,34 +75,45 @@ ], "dependencies": { "@rdfjs/data-model": "^1.2.0", + "@rdfjs/fetch": "^2.1.0", "@solid/identity-token-verifier": "^0.7.0", "@types/arrayify-stream": "^1.0.0", "@types/async-lock": "^1.1.2", + "@types/bcrypt": "^3.0.0", "@types/cors": "^2.8.9", "@types/end-of-stream": "^1.4.0", "@types/mime-types": "^2.1.0", "@types/n3": "^1.4.4", "@types/node": "^14.10.2", + "@types/nodemailer": "^6.4.0", "@types/pump": "^1.1.0", "@types/punycode": "^2.1.0", "@types/rdf-js": "^4.0.0", + "@types/rdfjs__fetch": "^2.0.2", + "@types/rdfjs__fetch-lite": "^2.0.2", "@types/redis": "^2.8.28", "@types/redlock": "^4.0.1", "@types/sparqljs": "^3.1.0", "@types/streamify-array": "^1.0.0", + "@types/url-join": "^4.0.0", "@types/uuid": "^8.3.0", "@types/ws": "^7.4.0", "@types/yargs": "^16.0.0", "arrayify-stream": "^1.0.0", "async-lock": "^1.2.4", + "bcrypt": "^5.0.0", "componentsjs": "^4.1.0", "cors": "^2.8.5", + "ejs": "^3.1.5", "end-of-stream": "^1.4.4", "escape-string-regexp": "^4.0.0", "fetch-sparql-endpoint": "^1.8.0", "handlebars": "^4.7.6", + "jose": "^3.11.1", "mime-types": "^2.1.27", "n3": "^1.8.0", + "nodemailer": "^6.4.17", + "oidc-provider": "^6.29.8", "pump": "^3.0.0", "punycode": "^2.1.1", "rdf-parse": "^1.7.0", @@ -113,6 +124,7 @@ "sparqlalgebrajs": "^2.5.1", "sparqljs": "^3.1.2", "streamify-array": "^1.0.1", + "url-join": "^4.0.1", "uuid": "^8.3.0", "winston": "^3.3.3", "winston-transport": "^4.4.0", @@ -120,13 +132,18 @@ "yargs": "^16.0.0" }, "devDependencies": { + "@inrupt/solid-client-authn-node": "^1.7.2", "@microsoft/tsdoc-config": "^0.15.0", "@tsconfig/node12": "^1.0.7", + "@types/cheerio": "^0.22.27", + "@types/ejs": "^3.0.5", "@types/jest": "^26.0.13", "@types/rimraf": "^3.0.0", + "@types/set-cookie-parser": "^2.4.0", "@types/supertest": "^2.0.10", "@typescript-eslint/eslint-plugin": "^4.1.1", "@typescript-eslint/parser": "^4.1.1", + "cheerio": "^1.0.0-rc.5", "componentsjs-generator": "^2.1.0", "cross-fetch": "^3.0.6", "eslint": "^7.9.0", @@ -143,6 +160,7 @@ "node-mocks-http": "^1.8.1", "nodemon": "^2.0.4", "rimraf": "^3.0.2", + "set-cookie-parser": "^2.4.8", "stream-to-string": "^1.1.0", "supertest": "^6.0.0", "ts-jest": "^26.3.0", diff --git a/src/identity/IdentityProviderFactory.ts b/src/identity/IdentityProviderFactory.ts new file mode 100644 index 000000000..8b1f795c2 --- /dev/null +++ b/src/identity/IdentityProviderFactory.ts @@ -0,0 +1,89 @@ +import type { AnyObject, + CanBePromise, + interactionPolicy as InteractionPolicy, + KoaContextWithOIDC, + Configuration, + Account, + ErrorOut } from 'oidc-provider'; +import { Provider } from 'oidc-provider'; +import type { ResponseWriter } from '../ldp/http/ResponseWriter'; + +import type { ConfigurationFactory } from './configuration/ConfigurationFactory'; + +/** + * Creates a Provider from the oidc-provider library. + * This can be used for handling many of the oidc interactions during the IDP process. + * Full documentation can be found at https://github.com/panva/node-oidc-provider/blob/v6.x/docs/README.md + */ +export class IdentityProviderFactory { + private readonly issuer: string; + private readonly configurationFactory: ConfigurationFactory; + private readonly errorResponseWriter: ResponseWriter; + + public constructor(issuer: string, configurationFactory: ConfigurationFactory, + errorResponseWriter: ResponseWriter) { + this.issuer = issuer; + this.configurationFactory = configurationFactory; + this.errorResponseWriter = errorResponseWriter; + } + + public async createProvider(interactionPolicyOptions: { + policy?: InteractionPolicy.Prompt[]; + url?: (ctx: KoaContextWithOIDC) => CanBePromise; + }): Promise { + const configuration = await this.configurationFactory.createConfiguration(); + const augmentedConfig: Configuration = { + ...configuration, + interactions: { + policy: interactionPolicyOptions.policy, + url: interactionPolicyOptions.url, + }, + async findAccount(ctx: KoaContextWithOIDC, sub: string): Promise { + return { + accountId: sub, + async claims(): Promise<{ sub: string; [key: string]: any }> { + return { + sub, + webid: sub, + }; + }, + }; + }, + claims: { + ...configuration.claims, + webid: [ 'webid', 'client_webid' ], + }, + conformIdTokenClaims: false, + features: { + ...configuration.features, + registration: { enabled: true }, + dPoP: { enabled: true, ack: 'draft-01' }, + claimsParameter: { enabled: true }, + }, + subjectTypes: [ 'public', 'pairwise' ], + formats: { + // eslint-disable-next-line @typescript-eslint/naming-convention + AccessToken: 'jwt', + }, + audiences(): string { + return 'solid'; + }, + extraAccessTokenClaims(ctx, token): CanBePromise { + if ((token as any).accountId) { + return { + webid: (token as any).accountId, + // eslint-disable-next-line @typescript-eslint/naming-convention + client_webid: 'http://localhost:3001/', + aud: 'solid', + }; + } + return {}; + }, + renderError: + async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise => { + await this.errorResponseWriter.handleSafe({ response: ctx.res, result: error }); + }, + }; + return new Provider(this.issuer, augmentedConfig); + } +} diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts new file mode 100644 index 000000000..9fd421a8a --- /dev/null +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -0,0 +1,80 @@ +import type { Provider } from 'oidc-provider'; +import type { ResponseWriter } from '../ldp/http/ResponseWriter'; +import { getLoggerFor } from '../logging/LogUtil'; +import type { HttpHandlerInput } from '../server/HttpHandler'; +import { HttpHandler } from '../server/HttpHandler'; +import { isNativeError } from '../util/errors/ErrorUtil'; +import type { IdentityProviderFactory } from './IdentityProviderFactory'; +import type { InteractionHttpHandler } from './interaction/InteractionHttpHandler'; +import type { InteractionPolicy } from './interaction/InteractionPolicy'; + +/** + * Handles all requests relevant for the entire IDP interaction, + * by sending them to either the stored {@link InteractionHttpHandler}, + * or the generated {@link Provider} if the first does not support the request. + * + * The InteractionHttpHandler would handle all requests where we need custom behaviour, + * such as everything related to generating and validating an account. + * The Provider handles all the default request such as the initial handshake. + * + * This handler handles all requests since it assumes all those requests are relevant for the IDP interaction. + * A {@link RouterHandler} should be used to filter out other requests. + */ +export class IdentityProviderHttpHandler extends HttpHandler { + protected readonly logger = getLoggerFor(this); + + private readonly providerFactory: IdentityProviderFactory; + private readonly interactionPolicy: InteractionPolicy; + private readonly interactionHttpHandler: InteractionHttpHandler; + private readonly errorResponseWriter: ResponseWriter; + private provider?: Provider; + + public constructor( + providerFactory: IdentityProviderFactory, + interactionPolicy: InteractionPolicy, + interactionHttpHandler: InteractionHttpHandler, + errorResponseWriter: ResponseWriter, + ) { + super(); + this.providerFactory = providerFactory; + this.interactionPolicy = interactionPolicy; + this.interactionHttpHandler = interactionHttpHandler; + this.errorResponseWriter = errorResponseWriter; + } + + /** + * Create the provider or retrieve it if it has already been created. + */ + private async getProvider(): Promise { + if (!this.provider) { + try { + this.provider = await this.providerFactory.createProvider(this.interactionPolicy); + } catch (err: unknown) { + this.logger.error(`Failed to create Provider: ${isNativeError(err) ? err.message : 'Unknown error'}`); + throw err; + } + } + return this.provider; + } + + public async handle(input: HttpHandlerInput): Promise { + const provider = await this.getProvider(); + + try { + await this.interactionHttpHandler.canHandle({ ...input, provider }); + } catch { + this.logger.debug(`Sending request to oidc-provider: ${input.request.url}`); + return provider.callback(input.request, input.response); + } + + try { + await this.interactionHttpHandler.handle({ ...input, provider }); + } catch (error: unknown) { + // ResponseWriter can only handle native errors + if (!isNativeError(error)) { + throw error; + } + await this.errorResponseWriter.handleSafe({ response: input.response, result: error }); + } + } +} diff --git a/src/identity/configuration/ConfigurationFactory.ts b/src/identity/configuration/ConfigurationFactory.ts new file mode 100644 index 000000000..daf7e277d --- /dev/null +++ b/src/identity/configuration/ConfigurationFactory.ts @@ -0,0 +1,9 @@ +import type { Configuration } from 'oidc-provider'; + +/** + * Creates an IDP Configuration to be used in + * panva/node-oidc-provider + */ +export interface ConfigurationFactory { + createConfiguration: () => Promise; +} diff --git a/src/identity/configuration/KeyConfigurationFactory.ts b/src/identity/configuration/KeyConfigurationFactory.ts new file mode 100644 index 000000000..8da6475ca --- /dev/null +++ b/src/identity/configuration/KeyConfigurationFactory.ts @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/naming-convention, import/no-unresolved */ +// import/no-unresolved can't handle jose imports +import { randomBytes } from 'crypto'; +import type { JWK } from 'jose/jwk/from_key_like'; +import { fromKeyLike } from 'jose/jwk/from_key_like'; +import { generateKeyPair } from 'jose/util/generate_key_pair'; +import type { Adapter, Configuration } from 'oidc-provider'; +import urljoin from 'url-join'; +import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; +import { ensureTrailingSlash, trimTrailingSlashes } from '../../util/PathUtil'; +import type { AdapterFactory } from '../storage/AdapterFactory'; +import type { ConfigurationFactory } from './ConfigurationFactory'; + +/** + * An IDP Configuration Factory that generates and saves keys + * to the provided key value store. + */ +export class KeyConfigurationFactory implements ConfigurationFactory { + private readonly adapterFactory: AdapterFactory; + private readonly baseUrl: string; + private readonly idpPath: string; + private readonly storage: KeyValueStorage; + + public constructor( + adapterFactory: AdapterFactory, + baseUrl: string, + idpPath: string, + storage: KeyValueStorage, + ) { + this.adapterFactory = adapterFactory; + this.baseUrl = ensureTrailingSlash(baseUrl); + this.idpPath = trimTrailingSlashes(idpPath); + this.storage = storage; + } + + private get jwksKey(): string { + return `${this.idpPath}/jwks`; + } + + private async generateJwks(): Promise<{ keys: JWK[] }> { + // Check to see if the keys are already saved + const jwks = await this.storage.get(this.jwksKey) as { keys: JWK[] } | undefined; + if (jwks) { + return jwks; + } + // If they are not, generate and save them + const { privateKey } = await generateKeyPair('RS256'); + const jwk = await fromKeyLike(privateKey); + // In node v15.12.0 the JWKS does not get accepted because the JWK is not a plain object, + // which is why we convert it into a plain object here. + // Potentially this can be changed at a later point in time to `{ keys: [ jwk ]}`. + const newJwks = { keys: [{ ...jwk }]}; + await this.storage.set(this.jwksKey, newJwks); + return newJwks; + } + + private get cookieSecretKey(): string { + return `${this.idpPath}/cookie-secret`; + } + + private async generateCookieKeys(): Promise { + // Check to see if the keys are already saved + const cookieSecret = await this.storage.get(this.cookieSecretKey); + if (Array.isArray(cookieSecret)) { + return cookieSecret; + } + // If they are not, generate and save them + const newCookieSecret = [ randomBytes(64).toString('hex') ]; + await this.storage.set(this.cookieSecretKey, newCookieSecret); + return newCookieSecret; + } + + /** + * Creates the route string as required by the `oidc-provider` library. + * In case base URL is `http://test.com/foo/`, `idpPath` is `/idp` and `relative` is `device/auth`, + * this would result in `/foo/idp/device/auth`. + */ + private createRoute(relative: string): string { + return new URL(urljoin(this.baseUrl, this.idpPath, relative)).pathname; + } + + public async createConfiguration(): Promise { + // Cast necessary due to typing conflict between jose 2.x and 3.x + const jwks = await this.generateJwks() as any; + const cookieKeys = await this.generateCookieKeys(); + + // The adapter function MUST be a named function. + // See https://github.com/panva/node-oidc-provider/issues/799 + const factory = this.adapterFactory; + return { + adapter: function loadAdapter(name: string): Adapter { + return factory.createStorageAdapter(name); + }, + cookies: { + long: { signed: true, maxAge: 1 * 24 * 60 * 60 * 1000 }, + short: { signed: true }, + keys: cookieKeys, + }, + conformIdTokenClaims: false, + features: { + devInteractions: { enabled: false }, + deviceFlow: { enabled: true }, + introspection: { enabled: true }, + revocation: { enabled: true }, + registration: { enabled: true }, + claimsParameter: { enabled: true }, + }, + jwks, + ttl: { + AccessToken: 1 * 60 * 60, + AuthorizationCode: 10 * 60, + IdToken: 1 * 60 * 60, + DeviceCode: 10 * 60, + RefreshToken: 1 * 24 * 60 * 60, + }, + subjectTypes: [ 'public', 'pairwise' ], + routes: { + authorization: this.createRoute('auth'), + check_session: this.createRoute('session/check'), + code_verification: this.createRoute('device'), + device_authorization: this.createRoute('device/auth'), + end_session: this.createRoute('session/end'), + introspection: this.createRoute('token/introspection'), + jwks: this.createRoute('jwks'), + pushed_authorization_request: this.createRoute('request'), + registration: this.createRoute('reg'), + revocation: this.createRoute('token/revocation'), + token: this.createRoute('token'), + userinfo: this.createRoute('me'), + }, + }; + } +} diff --git a/src/identity/interaction/InteractionHttpHandler.ts b/src/identity/interaction/InteractionHttpHandler.ts new file mode 100644 index 000000000..a2751a238 --- /dev/null +++ b/src/identity/interaction/InteractionHttpHandler.ts @@ -0,0 +1,9 @@ +import type { Provider } from 'oidc-provider'; +import type { HttpHandlerInput } from '../../server/HttpHandler'; +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; + +export type InteractionHttpHandlerInput = HttpHandlerInput & { + provider: Provider; +}; + +export abstract class InteractionHttpHandler extends AsyncHandler {} diff --git a/src/identity/interaction/InteractionPolicy.ts b/src/identity/interaction/InteractionPolicy.ts new file mode 100644 index 000000000..38e92f90e --- /dev/null +++ b/src/identity/interaction/InteractionPolicy.ts @@ -0,0 +1,9 @@ +import type { CanBePromise, interactionPolicy, KoaContextWithOIDC } from 'oidc-provider'; + +/** + * Config options to communicate exactly how to handle requests. + */ +export interface InteractionPolicy { + policy: interactionPolicy.Prompt[]; + url: (ctx: KoaContextWithOIDC) => CanBePromise; +} diff --git a/src/identity/interaction/SessionHttpHandler.ts b/src/identity/interaction/SessionHttpHandler.ts new file mode 100644 index 000000000..0097932d1 --- /dev/null +++ b/src/identity/interaction/SessionHttpHandler.ts @@ -0,0 +1,24 @@ +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import type { InteractionHttpHandlerInput } from './InteractionHttpHandler'; +import { InteractionHttpHandler } from './InteractionHttpHandler'; +import type { InteractionCompleter } from './util/InteractionCompleter'; + +/** + * Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId. + */ +export class SessionHttpHandler extends InteractionHttpHandler { + private readonly interactionCompleter: InteractionCompleter; + + public constructor(interactionCompleter: InteractionCompleter) { + super(); + this.interactionCompleter = interactionCompleter; + } + + public async handle(input: InteractionHttpHandlerInput): Promise { + const details = await input.provider.interactionDetails(input.request, input.response); + if (!details.session || !details.session.accountId) { + throw new NotImplementedHttpError('Only confirm actions with a session and accountId are supported'); + } + await this.interactionCompleter.handleSafe({ ...input, webId: details.session.accountId }); + } +} diff --git a/src/identity/interaction/email-password/AccountInteractionPolicy.ts b/src/identity/interaction/email-password/AccountInteractionPolicy.ts new file mode 100644 index 000000000..c0abc9e6e --- /dev/null +++ b/src/identity/interaction/email-password/AccountInteractionPolicy.ts @@ -0,0 +1,37 @@ +import type { KoaContextWithOIDC } from 'oidc-provider'; +import { interactionPolicy } from 'oidc-provider'; +import urljoin from 'url-join'; +import type { + InteractionPolicy, +} from '../InteractionPolicy'; + +/** + * Interaction policy that maps URLs to `${idpPath}/interaction/${context uid}`. + * Uses the `select_account` interaction policy. + */ +export class AccountInteractionPolicy implements InteractionPolicy { + public readonly policy: interactionPolicy.Prompt[]; + public readonly url: (ctx: KoaContextWithOIDC) => string; + + public constructor(idpPath: string) { + if (!idpPath.startsWith('/')) { + throw new Error('idpPath needs to start with a /'); + } + const interactions = interactionPolicy.base(); + const selectAccount = new interactionPolicy.Prompt({ + name: 'select_account', + requestable: true, + }); + interactions.add(selectAccount, 0); + this.policy = interactions; + this.url = this.createUrlFunction(idpPath); + } + + /** + * Helper function to create the function that will be put in `url`. + * Needs to be done like this since the `this` reference is lost when passing this value along. + */ + private createUrlFunction(idpPath: string): (ctx: KoaContextWithOIDC) => string { + return (ctx: KoaContextWithOIDC): string => urljoin(idpPath, 'interaction', ctx.oidc.uid); + } +} diff --git a/src/identity/interaction/email-password/EmailPasswordUtil.ts b/src/identity/interaction/email-password/EmailPasswordUtil.ts new file mode 100644 index 000000000..32eb6d0b1 --- /dev/null +++ b/src/identity/interaction/email-password/EmailPasswordUtil.ts @@ -0,0 +1,44 @@ +import assert from 'assert'; +import { isNativeError } from '../../../util/errors/ErrorUtil'; +import { HttpError } from '../../../util/errors/HttpError'; +import { IdpInteractionError } from '../util/IdpInteractionError'; + +/** + * Throws an IdpInteractionError with contents depending on the type of input error. + * Default status code is 500 and default error message is 'Unknown Error'. + * @param error - Error to create an IdPInteractionError from. + * @param prefilled - Prefilled data for IdpInteractionError. + */ +export function throwIdpInteractionError(error: unknown, prefilled?: any): never { + if (IdpInteractionError.isInstance(error)) { + if (prefilled) { + throw new IdpInteractionError(error.statusCode, error.message, { ...error.prefilled, ...prefilled }); + } else { + throw error; + } + } else if (HttpError.isInstance(error)) { + throw new IdpInteractionError(error.statusCode, error.message, prefilled); + } else if (isNativeError(error)) { + throw new IdpInteractionError(500, error.message, prefilled); + } else { + throw new IdpInteractionError(500, 'Unknown Error', prefilled); + } +} + +/** + * Asserts that `password` is a string that matches `confirmPassword`. + * Will throw an Error otherwise. + * @param password - Password to assert. + * @param confirmPassword - Confirmation of password to match. + */ +export function assertPassword(password: any, confirmPassword: any): asserts password is string { + assert(typeof password === 'string' && password.length > 0, 'Password required'); + assert( + typeof confirmPassword === 'string' && confirmPassword.length > 0, + 'Password confirmation required', + ); + assert( + password === confirmPassword, + 'Password and confirmation do not match', + ); +} diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts new file mode 100644 index 000000000..ea66130f2 --- /dev/null +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -0,0 +1,112 @@ +import assert from 'assert'; +import urljoin from 'url-join'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { HttpResponse } from '../../../../server/HttpResponse'; +import { ensureTrailingSlash } from '../../../../util/PathUtil'; +import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler'; +import { InteractionHttpHandler } from '../../InteractionHttpHandler'; +import type { EmailSender } from '../../util/EmailSender'; +import { getFormDataRequestBody } from '../../util/FormDataUtil'; +import type { IdpRenderHandler } from '../../util/IdpRenderHandler'; +import type { TemplateRenderer } from '../../util/TemplateRenderer'; +import { throwIdpInteractionError } from '../EmailPasswordUtil'; +import type { AccountStore } from '../storage/AccountStore'; + +export interface ForgotPasswordHandlerArgs { + messageRenderHandler: IdpRenderHandler; + accountStore: AccountStore; + baseUrl: string; + idpPath: string; + emailTemplateRenderer: TemplateRenderer<{ resetLink: string }>; + emailSender: EmailSender; +} + +/** + * Handles the submission of the ForgotPassword form + */ +export class ForgotPasswordHandler extends InteractionHttpHandler { + protected readonly logger = getLoggerFor(this); + + private readonly messageRenderHandler: IdpRenderHandler; + private readonly accountStore: AccountStore; + private readonly baseUrl: string; + private readonly idpPath: string; + private readonly emailTemplateRenderer: TemplateRenderer<{ resetLink: string }>; + private readonly emailSender: EmailSender; + + public constructor(args: ForgotPasswordHandlerArgs) { + super(); + this.messageRenderHandler = args.messageRenderHandler; + this.accountStore = args.accountStore; + this.baseUrl = ensureTrailingSlash(args.baseUrl); + this.idpPath = args.idpPath; + this.emailTemplateRenderer = args.emailTemplateRenderer; + this.emailSender = args.emailSender; + } + + public async handle(input: InteractionHttpHandlerInput): Promise { + const interactionDetails = await input.provider.interactionDetails(input.request, input.response); + try { + // Validate incoming data + const { email } = await getFormDataRequestBody(input.request); + assert(typeof email === 'string' && email.length > 0, 'Email required'); + + await this.resetPassword(email); + await this.sendResponse(input.response, interactionDetails, email); + } catch (err: unknown) { + throwIdpInteractionError(err, {}); + } + } + + /** + * Generates a record to reset the password for the given email address and then mails it. + * In case there is no account, no error wil be thrown for privacy reasons. + * Instead nothing will happen instead. + */ + private async resetPassword(email: string): Promise { + let recordId: string; + try { + recordId = await this.accountStore.generateForgotPasswordRecord(email); + } catch { + // Don't emit an error for privacy reasons + this.logger.warn(`Password reset request for unknown email ${email}`); + return; + } + await this.sendResetMail(recordId, email); + } + + /** + * Generates the link necessary for resetting the password and mails it to the given email address. + */ + private async sendResetMail(recordId: string, email: string): Promise { + this.logger.info(`Sending password reset to ${email}`); + const resetLink = urljoin(this.baseUrl, this.idpPath, `resetpassword?rid=${recordId}`); + const renderedEmail = await this.emailTemplateRenderer.handleSafe({ resetLink }); + await this.emailSender.handleSafe({ + recipient: email, + subject: 'Reset your password', + text: `To reset your password, go to this link: ${resetLink}`, + html: renderedEmail, + }); + } + + /** + * Sends a response through the messageRenderHandler. + * @param response - HttpResponse to send to. + * @param details - Details of the interaction. + * @param email - Will be inserted in `prefilled` for the template. + */ + private async sendResponse(response: HttpResponse, details: { uid: string }, email: string): Promise { + // Send response + await this.messageRenderHandler.handleSafe({ + response, + props: { + details, + errorMessage: '', + prefilled: { + email, + }, + }, + }); + } +} diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts new file mode 100644 index 000000000..e0a4577ea --- /dev/null +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -0,0 +1,60 @@ +import assert from 'assert'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { HttpRequest } from '../../../../server/HttpRequest'; +import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler'; +import { InteractionHttpHandler } from '../../InteractionHttpHandler'; +import { getFormDataRequestBody } from '../../util/FormDataUtil'; +import type { InteractionCompleter } from '../../util/InteractionCompleter'; +import { throwIdpInteractionError } from '../EmailPasswordUtil'; +import type { AccountStore } from '../storage/AccountStore'; + +export interface LoginHandlerArgs { + accountStore: AccountStore; + interactionCompleter: InteractionCompleter; +} + +/** + * Handles the submission of the Login Form and logs the user in. + */ +export class LoginHandler extends InteractionHttpHandler { + protected readonly logger = getLoggerFor(this); + + private readonly accountStore: AccountStore; + private readonly interactionCompleter: InteractionCompleter; + + public constructor(args: LoginHandlerArgs) { + super(); + this.accountStore = args.accountStore; + this.interactionCompleter = args.interactionCompleter; + } + + public async handle(input: InteractionHttpHandlerInput): Promise { + const { email, password, remember } = await this.parseInput(input.request); + try { + // Try to log in, will error if email/password combination is invalid + const webId = await this.accountStore.authenticate(email, password); + await this.interactionCompleter.handleSafe({ ...input, webId, shouldRemember: Boolean(remember) }); + this.logger.debug(`Logging in user ${email}`); + } catch (err: unknown) { + throwIdpInteractionError(err, { email }); + } + } + + /** + * Parses and validates the input form data. + * Will throw an {@link IdpInteractionError} in case something is wrong. + * All relevant data that was correct up to that point will be prefilled. + */ + private async parseInput(request: HttpRequest): Promise<{ email: string; password: string; remember: boolean }> { + const prefilled: Record = {}; + try { + const { email, password, remember } = await getFormDataRequestBody(request); + assert(typeof email === 'string' && email.length > 0, 'Email required'); + prefilled.email = email; + assert(typeof password === 'string' && password.length > 0, 'Password required'); + return { email, password, remember: Boolean(remember) }; + } catch (err: unknown) { + throwIdpInteractionError(err, prefilled); + } + } +} diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts new file mode 100644 index 000000000..2f7b7b4e4 --- /dev/null +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -0,0 +1,84 @@ +import assert from 'assert'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { HttpRequest } from '../../../../server/HttpRequest'; +import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler'; +import { InteractionHttpHandler } from '../../InteractionHttpHandler'; +import { getFormDataRequestBody } from '../../util/FormDataUtil'; +import type { InteractionCompleter } from '../../util/InteractionCompleter'; +import type { OwnershipValidator } from '../../util/OwnershipValidator'; +import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; +import type { AccountStore } from '../storage/AccountStore'; + +const emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u; + +interface RegistrationHandlerArgs { + ownershipValidator: OwnershipValidator; + accountStore: AccountStore; + interactionCompleter: InteractionCompleter; +} + +// Results when parsing the input form data +type ParseResult = { + email: string; + password: string; + webId: string; + remember: boolean; +}; + +/** + * Handles the submission of the registration form. + * Creates the user and logs them in if successful. + */ +export class RegistrationHandler extends InteractionHttpHandler { + protected readonly logger = getLoggerFor(this); + + private readonly ownershipValidator: OwnershipValidator; + private readonly accountStore: AccountStore; + private readonly interactionCompleter: InteractionCompleter; + + public constructor(args: RegistrationHandlerArgs) { + super(); + this.ownershipValidator = args.ownershipValidator; + this.accountStore = args.accountStore; + this.interactionCompleter = args.interactionCompleter; + } + + public async handle(input: InteractionHttpHandlerInput): Promise { + const interactionDetails = await input.provider.interactionDetails(input.request, input.response); + const { email, webId, password, remember } = await this.parseInput(input.request); + try { + // Check if WebId contains required triples and register new account if successful + await this.ownershipValidator.handleSafe({ webId, interactionId: interactionDetails.uid }); + await this.accountStore.create(email, webId, password); + await this.interactionCompleter.handleSafe({ + ...input, + webId, + shouldRemember: Boolean(remember), + }); + this.logger.debug(`Registering agent ${email} with WebId ${webId}`); + } catch (err: unknown) { + throwIdpInteractionError(err, { email, webId }); + } + } + + /** + * Parses and validates the input form data. + * Will throw an {@link IdpInteractionError} in case something is wrong. + * All relevant data that was correct up to that point will be prefilled. + */ + private async parseInput(request: HttpRequest): Promise { + const prefilled: Record = {}; + try { + const { email, webId, password, confirmPassword, remember } = await getFormDataRequestBody(request); + assert(typeof email === 'string' && email.length > 0, 'Email required'); + assert(emailRegex.test(email), 'Invalid email'); + prefilled.email = email; + assert(typeof webId === 'string' && webId.length > 0, 'WebId required'); + prefilled.webId = webId; + assertPassword(password, confirmPassword); + return { email, password, webId, remember: Boolean(remember) }; + } catch (err: unknown) { + throwIdpInteractionError(err, prefilled); + } + } +} diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts new file mode 100644 index 000000000..c19aedee0 --- /dev/null +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -0,0 +1,77 @@ +import assert from 'assert'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { HttpHandlerInput } from '../../../../server/HttpHandler'; +import { HttpHandler } from '../../../../server/HttpHandler'; +import type { RenderHandler } from '../../../../server/util/RenderHandler'; +import { isNativeError } from '../../../../util/errors/ErrorUtil'; +import { getFormDataRequestBody } from '../../util/FormDataUtil'; +import { assertPassword } from '../EmailPasswordUtil'; +import type { AccountStore } from '../storage/AccountStore'; +import type { ResetPasswordRenderHandler } from './ResetPasswordRenderHandler'; + +export interface ResetPasswordHandlerArgs { + accountStore: AccountStore; + renderHandler: ResetPasswordRenderHandler; + messageRenderHandler: RenderHandler<{ message: string }>; +} + +/** + * Handles the submission of the ResetPassword form: + * this is the form that is linked in the reset password email. + */ +export class ResetPasswordHandler extends HttpHandler { + protected readonly logger = getLoggerFor(this); + + private readonly accountStore: AccountStore; + private readonly renderHandler: ResetPasswordRenderHandler; + private readonly messageRenderHandler: RenderHandler<{ message: string }>; + + public constructor(args: ResetPasswordHandlerArgs) { + super(); + this.accountStore = args.accountStore; + this.renderHandler = args.renderHandler; + this.messageRenderHandler = args.messageRenderHandler; + } + + public async handle(input: HttpHandlerInput): Promise { + let prefilledRecordId = ''; + try { + // Validate input data + const { password, confirmPassword, recordId } = await getFormDataRequestBody(input.request); + assert( + typeof recordId === 'string' && recordId.length > 0, + 'Invalid request. Open the link from your email again', + ); + prefilledRecordId = recordId; + assertPassword(password, confirmPassword); + + await this.resetPassword(recordId, password); + await this.messageRenderHandler.handleSafe({ + response: input.response, + props: { + message: 'Your password was successfully reset.', + }, + }); + } catch (err: unknown) { + const errorMessage: string = isNativeError(err) ? err.message : 'An unknown error occurred'; + await this.renderHandler.handleSafe({ + response: input.response, + props: { + errorMessage, + recordId: prefilledRecordId, + }, + }); + } + } + + /** + * Resets the password for the account associated with the given recordId. + */ + private async resetPassword(recordId: string, newPassword: string): Promise { + const email = await this.accountStore.getForgotPasswordRecord(recordId); + assert(email, 'This reset password link is no longer valid.'); + await this.accountStore.deleteForgotPasswordRecord(recordId); + await this.accountStore.changePassword(email, newPassword); + this.logger.debug(`Resetting password for user ${email}`); + } +} diff --git a/src/identity/interaction/email-password/handler/ResetPasswordRenderHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordRenderHandler.ts new file mode 100644 index 000000000..6957fa2c3 --- /dev/null +++ b/src/identity/interaction/email-password/handler/ResetPasswordRenderHandler.ts @@ -0,0 +1,12 @@ +import { RenderHandler } from '../../../../server/util/RenderHandler'; + +export interface ResetPasswordRenderHandlerProps { + errorMessage: string; + recordId: string; +} + +/** + * A special {@link RenderHandler} for the Reset Password form + * that includes the required props for rendering the reset password form. + */ +export abstract class ResetPasswordRenderHandler extends RenderHandler {} diff --git a/src/identity/interaction/email-password/handler/ResetPasswordViewHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordViewHandler.ts new file mode 100644 index 000000000..a2db4f473 --- /dev/null +++ b/src/identity/interaction/email-password/handler/ResetPasswordViewHandler.ts @@ -0,0 +1,36 @@ +import assert from 'assert'; +import { parse } from 'url'; +import type { HttpHandlerInput } from '../../../../server/HttpHandler'; +import { HttpHandler } from '../../../../server/HttpHandler'; +import { throwIdpInteractionError } from '../EmailPasswordUtil'; +import type { ResetPasswordRenderHandler } from './ResetPasswordRenderHandler'; + +/** + * Handles the creation of the Reset Password form + * after the user clicks on it from the link provided in the email. + */ +export class ResetPasswordViewHandler extends HttpHandler { + private readonly renderHandler: ResetPasswordRenderHandler; + + public constructor(renderHandler: ResetPasswordRenderHandler) { + super(); + this.renderHandler = renderHandler; + } + + public async handle({ request, response }: HttpHandlerInput): Promise { + try { + assert(request.url, 'The request must have a URL'); + const recordId = parse(request.url, true).query.rid; + assert( + typeof recordId === 'string' && recordId.length > 0, + 'A forgot password record ID must be provided. Use the link you have received by email.', + ); + await this.renderHandler.handleSafe({ + response, + props: { errorMessage: '', recordId }, + }); + } catch (error: unknown) { + throwIdpInteractionError(error, {}); + } + } +} diff --git a/src/identity/interaction/email-password/storage/AccountStore.ts b/src/identity/interaction/email-password/storage/AccountStore.ts new file mode 100644 index 000000000..16f94be6f --- /dev/null +++ b/src/identity/interaction/email-password/storage/AccountStore.ts @@ -0,0 +1,57 @@ +/** + * Storage needed for the email-password interaction + */ +export interface AccountStore { + /** + * Authenticate if the username and password are correct and return the webId + * if it is. Return an error if it is not. + * @param email - the user's email + * @param password - this user's password + * @returns The user's WebId + */ + authenticate: (email: string, password: string) => Promise; + + /** + * Creates a new account + * @param email - the account email + * @param webId - account webId + * @param password - account password + */ + create: (email: string, webId: string, password: string) => Promise; + + /** + * Changes the password + * @param email - the user's email + * @param password - the user's password + */ + changePassword: (email: string, password: string) => Promise; + + /** + * Delete the account + * @param email - the user's email + */ + deleteAccount: (email: string) => Promise; + + /** + * Creates a Forgot Password Confirmation Record. This will be to remember that + * a user has made a request to reset a password. Throws an error if the email doesn't + * exist + * @param email - the user's email + * @returns the record id. This should be included in the reset password link + */ + generateForgotPasswordRecord: (email: string) => Promise; + + /** + * Gets the email associated with the forgot password confirmation record or undefined + * if it's not present + * @param recordId - the record id retrieved from the link + * @returns the user's email + */ + getForgotPasswordRecord: (recordId: string) => Promise; + + /** + * Deletes the Forgot Password Confirmation Record + * @param recordId - the record id of the forgot password confirmation record + */ + deleteForgotPasswordRecord: (recordId: string) => Promise; +} diff --git a/src/identity/interaction/email-password/storage/BaseAccountStore.ts b/src/identity/interaction/email-password/storage/BaseAccountStore.ts new file mode 100644 index 000000000..7f3b5b4f8 --- /dev/null +++ b/src/identity/interaction/email-password/storage/BaseAccountStore.ts @@ -0,0 +1,121 @@ +import assert from 'assert'; +import { hash, compare } from 'bcrypt'; +import { v4 } from 'uuid'; +import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; +import type { AccountStore } from './AccountStore'; + +/** + * A payload to persist a user account + */ +export interface AccountPayload { + webId: string; + email: string; + password: string; +} + +/** + * A payload to persist the fact that a user + * has requested to reset their password + */ +export interface ForgotPasswordPayload { + email: string; + recordId: string; +} + +export type EmailPasswordData = AccountPayload | ForgotPasswordPayload; + +export interface BaseAccountStoreArgs { + storageName: string; + storage: KeyValueStorage; + saltRounds: number; +} + +/** + * A EmailPasswordStore that uses a KeyValueStorage + * to persist its information. + */ +export class BaseAccountStore implements AccountStore { + private readonly storageName: string; + private readonly storage: KeyValueStorage; + private readonly saltRounds: number; + + public constructor(args: BaseAccountStoreArgs) { + this.storageName = args.storageName; + this.storage = args.storage; + this.saltRounds = args.saltRounds; + } + + /** + * Generates a ResourceIdentifier to store data for the given email. + */ + private getAccountResourceIdentifier(email: string): string { + return `${this.storageName}/account/${encodeURIComponent(email)}`; + } + + /** + * Generates a ResourceIdentifier to store data for the given recordId. + */ + private getForgotPasswordRecordResourceIdentifier(recordId: string): string { + return `${this.storageName}/forgot-password-resource-identifier/${encodeURIComponent(recordId)}`; + } + + /** + * Helper function that converts the given e-mail to an account identifier + * and retrieves the account data from the internal storage. + */ + private async getAccountPayload(email: string): Promise<{ key: string; account?: AccountPayload }> { + const key = this.getAccountResourceIdentifier(email); + const account = await this.storage.get(key) as AccountPayload | undefined; + return { key, account }; + } + + public async authenticate(email: string, password: string): Promise { + const { account } = await this.getAccountPayload(email); + assert(account, 'No account by that email'); + assert(await compare(password, account.password), 'Incorrect password'); + return account.webId; + } + + public async create(email: string, webId: string, password: string): Promise { + const { key, account } = await this.getAccountPayload(email); + assert(!account, 'Account already exists'); + const payload: AccountPayload = { + email, + webId, + password: await hash(password, this.saltRounds), + }; + await this.storage.set(key, payload); + } + + public async changePassword(email: string, password: string): Promise { + const { key, account } = await this.getAccountPayload(email); + assert(account, 'Account does not exist'); + account.password = await hash(password, this.saltRounds); + await this.storage.set(key, account); + } + + public async deleteAccount(email: string): Promise { + await this.storage.delete(this.getAccountResourceIdentifier(email)); + } + + public async generateForgotPasswordRecord(email: string): Promise { + const recordId = v4(); + const { account } = await this.getAccountPayload(email); + assert(account, 'Account does not exist'); + await this.storage.set( + this.getForgotPasswordRecordResourceIdentifier(recordId), + { recordId, email }, + ); + return recordId; + } + + public async getForgotPasswordRecord(recordId: string): Promise { + const identifier = this.getForgotPasswordRecordResourceIdentifier(recordId); + const forgotPasswordRecord = await this.storage.get(identifier) as ForgotPasswordPayload | undefined; + return forgotPasswordRecord?.email; + } + + public async deleteForgotPasswordRecord(recordId: string): Promise { + await this.storage.delete(this.getForgotPasswordRecordResourceIdentifier(recordId)); + } +} diff --git a/src/identity/interaction/util/BaseEmailSender.ts b/src/identity/interaction/util/BaseEmailSender.ts new file mode 100644 index 000000000..6c1c56f2b --- /dev/null +++ b/src/identity/interaction/util/BaseEmailSender.ts @@ -0,0 +1,40 @@ +import { createTransport } from 'nodemailer'; +import type Mail from 'nodemailer/lib/mailer'; +import { EmailSender } from './EmailSender'; +import type { EmailArgs } from './EmailSender'; + +export interface EmailSenderArgs { + emailConfig: { + host: string; + port: number; + auth: { + user: string; + pass: string; + }; + }; + senderName?: string; +} + +/** + * Sends e-mails using nodemailer. + */ +export class BaseEmailSender extends EmailSender { + private readonly mailTransporter: Mail; + private readonly senderName: string; + + public constructor(args: EmailSenderArgs) { + super(); + this.mailTransporter = createTransport(args.emailConfig); + this.senderName = args.senderName ?? 'Solid'; + } + + public async handle({ recipient, subject, text, html }: EmailArgs): Promise { + await this.mailTransporter.sendMail({ + from: this.senderName, + to: recipient, + subject, + text, + html, + }); + } +} diff --git a/src/identity/interaction/util/EjsTemplateRenderer.ts b/src/identity/interaction/util/EjsTemplateRenderer.ts new file mode 100644 index 000000000..040c6077a --- /dev/null +++ b/src/identity/interaction/util/EjsTemplateRenderer.ts @@ -0,0 +1,25 @@ +import { renderFile } from 'ejs'; +import { joinFilePath } from '../../../util/PathUtil'; +import { TemplateRenderer } from './TemplateRenderer'; + +/** + * Renders options using a given EJS template location and returns the result as a string. + * This is useful for rendering emails. + */ +export class EjsTemplateRenderer extends TemplateRenderer { + private readonly templatePath: string; + private readonly templateFile: string; + + public constructor(templatePath: string, templateFile: string) { + super(); + this.templatePath = templatePath; + this.templateFile = templateFile; + } + + public async handle(options: T): Promise { + return renderFile( + joinFilePath(this.templatePath, this.templateFile), + options, + ); + } +} diff --git a/src/identity/interaction/util/EmailSender.ts b/src/identity/interaction/util/EmailSender.ts new file mode 100644 index 000000000..30a9a748d --- /dev/null +++ b/src/identity/interaction/util/EmailSender.ts @@ -0,0 +1,13 @@ +import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; + +export interface EmailArgs { + recipient: string; + subject: string; + text: string; + html: string; +} + +/** + * A class that can send an e-mail. + */ +export abstract class EmailSender extends AsyncHandler {} diff --git a/src/identity/interaction/util/FormDataUtil.ts b/src/identity/interaction/util/FormDataUtil.ts new file mode 100644 index 000000000..95de98e13 --- /dev/null +++ b/src/identity/interaction/util/FormDataUtil.ts @@ -0,0 +1,17 @@ +import type { ParsedUrlQuery } from 'querystring'; +import { parse } from 'querystring'; +import type { HttpRequest } from '../../../server/HttpRequest'; +import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../../util/ContentTypes'; +import { UnsupportedMediaTypeHttpError } from '../../../util/errors/UnsupportedMediaTypeHttpError'; +import { readableToString } from '../../../util/StreamUtil'; + +/** + * Takes in a request and parses its body as 'application/x-www-form-urlencoded' + */ +export async function getFormDataRequestBody(request: HttpRequest): Promise { + if (request.headers['content-type'] !== APPLICATION_X_WWW_FORM_URLENCODED) { + throw new UnsupportedMediaTypeHttpError(); + } + const body = await readableToString(request); + return parse(body); +} diff --git a/src/identity/interaction/util/IdpInteractionError.ts b/src/identity/interaction/util/IdpInteractionError.ts new file mode 100644 index 000000000..0b85bf833 --- /dev/null +++ b/src/identity/interaction/util/IdpInteractionError.ts @@ -0,0 +1,18 @@ +import { HttpError } from '../../../util/errors/HttpError'; + +/** + * An error made for IDP Interactions. It allows a function to set the prefilled + * information that would be included in a response UI render. + */ +export class IdpInteractionError extends HttpError { + public readonly prefilled: Record; + + public constructor(status: number, message: string, prefilled: Record) { + super(status, 'IdpInteractionError', message); + this.prefilled = prefilled; + } + + public static isInstance(error: unknown): error is IdpInteractionError { + return HttpError.isInstance(error) && typeof (error as any).prefilled === 'object'; + } +} diff --git a/src/identity/interaction/util/IdpRenderHandler.ts b/src/identity/interaction/util/IdpRenderHandler.ts new file mode 100644 index 000000000..c0648f1d3 --- /dev/null +++ b/src/identity/interaction/util/IdpRenderHandler.ts @@ -0,0 +1,14 @@ +import { RenderHandler } from '../../../server/util/RenderHandler'; + +export interface IdpRenderHandlerProps { + details: { + uid: string; + }; + errorMessage: string; + prefilled: Record; +} + +/** + * A special Render Handler that renders an IDP form + */ +export abstract class IdpRenderHandler extends RenderHandler {} diff --git a/src/identity/interaction/util/IdpRouteController.ts b/src/identity/interaction/util/IdpRouteController.ts new file mode 100644 index 000000000..f6b209cfe --- /dev/null +++ b/src/identity/interaction/util/IdpRouteController.ts @@ -0,0 +1,46 @@ +import type { HttpHandler } from '../../../server/HttpHandler'; +import { RouterHandler } from '../../../server/util/RouterHandler'; +import { isNativeError } from '../../../util/errors/ErrorUtil'; +import type { InteractionHttpHandlerInput } from '../InteractionHttpHandler'; +import { IdpInteractionError } from './IdpInteractionError'; +import type { IdpRenderHandler } from './IdpRenderHandler'; + +/** + * Handles an IDP interaction route. All routes need to extract interaction details to render + * the UI and accept a POST request to do some action. + */ +export class IdpRouteController extends RouterHandler { + private readonly renderHandler: IdpRenderHandler; + + public constructor(pathName: string, renderHandler: IdpRenderHandler, postHandler: HttpHandler) { + super(postHandler, [ 'GET', 'POST' ], [ pathName ]); + this.renderHandler = renderHandler; + } + + /** + * Calls the renderHandler to render using the given response and props. + * `details` typed as any since the `interactionDetails` output typings are not exposed. + */ + private async render(input: InteractionHttpHandlerInput, details: any, errorMessage = '', prefilled = {}): + Promise { + return this.renderHandler.handleSafe({ + response: input.response, + props: { details, errorMessage, prefilled }, + }); + } + + public async handle(input: InteractionHttpHandlerInput): Promise { + const interactionDetails = await input.provider.interactionDetails(input.request, input.response); + if (input.request.method === 'GET') { + await this.render(input, interactionDetails); + } else if (input.request.method === 'POST') { + try { + await this.handler.handleSafe(input); + } catch (err: unknown) { + const errorMessage = isNativeError(err) ? err.message : 'An unknown error occurred'; + const prefilled = IdpInteractionError.isInstance(err) ? err.prefilled : {}; + await this.render(input, interactionDetails, errorMessage, prefilled); + } + } + } +} diff --git a/src/identity/interaction/util/InitialInteractionHandler.ts b/src/identity/interaction/util/InitialInteractionHandler.ts new file mode 100644 index 000000000..e635296cb --- /dev/null +++ b/src/identity/interaction/util/InitialInteractionHandler.ts @@ -0,0 +1,48 @@ +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { InteractionHttpHandlerInput } from '../InteractionHttpHandler'; +import { InteractionHttpHandler } from '../InteractionHttpHandler'; +import type { IdpRenderHandler } from './IdpRenderHandler'; + +export interface RenderHandlerMap { + [key: string]: IdpRenderHandler; + default: IdpRenderHandler; +} + +/** + * An {@link InteractionHttpHandler} that redirects requests + * to a specific {@link IdpRenderHandler} based on their prompt. + * A list of possible prompts can be found at https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + * In case there is no prompt or there is no match in the input map, + * the `default` render handler will be used. + * + * Specifically, the prompt determines how the server should handle re-authentication and consent. + * + * Since this class specifically redirects to render handlers, + * it is advised to wrap it in a {@link RouterHandler} that only allows GET requests. + */ +export class InitialInteractionHandler extends InteractionHttpHandler { + protected readonly logger = getLoggerFor(this); + + private readonly renderHandlerMap: RenderHandlerMap; + + public constructor(renderHandlerMap: RenderHandlerMap) { + super(); + this.renderHandlerMap = renderHandlerMap; + } + + public async handle({ request, response, provider }: InteractionHttpHandlerInput): Promise { + const interactionDetails = await provider.interactionDetails(request, response); + const name = interactionDetails.prompt.name in this.renderHandlerMap ? interactionDetails.prompt.name : 'default'; + + this.logger.debug(`Calling ${name} render handler.`); + + await this.renderHandlerMap[name].handleSafe({ + response, + props: { + details: interactionDetails, + errorMessage: '', + prefilled: {}, + }, + }); + } +} diff --git a/src/identity/interaction/util/InteractionCompleter.ts b/src/identity/interaction/util/InteractionCompleter.ts new file mode 100644 index 000000000..1bf43fd71 --- /dev/null +++ b/src/identity/interaction/util/InteractionCompleter.ts @@ -0,0 +1,28 @@ +import type { InteractionResults } from 'oidc-provider'; +import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; +import type { InteractionHttpHandlerInput } from '../InteractionHttpHandler'; + +export interface InteractionCompleterInput extends InteractionHttpHandlerInput { + webId: string; + shouldRemember?: boolean; +} + +/** + * Completes an IDP interaction, logging the user in. + */ +export class InteractionCompleter extends AsyncHandler { + public async handle(input: InteractionCompleterInput): Promise { + const result: InteractionResults = { + login: { + account: input.webId, + remember: input.shouldRemember, + ts: Math.floor(Date.now() / 1000), + }, + consent: { + rejectedScopes: input.shouldRemember ? [] : [ 'offline_access' ], + }, + }; + + return input.provider.interactionFinished(input.request, input.response, result); + } +} diff --git a/src/identity/interaction/util/IssuerOwnershipValidator.ts b/src/identity/interaction/util/IssuerOwnershipValidator.ts new file mode 100644 index 000000000..f2dcb6f56 --- /dev/null +++ b/src/identity/interaction/util/IssuerOwnershipValidator.ts @@ -0,0 +1,45 @@ +import { DataFactory } from 'n3'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { SOLID } from '../../../util/Vocabularies'; +import { fetchDataset } from '../../util/FetchUtil'; +import { OwnershipValidator } from './OwnershipValidator'; +const { literal, namedNode, quad } = DataFactory; + +/** + * Validates if a WebID can be registered based on whether it references this as an issuer. + */ +export class IssuerOwnershipValidator extends OwnershipValidator { + protected readonly logger = getLoggerFor(this); + + private readonly issuer: string; + + public constructor(issuer: string) { + super(); + this.issuer = issuer; + } + + public async handle({ webId, interactionId }: { webId: string; interactionId: string }): Promise { + const dataset = await fetchDataset(webId); + const hasIssuer = dataset.has( + quad(namedNode(webId), SOLID.terms.oidcIssuer, namedNode(this.issuer)), + ); + const hasRegistrationToken = dataset.has( + quad( + namedNode(webId), + SOLID.terms.oidcIssuerRegistrationToken, + literal(interactionId), + ), + ); + if (!hasIssuer || !hasRegistrationToken) { + this.logger.debug(`Missing issuer and/or registration token at ${webId}`); + let errorMessage = !hasIssuer ? + `<${webId}> <${SOLID.terms.oidcIssuer.value}> <${this.issuer}> .\n` : + ''; + errorMessage += !hasRegistrationToken ? + `<${webId}> <${SOLID.terms.oidcIssuerRegistrationToken.value}> "${interactionId}" .\n` : + ''; + errorMessage += 'Must be added to the WebId'; + throw new Error(errorMessage); + } + } +} diff --git a/src/identity/interaction/util/OwnershipValidator.ts b/src/identity/interaction/util/OwnershipValidator.ts new file mode 100644 index 000000000..5719990a4 --- /dev/null +++ b/src/identity/interaction/util/OwnershipValidator.ts @@ -0,0 +1,7 @@ +import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; + +/** + * A class that validates if a someone owns a WebId. Will + * throw an error if the WebId is not valid. + */ +export abstract class OwnershipValidator extends AsyncHandler<{ webId: string; interactionId: string }> {} diff --git a/src/identity/interaction/util/TemplateRenderer.ts b/src/identity/interaction/util/TemplateRenderer.ts new file mode 100644 index 000000000..e2c38bf6f --- /dev/null +++ b/src/identity/interaction/util/TemplateRenderer.ts @@ -0,0 +1,6 @@ +import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; + +/** + * Renders given options + */ +export abstract class TemplateRenderer extends AsyncHandler {} diff --git a/src/identity/storage/AdapterFactory.ts b/src/identity/storage/AdapterFactory.ts new file mode 100644 index 000000000..4ea072a36 --- /dev/null +++ b/src/identity/storage/AdapterFactory.ts @@ -0,0 +1,9 @@ +import type { Adapter } from 'oidc-provider'; + +/** + * A factory that generates a StorageAdapter to be used + * by the IDP to persist information. + */ +export interface AdapterFactory { + createStorageAdapter: (name: string) => Adapter; +} diff --git a/src/identity/storage/ExpiringAdapterFactory.ts b/src/identity/storage/ExpiringAdapterFactory.ts new file mode 100644 index 000000000..3c47c325d --- /dev/null +++ b/src/identity/storage/ExpiringAdapterFactory.ts @@ -0,0 +1,129 @@ +import type { Adapter, AdapterPayload } from 'oidc-provider'; +import { getLoggerFor } from '../../logging/LogUtil'; +import type { ExpiringStorage } from '../../storage/keyvalue/ExpiringStorage'; +import type { AdapterFactory } from './AdapterFactory'; + +export interface ExpiringAdapterArgs { + storageName: string; + storage: ExpiringStorage; +} + +/** + * An IDP storage adapter that uses an ExpiringStorage + * to persist data. + */ +export class ExpiringAdapter implements Adapter { + protected readonly logger = getLoggerFor(this); + + private readonly storageName: string; + private readonly name: string; + private readonly storage: ExpiringStorage; + + public constructor(name: string, args: ExpiringAdapterArgs) { + this.name = name; + this.storageName = args.storageName; + this.storage = args.storage; + } + + private grantKeyFor(id: string): string { + return `${this.storageName}/grant/${encodeURIComponent(id)}`; + } + + private userCodeKeyFor(userCode: string): string { + return `${this.storageName}/user_code/${encodeURIComponent(userCode)}`; + } + + private uidKeyFor(uid: string): string { + return `${this.storageName}/uid/${encodeURIComponent(uid)}`; + } + + private keyFor(id: string): string { + return `${this.storageName}/${this.name}/${encodeURIComponent(id)}`; + } + + public async upsert(id: string, payload: AdapterPayload, expiresIn?: number): Promise { + // Despite what the typings say, `expiresIn` can be undefined + const expires = expiresIn ? new Date(Date.now() + (expiresIn * 1000)) : undefined; + const key = this.keyFor(id); + + this.logger.debug(`Storing payload data for ${id}`); + + const storagePromises: Promise[] = [ + this.storage.set(key, payload, expires), + ]; + if (payload.grantId) { + storagePromises.push( + (async(): Promise => { + const grantKey = this.grantKeyFor(payload.grantId as string); + const grants = (await this.storage.get(grantKey) || []) as string[]; + grants.push(key); + await this.storage.set(grantKey, grants, expires); + })(), + ); + } + if (payload.userCode) { + storagePromises.push(this.storage.set(this.userCodeKeyFor(payload.userCode), id, expires)); + } + if (payload.uid) { + storagePromises.push(this.storage.set(this.uidKeyFor(payload.uid), id, expires)); + } + await Promise.all(storagePromises); + } + + public async find(id: string): Promise { + return await this.storage.get(this.keyFor(id)) as AdapterPayload | undefined; + } + + public async findByUserCode(userCode: string): Promise { + const id = await this.storage.get(this.userCodeKeyFor(userCode)) as string; + return this.find(id); + } + + public async findByUid(uid: string): Promise { + const id = await this.storage.get(this.uidKeyFor(uid)) as string; + return this.find(id); + } + + public async destroy(id: string): Promise { + await this.storage.delete(this.keyFor(id)); + } + + public async revokeByGrantId(grantId: string): Promise { + this.logger.debug(`Revoking grantId ${grantId}`); + const grantKey = this.grantKeyFor(grantId); + const grants = await this.storage.get(grantKey) as string[] | undefined; + if (!grants) { + return; + } + const deletePromises: Promise[] = []; + grants.forEach((grant): void => { + deletePromises.push(this.storage.delete(grant)); + }); + deletePromises.push(this.storage.delete(grantKey)); + await Promise.all(deletePromises); + } + + public async consume(id: string): Promise { + const payload = await this.find(id); + if (!payload) { + return; + } + payload.consumed = Math.floor(Date.now() / 1000); + await this.storage.set(this.keyFor(id), payload); + } +} + +/** + * The factory for a ExpiringStorageAdapter + */ +export class ExpiringAdapterFactory implements AdapterFactory { + private readonly args: ExpiringAdapterArgs; + + public constructor(args: ExpiringAdapterArgs) { + this.args = args; + } + + public createStorageAdapter(name: string): ExpiringAdapter { + return new ExpiringAdapter(name, this.args); + } +} diff --git a/src/identity/storage/WrappedFetchAdapterFactory.ts b/src/identity/storage/WrappedFetchAdapterFactory.ts new file mode 100644 index 000000000..19931009a --- /dev/null +++ b/src/identity/storage/WrappedFetchAdapterFactory.ts @@ -0,0 +1,121 @@ +import { DataFactory } from 'n3'; +import type { Adapter, AdapterPayload } from 'oidc-provider'; +import type { Dataset, Quad } from 'rdf-js'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { SOLID } from '../../util/Vocabularies'; +import { fetchDataset } from '../util/FetchUtil'; +import type { AdapterFactory } from './AdapterFactory'; +import namedNode = DataFactory.namedNode; + +/** + * This {@link Adapter} redirects the `find` call to its source adapter. + * In case no client data was found in the source for the given WebId, + * this class will do an HTTP GET request to that WebId. + * If a valid `solid:oidcRegistration` triple is found there, + * that data will be returned instead. + */ +export class WrappedFetchAdapter implements Adapter { + protected readonly logger = getLoggerFor(this); + + private readonly source: Adapter; + private readonly name: string; + + public constructor(name: string, source: Adapter) { + this.source = source; + this.name = name; + } + + public async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise { + return this.source.upsert(id, payload, expiresIn); + } + + public async find(id: string): Promise { + const payload = await this.source.find(id); + + // No payload is stored for the given WebId. + // Try to see if a solid:oidcRegistration triple is stored at the WebId that can be used instead. + if (!payload && this.name === 'Client') { + this.logger.debug(`Looking for payload data at ${id}`); + let dataset: Dataset; + try { + dataset = await fetchDataset(id); + } catch { + this.logger.debug(`Looking for payload data failed at ${id}`); + return payload; + } + + // Get the OIDC Registration JSON + const rawRegistrationJsonQuads = dataset.match(namedNode(id), SOLID.terms.oidcRegistration); + + // Check all the registrations to see if any are valid. + for (const rawRegistrationJsonQuad of rawRegistrationJsonQuads) { + try { + return this.validateRegistrationQuad(rawRegistrationJsonQuad, id); + } catch { + // Keep looking for a valid quad + } + } + this.logger.debug(`No payload data was found at ${id}`); + } + + // Will also be returned if no valid registration data was found above + return payload; + } + + public async findByUserCode(userCode: string): Promise { + return this.source.findByUserCode(userCode); + } + + public async findByUid(uid: string): Promise { + return this.source.findByUid(uid); + } + + public async destroy(id: string): Promise { + return this.source.destroy(id); + } + + public async revokeByGrantId(grantId: string): Promise { + return this.source.revokeByGrantId(grantId); + } + + public async consume(id: string): Promise { + return this.source.consume(id); + } + + /** + * Validates if the quad object contains valid JSON with the required client_id. + * In case of success, the AdapterPayload will be returned, otherwise an error will be thrown. + */ + private validateRegistrationQuad(quad: Quad, id: string): AdapterPayload { + const rawRegistrationJson = quad.object.value; + let registrationJson; + try { + registrationJson = JSON.parse(rawRegistrationJson); + } catch { + throw new Error('Could not parse registration JSON'); + } + + // Ensure the registration JSON matches the client WebId + if (id !== registrationJson.client_id) { + throw new Error('The client registration `client_id` field must match the Client WebId'); + } + return { + ...registrationJson, + // Snake case is required for tokens + // eslint-disable-next-line @typescript-eslint/naming-convention + token_endpoint_auth_method: 'none', + }; + } +} + +export class WrappedFetchAdapterFactory implements AdapterFactory { + private readonly source: AdapterFactory; + + public constructor(source: AdapterFactory) { + this.source = source; + } + + public createStorageAdapter(name: string): Adapter { + return new WrappedFetchAdapter(name, this.source.createStorageAdapter(name)); + } +} diff --git a/src/identity/util/FetchUtil.ts b/src/identity/util/FetchUtil.ts new file mode 100644 index 000000000..2b92af2be --- /dev/null +++ b/src/identity/util/FetchUtil.ts @@ -0,0 +1,30 @@ +import fetch from '@rdfjs/fetch'; +import type { DatasetResponse } from '@rdfjs/fetch-lite'; +import type { Dataset } from 'rdf-js'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { isNativeError } from '../../util/errors/ErrorUtil'; + +const logger = getLoggerFor('FetchUtil'); + +/** + * Fetches an RDF dataset from the given URL. + */ +export async function fetchDataset(url: string): Promise { + let rawResponse: DatasetResponse; + try { + rawResponse = (await fetch(url)) as DatasetResponse; + } catch (err: unknown) { + const errorMessage = `Cannot fetch ${url}: ${isNativeError(err) ? err.message : 'Unknown error'}`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + let dataset: Dataset; + try { + dataset = await rawResponse.dataset(); + } catch (err: unknown) { + const errorMessage = `Could not parse RDF in ${url}: ${isNativeError(err) ? err.message : 'Unknown error'}`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + return dataset; +} diff --git a/src/index.ts b/src/index.ts index 64c55f3e1..35b448c90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,54 @@ export * from './authorization/AuxiliaryAuthorizer'; export * from './authorization/WebAclAuthorization'; export * from './authorization/WebAclAuthorizer'; +// Identity/Configuration +export * from './identity/configuration/ConfigurationFactory'; +export * from './identity/configuration/KeyConfigurationFactory'; + +// Identity/Interaction/Email-Password/Handler +export * from './identity/interaction/email-password/handler/ForgotPasswordHandler'; +export * from './identity/interaction/email-password/handler/LoginHandler'; +export * from './identity/interaction/email-password/handler/RegistrationHandler'; +export * from './identity/interaction/email-password/handler/ResetPasswordHandler'; +export * from './identity/interaction/email-password/handler/ResetPasswordRenderHandler'; +export * from './identity/interaction/email-password/handler/ResetPasswordViewHandler'; + +// Identity/Interaction/Email-Password/Storage +export * from './identity/interaction/email-password/storage/AccountStore'; +export * from './identity/interaction/email-password/storage/BaseAccountStore'; + +// Identity/Interaction/Email-Password +export * from './identity/interaction/email-password/AccountInteractionPolicy'; +export * from './identity/interaction/email-password/EmailPasswordUtil'; + +// Identity/Interaction/Util +export * from './identity/interaction/util/BaseEmailSender'; +export * from './identity/interaction/util/EjsTemplateRenderer'; +export * from './identity/interaction/util/EmailSender'; +export * from './identity/interaction/util/FormDataUtil'; +export * from './identity/interaction/util/IdpInteractionError'; +export * from './identity/interaction/util/IdpRenderHandler'; +export * from './identity/interaction/util/IdpRouteController'; +export * from './identity/interaction/util/InitialInteractionHandler'; +export * from './identity/interaction/util/InteractionCompleter'; +export * from './identity/interaction/util/IssuerOwnershipValidator'; +export * from './identity/interaction/util/OwnershipValidator'; +export * from './identity/interaction/util/TemplateRenderer'; + +// Identity/Interaction +export * from './identity/interaction/InteractionHttpHandler'; +export * from './identity/interaction/InteractionPolicy'; +export * from './identity/interaction/SessionHttpHandler'; + +// Identity/Storage +export * from './identity/storage/AdapterFactory'; +export * from './identity/storage/ExpiringAdapterFactory'; +export * from './identity/storage/WrappedFetchAdapterFactory'; + +// Identity +export * from './identity/IdentityProviderFactory'; +export * from './identity/IdentityProviderHttpHandler'; + // Init export * from './init/AclInitializer'; export * from './init/AppRunner'; @@ -155,6 +203,11 @@ export * from './server/middleware/HeaderHandler'; export * from './server/middleware/StaticAssetHandler'; export * from './server/middleware/WebSocketAdvertiser'; +// Server/Util +export * from './server/util/RenderEjsHandler'; +export * from './server/util/RenderHandler'; +export * from './server/util/RouterHandler'; + // Storage/Accessors export * from './storage/accessors/DataAccessor'; export * from './storage/accessors/FileDataAccessor'; @@ -173,12 +226,13 @@ export * from './storage/conversion/RdfToQuadConverter'; export * from './storage/conversion/RepresentationConverter'; export * from './storage/conversion/TypedRepresentationConverter'; -// Storage/KeyValueStorage +// Storage/KeyValue +export * from './storage/keyvalue/ExpiringStorage'; export * from './storage/keyvalue/JsonFileStorage'; export * from './storage/keyvalue/JsonResourceStorage'; export * from './storage/keyvalue/KeyValueStorage'; export * from './storage/keyvalue/MemoryMapStorage'; -export * from './storage/keyvalue/ResourceIdentifierStorage'; +export * from './storage/keyvalue/WrappedExpiringStorage'; // Storage/Mapping export * from './storage/mapping/BaseFileIdentifierMapper'; diff --git a/src/init/AppRunner.ts b/src/init/AppRunner.ts index 769f0b70e..6ae037d87 100644 --- a/src/init/AppRunner.ts +++ b/src/init/AppRunner.ts @@ -74,6 +74,7 @@ export class AppRunner { config: { type: 'string', alias: 'c' }, loggingLevel: { type: 'string', alias: 'l', default: 'info' }, mainModulePath: { type: 'string', alias: 'm' }, + idpTemplateFolder: { type: 'string' }, port: { type: 'number', alias: 'p', default: 3000 }, rootFilePath: { type: 'string', alias: 'f', default: './' }, sparqlEndpoint: { type: 'string', alias: 's' }, @@ -130,6 +131,8 @@ export class AppRunner { 'urn:solid-server:default:variable:sparqlEndpoint': params.sparqlEndpoint, 'urn:solid-server:default:variable:podConfigJson': this.resolveFilePath(params.podConfigJson), + 'urn:solid-server:default:variable:idpTemplateFolder': + this.resolveFilePath(params.idpTemplateFolder, 'templates/idp'), }; } @@ -156,4 +159,5 @@ export interface ConfigVariables { rootFilePath?: string; sparqlEndpoint?: string; podConfigJson?: string; + idpTemplateFolder?: string; } diff --git a/src/init/ConfigPodInitializer.ts b/src/init/ConfigPodInitializer.ts index 7704a4ddb..e14471ac8 100644 --- a/src/init/ConfigPodInitializer.ts +++ b/src/init/ConfigPodInitializer.ts @@ -1,4 +1,3 @@ -import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; import { getLoggerFor } from '../logging/LogUtil'; import type { ComponentsJsFactory } from '../pods/generate/ComponentsJsFactory'; import { TEMPLATE, TEMPLATE_VARIABLE } from '../pods/generate/variables/Variables'; @@ -21,11 +20,11 @@ export class ConfigPodInitializer extends Initializer { protected readonly logger = getLoggerFor(this); private readonly storeFactory: ComponentsJsFactory; private readonly configStorage: KeyValueStorage; - private readonly routingStorage: KeyValueStorage; + private readonly routingStorage: KeyValueStorage; public constructor(storeFactory: ComponentsJsFactory, configStorage: KeyValueStorage, - routingStorage: KeyValueStorage) { + routingStorage: KeyValueStorage) { super(); this.storeFactory = storeFactory; this.configStorage = configStorage; @@ -38,7 +37,7 @@ export class ConfigPodInitializer extends Initializer { const config = value as NodeJS.Dict; const store: ResourceStore = await this.storeFactory.generate(config[TEMPLATE_VARIABLE.templateConfig]!, TEMPLATE.ResourceStore, config); - await this.routingStorage.set({ path }, store); + await this.routingStorage.set(path, store); this.logger.debug(`Initialized pod at ${path}`); count += 1; } diff --git a/src/pods/ConfigPodManager.ts b/src/pods/ConfigPodManager.ts index 87a73cc07..da502ecc6 100644 --- a/src/pods/ConfigPodManager.ts +++ b/src/pods/ConfigPodManager.ts @@ -24,7 +24,7 @@ export class ConfigPodManager implements PodManager { protected readonly logger = getLoggerFor(this); private readonly idGenerator: IdentifierGenerator; private readonly podGenerator: PodGenerator; - private readonly routingStorage: KeyValueStorage; + private readonly routingStorage: KeyValueStorage; private readonly resourcesGenerator: ResourcesGenerator; /** @@ -34,7 +34,7 @@ export class ConfigPodManager implements PodManager { * @param routingStorage - Where to store the generated pods so they can be routed to. */ public constructor(idGenerator: IdentifierGenerator, podGenerator: PodGenerator, - resourcesGenerator: ResourcesGenerator, routingStorage: KeyValueStorage) { + resourcesGenerator: ResourcesGenerator, routingStorage: KeyValueStorage) { this.idGenerator = idGenerator; this.podGenerator = podGenerator; this.routingStorage = routingStorage; @@ -51,7 +51,7 @@ export class ConfigPodManager implements PodManager { const count = await addGeneratedResources(identifier, settings, this.resourcesGenerator, store); this.logger.info(`Added ${count} resources to ${identifier.path}`); - await this.routingStorage.set(identifier, store); + await this.routingStorage.set(identifier.path, store); return identifier; } diff --git a/src/pods/settings/PodSettings.ts b/src/pods/settings/PodSettings.ts index 24e4cc240..1a4229336 100644 --- a/src/pods/settings/PodSettings.ts +++ b/src/pods/settings/PodSettings.ts @@ -30,7 +30,7 @@ export interface PodSettings extends NodeJS.Dict { */ oidcIssuer?: string; /** - * A registration token for linking the owner's WebId to an IdP. + * A registration token for linking the owner's WebId to an IDP. */ oidcIssuerRegistrationToken?: string; } diff --git a/src/server/BaseHttpServerFactory.ts b/src/server/BaseHttpServerFactory.ts index 1dd4d2e31..ee45ae6bd 100644 --- a/src/server/BaseHttpServerFactory.ts +++ b/src/server/BaseHttpServerFactory.ts @@ -29,7 +29,7 @@ export class BaseHttpServerFactory implements HttpServerFactory { const server = createServer( async(request: IncomingMessage, response: ServerResponse): Promise => { try { - this.logger.info(`Received request for ${request.url}`); + this.logger.info(`Received ${request.method} request for ${request.url}`); await this.handler.handleSafe({ request: guardStream(request), response }); } catch (error: unknown) { const errMsg = isNativeError(error) ? `${error.name}: ${error.message}\n${error.stack}` : 'Unknown error.'; diff --git a/src/server/util/RenderEjsHandler.ts b/src/server/util/RenderEjsHandler.ts new file mode 100644 index 000000000..07c444eaf --- /dev/null +++ b/src/server/util/RenderEjsHandler.ts @@ -0,0 +1,26 @@ +import { renderFile } from 'ejs'; +import { joinFilePath } from '../../util/PathUtil'; +import type { HttpResponse } from '../HttpResponse'; +import { RenderHandler } from './RenderHandler'; + +/** + * A Render Handler that uses EJS templates to render a response. + */ +export class RenderEjsHandler extends RenderHandler { + private readonly templatePath: string; + private readonly templateFile: string; + + public constructor(templatePath: string, templateFile: string) { + super(); + this.templatePath = templatePath; + this.templateFile = templateFile; + } + + public async handle(input: { response: HttpResponse; props: T }): Promise { + const { props, response } = input; + const renderedHtml = await renderFile(joinFilePath(this.templatePath, this.templateFile), props || {}); + // eslint-disable-next-line @typescript-eslint/naming-convention + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end(renderedHtml); + } +} diff --git a/src/server/util/RenderHandler.ts b/src/server/util/RenderHandler.ts new file mode 100644 index 000000000..c1ab7f39d --- /dev/null +++ b/src/server/util/RenderHandler.ts @@ -0,0 +1,9 @@ +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; +import type { HttpResponse } from '../HttpResponse'; + +export interface RenderHandlerInput {} + +/** + * Renders a result with the given props and sends it to the HttpResponse. + */ +export abstract class RenderHandler extends AsyncHandler<{ response: HttpResponse; props: T }> {} diff --git a/src/server/util/RouterHandler.ts b/src/server/util/RouterHandler.ts new file mode 100644 index 000000000..bbd1b9793 --- /dev/null +++ b/src/server/util/RouterHandler.ts @@ -0,0 +1,46 @@ +import { parse } from 'url'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import type { HttpHandlerInput } from '../HttpHandler'; +import { HttpHandler } from '../HttpHandler'; + +/** + * An HttpHandler that checks if a given method and path are satisfied + * and allows its handler to be executed if so. + */ +export class RouterHandler extends HttpHandler { + protected readonly handler: HttpHandler; + protected readonly allowedMethods: string[]; + protected readonly allowedPathNamesRegEx: RegExp[]; + + public constructor(handler: HttpHandler, allowedMethods: string[], allowedPathNames: string[]) { + super(); + this.handler = handler; + this.allowedMethods = allowedMethods; + this.allowedPathNamesRegEx = allowedPathNames.map((pn): RegExp => new RegExp(pn, 'u')); + } + + public async canHandle(input: HttpHandlerInput): Promise { + if (!input.request.url) { + throw new Error('Cannot handle request without a url'); + } + if (!input.request.method) { + throw new Error('Cannot handle request without a method'); + } + if (!this.allowedMethods.includes(input.request.method)) { + throw new MethodNotAllowedHttpError(`${input.request.method} is not allowed.`); + } + const { pathname } = parse(input.request.url); + if (!pathname) { + throw new Error('Cannot handle request without pathname'); + } + if (!this.allowedPathNamesRegEx.some((regex): boolean => regex.test(pathname))) { + throw new NotFoundHttpError(`Cannot handle route ${pathname}`); + } + await this.handler.canHandle(input); + } + + public async handle(input: HttpHandlerInput): Promise { + await this.handler.handle(input); + } +} diff --git a/src/storage/keyvalue/ExpiringStorage.ts b/src/storage/keyvalue/ExpiringStorage.ts new file mode 100644 index 000000000..ac53e8faf --- /dev/null +++ b/src/storage/keyvalue/ExpiringStorage.ts @@ -0,0 +1,19 @@ +import type { KeyValueStorage } from './KeyValueStorage'; + +/** + * A KeyValueStorage in which the values can expire. + * Entries with no expiration date never expire. + */ +export interface ExpiringStorage extends KeyValueStorage { + /** + * Sets the value for the given key. + * Should error if the data is already expired. + * + * @param key - Key to set/update. + * @param value - Value to store. + * @param expires - When this value expires. Never if undefined. + * + * @returns The storage. + */ + set: (key: TKey, value: TValue, expires?: Date) => Promise; +} diff --git a/src/storage/keyvalue/JsonResourceStorage.ts b/src/storage/keyvalue/JsonResourceStorage.ts index 1b120a88e..e594528be 100644 --- a/src/storage/keyvalue/JsonResourceStorage.ts +++ b/src/storage/keyvalue/JsonResourceStorage.ts @@ -1,28 +1,35 @@ +import { URL } from 'url'; import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { ensureTrailingSlash } from '../../util/PathUtil'; import { readableToString } from '../../util/StreamUtil'; +import { LDP } from '../../util/Vocabularies'; import type { ResourceStore } from '../ResourceStore'; import type { KeyValueStorage } from './KeyValueStorage'; /** - * A {@link KeyValueStorage} for strings using a {@link ResourceStore} as backend. + * A {@link KeyValueStorage} for JSON-like objects using a {@link ResourceStore} as backend. * - * Values will be sent as data streams to the given identifiers, - * so how these are stored depend on the underlying store. + * The keys will be transformed so they can be safely used + * as a resource name in the given container. + * Values will be sent as data streams, + * so how these are stored depends on the underlying store. * * All non-404 errors will be re-thrown. */ -export class JsonResourceStorage implements KeyValueStorage { +export class JsonResourceStorage implements KeyValueStorage { private readonly source: ResourceStore; + private readonly container: string; - public constructor(source: ResourceStore) { + public constructor(source: ResourceStore, baseUrl: string, container: string) { this.source = source; + this.container = ensureTrailingSlash(new URL(container, baseUrl).href); } - public async get(identifier: ResourceIdentifier): Promise { + public async get(key: string): Promise { try { + const identifier = this.createIdentifier(key); const representation = await this.source.getRepresentation(identifier, { type: { 'application/json': 1 }}); return JSON.parse(await readableToString(representation.data)); } catch (error: unknown) { @@ -32,27 +39,21 @@ export class JsonResourceStorage implements KeyValueStorage { - try { - const representation = await this.source.getRepresentation(identifier, { type: { 'application/json': 1 }}); - representation.data.destroy(); - return true; - } catch (error: unknown) { - if (!NotFoundHttpError.isInstance(error)) { - throw error; - } - return false; - } + public async has(key: string): Promise { + const identifier = this.createIdentifier(key); + return await this.source.resourceExists(identifier); } - public async set(identifier: ResourceIdentifier, value: unknown): Promise { + public async set(key: string, value: unknown): Promise { + const identifier = this.createIdentifier(key); const representation = new BasicRepresentation(JSON.stringify(value), identifier, 'application/json'); await this.source.setRepresentation(identifier, representation); return this; } - public async delete(identifier: ResourceIdentifier): Promise { + public async delete(key: string): Promise { try { + const identifier = this.createIdentifier(key); await this.source.deleteResource(identifier); return true; } catch (error: unknown) { @@ -63,8 +64,31 @@ export class JsonResourceStorage implements KeyValueStorage { + const container = await this.source.getRepresentation({ path: this.container }, {}); + // Only need the metadata + container.data.destroy(); + const members = container.metadata.getAll(LDP.terms.contains).map((term): string => term.value); + for (const member of members) { + const representation = await this.source.getRepresentation({ path: member }, { type: { 'application/json': 1 }}); + const json = JSON.parse(await readableToString(representation.data)); + yield [ this.parseMember(member), json ]; + } + } + + /** + * Converts a key into an identifier for internal storage. + */ + private createIdentifier(key: string): ResourceIdentifier { + const buffer = Buffer.from(key); + return { path: `${this.container}${buffer.toString('base64')}` }; + } + + /** + * Converts an internal storage identifier string into the original identifier key. + */ + private parseMember(member: string): string { + const buffer = Buffer.from(member.slice(this.container.length), 'base64'); + return buffer.toString('utf-8'); } } diff --git a/src/storage/keyvalue/KeyValueStorage.ts b/src/storage/keyvalue/KeyValueStorage.ts index 581a43f3f..95c50725a 100644 --- a/src/storage/keyvalue/KeyValueStorage.ts +++ b/src/storage/keyvalue/KeyValueStorage.ts @@ -1,7 +1,5 @@ /** * A simple storage solution that can be used for internal values that need to be stored. - * In general storages taking objects as keys are expected to work with different instances - * of an object with the same values. Exceptions to this expectation should be clearly documented. */ export interface KeyValueStorage { /** diff --git a/src/storage/keyvalue/MemoryMapStorage.ts b/src/storage/keyvalue/MemoryMapStorage.ts index fc5294ddd..ade39e4d2 100644 --- a/src/storage/keyvalue/MemoryMapStorage.ts +++ b/src/storage/keyvalue/MemoryMapStorage.ts @@ -2,34 +2,32 @@ import type { KeyValueStorage } from './KeyValueStorage'; /** * A {@link KeyValueStorage} which uses a JavaScript Map for internal storage. - * Warning: Uses a Map object, which internally uses `Object.is` for key equality, - * so object keys have to be the same objects. */ -export class MemoryMapStorage implements KeyValueStorage { - private readonly data: Map; +export class MemoryMapStorage implements KeyValueStorage { + private readonly data: Map; public constructor() { - this.data = new Map(); + this.data = new Map(); } - public async get(key: TKey): Promise { + public async get(key: string): Promise { return this.data.get(key); } - public async has(key: TKey): Promise { + public async has(key: string): Promise { return this.data.has(key); } - public async set(key: TKey, value: TValue): Promise { + public async set(key: string, value: TValue): Promise { this.data.set(key, value); return this; } - public async delete(key: TKey): Promise { + public async delete(key: string): Promise { return this.data.delete(key); } - public async* entries(): AsyncIterableIterator<[TKey, TValue]> { + public async* entries(): AsyncIterableIterator<[string, TValue]> { for (const entry of this.data.entries()) { yield entry; } diff --git a/src/storage/keyvalue/ResourceIdentifierStorage.ts b/src/storage/keyvalue/ResourceIdentifierStorage.ts deleted file mode 100644 index 58af6e364..000000000 --- a/src/storage/keyvalue/ResourceIdentifierStorage.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; -import type { KeyValueStorage } from './KeyValueStorage'; - -/** - * Wrapper class that internally converts ResourceIdentifiers to strings so Storages - * that do not check value equivalence can be used with ResourceIdentifiers. - * - * Specifically: this makes it so a Storage based on a Map object can be used with ResourceIdentifiers. - */ -export class ResourceIdentifierStorage implements KeyValueStorage { - private readonly source: KeyValueStorage; - - public constructor(source: KeyValueStorage) { - this.source = source; - } - - public async get(key: ResourceIdentifier): Promise { - return this.source.get(key.path); - } - - public async has(key: ResourceIdentifier): Promise { - return this.source.has(key.path); - } - - public async set(key: ResourceIdentifier, value: T): Promise { - await this.source.set(key.path, value); - return this; - } - - public async delete(key: ResourceIdentifier): Promise { - return this.source.delete(key.path); - } - - public async* entries(): AsyncIterableIterator<[ResourceIdentifier, T]> { - for await (const [ path, value ] of this.source.entries()) { - yield [{ path }, value ]; - } - } -} diff --git a/src/storage/keyvalue/WrappedExpiringStorage.ts b/src/storage/keyvalue/WrappedExpiringStorage.ts new file mode 100644 index 000000000..27e66f8a5 --- /dev/null +++ b/src/storage/keyvalue/WrappedExpiringStorage.ts @@ -0,0 +1,119 @@ +import { InternalServerError } from '../../util/errors/InternalServerError'; +import type { ExpiringStorage } from './ExpiringStorage'; +import type { KeyValueStorage } from './KeyValueStorage'; + +// Used as internal storage format +export type Expires = { expires?: string; payload: T }; + +/** + * A storage that wraps around another storage and expires resources based on the given (optional) expiry date. + * Will delete expired entries when trying to get their value. + * Has a timer that will delete all expired data every hour (default value). + */ +export class WrappedExpiringStorage implements ExpiringStorage { + private readonly source: KeyValueStorage>; + private readonly timer: NodeJS.Timeout; + + /** + * @param source - KeyValueStorage to actually store the data. + * @param timeout - How often the expired data needs to be checked in minutes. + */ + public constructor(source: KeyValueStorage>, timeout = 60) { + this.source = source; + this.timer = setInterval(this.removeExpiredEntries.bind(this), timeout * 60 * 1000); + } + + public async get(key: TKey): Promise { + return this.getUnexpired(key); + } + + public async has(key: TKey): Promise { + return Boolean(await this.getUnexpired(key)); + } + + public async set(key: TKey, value: TValue, expires?: Date): Promise { + if (this.isExpired(expires)) { + throw new InternalServerError('Value is already expired'); + } + await this.source.set(key, this.toExpires(value, expires)); + return this; + } + + public async delete(key: TKey): Promise { + return this.source.delete(key); + } + + public async* entries(): AsyncIterableIterator<[TKey, TValue]> { + // Not deleting expired entries here to prevent iterator issues + for await (const [ key, value ] of this.source.entries()) { + const { expires, payload } = this.toData(value); + if (!this.isExpired(expires)) { + yield [ key, payload ]; + } + } + } + + /** + * Deletes all entries that have expired. + */ + private async removeExpiredEntries(): Promise { + const expired: TKey[] = []; + for await (const [ key, value ] of this.source.entries()) { + const { expires } = this.toData(value); + if (this.isExpired(expires)) { + expired.push(key); + } + } + await Promise.all(expired.map(async(key): Promise => this.source.delete(key))); + } + + /** + * Tries to get the data for the given key. + * In case the data exists but has expired, + * it will be deleted and `undefined` will be returned instead. + */ + private async getUnexpired(key: TKey): Promise { + const data = await this.source.get(key); + if (!data) { + return; + } + const { expires, payload } = this.toData(data); + if (this.isExpired(expires)) { + await this.source.delete(key); + return; + } + return payload; + } + + /** + * Checks if the given data entry has expired. + */ + private isExpired(expires?: Date): boolean { + return typeof expires !== 'undefined' && expires < new Date(); + } + + /** + * Creates a new object where the `expires` field is a string instead of a Date. + */ + private toExpires(data: TValue, expires?: Date): Expires { + return { expires: expires?.toISOString(), payload: data }; + } + + /** + * Creates a new object where the `expires` field is a Date instead of a string. + */ + private toData(expireData: Expires): { expires?: Date; payload: TValue } { + const result: { expires?: Date; payload: TValue } = { payload: expireData.payload }; + if (expireData.expires) { + result.expires = new Date(expireData.expires); + } + return result; + } + + /** + * Stops the continuous cleanup timer. + */ + public finalize(): void { + clearInterval(this.timer); + } +} diff --git a/src/storage/routing/BaseUrlRouterRule.ts b/src/storage/routing/BaseUrlRouterRule.ts index ab5b6210d..9579efe50 100644 --- a/src/storage/routing/BaseUrlRouterRule.ts +++ b/src/storage/routing/BaseUrlRouterRule.ts @@ -15,9 +15,9 @@ import { RouterRule } from './RouterRule'; */ export class BaseUrlRouterRule extends RouterRule { private readonly baseStore?: ResourceStore; - private readonly stores: KeyValueStorage; + private readonly stores: KeyValueStorage; - public constructor(stores: KeyValueStorage, baseStore?: ResourceStore) { + public constructor(stores: KeyValueStorage, baseStore?: ResourceStore) { super(); this.baseStore = baseStore; this.stores = stores; @@ -39,7 +39,7 @@ export class BaseUrlRouterRule extends RouterRule { */ private async findStore(identifier: ResourceIdentifier): Promise { for await (const [ key, store ] of this.stores.entries()) { - if (identifier.path.startsWith(key.path)) { + if (identifier.path.startsWith(key)) { return store; } } diff --git a/src/util/ContentTypes.ts b/src/util/ContentTypes.ts index ffd7c5c77..82a9f4269 100644 --- a/src/util/ContentTypes.ts +++ b/src/util/ContentTypes.ts @@ -1,7 +1,9 @@ // Well-known content types export const TEXT_TURTLE = 'text/turtle'; +export const APPLICATION_JSON = 'application/json'; export const APPLICATION_OCTET_STREAM = 'application/octet-stream'; export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update'; +export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded'; // Internal content types (not exposed over HTTP) export const INTERNAL_ALL = 'internal/*'; diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts index 7bc41ea64..6475f4a1c 100644 --- a/src/util/PathUtil.ts +++ b/src/util/PathUtil.ts @@ -94,7 +94,7 @@ export function getExtension(path: string): string { */ function transformPathComponents(path: string, transform: (part: string) => string): string { const [ , base, queryString ] = /^([^?]*)(.*)$/u.exec(path)!; - const transformed = base.split('/').map(transform).join('/'); + const transformed = base.split('/').map((element): string => transform(element)).join('/'); return !queryString ? transformed : `${transformed}${queryString}`; } diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 64354650e..a8ab1b99c 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -121,6 +121,12 @@ export const XSD = createUriAndTermNamespace('http://www.w3.org/2001/XMLSchema#' 'integer', ); +export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms#', + 'oidcIssuer', + 'oidcIssuerRegistrationToken', + 'oidcRegistration', +); + // Alias for commonly used types export const CONTENT_TYPE = MA.format; export const CONTENT_TYPE_TERM = MA.terms.format; diff --git a/src/util/errors/ConfigurationError.ts b/src/util/errors/ConfigurationError.ts new file mode 100644 index 000000000..95638aaac --- /dev/null +++ b/src/util/errors/ConfigurationError.ts @@ -0,0 +1,8 @@ +/** + * An error thrown when something is flawed about the configuration. + */ +export class ConfigurationError extends Error { + public constructor(message: string) { + super(message); + } +} diff --git a/src/util/handlers/WaterfallHandler.ts b/src/util/handlers/WaterfallHandler.ts index 4e7880369..cfd3bd7e0 100644 --- a/src/util/handlers/WaterfallHandler.ts +++ b/src/util/handlers/WaterfallHandler.ts @@ -99,7 +99,7 @@ export class WaterfallHandler implements AsyncHandler { const message = `No handler supports the given input: [${joined}]`; // Check if all errors have the same status code - if (errors.every((error): boolean => error.statusCode === errors[0].statusCode)) { + if (errors.length > 0 && errors.every((error): boolean => error.statusCode === errors[0].statusCode)) { throw new HttpError(errors[0].statusCode, errors[0].name, message); } diff --git a/src/util/locking/GreedyReadWriteLocker.ts b/src/util/locking/GreedyReadWriteLocker.ts index 073fff52b..972bcfaed 100644 --- a/src/util/locking/GreedyReadWriteLocker.ts +++ b/src/util/locking/GreedyReadWriteLocker.ts @@ -26,7 +26,7 @@ export interface GreedyReadWriteSuffixes { */ export class GreedyReadWriteLocker implements ReadWriteLocker { private readonly locker: ResourceLocker; - private readonly storage: KeyValueStorage; + private readonly storage: KeyValueStorage; private readonly suffixes: GreedyReadWriteSuffixes; /** @@ -36,7 +36,7 @@ export class GreedyReadWriteLocker implements ReadWriteLocker { * `count` is used for the identifier used to store the counter. * `read` and `write` are used for the 2 types of locks that are needed. */ - public constructor(locker: ResourceLocker, storage: KeyValueStorage, + public constructor(locker: ResourceLocker, storage: KeyValueStorage, suffixes: GreedyReadWriteSuffixes = { count: 'count', read: 'read', write: 'write' }) { this.locker = locker; this.storage = storage; @@ -56,7 +56,7 @@ export class GreedyReadWriteLocker implements ReadWriteLocker { if (identifier.path.endsWith(`.${this.suffixes.count}`)) { throw new ForbiddenHttpError('This resource is used for internal purposes.'); } - const write = this.getWriteLockIdentifier(identifier); + const write = this.getWriteLockKey(identifier); await this.locker.acquire(write); try { return await whileLocked(); @@ -66,23 +66,23 @@ export class GreedyReadWriteLocker implements ReadWriteLocker { } /** - * This identifier is used for storing the count of active read operations. + * This key is used for storing the count of active read operations. */ - private getCountIdentifier(identifier: ResourceIdentifier): ResourceIdentifier { - return { path: `${identifier.path}.${this.suffixes.count}` }; + private getCountKey(identifier: ResourceIdentifier): string { + return `${identifier.path}.${this.suffixes.count}`; } /** * This is the identifier for the read lock: the lock that is used to safely update and read the count. */ - private getReadLockIdentifier(identifier: ResourceIdentifier): ResourceIdentifier { + private getReadLockKey(identifier: ResourceIdentifier): ResourceIdentifier { return { path: `${identifier.path}.${this.suffixes.read}` }; } /** * This is the identifier for the write lock, making sure there is at most 1 write operation active. */ - private getWriteLockIdentifier(identifier: ResourceIdentifier): ResourceIdentifier { + private getWriteLockKey(identifier: ResourceIdentifier): ResourceIdentifier { return { path: `${identifier.path}.${this.suffixes.write}` }; } @@ -94,7 +94,7 @@ export class GreedyReadWriteLocker implements ReadWriteLocker { const count = await this.incrementCount(identifier, +1); if (count === 1) { // There is at least 1 read operation so write operations are blocked - const write = this.getWriteLockIdentifier(identifier); + const write = this.getWriteLockKey(identifier); await this.locker.acquire(write); } }); @@ -108,7 +108,7 @@ export class GreedyReadWriteLocker implements ReadWriteLocker { const count = await this.incrementCount(identifier, -1); if (count === 0) { // All read locks have been released so a write operation is possible again - const write = this.getWriteLockIdentifier(identifier); + const write = this.getWriteLockKey(identifier); await this.locker.release(write); } }); @@ -119,7 +119,7 @@ export class GreedyReadWriteLocker implements ReadWriteLocker { */ private async withInternalReadLock(identifier: ResourceIdentifier, whileLocked: () => (Promise | T)): Promise { - const read = this.getReadLockIdentifier(identifier); + const read = this.getReadLockKey(identifier); await this.locker.acquire(read); try { return await whileLocked(); @@ -134,14 +134,14 @@ export class GreedyReadWriteLocker implements ReadWriteLocker { * Deletes the data when the count reaches zero. */ private async incrementCount(identifier: ResourceIdentifier, mod: number): Promise { - const countIdentifier = this.getCountIdentifier(identifier); - let number = await this.storage.get(countIdentifier) ?? 0; + const countKey = this.getCountKey(identifier); + let number = await this.storage.get(countKey) ?? 0; number += mod; if (number === 0) { // Make sure there is no remaining data once all locks are released - await this.storage.delete(countIdentifier); + await this.storage.delete(countKey); } else if (number > 0) { - await this.storage.set(countIdentifier, number); + await this.storage.set(countKey, number); } else { // Failsafe in case something goes wrong with the count storage throw new InternalServerError('Read counter would become negative. Something is wrong with the count storage.'); diff --git a/src/util/locking/RedisResourceLocker.ts b/src/util/locking/RedisResourceLocker.ts index 804d67cd0..23715d13c 100644 --- a/src/util/locking/RedisResourceLocker.ts +++ b/src/util/locking/RedisResourceLocker.ts @@ -97,7 +97,7 @@ export class RedisResourceLocker implements ResourceLocker { } } - public async quit(): Promise { + public async finalize(): Promise { // This for loop is an extra failsafe, // this extra code won't slow down anything, this function will only be called to shut down in peace for (const [ , { lock }] of this.lockMap.entries()) { diff --git a/templates/idp/email-password-interaction/confirm.ejs b/templates/idp/email-password-interaction/confirm.ejs new file mode 100644 index 000000000..809f1ab7f --- /dev/null +++ b/templates/idp/email-password-interaction/confirm.ejs @@ -0,0 +1,29 @@ + + + + + Authorize + + + +
+
+
+

Authorize

+ +
+
+
+ + diff --git a/templates/idp/email-password-interaction/emailSent.ejs b/templates/idp/email-password-interaction/emailSent.ejs new file mode 100644 index 000000000..11bbc9223 --- /dev/null +++ b/templates/idp/email-password-interaction/emailSent.ejs @@ -0,0 +1,41 @@ + + + + + Sign-in + + + +
+
+
+

Email Sent

+ +
+
+
+ + diff --git a/templates/idp/email-password-interaction/error.ejs b/templates/idp/email-password-interaction/error.ejs new file mode 100644 index 000000000..6d872bb68 --- /dev/null +++ b/templates/idp/email-password-interaction/error.ejs @@ -0,0 +1,23 @@ + + + + + <%= message %> + + + +
+
+
+
+
+

+ <%= message %> +

+
+
+
+
+
+ + diff --git a/templates/idp/email-password-interaction/forgotPassword.ejs b/templates/idp/email-password-interaction/forgotPassword.ejs new file mode 100644 index 000000000..0afd8e531 --- /dev/null +++ b/templates/idp/email-password-interaction/forgotPassword.ejs @@ -0,0 +1,44 @@ + + + + + Forgot Password + + + +
+
+
+

Forgot Password

+ +
+
+
+ + diff --git a/templates/idp/email-password-interaction/login.ejs b/templates/idp/email-password-interaction/login.ejs new file mode 100644 index 000000000..3c63626e5 --- /dev/null +++ b/templates/idp/email-password-interaction/login.ejs @@ -0,0 +1,54 @@ + + + + + Sign-in + + + +
+
+
+

Sign In

+ +
+
+
+ + diff --git a/templates/idp/email-password-interaction/main.css b/templates/idp/email-password-interaction/main.css new file mode 100644 index 000000000..27bd35ff6 --- /dev/null +++ b/templates/idp/email-password-interaction/main.css @@ -0,0 +1,2948 @@ +/* scss/Variables/_Variables.scss */ +/* Colors - inrupt Branded - Generic*/ +/* inrupt Additional Specific Color Variables */ +/* Typography */ +/* Typography Variables */ +/* additional Variables */ +/* Reset */ +.component { + font-family: "Raleway", "Roboto", sans-serif; + font-size: 1em; + line-height: 1; } + .component .source { + font-family: "Raleway", "Roboto", sans-serif; + font-size: 1em; + line-height: 1; } + +.row-wrap { + width: 100%; + margin-bottom: 3em; } + +.wrap { + display: flex; + justify-content: space-around; } + +ul { + padding: 0; + margin: 0; + list-style: none; } + +ul li { + padding: 0; + margin: 0; } + +/* Gradient Functions */ +/* Border Radius Mixin Controller */ +/* Box Shadow Mixin Controller */ +/* Appearance Mixin Controller */ +/* Media Query Mixins */ +/* scss/Animation/_Animations.scss */ +/* +============================================== +slideDown +============================================== +*/ +.slideDown { + animation-name: slideDown; + -webkit-animation-name: slideDown; + animation-duration: 1s; + -webkit-animation-duration: 1s; + animation-timing-function: ease; + -webkit-animation-timing-function: ease; + visibility: visible !important; } + +@keyframes slideDown { + 0% { + transform: translateY(-100%); } + 50% { + transform: translateY(8%); } + 65% { + transform: translateY(-4%); } + 80% { + transform: translateY(4%); } + 95% { + transform: translateY(-2%); } + 100% { + transform: translateY(0%); } } + +@-webkit-keyframes slideDown { + 0% { + -webkit-transform: translateY(-100%); } + 50% { + -webkit-transform: translateY(8%); } + 65% { + -webkit-transform: translateY(-4%); } + 80% { + -webkit-transform: translateY(4%); } + 95% { + -webkit-transform: translateY(-2%); } + 100% { + -webkit-transform: translateY(0%); } } + +/* +============================================== +slideUp +============================================== +*/ +.slideUp { + animation-name: slideUp; + -webkit-animation-name: slideUp; + animation-duration: 1s; + -webkit-animation-duration: 1s; + animation-timing-function: ease; + -webkit-animation-timing-function: ease; + visibility: visible !important; } + +@keyframes slideUp { + 0% { + transform: translateY(100%); } + 50% { + transform: translateY(-8%); } + 65% { + transform: translateY(4%); } + 80% { + transform: translateY(-4%); } + 95% { + transform: translateY(2%); } + 100% { + transform: translateY(0%); } } + +@-webkit-keyframes slideUp { + 0% { + -webkit-transform: translateY(100%); } + 50% { + -webkit-transform: translateY(-8%); } + 65% { + -webkit-transform: translateY(4%); } + 80% { + -webkit-transform: translateY(-4%); } + 95% { + -webkit-transform: translateY(2%); } + 100% { + -webkit-transform: translateY(0%); } } + +/* +============================================== +slideLeft +============================================== +*/ +.slideLeft { + animation-name: slideLeft; + -webkit-animation-name: slideLeft; + animation-duration: 1s; + -webkit-animation-duration: 1s; + animation-timing-function: ease-in-out; + -webkit-animation-timing-function: ease-in-out; + visibility: visible; } + +@keyframes slideLeft { + 0% { + transform: translateX(150%); } + 100% { + transform: translateX(0%); } } + +@-webkit-keyframes slideLeft { + 0% { + -webkit-transform: translateX(150%); } + 100% { + -webkit-transform: translateX(0%); } } + +/* +============================================== +exitLeft +============================================== +*/ +.exitLeft { + animation-name: exitLeft; + -webkit-animation-name: exitLeft; + animation-duration: 1s; + -webkit-animation-duration: 1s; + animation-timing-function: ease-in-out; + -webkit-animation-timing-function: ease-in-out; + visibility: visible; } + +@keyframes exitLeft { + 0% { + transform: translateX(0%); } + 100% { + transform: translateX(150%); } } + +@-webkit-keyframes exitLeft { + 0% { + -webkit-transform: translateX(0%); } + 100% { + -webkit-transform: translateX(150%); } } + +/* +============================================== +slideRight +============================================== +*/ +.slideRight { + animation-name: slideRight; + -webkit-animation-name: slideRight; + animation-duration: 1s; + -webkit-animation-duration: 1s; + animation-timing-function: ease-in-out; + -webkit-animation-timing-function: ease-in-out; + visibility: visible !important; } + +@keyframes slideRight { + 0% { + transform: translateX(-150%); } + 100% { + transform: translateX(0%); } } + +@-webkit-keyframes slideRight { + 0% { + -webkit-transform: translateX(-150%); } + 100% { + -webkit-transform: translateX(0%); } } + +/* +============================================== +slideExpandUp +============================================== +*/ +.slideExpandUp { + animation-name: slideExpandUp; + -webkit-animation-name: slideExpandUp; + animation-duration: 1.6s; + -webkit-animation-duration: 1.6s; + animation-timing-function: ease-out; + -webkit-animation-timing-function: ease-out; + visibility: visible !important; } + +@keyframes slideExpandUp { + 0% { + transform: translateY(100%) scaleX(0.5); } + 100% { + transform: translateY(0%) scaleX(1); } } + +@-webkit-keyframes slideExpandUp { + 0% { + -webkit-transform: translateY(100%) scaleX(0.5); } + 100% { + -webkit-transform: translateY(0%) scaleX(1); } } + +/* +============================================== +expandUp +============================================== +*/ +.expandUp { + animation-name: expandUp; + -webkit-animation-name: expandUp; + animation-duration: 0.7s; + -webkit-animation-duration: 0.7s; + animation-timing-function: ease; + -webkit-animation-timing-function: ease; + visibility: visible !important; } + +@keyframes expandUp { + 0% { + transform: translateY(100%) scale(0.6) scaleY(0.5); } + 100% { + transform: translateY(0%) scale(1) scaleY(1); } } + +@-webkit-keyframes expandUp { + 0% { + -webkit-transform: translateY(100%) scale(0.6) scaleY(0.5); } + 100% { + -webkit-transform: translateY(0%) scale(1) scaleY(1); } } + +/* +============================================== +fadeIn +============================================== +*/ +.fadeIn { + animation-name: fadeIn; + -webkit-animation-name: fadeIn; + animation-duration: 1.5s; + -webkit-animation-duration: 1.5s; + animation-timing-function: ease-in-out; + -webkit-animation-timing-function: ease-in-out; + visibility: visible !important; } + +@keyframes fadeIn { + 0% { + transform: scale(0); + opacity: 0.0; } + 100% { + transform: scale(1); + opacity: 1; } } + +@-webkit-keyframes fadeIn { + 0% { + -webkit-transform: scale(0); + opacity: 0.0; } + 100% { + -webkit-transform: scale(1); + opacity: 1; } } + +/* +============================================== +expandOpen +============================================== +*/ +.expandOpen { + animation-name: expandOpen; + -webkit-animation-name: expandOpen; + animation-duration: 1.2s; + -webkit-animation-duration: 1.2s; + animation-timing-function: ease-out; + -webkit-animation-timing-function: ease-out; + visibility: visible !important; } + +@keyframes expandOpen { + 0% { + transform: scale(1.8); } + 100% { + transform: scale(1); } } + +@-webkit-keyframes expandOpen { + 0% { + -webkit-transform: scale(1.8); } + 100% { + -webkit-transform: scale(1); } } + +/* +============================================== +bigEntrance +============================================== +*/ +.bigEntrance { + animation-name: bigEntrance; + -webkit-animation-name: bigEntrance; + animation-duration: 1.6s; + -webkit-animation-duration: 1.6s; + animation-timing-function: ease-out; + -webkit-animation-timing-function: ease-out; + visibility: visible !important; } + +@keyframes bigEntrance { + 0% { + transform: scale(0.3) rotate(6deg) translateX(-30%) translateY(30%); + opacity: 0.2; } + 100% { + transform: scale(1) rotate(0deg) translateX(0%) translateY(0%); + opacity: 1; } } + +@-webkit-keyframes bigEntrance { + 0% { + -webkit-transform: scale(0.3) rotate(6deg) translateX(-30%) translateY(30%); + opacity: 0.2; } + 100% { + -webkit-transform: scale(1) rotate(0deg) translateX(0%) translateY(0%); + opacity: 1; } } + +/* +============================================== +hatch +============================================== +*/ +.hatch { + animation-name: hatch; + -webkit-animation-name: hatch; + animation-duration: 2s; + -webkit-animation-duration: 2s; + animation-timing-function: ease-in-out; + -webkit-animation-timing-function: ease-in-out; + transform-origin: 50% 100%; + -ms-transform-origin: 50% 100%; + -webkit-transform-origin: 50% 100%; + visibility: visible !important; } + +@keyframes hatch { + 0% { + transform: rotate(0deg) scaleY(0.6); } + 100% { + transform: rotate(0deg); } } + +@-webkit-keyframes hatch { + 0% { + -webkit-transform: rotate(0deg) scaleY(0.6); } + 100% { + -webkit-transform: rotate(0deg); } } + +/* +============================================== +bounce +============================================== +*/ +.bounce { + animation-name: bounce; + -webkit-animation-name: bounce; + animation-duration: 1.6s; + -webkit-animation-duration: 1.6s; + animation-timing-function: ease; + -webkit-animation-timing-function: ease; + transform-origin: 50% 100%; + -ms-transform-origin: 50% 100%; + -webkit-transform-origin: 50% 100%; } + +@keyframes bounce { + 0% { + transform: translateY(0%) scaleY(0.6); } + 60% { + transform: translateY(-100%) scaleY(1.1); } + 70% { + transform: translateY(0%) scaleY(0.95) scaleX(1.05); } + 80% { + transform: translateY(0%) scaleY(1.05) scaleX(1); } + 90% { + transform: translateY(0%) scaleY(0.95) scaleX(1); } + 100% { + transform: translateY(0%) scaleY(1) scaleX(1); } } + +@-webkit-keyframes bounce { + 0% { + -webkit-transform: translateY(0%) scaleY(0.6); } + 60% { + -webkit-transform: translateY(-100%) scaleY(1.1); } + 70% { + -webkit-transform: translateY(0%) scaleY(0.95) scaleX(1.05); } + 80% { + -webkit-transform: translateY(0%) scaleY(1.05) scaleX(1); } + 90% { + -webkit-transform: translateY(0%) scaleY(0.95) scaleX(1); } + 100% { + -webkit-transform: translateY(0%) scaleY(1) scaleX(1); } } + +/* +============================================== +pulse +============================================== +*/ +.pulse { + animation-name: pulse; + -webkit-animation-name: pulse; + animation-duration: 1.5s; + -webkit-animation-duration: 1.5s; + animation-iteration-count: infinite; + -webkit-animation-iteration-count: infinite; } + +@keyframes pulse { + 0% { + transform: scale(0.9); + opacity: 0.7; } + 50% { + transform: scale(1); + opacity: 1; } + 100% { + transform: scale(0.9); + opacity: 0.7; } } + +@-webkit-keyframes pulse { + 0% { + -webkit-transform: scale(0.95); + opacity: 0.7; } + 50% { + -webkit-transform: scale(1); + opacity: 1; } + 100% { + -webkit-transform: scale(0.95); + opacity: 0.7; } } + +/* +============================================== +floating +============================================== +*/ +.floating { + animation-name: floating; + -webkit-animation-name: floating; + animation-duration: 1.5s; + -webkit-animation-duration: 1.5s; + animation-iteration-count: infinite; + -webkit-animation-iteration-count: infinite; } + +@keyframes floating { + 0% { + transform: translateY(0%); } + 50% { + transform: translateY(8%); } + 100% { + transform: translateY(0%); } } + +@-webkit-keyframes floating { + 0% { + -webkit-transform: translateY(0%); } + 50% { + -webkit-transform: translateY(8%); } + 100% { + -webkit-transform: translateY(0%); } } + +/* +============================================== +tossing +============================================== +*/ +.tossing { + animation-name: tossing; + -webkit-animation-name: tossing; + animation-duration: 2.5s; + -webkit-animation-duration: 2.5s; + animation-iteration-count: infinite; + -webkit-animation-iteration-count: infinite; } + +@keyframes tossing { + 0% { + transform: rotate(-4deg); } + 50% { + transform: rotate(4deg); } + 100% { + transform: rotate(-4deg); } } + +@-webkit-keyframes tossing { + 0% { + -webkit-transform: rotate(-4deg); } + 50% { + -webkit-transform: rotate(4deg); } + 100% { + -webkit-transform: rotate(-4deg); } } + +/* +============================================== +pullUp +============================================== +*/ +.pullUp { + animation-name: pullUp; + -webkit-animation-name: pullUp; + animation-duration: 1.1s; + -webkit-animation-duration: 1.1s; + animation-timing-function: ease-out; + -webkit-animation-timing-function: ease-out; + transform-origin: 50% 100%; + -ms-transform-origin: 50% 100%; + -webkit-transform-origin: 50% 100%; } + +@keyframes pullUp { + 0% { + transform: scaleY(0.1); } + 100% { + transform: scaleY(1); } } + +@-webkit-keyframes pullUp { + 0% { + -webkit-transform: scaleY(0.1); } + 100% { + -webkit-transform: scaleY(1); } } + +/* +============================================== +pullDown +============================================== +*/ +.pullDown { + animation-name: pullDown; + -webkit-animation-name: pullDown; + animation-duration: 1.1s; + -webkit-animation-duration: 1.1s; + animation-timing-function: ease-out; + -webkit-animation-timing-function: ease-out; + transform-origin: 50% 0; + -ms-transform-origin: 50% 0; + -webkit-transform-origin: 50% 0; } + +@keyframes pullDown { + 0% { + transform: scaleY(0.1); } + 100% { + transform: scaleY(1); } } + +@-webkit-keyframes pullDown { + 0% { + -webkit-transform: scaleY(0.1); } + 100% { + -webkit-transform: scaleY(1); } } + +/* +============================================== +stretchLeft +============================================== +*/ +.stretchLeft { + animation-name: stretchLeft; + -webkit-animation-name: stretchLeft; + animation-duration: 1.5s; + -webkit-animation-duration: 1.5s; + animation-timing-function: ease-out; + -webkit-animation-timing-function: ease-out; + transform-origin: 100% 0; + -ms-transform-origin: 100% 0; + -webkit-transform-origin: 100% 0; } + +@keyframes stretchLeft { + 0% { + transform: scaleX(0.3); } + 100% { + transform: scaleX(1); } } + +@-webkit-keyframes stretchLeft { + 0% { + -webkit-transform: scaleX(0.3); } + 100% { + -webkit-transform: scaleX(1); } } + +/* +============================================== +stretchRight +============================================== +*/ +.stretchRight { + animation-name: stretchRight; + -webkit-animation-name: stretchRight; + animation-duration: 1.5s; + -webkit-animation-duration: 1.5s; + animation-timing-function: ease-out; + -webkit-animation-timing-function: ease-out; + transform-origin: 0 0; + -ms-transform-origin: 0 0; + -webkit-transform-origin: 0 0; } + +@keyframes stretchRight { + 0% { + transform: scaleX(0.3); } + 100% { + transform: scaleX(1); } } + +@-webkit-keyframes stretchRight { + 0% { + -webkit-transform: scaleX(0.3); } + 100% { + -webkit-transform: scaleX(1); } } + +/* scss/Core/_Typography.scss */ +.text-raleway { + font-family: "Raleway", "Roboto", sans-serif; + padding-bottom: 1em; } + .text-raleway__title { + text-transform: uppercase; + line-height: 1; + margin-bottom: 0.2em; + font-size: 1.2em; } + .text-raleway__sub-title { + font-weight: 300; + font-size: 0.8em; + color: #acbac7; + line-height: 1; + margin: 0; } + .text-raleway--bold-text { + font-weight: 700; } + +.text-roboto { + font-family: "Roboto", sans-serif; + font-weight: 400; + padding-bottom: 1em; } + .text-roboto__title { + text-transform: uppercase; + line-height: 1; + margin-bottom: 0.2em; + font-size: 1.2em; } + .text-roboto__sub-title { + font-weight: 300; + font-size: 0.8em; + color: #acbac7; + line-height: 1; + margin: 0; } + .text-roboto--bold-text { + font-weight: 700; } + .text-roboto--light-text { + font-weight: 300; } + +/* Default Color overrides - White Text */ +.title--white, .text--white { + color: #ffffff; } + +h1, +h2, +h3, +h4, +h5, +h6, +p, +em, +b, +small { + color: #354866; } + +h1 { + font-family: "Raleway", "Roboto", sans-serif; + font-weight: 700; + font-size: 2.5rem; } + h1.title--white { + color: #ffffff; } + +h2 { + font-family: "Raleway", "Roboto", sans-serif; + font-weight: 300; + font-size: 2rem; } + +h3 { + font-family: "Roboto", sans-serif; + font-weight: 300; + font-size: 1.9rem; } + +h4 { + font-family: "Raleway", "Roboto", sans-serif; + text-transform: uppercase; + font-weight: 900; + color: #5361FD; + font-size: 1.25rem; } + +h5 { + font-family: "Raleway", "Roboto", sans-serif; + text-transform: uppercase; + font-size: 1rem; + color: #7D7D7D; } + +h6 { + font-family: "Roboto", sans-serif; + text-transform: uppercase; + font-size: 0.8rem; } + +h1 a, +h2 a, +h3 a, +h5 a, +h6 a { + text-decoration: none; } + +small { + font-family: "Roboto", sans-serif; + font-size: 0.625rem; + font-weight: 300; } + +em { + font-style: italic; } + +b { + font-weight: 700; } + +p { + font-family: "Roboto", sans-serif; + font-size: 1em; + line-height: 1.2; + font-weight: 300; + color: #666666; } + +a { + font-family: "Roboto", sans-serif; + color: #7C4DFF; + text-decoration: underline; } + +ul, +ol { + color: #354866; } + ul li, + ol li { + font-family: "Roboto", sans-serif; + font-size: 1em; + line-height: 1.6; + font-weight: 300; } + +/* scss/Core/_Colors.scss */ +.swatch-wrap { + width: 100%; + display: flex; + justify-content: flex-start; + flex-wrap: wrap; } + +.swatch { + display: flex; + flex-wrap: wrap; + align-content: center; + justify-content: space-around; + padding: 1em; + height: 250px; + flex-basis: 30%; + border-radius: 4px; + margin: 1em; } + .swatch__item { + display: flex; + flex-wrap: wrap; + align-content: center; + justify-content: space-around; + padding: 1em; + height: 300px; + flex-basis: 33%; + border-radius: 4px; + /* Gradients */ } + .swatch__item__title { + color: #fff; + width: 100%; + text-align: center; + line-height: 1; + margin: 0; + text-transform: uppercase; + font-weight: 700; } + .swatch__item__value { + font-weight: 300; + color: #fff; + width: 100%; + text-align: center; + line-height: 1; + margin: 0; + text-transform: uppercase; } + .swatch__item--primary { + background-color: #7C4DFF; } + .swatch__item--secondary { + background-color: #01C9EA; } + .swatch__item--tertiary { + background-color: #18A9E6; } + .swatch__item--tertiary-1 { + background-color: #DAE0E6; } + .swatch__item--tertiary-2 { + background-color: #083575; } + .swatch__item--tertiary-3 { + background-color: #01FAAB; } + .swatch__item--warning { + background-color: #FFA600; } + .swatch__item--error { + background-color: #D0021B; } + .swatch__item--primary-gradient { + background: #7C4DFF; + background: -webkit-linear-gradient(left, #7C4DFF 0%, #18A9E6 50%, #01C9EA 100%); + background: linear-gradient(to right, #7C4DFF 0%, #18A9E6 50%, #01C9EA 100%); } + +/* scss/Core/_Logos-Branding.scss */ +.logo__item { + display: flex; + width: 50%; } + .logo__item--primary-color { + background: #7C4DFF; } + +.logos { + display: flex; + justify-content: space-around; } + .logos .logo { + max-width: 45%; + width: 100%; + display: flex; + align-items: center; + padding-bottom: 2em; } + .logos .logo > img { + width: 100%; + height: 100%; + padding-bottom: 2em; } + .logos .logo > img.logo-small { + width: 200px; + margin: 0 auto; + margin-top: 1rem; } + +/* scss/Core/_Grid.scss */ +/* Grid System */ +.grid, .ids-container { + display: grid; } + .grid__two-column, .ids-container__two-column { + grid-template-columns: 1fr 1fr; } + .grid__three-column, .ids-container__three-column { + grid-template-columns: 1fr 1fr 1fr; } + .grid__four-column, .ids-container__four-column { + grid-template-columns: 1fr 1fr 1fr 1fr; } + .grid__five-column, .ids-container__five-column { + grid-template-columns: 1fr 1fr 1fr 1fr 1fr; } + .grid .item__span-2-columns, .ids-container .item__span-2-columns { + grid-column: span 2; } + .grid .item__span-3-columns, .ids-container .item__span-3-columns { + grid-column: span 3; } + .grid .item__span-4-columns, .ids-container .item__span-4-columns { + grid-column: span 4; } + .grid .item__span-5-columns, .ids-container .item__span-5-columns { + grid-column: span 5; } + +/* Demo Styles for Style Guide */ +.grid__two-column.demo { + padding: 1em 0; + grid-gap: 1%; + grid-row-gap: 1em; } + +.demoBlock { + background: #7C4DFF; + color: #fff; + margin: 1em; + justify-self: stretch; + line-height: 5; + text-align: center; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + -o-border-radius: 2px; + border-radius: 2px; } + +.demoContainer { + background: rgba(8, 53, 117, 0.05); } + +/* scss/Atoms/_Buttons.scss */ +/* Default button Styles */ +button, +link, +.ids-button, +.ids-link { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + font-family: "Raleway", "Roboto", sans-serif; + color: #449df5; + border: 1px solid #DAE0E6; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + cursor: pointer; + font-size: .8em; + text-decoration: none; + padding: 0.5em 4em; + transition: 0.25s all ease-in-out; + outline: none; } + button:hover, button[hover], + link:hover, + link[hover], + .ids-button:hover, + .ids-button[hover], + .ids-link:hover, + .ids-link[hover] { + border-color: #449DF5; + transition: 0.25s all ease-in-out; + color: #449DF5; } + button[disabled], + link[disabled], + .ids-button[disabled], + .ids-link[disabled] { + opacity: 0.5; + cursor: initial; } + button[disabled]:hover, + link[disabled]:hover, + .ids-button[disabled]:hover, + .ids-link[disabled]:hover { + border-color: #DAE0E6; + transition: 0.25s all ease-in-out; + color: #354866; } + button:active, button[active], + link:active, + link[active], + .ids-button:active, + .ids-button[active], + .ids-link:active, + .ids-link[active] { + background: #449DF5; + border-color: #449DF5; + color: #fff; + outline: none; } + +/* Filled button Styles */ +.ids-button-filled, +.ids-link-filled { + height: 40px; + width: 100%; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + text-transform: uppercase; + font-size: 12px; + letter-spacing: 1.07px; + line-height: 17px; + margin: 0 0 16px 0; + display: flex; + justify-content: center; + align-items: center; + text-decoration: none; + transition: all 0.5s ease-in-out; + font-family: "Raleway", "Roboto", sans-serif; + font-weight: 700; + /* Gradients */ } + .ids-button-filled--primary, + .ids-link-filled--primary { + background: #7C4DFF; } + .ids-button-filled--primary:hover, + .ids-link-filled--primary:hover { + background: #8f67ff; } + .ids-button-filled--secondary, + .ids-link-filled--secondary { + background-color: #01C9EA; } + .ids-button-filled--secondary:hover, + .ids-link-filled--secondary:hover { + background: #07dbfe; } + .ids-button-filled--tertiary, + .ids-link-filled--tertiary { + background-color: #18A9E6; } + .ids-button-filled--tertiary:hover, + .ids-link-filled--tertiary:hover { + background: #2eb2e9; } + .ids-button-filled--tertiary-1, + .ids-link-filled--tertiary-1 { + background-color: #DAE0E6; } + .ids-button-filled--tertiary-1:hover, + .ids-link-filled--tertiary-1:hover { + background: #e9edf0; } + .ids-button-filled--tertiary-2, + .ids-link-filled--tertiary-2 { + background-color: #083575; } + .ids-button-filled--tertiary-2:hover, + .ids-link-filled--tertiary-2:hover { + background: #0a408d; } + .ids-button-filled--tertiary-3, + .ids-link-filled--tertiary-3 { + background-color: #01FAAB; } + .ids-button-filled--tertiary-3:hover, + .ids-link-filled--tertiary-3:hover { + background: #16feb5; } + .ids-button-filled--primary-gradient, + .ids-link-filled--primary-gradient { + background: #7C4DFF; + background: -webkit-linear-gradient(left, #7C4DFF 0%, #18A9E6 50%, #01C9EA 100%); + background: linear-gradient(to right, #7C4DFF 0%, #18A9E6 50%, #01C9EA 100%); } + .ids-button-filled:hover, + .ids-link-filled:hover { + filter: opacity(95%); + text-decoration: none; + cursor: pointer; + transition: all 0.5s ease-in-out; } + +/* Outline/Stroke Button Styles */ +.ids-button-stroke, +.ids-link-stroke { + height: 40px; + width: 100%; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + text-transform: uppercase; + font-size: 12px; + letter-spacing: 1.07px; + line-height: 16px; + margin: 0 0 16px 0; + display: flex; + justify-content: center; + align-items: center; + text-decoration: none; + transition: all 0.5s ease-in-out; + border-width: 1px; + border-style: solid; + border-color: #DAE0E6; + background: rgba(255, 255, 255, 0); + font-weight: 700; + /* Gradients */ } + .ids-button-stroke--primary, + .ids-link-stroke--primary { + color: #7C4DFF; + border-color: #7C4DFF; } + .ids-button-stroke--primary:hover, + .ids-link-stroke--primary:hover { + background: #7c4dff; + color: #fff; } + .ids-button-stroke--secondary, + .ids-link-stroke--secondary { + color: #01C9EA; + border-color: #01C9EA; } + .ids-button-stroke--secondary:hover, + .ids-link-stroke--secondary:hover { + background: #01c9ea; + color: #fff; } + .ids-button-stroke--tertiary, + .ids-link-stroke--tertiary { + color: #18A9E6; + border-color: #18A9E6; } + .ids-button-stroke--tertiary:hover, + .ids-link-stroke--tertiary:hover { + background: #18a9e6; + color: #fff; } + .ids-button-stroke--tertiary-1, + .ids-link-stroke--tertiary-1 { + background-color: #DAE0E6; } + .ids-button-stroke--tertiary-1:hover, + .ids-link-stroke--tertiary-1:hover { + background: #e9edf0; } + .ids-button-stroke--tertiary-2, + .ids-link-stroke--tertiary-2 { + background-color: #083575; } + .ids-button-stroke--tertiary-2:hover, + .ids-link-stroke--tertiary-2:hover { + background: #0a408d; } + .ids-button-stroke--tertiary-3, + .ids-link-stroke--tertiary-3 { + background-color: #01FAAB; } + .ids-button-stroke--tertiary-3:hover, + .ids-link-stroke--tertiary-3:hover { + background: #16feb5; } + .ids-button-stroke--primary-gradient, + .ids-link-stroke--primary-gradient { + background: #7C4DFF; + background: -webkit-linear-gradient(left, #7C4DFF 0%, #18A9E6 50%, #01C9EA 100%); + background: linear-gradient(to right, #7C4DFF 0%, #18A9E6 50%, #01C9EA 100%); } + +/* scss/Atoms/_Pills.scss */ +button { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + color: #449df5; + border: 1px solid #DAE0E6; + cursor: pointer; + font-size: .8em; + padding: 0.5em 1em; + transition: 0.25s all ease-in-out; + outline: none; } + button.pill { + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + -o-border-radius: 100px; + border-radius: 100px; } + button:hover, button[hover] { + border-color: #449DF5; + transition: 0.25s all ease-in-out; + color: #449DF5; } + button[disabled] { + opacity: 0.5; } + button:active, button[active], button.active { + background: #449DF5; + border-color: #449DF5; + color: #fff; + outline: none; } + button.filter { + align-items: center; + justify-content: center; + background: #e9edf0; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + padding: 0.25em 1em; } + button.filter img { + max-width: 13px; + width: 100%; + padding-right: 0.2em; + margin-top: 1px; } + button.filter:hover, button.filter[hover] { + border-color: #449DF5; + transition: 0.25s all ease-in-out; + color: #354866; } + button.filter[disabled] { + opacity: 0.5; } + button.filter:active, button.filter[active], button.filter.active { + border-color: #449DF5; + transition: 0.25s all ease-in-out; + color: #449DF5; + background: #edf6fe; } + +/* scss/Atoms/_Input.scss */ +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + border: 1px solid #DAE0E6; + background: none; + font-size: .8em; + padding: .5em 0; + text-indent: .5em; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; } + select::after { + content: "\f107"; + font-family: FontAwesome; + font-size: 11px; + color: #aaa; + right: 8px; + top: 3px; + padding: 0 0 1px; + position: absolute; + pointer-events: none; } + select::before { + content: "\f107"; + font-family: FontAwesome; + right: 4px; + top: 0px; + width: 20px; + height: 16px; + background: #fff; + position: absolute; + pointer-events: none; + display: block; } + +input, select, textarea { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + width: 100%; + font-family: "Raleway", "Roboto", sans-serif; + outline: none; + transition: all 0.25s ease-in-out; } + +/*input:read-only { + border: none; + &:focus { + outline: none; + } + } */ +input, +textarea { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + width: 100%; + font-family: "Raleway", "Roboto", sans-serif; + outline: none; + transition: all 0.25s ease-in-out; } + input:disabled, input[disabled], + textarea:disabled, + textarea[disabled] { + pointer-events: none; } + input[type="text"], + input[type="password"], + input[type="email"], + textarea[type="text"] { + border: 1px solid #DAE0E6; + font-size: 0.8em; + padding: 0.5em 0; + text-indent: 0.75em; + border-radius: 4px; } + input[type="text"]:active, input[type="text"][active], input[type="text"]:focus, + input[type="email"]:active, input[type="email"][active], input[type="email"]:focus, + input[type="password"]:active, input[type="password"][active], input[type="password"]:focus, + textarea[type="text"]:active, + textarea[type="text"][active], + textarea[type="text"]:focus { + border-color: #449DF5; } + input[type="text"]:invalid, input[type="text"][invalid], + input[type="email"]:invalid, input[type="eamil"][invalid], + input[type="password"]:invalid, input[type="password"][invalid], + textarea[type="text"]:invalid, + textarea[type="text"][invalid] { + border-color: #D0021B; } + input[type="text"]::placeholder, + input[type="email"]::placeholder, + input[type="password"]::placeholder, + textarea[type="text"]::placeholder { + color: rgba(0, 0, 0, 0.3); } + +.read-only, [readonly] { + border: none; + pointer-events: none; } + .read-only:focus, .read-only :active, [readonly]:focus, [readonly] :active { + outline: none; + border: none; + pointer-events: none; } + +.input-wrap { + display: flex; + flex-wrap: wrap; + margin: 1em 0; } + .input-wrap--inline { + flex-wrap: nowrap !important; + align-items: center; } + .input-wrap--inline label { + margin-right: .5em; + line-height: 1; + font-family: "Raleway", "Roboto", sans-serif; + height: auto; } + .input-wrap--inline label span.icon, .input-wrap--inline label i.icon { + font-size: 1rem; } + .input-wrap label { + font-size: .8em; + color: #354866; + font-family: "Raleway", "Roboto", sans-serif; + padding-bottom: 0.3em; } + .input-wrap input, .input-wrap select, .input-wrap textarea { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + width: 100%; + font-family: "Raleway", "Roboto", sans-serif; + outline: none; + transition: all 0.25s ease-in-out; } + .input-wrap input[type="text"], .input-wrap select[type="text"], .input-wrap textarea[type="text"] { + border: 1px solid #DAE0E6; + font-size: .8em; + padding: .5em 0; + text-indent: .5em; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; } + .input-wrap input[type="text"]:active, .input-wrap input[type="text"][active], .input-wrap input[type="text"]:focus, .input-wrap select[type="text"]:active, .input-wrap select[type="text"][active], .input-wrap select[type="text"]:focus, .input-wrap textarea[type="text"]:active, .input-wrap textarea[type="text"][active], .input-wrap textarea[type="text"]:focus { + border-color: #449DF5; } + .input-wrap input[type="text"]:invalid, .input-wrap input[type="text"][invalid], .input-wrap select[type="text"]:invalid, .input-wrap select[type="text"][invalid], .input-wrap textarea[type="text"]:invalid, .input-wrap textarea[type="text"][invalid] { + border-color: #D0021B; } + +input.read-only, input[readonly], +select.read-only, +select[readonly], +textarea.read-only, +textarea[readonly], +input[type="text"].read-only, +input[type="text"][readonly], +input[type="email"].read-only, +input[type="email"][readonly], +input[type="number"].read-only, +input[type="number"][readonly] { + border: none; + pointer-events: none; + background: none; } + input.read-only:focus, input.read-only :active, input[readonly]:focus, input[readonly] :active, + select.read-only:focus, + select.read-only :active, + select[readonly]:focus, + select[readonly] :active, + textarea.read-only:focus, + textarea.read-only :active, + textarea[readonly]:focus, + textarea[readonly] :active, + input[type="text"].read-only:focus, + input[type="text"].read-only :active, + input[type="text"][readonly]:focus, + input[type="text"][readonly] :active, + input[type="email"].read-only:focus, + input[type="email"].read-only :active, + input[type="email"][readonly]:focus, + input[type="email"][readonly] :active, + input[type="number"].read-only:focus, + input[type="number"].read-only :active, + input[type="number"][readonly]:focus, + input[type="number"][readonly] :active { + outline: none; + border: none; + pointer-events: none; } + +.input-wrap input.read-only, .input-wrap input[readonly], +.input-wrap select.read-only, +.input-wrap select[readonly], +.input-wrap textarea.read-only, +.input-wrap textarea[readonly], +.input-wrap input[type="text"].read-only, +.input-wrap input[type="text"][readonly], +.input-wrap input[type="email"].read-only, +.input-wrap input[type="email"][readonly], +.input-wrap input[type="number"].read-only, +.input-wrap input[type="number"][readonly], +.input-wrap--inline input.read-only, +.input-wrap--inline input[readonly], +.input-wrap--inline select.read-only, +.input-wrap--inline select[readonly], +.input-wrap--inline textarea.read-only, +.input-wrap--inline textarea[readonly], +.input-wrap--inline input[type="text"].read-only, +.input-wrap--inline input[type="text"][readonly], +.input-wrap--inline input[type="email"].read-only, +.input-wrap--inline input[type="email"][readonly], +.input-wrap--inline input[type="number"].read-only, +.input-wrap--inline input[type="number"][readonly], +.select-wrap input.read-only, +.select-wrap input[readonly], +.select-wrap select.read-only, +.select-wrap select[readonly], +.select-wrap textarea.read-only, +.select-wrap textarea[readonly], +.select-wrap input[type="text"].read-only, +.select-wrap input[type="text"][readonly], +.select-wrap input[type="email"].read-only, +.select-wrap input[type="email"][readonly], +.select-wrap input[type="number"].read-only, +.select-wrap input[type="number"][readonly], +.select-wrap--inline input.read-only, +.select-wrap--inline input[readonly], +.select-wrap--inline select.read-only, +.select-wrap--inline select[readonly], +.select-wrap--inline textarea.read-only, +.select-wrap--inline textarea[readonly], +.select-wrap--inline input[type="text"].read-only, +.select-wrap--inline input[type="text"][readonly], +.select-wrap--inline input[type="email"].read-only, +.select-wrap--inline input[type="email"][readonly], +.select-wrap--inline input[type="number"].read-only, +.select-wrap--inline input[type="number"][readonly], +.custom-select input.read-only, +.custom-select input[readonly], +.custom-select select.read-only, +.custom-select select[readonly], +.custom-select textarea.read-only, +.custom-select textarea[readonly], +.custom-select input[type="text"].read-only, +.custom-select input[type="text"][readonly], +.custom-select input[type="email"].read-only, +.custom-select input[type="email"][readonly], +.custom-select input[type="number"].read-only, +.custom-select input[type="number"][readonly] { + border: none; + pointer-events: none; } + .input-wrap input.read-only:focus, .input-wrap input.read-only :active, .input-wrap input[readonly]:focus, .input-wrap input[readonly] :active, + .input-wrap select.read-only:focus, + .input-wrap select.read-only :active, + .input-wrap select[readonly]:focus, + .input-wrap select[readonly] :active, + .input-wrap textarea.read-only:focus, + .input-wrap textarea.read-only :active, + .input-wrap textarea[readonly]:focus, + .input-wrap textarea[readonly] :active, + .input-wrap input[type="text"].read-only:focus, + .input-wrap input[type="text"].read-only :active, + .input-wrap input[type="text"][readonly]:focus, + .input-wrap input[type="text"][readonly] :active, + .input-wrap input[type="email"].read-only:focus, + .input-wrap input[type="email"].read-only :active, + .input-wrap input[type="email"][readonly]:focus, + .input-wrap input[type="email"][readonly] :active, + .input-wrap input[type="number"].read-only:focus, + .input-wrap input[type="number"].read-only :active, + .input-wrap input[type="number"][readonly]:focus, + .input-wrap input[type="number"][readonly] :active, + .input-wrap--inline input.read-only:focus, + .input-wrap--inline input.read-only :active, + .input-wrap--inline input[readonly]:focus, + .input-wrap--inline input[readonly] :active, + .input-wrap--inline select.read-only:focus, + .input-wrap--inline select.read-only :active, + .input-wrap--inline select[readonly]:focus, + .input-wrap--inline select[readonly] :active, + .input-wrap--inline textarea.read-only:focus, + .input-wrap--inline textarea.read-only :active, + .input-wrap--inline textarea[readonly]:focus, + .input-wrap--inline textarea[readonly] :active, + .input-wrap--inline input[type="text"].read-only:focus, + .input-wrap--inline input[type="text"].read-only :active, + .input-wrap--inline input[type="text"][readonly]:focus, + .input-wrap--inline input[type="text"][readonly] :active, + .input-wrap--inline input[type="email"].read-only:focus, + .input-wrap--inline input[type="email"].read-only :active, + .input-wrap--inline input[type="email"][readonly]:focus, + .input-wrap--inline input[type="email"][readonly] :active, + .input-wrap--inline input[type="number"].read-only:focus, + .input-wrap--inline input[type="number"].read-only :active, + .input-wrap--inline input[type="number"][readonly]:focus, + .input-wrap--inline input[type="number"][readonly] :active, + .select-wrap input.read-only:focus, + .select-wrap input.read-only :active, + .select-wrap input[readonly]:focus, + .select-wrap input[readonly] :active, + .select-wrap select.read-only:focus, + .select-wrap select.read-only :active, + .select-wrap select[readonly]:focus, + .select-wrap select[readonly] :active, + .select-wrap textarea.read-only:focus, + .select-wrap textarea.read-only :active, + .select-wrap textarea[readonly]:focus, + .select-wrap textarea[readonly] :active, + .select-wrap input[type="text"].read-only:focus, + .select-wrap input[type="text"].read-only :active, + .select-wrap input[type="text"][readonly]:focus, + .select-wrap input[type="text"][readonly] :active, + .select-wrap input[type="email"].read-only:focus, + .select-wrap input[type="email"].read-only :active, + .select-wrap input[type="email"][readonly]:focus, + .select-wrap input[type="email"][readonly] :active, + .select-wrap input[type="number"].read-only:focus, + .select-wrap input[type="number"].read-only :active, + .select-wrap input[type="number"][readonly]:focus, + .select-wrap input[type="number"][readonly] :active, + .select-wrap--inline input.read-only:focus, + .select-wrap--inline input.read-only :active, + .select-wrap--inline input[readonly]:focus, + .select-wrap--inline input[readonly] :active, + .select-wrap--inline select.read-only:focus, + .select-wrap--inline select.read-only :active, + .select-wrap--inline select[readonly]:focus, + .select-wrap--inline select[readonly] :active, + .select-wrap--inline textarea.read-only:focus, + .select-wrap--inline textarea.read-only :active, + .select-wrap--inline textarea[readonly]:focus, + .select-wrap--inline textarea[readonly] :active, + .select-wrap--inline input[type="text"].read-only:focus, + .select-wrap--inline input[type="text"].read-only :active, + .select-wrap--inline input[type="text"][readonly]:focus, + .select-wrap--inline input[type="text"][readonly] :active, + .select-wrap--inline input[type="email"].read-only:focus, + .select-wrap--inline input[type="email"].read-only :active, + .select-wrap--inline input[type="email"][readonly]:focus, + .select-wrap--inline input[type="email"][readonly] :active, + .select-wrap--inline input[type="number"].read-only:focus, + .select-wrap--inline input[type="number"].read-only :active, + .select-wrap--inline input[type="number"][readonly]:focus, + .select-wrap--inline input[type="number"][readonly] :active, + .custom-select input.read-only:focus, + .custom-select input.read-only :active, + .custom-select input[readonly]:focus, + .custom-select input[readonly] :active, + .custom-select select.read-only:focus, + .custom-select select.read-only :active, + .custom-select select[readonly]:focus, + .custom-select select[readonly] :active, + .custom-select textarea.read-only:focus, + .custom-select textarea.read-only :active, + .custom-select textarea[readonly]:focus, + .custom-select textarea[readonly] :active, + .custom-select input[type="text"].read-only:focus, + .custom-select input[type="text"].read-only :active, + .custom-select input[type="text"][readonly]:focus, + .custom-select input[type="text"][readonly] :active, + .custom-select input[type="email"].read-only:focus, + .custom-select input[type="email"].read-only :active, + .custom-select input[type="email"][readonly]:focus, + .custom-select input[type="email"][readonly] :active, + .custom-select input[type="number"].read-only:focus, + .custom-select input[type="number"].read-only :active, + .custom-select input[type="number"][readonly]:focus, + .custom-select input[type="number"][readonly] :active { + outline: none; + border: none; + pointer-events: none; } + +.input-wrap.read-only::after, .input-wrap[readonly]::after, +.input-wrap--inline.read-only::after, +.input-wrap--inline[readonly]::after, +.select-wrap.read-only::after, +.select-wrap[readonly]::after, +.select-wrap--inline.read-only::after, +.select-wrap--inline[readonly]::after, +.custom-select.read-only::after, +.custom-select[readonly]::after { + content: ""; + display: none; } + +.input-wrap.read-only::before, .input-wrap[readonly]::before, +.input-wrap--inline.read-only::before, +.input-wrap--inline[readonly]::before, +.select-wrap.read-only::before, +.select-wrap[readonly]::before, +.select-wrap--inline.read-only::before, +.select-wrap--inline[readonly]::before, +.custom-select.read-only::before, +.custom-select[readonly]::before { + content: ""; + display: none; } + +/* scss/Atoms/_Select.scss */ +.select-wrap { + width: 100%; + display: flex; + align-content: center; + background: #fff; } + .select-wrap::after { + color: #DAE0E6; + content: "\f107"; + font-family: "FontAwesome"; + align-self: center; + position: relative; + font-size: 2em; + top: 0; + left: -1em; } + .select-wrap select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: #fff; + outline: none; + color: #fff; } + .select-wrap select:active, .select-wrap select:focus { + border-color: #449DF5; } + .select-wrap select option { + appearance: menulist; + -webkit-appearance: menulist; + -moz-appearance: menulist; + color: #fff; + background: blue; } + +/* scss/Atoms/_Checkbox.scss */ +label.checkbox { + display: flex; + align-items: center; } + label.checkbox input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + border: 1px solid #DAE0E6; + width: 2em; + height: 2em; + margin-right: 1em; + margin-left: 0; + cursor: pointer; + -webkit-border-radius: 1px; + -moz-border-radius: 1px; + -o-border-radius: 1px; + border-radius: 4px; } + label.checkbox input[type="checkbox"]:hover { + border-color: #449DF5; } + label.checkbox input[type="checkbox"]:checked { + border-color: #449DF5; + color: #449DF5; } + label.checkbox input[type="checkbox"]:checked::before { + color: #449DF5; + content: "\2713"; + align-self: center; + position: relative; + font-size: 1.5em; + display: flex; + justify-content: center; + align-items: center; } + label.checkbox input[type="checkbox"][intermediate] { + border-color: #449DF5; + color: #449DF5; } + label.checkbox input[type="checkbox"][intermediate]::before { + color: #449DF5; + content: "X"; + align-self: center; + position: relative; + font-size: 1.5em; + display: inline-block; + top: 0.1em; + left: .2em; } + label.checkbox[disabled] { + opacity: 0.5; + cursor: initial; + pointer-events: none; } + +/* scss/Atoms/_Radio.scss */ +label.radio { + display: flex; + align-items: center; + padding: 0; } + label.radio input[type="radio"] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + border: 1px solid #DAE0E6; + width: 2em; + height: 2em; + margin-right: 1em; + cursor: pointer; + -webkit-border-radius: 20em; + -moz-border-radius: 20em; + -o-border-radius: 20em; + border-radius: 20em; } + label.radio input[type="radio"]:hover { + border-color: #449DF5; } + label.radio input[type="radio"]:checked { + border-color: #449DF5; + color: #449DF5; + font-weight: bold; } + label.radio input[type="radio"]:checked::before { + content: " "; + align-self: center; + position: relative; + display: inline-block; + width: 12px; + height: 12px; + -webkit-border-radius: 50em; + -moz-border-radius: 50em; + -o-border-radius: 50em; + border-radius: 50em; + background-color: #449DF5; } + label.radio input[type="radio"]:checked + label { + font-weight: 700; } + label.radio[disabled] { + opacity: 0.5; + cursor: initial; } + +input[type="radio"] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + border: 1px solid #DAE0E6; + width: 2em; + height: 2em; + margin-right: 1em; + cursor: pointer; + -webkit-border-radius: 20em; + -moz-border-radius: 20em; + -o-border-radius: 20em; + border-radius: 20em; } + input[type="radio"]:hover { + border-color: #449DF5; } + input[type="radio"]:checked { + border-color: #449DF5; + color: #449DF5; + font-weight: bold; } + input[type="radio"]:checked::before { + content: " "; + align-self: center; + position: relative; + display: block; + width: 12px; + height: 12px; + -webkit-border-radius: 50em; + -moz-border-radius: 50em; + -o-border-radius: 50em; + border-radius: 50em; + background-color: #449DF5; + top: 17%; + left: 19%; } + +/* scss/Atoms/_Toggle.scss */ +label.switch { + position: relative; + display: inline-block; + min-width: 50px; + height: 15px; + margin: 1em; + /* Rounded sliders */ } + label.switch input[type="checkbox" i], label.switch input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + opacity: 0; + width: 0; + height: 0; } + label.switch span.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: .4s; + transition: .4s; } + label.switch span.slider:before { + position: absolute; + content: ""; + height: 1.5em; + width: 1.5em; + left: 0; + bottom: -.3em; + box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1); + background-color: #fff; + -webkit-transition: .4s; + transition: .4s; } + label.switch input[type="checkbox"]:checked + .slider { + background-color: #449DF5; } + label.switch input[type="checkbox"]:checked + .slider:before { + -webkit-transform: translateX(110%); + -ms-transform: translateX(110%); + transform: translateX(110%); } + label.switch .slider.round { + -webkit-border-radius: 34px; + -moz-border-radius: 34px; + -o-border-radius: 34px; + border-radius: 34px; } + label.switch .slider.round:before { + -webkit-border-radius: 34px; + -moz-border-radius: 34px; + -o-border-radius: 34px; + border-radius: 34px; } + +input[type="checkbox"]:disabled + .slider { + opacity: .5; + cursor: initial; + pointer-events: none; } + +/* scss/Atoms/_Range-Slider.scss */ +input[type="range"] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + height: 2px; + -webkit-border-radius: 1px; + -moz-border-radius: 1px; + -o-border-radius: 1px; + border-radius: 1px; + -webkit-box-sizing: content-box; + box-sizing: content-box; } + input[type="range"]::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-color: #fff; + height: 20px; + width: 20px; + border-radius: 50em; + box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.2); } + +[type='range'] { + --range: calc(var(--max) - var(--min)); + --ratio: calc((var(--val) - var(--min))/var(--range)); + --sx: calc(.5*1.5em + var(--ratio)*(100% - 1.5em)); + margin: 0; + padding: 0; + width: 100%; + height: 1.5em; + background: transparent; + font: 1em/1 arial, sans-serif; } + [type='range'], [type='range']::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; } + [type='range']::-webkit-slider-runnable-track { + box-sizing: border-box; + border: none; + width: 100%; + height: 0.25em; + background: #ccc; + -webkit-border-radius: 1em; + -moz-border-radius: 1em; + -o-border-radius: 1em; + border-radius: 1em; + outline: none; } + .js [type='range']::-webkit-slider-runnable-track { + background: transparent; } + [type='range']::-webkit-slider-runnable-track:focus, [type='range']::-webkit-slider-runnable-track:active { + outline: none; + border: none; } + [type='range']::-moz-range-track { + box-sizing: border-box; + border: none; + width: 100%; + height: 0.25em; + background: #ccc; + -webkit-border-radius: 1em; + -moz-border-radius: 1em; + -o-border-radius: 1em; + border-radius: 1em; + outline: none; } + [type='range']::-moz-range-track:focus, [type='range']::-moz-range-track:active { + outline: none; + border: none; } + [type='range']::-ms-track { + box-sizing: border-box; + border: none; + width: 100%; + height: 0.25em; + background: #ccc; + -webkit-border-radius: 1em; + -moz-border-radius: 1em; + -o-border-radius: 1em; + border-radius: 1em; + outline: none; } + [type='range']::-ms-track:focus, [type='range']::-ms-track:active { + outline: none; + border: none; } + [type='range']::-moz-range-progress { + display: block; + background: rgba(1, 201, 234, 0.8); } + [type='range']::-ms-fill-lower { + display: block; + background: rgba(1, 201, 234, 0.8); } + [type='range']::-webkit-slider-thumb { + margin-top: -0.625em; + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0); + width: 1.5em; + height: 1.5em; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; + -o-border-radius: 50%; + border-radius: 50%; + background: #fff; + cursor: pointer; + box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + -webkit-box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + -moz-box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + -o-box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + transition: all 0.25s ease-in-out; } + [type='range']::-webkit-slider-thumb:hover { + border: 1px solid #01c9ea; + transition: all 0.25s ease-in-out; } + [type='range']::-webkit-slider-thumb:focus { + outline: none; } + [type='range']::-moz-range-thumb { + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0); + width: 1.5em; + height: 1.5em; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; + -o-border-radius: 50%; + border-radius: 50%; + background: #fff; + cursor: pointer; + box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + -webkit-box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + -moz-box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + -o-box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + transition: all 0.25s ease-in-out; } + [type='range']::-moz-range-thumb:hover { + border: 1px solid #01c9ea; + transition: all 0.25s ease-in-out; } + [type='range']::-moz-range-thumb:focus { + outline: none; } + [type='range']::-ms-thumb { + margin-top: 0; + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0); + width: 1.5em; + height: 1.5em; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; + -o-border-radius: 50%; + border-radius: 50%; + background: #fff; + cursor: pointer; + box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + -webkit-box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + -moz-box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + -o-box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.3); + transition: all 0.25s ease-in-out; } + [type='range']::-ms-thumb:hover { + border: 1px solid #01c9ea; + transition: all 0.25s ease-in-out; } + [type='range']::-ms-thumb:focus { + outline: none; } + [type='range']::-ms-tooltip { + display: none; } + +/* scss/Atoms/_Card.scss */ +.card { + display: flex; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2); + -webkit-box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2); + -moz-box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2); + -o-box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2); + align-items: center; + flex-wrap: wrap; + flex-direction: column; + justify-content: center; + padding: 1em 0; + border: 1px solid rgba(124, 77, 255, 0); + transition: all .25s ease-in-out; + width: 100%; } + .card.interactive { + cursor: pointer; } + .card.interactive:hover { + border: 1px solid #7c4dff; + transition: all .25s ease-in-out; } + +/* scss/Molecules/_Notifications.scss */ +/* Banner Notification Styles */ +.banner-wrap { + width: 100%; + display: flex; + padding: 1em; + flex-direction: row; + justify-content: space-between; + margin-bottom: 1em; + box-sizing: border-box; + /* Gradients */ } + .banner-wrap--primary { + background-color: #7C4DFF; + color: #fff; } + .banner-wrap--secondary { + background-color: #01C9EA; + color: #fff; } + .banner-wrap--tertiary { + background-color: #18A9E6; + color: #fff; } + .banner-wrap--tertiary-1 { + background-color: #DAE0E6; } + .banner-wrap--tertiary-2 { + background-color: #083575; } + .banner-wrap--tertiary-3 { + background-color: #01FAAB; } + .banner-wrap--error { + background-color: #D0021B; + color: #fff; } + .banner-wrap--warning { + background-color: #FFA600; + color: #fff; } + .banner-wrap--primary-gradient { + background: #7C4DFF; + background: -webkit-linear-gradient(left, #7C4DFF 0%, #18A9E6 50%, #01C9EA 100%); + background: linear-gradient(to right, #7C4DFF 0%, #18A9E6 50%, #01C9EA 100%); } + +.banner, .banner-wrap { + width: 100%; + display: flex; + padding: 1em; + flex-direction: row; + justify-content: space-between; + margin-bottom: 1em; + box-sizing: border-box; } + +/* Toaster Notification Styles */ +.toaster-wrap, .toaster { + display: flex; + padding: 1em; + flex-direction: row; + justify-content: space-between; + margin-bottom: 1em; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + width: 100%; + cursor: pointer; + box-sizing: border-box; } + .toaster-wrap:first-of-type, .toaster:first-of-type { + margin-left: 0; } + +.toaster-wrap { + transition: all 0.25s ease-in-out; + cursor: pointer; + box-sizing: border-box; } + .toaster-wrap--primary { + background-color: rgba(124, 77, 255, 0.9); + color: #fff; + transition: all 0.25s ease-in-out; } + .toaster-wrap--primary:hover { + background-color: #7c4dff; + transition: all 0.25s ease-in-out; } + .toaster-wrap--secondary { + background-color: rgba(1, 201, 234, 0.9); + color: #fff; + transition: all 0.25s ease-in-out; } + .toaster-wrap--secondary:hover { + background-color: #01c9ea; + transition: all 0.25s ease-in-out; } + .toaster-wrap--tertiary { + background-color: rgba(24, 169, 230, 0.9); + color: #fff; + transition: all 0.25s ease-in-out; } + .toaster-wrap--tertiary:hover { + background-color: #18a9e6; + transition: all 0.25s ease-in-out; } + .toaster-wrap--tertiary-1 { + background-color: rgba(218, 224, 230, 0.9); + transition: all 0.25s ease-in-out; } + .toaster-wrap--tertiary-1:hover { + background-color: #dae0e6; + transition: all 0.25s ease-in-out; } + .toaster-wrap--tertiary-2 { + background-color: rgba(8, 53, 117, 0.9); + transition: all 0.25s ease-in-out; } + .toaster-wrap--tertiary-2:hover { + background-color: #083575; + transition: all 0.25s ease-in-out; } + .toaster-wrap--tertiary-3 { + background-color: rgba(1, 250, 171, 0.9); + transition: all 0.25s ease-in-out; } + .toaster-wrap--tertiary-3:hover { + background-color: #01faab; + transition: all 0.25s ease-in-out; } + .toaster-wrap--error { + background-color: #D0021B; + transition: all 0.25s ease-in-out; } + .toaster-wrap--error:hover { + background-color: #d0021b; + transition: all 0.25s ease-in-out; } + .toaster-wrap--warning { + background-color: #FFA600; + transition: all 0.25s ease-in-out; } + .toaster-wrap--warning:hover { + background-color: #ffa600; + transition: all 0.25s ease-in-out; } + +.toaster-wrap { + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + box-sizing: border-box; } + .toaster-wrap__content { + color: #fff; } + .toaster-wrap__content .content__title { + font-weight: bold; + text-transform: uppercase; + margin: 0; + margin-bottom: 0.25em; + color: #fff; } + .toaster-wrap__content .content__message { + margin: 0; + font-size: 0.9rem; + color: #fff; } + +.banner-wrap__dismiss, .banner-wrap__dismiss i, +.toaster-wrap__dismiss, .toaster-wrap__dismiss i { + color: #fff; + opacity: 0.7; + transition: all 0.25s ease-in-out; + cursor: pointer; } + .banner-wrap__dismiss:hover, .banner-wrap__dismiss i:hover, + .toaster-wrap__dismiss:hover, .toaster-wrap__dismiss i:hover { + transition: all 0.25s ease-in-out; + opacity: 1; } + +/* Demo Display Styles for Toaster Wrap */ +section.grid.grid__three-column.demo { + grid-gap: 1%; } + +.banner-wrap { + box-sizing: border-box; } + .banner-wrap--footer { + box-sizing: border-box; + position: fixed; + bottom: 0; + margin: 0; + left: 0; + background: #7C4DFF; + color: #fff; + z-index: 9999; + padding: 0; + box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1); } + .banner-wrap--footer > .banner-wrap__content { + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + width: 100%; } + .banner-wrap--footer > .banner-wrap__content > i.icon { + font-size: 1.5rem; + margin-right: .5em; } + .banner-wrap--footer > .banner-wrap__content > a { + color: #fff; + text-decoration: none; + text-transform: uppercase; + width: 100%; + padding: 1em; + text-align: center; + margin: 0 auto; + transition: all 0.25s ease-in-out; } + .banner-wrap--footer > .banner-wrap__content > a:hover { + background: #7443ff; + transition: all 0.25s ease-in-out; } + +/* scss/Molecules/_Table.scss */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + overflow: scroll; } + table thead { + background: #fff; } + table thead tr { + text-align: left; + color: #666666; + padding: 0 1em; } + table thead tr th { + padding: 1em; + border-top: 1px solid rgba(218, 224, 230, 0.8); + border-bottom: 1px solid rgba(218, 224, 230, 0.8); + white-space: nowrap; + width: 1%; } + table thead tr th.sortable { + cursor: pointer; } + table thead tr th.name-value { + padding-left: 0; } + table tbody { + padding: 0 1em; } + table tbody tr { + color: #666666; + cursor: pointer; + transition: all 0.5s ease-in-out; + padding: 0 1em; + border-bottom: 1px solid rgba(218, 224, 230, 0.8); } + table tbody tr:hover { + background: rgba(124, 77, 255, 0.2); } + table tbody tr td { + padding: 1em; + white-space: nowrap; + width: 1%; } + table tbody tr td span.file-preview { + text-align: center; } + table tbody tr td span.file-preview img { + display: block; + margin: 0 auto; + text-align: center; + max-width: 30px; + height: 30px; + width: 100%; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + -o-border-radius: 2px; + border-radius: 2px; } + table tbody tr td.menu-additional { + text-align: center; + margin: 0 auto; + min-width: 1em; } + table tbody tr td.menu-additional i { + text-align: center; + margin: 0 auto; + display: block; } + table tbody tr td.preview { + width: 55px; + white-space: nowrap; + display: block; + padding: 1em 0; } + table tbody tr td.preview span.type-icon { + position: relative; + margin-bottom: 0; + top: 1em; + left: .5em; + width: 10px; + margin-top: -21px; + display: block; } + table tbody tr td.preview span.type-icon img { + min-width: 18px; + width: 100%; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); } + table tbody tr td.name-value { + max-width: 250px; + min-width: 150px; + padding-left: 0; } + +table.fixed-col__table thead tr th.preview { + width: 55px; + border-bottom: 1px solid rgba(218, 224, 230, 0.8); + height: 45px; } + table.fixed-col__table thead tr th.preview span.type-icon { + position: relative; + margin-bottom: 0; + top: 1em; + left: .5em; + width: 10px; + margin-top: -21px; + display: block; } + table.fixed-col__table thead tr th.preview span.type-icon img { + min-width: 18px; + width: 100%; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); } + table.fixed-col__table thead tr th.preview.fixed-col__item { + position: absolute; + top: auto; + background: #fff; + z-index: 100; } + +table.fixed-col__table thead tr th.name { + border-right: 1px solid rgba(218, 224, 230, 0.8); + border-bottom: 1px solid rgba(218, 224, 230, 0.8); + top: auto; + width: 200px; + left: 55px; + padding: 1em 0; } + table.fixed-col__table thead tr th.name.fixed-col__item { + position: absolute; + top: auto; + background: #fff; + z-index: 100; } + +table.fixed-col__table tbody tr td.fixed-col__item { + position: absolute; + top: auto; + background: #fff; + z-index: 100; } + table.fixed-col__table tbody tr td.fixed-col__item.preview { + width: 55px; + border-bottom: 1px solid rgba(218, 224, 230, 0.8); + height: 50px; } + table.fixed-col__table tbody tr td.fixed-col__item.preview span.type-icon { + position: relative; + margin-bottom: 0; + top: 1em; + left: .5em; + width: 10px; + margin-top: -21px; + display: block; } + table.fixed-col__table tbody tr td.fixed-col__item.preview span.type-icon img { + min-width: 18px; + width: 100%; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); } + table.fixed-col__table tbody tr td.fixed-col__item.name-value { + width: 200px; + padding-left: 1em; + left: 55px; + border-right: 1px solid rgba(218, 224, 230, 0.8); + border-right: 1px solid rgba(218, 224, 230, 0.8); } + +.fixed-col { + position: absolute; + top: auto; + background: #fff; + z-index: 100; } + .fixed-col__item { + position: absolute; + top: auto; + background: #fff; + z-index: 100; } + +.fixed-table-wrap { + max-width: 400px; + /* width value for example purpose only, change or remove to suit your needs */ + width: 100%; + display: block; + position: relative; } + .fixed-table-wrap .fixed-table-scroller { + left: 350px; + overflow: scroll; + padding-bottom: 5px; + width: 500px; } + +/* scss/Organisms/_Navigation.scss */ +header.header, footer.footer, header.header__desktop { + width: 100%; + box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.1); + height: 60px; + background: white; + /* Default white background color for header bar. change this value to customize your header background color. */ + transition: all ease-in-out .5s; } + header.header .header-wrap, header.header .footer-wrap, footer.footer .header-wrap, footer.footer .footer-wrap, header.header__desktop .header-wrap, header.header__desktop .footer-wrap { + display: flex; + justify-content: flex-start; + align-items: center; + flex-direction: row; + padding: 0; + transition: all ease-in-out .5s; + height: 100%; } + header.header .header-wrap .logo, header.header .footer-wrap .logo, footer.footer .header-wrap .logo, footer.footer .footer-wrap .logo, header.header__desktop .header-wrap .logo, header.header__desktop .footer-wrap .logo { + padding: 0; + margin: 0; + max-width: 30%; } + header.header .header-wrap .logo__item, header.header .footer-wrap .logo__item, footer.footer .header-wrap .logo__item, footer.footer .footer-wrap .logo__item, header.header__desktop .header-wrap .logo__item, header.header__desktop .footer-wrap .logo__item { + padding: 0 !important; + margin: 0; } + header.header .header-wrap .logo-block, header.header .footer-wrap .logo-block, footer.footer .header-wrap .logo-block, footer.footer .footer-wrap .logo-block, header.header__desktop .header-wrap .logo-block, header.header__desktop .footer-wrap .logo-block { + padding-left: 1.5rem; + /* change to relative padding needed for logo alignment */ } + header.header .header-wrap .logo-block img, header.header .footer-wrap .logo-block img, footer.footer .header-wrap .logo-block img, footer.footer .footer-wrap .logo-block img, header.header__desktop .header-wrap .logo-block img, header.header__desktop .footer-wrap .logo-block img { + max-height: 60px; + height: 100%; + max-width: 100%; + width: 100%; } + header.header__mobile, footer.footer__mobile, header.header__desktop__mobile { + width: 100%; + max-width: 500px; } + header.header__mobile .header-wrap, footer.footer__mobile .header-wrap, header.header__desktop__mobile .header-wrap { + padding: 0; } + header.header__mobile .header-wrap > nav.nav, footer.footer__mobile .header-wrap > nav.nav, header.header__desktop__mobile .header-wrap > nav.nav { + /* End mobile primary menu */ + /* End mobile toolbar menu */ } + header.header__mobile .header-wrap > nav.nav__primary, footer.footer__mobile .header-wrap > nav.nav__primary, header.header__desktop__mobile .header-wrap > nav.nav__primary { + margin-left: 0; } + header.header__mobile .header-wrap > nav.nav__primary > ul > li, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li { + line-height: 1; + padding: 0; } + header.header__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar { + padding: 0; } + header.header__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > a, header.header__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > button, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > a, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > button, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > a, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > button { + cursor: pointer; } + header.header__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > a > span.icon img, header.header__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > button > span.icon img, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > a > span.icon img, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > button > span.icon img, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > a > span.icon img, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.user-avatar > button > span.icon img { + max-height: 40px; } + header.header__mobile .header-wrap > nav.nav__primary > ul > li.has-children, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.has-children, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.has-children { + padding-left: 0.75em; + padding-right: 0.75em; } + header.header__mobile .header-wrap > nav.nav__primary > ul > li.has-children > .sub-nav-dropdown > span, header.header__mobile .header-wrap > nav.nav__primary > ul > li.has-children .sub-nav-dropdown > span, header.header__mobile .header-wrap > nav.nav__primary > ul > li.has-children > ul > span, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.has-children > .sub-nav-dropdown > span, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.has-children .sub-nav-dropdown > span, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.has-children > ul > span, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.has-children > .sub-nav-dropdown > span, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.has-children .sub-nav-dropdown > span, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.has-children > ul > span { + color: #5361FD; + font-size: 1.1rem; } + header.header__mobile .header-wrap > nav.nav__primary > ul > li.has-children > .sub-nav-dropdown > span.label, header.header__mobile .header-wrap > nav.nav__primary > ul > li.has-children .sub-nav-dropdown > span.label, header.header__mobile .header-wrap > nav.nav__primary > ul > li.has-children > ul > span.label, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.has-children > .sub-nav-dropdown > span.label, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.has-children .sub-nav-dropdown > span.label, footer.footer__mobile .header-wrap > nav.nav__primary > ul > li.has-children > ul > span.label, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.has-children > .sub-nav-dropdown > span.label, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.has-children .sub-nav-dropdown > span.label, header.header__desktop__mobile .header-wrap > nav.nav__primary > ul > li.has-children > ul > span.label { + font-size: 1.2rem; + margin-right: .5em; } + header.header__mobile .header-wrap > nav.nav__toolbar > ul, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul { + height: 60px; } + header.header__mobile .header-wrap > nav.nav__toolbar > ul > li, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul > li, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul > li { + border: none; + padding: 0; } + header.header__mobile .header-wrap > nav.nav__toolbar > ul > li > a, header.header__mobile .header-wrap > nav.nav__toolbar > ul > li > button, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul > li > a, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul > li > button, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul > li > a, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul > li > button { + padding-left: 0.75em; + padding-right: 0.75em; + cursor: pointer; } + header.header__mobile .header-wrap > nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown > span.label, header.header__mobile .header-wrap > nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown > span.label, header.header__mobile .header-wrap > nav.nav__toolbar > ul > li.has-children > ul > span.label, header.header__mobile .header-wrap > nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown > span.label, header.header__mobile .header-wrap > nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown > span.label, header.header__mobile .header-wrap > nav.nav__toolbar > ul > li .sub-nav-dropdown > ul > span.label, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown > span.label, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown > span.label, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul > li.has-children > ul > span.label, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown > span.label, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown > span.label, footer.footer__mobile .header-wrap > nav.nav__toolbar > ul > li .sub-nav-dropdown > ul > span.label, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown > span.label, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown > span.label, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul > li.has-children > ul > span.label, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown > span.label, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown > span.label, header.header__desktop__mobile .header-wrap > nav.nav__toolbar > ul > li .sub-nav-dropdown > ul > span.label { + font-size: 1.3rem; } + +header.header.fixed, header.header--fixed { + /* should be used with a
element */ + position: fixed; + z-index: 5000; + /* Adjust to meet z-index needs */ + top: 0; + left: 0; + width: 100vw; } + +header.header__desktop .header-wrap { + /* tablet media query */ + /* Mobile media query */ + /* Desktop Media Query for Navigation Bar */ + /* Desktop Media Query */ } + @media (max-width: 1023px) { + header.header__desktop .header-wrap nav.nav__primary, + header.header__desktop .header-wrap nav.nav__toolbar { + display: none; } + header.header__desktop .header-wrap .mobile-navigation__toggle { + display: flex; } } + @media (max-width: 767px) { + header.header__desktop .header-wrap nav.nav__primary, + header.header__desktop .header-wrap nav.nav__toolbar { + display: none; } + header.header__desktop .header-wrap .mobile-navigation__toggle { + display: flex; } } + @media (min-width: 1024px) { + header.header__desktop .header-wrap nav.nav__primary, + header.header__desktop .header-wrap nav.nav__toolbar { + display: flex; } + header.header__desktop .header-wrap .mobile-navigation__toggle { + display: none; } } + +.header-spacer { + position: relative; + top: 0; + height: 60px; + /* adjust value to height of header navigation */ + display: block; } + +.mobile-navigation__toggle { + margin-left: auto; + height: 100%; } + .mobile-navigation__toggle > button, .mobile-navigation__toggle > a { + appearance: none; + -webkit-appearance: none; + border: none; + padding: 0 2rem; + background: white; + border-radius: 0; + cursor: pointer; } + .mobile-navigation__toggle > button:hover, .mobile-navigation__toggle > button:active, .mobile-navigation__toggle > a:hover, .mobile-navigation__toggle > a:active { + background: rgba(124, 77, 255, 0); + background: -webkit-linear-gradient(left, rgba(124, 77, 255, 0) 0%, rgba(24, 169, 230, 0) 50%, rgba(1, 201, 234, 0) 100%); + background: linear-gradient(to right, rgba(124, 77, 255, 0) 0%, rgba(24, 169, 230, 0) 50%, rgba(1, 201, 234, 0) 100%); } + +nav.nav > ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + align-items: center; + min-height: 60px; + height: 100%; } + nav.nav > ul > li { + display: flex; + align-items: center; + flex-direction: row; + flex-wrap: nowrap; + cursor: pointer; + line-height: 1; + transition: all 0.5s ease-in-out; + background: rgba(124, 77, 255, 0); + background: -webkit-linear-gradient(left, rgba(124, 77, 255, 0) 0%, rgba(24, 169, 230, 0) 50%, rgba(1, 201, 234, 0) 100%); + background: linear-gradient(to right, rgba(124, 77, 255, 0) 0%, rgba(24, 169, 230, 0) 50%, rgba(1, 201, 234, 0) 100%); } + nav.nav > ul > li > a, nav.nav > ul > li > button { + text-decoration: none; + font-weight: 700; + text-align: center; + appearance: none; + -webkit-appearance: none; + border: none; + background: none; + padding: 0; + cursor: pointer; } + nav.nav > ul > li > a > span.icon, nav.nav > ul > li > button > span.icon { + margin: 0 auto; + text-align: center; } + nav.nav > ul > li > a > span.icon > img, nav.nav > ul > li > button > span.icon > img { + max-height: 20px; + width: 100%; } + nav.nav > ul > li > a > span.label, nav.nav > ul > li > button > span.label { + margin: 0 auto; + text-align: center; + color: #083575; + font-size: 0.7rem; } + +nav.nav__primary { + margin-left: 1em; } + nav.nav__primary > ul { + height: 60px; } + nav.nav__primary > ul > li { + cursor: pointer; + transition: all 0.5s ease-in-out; + height: 100%; } + nav.nav__primary > ul > li > a, nav.nav__primary > ul > li > button { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + justify-content: center; + padding-left: 2em; + padding-right: 2em; + transition: all 0.5s ease-in-out; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + -o-border-radius: 0px; + border-radius: 0px; + background: rgba(124, 77, 255, 0); + background: -webkit-linear-gradient(left, rgba(124, 77, 255, 0) 0%, rgba(24, 169, 230, 0) 50%, rgba(1, 201, 234, 0) 100%); + background: linear-gradient(to right, rgba(124, 77, 255, 0) 0%, rgba(24, 169, 230, 0) 50%, rgba(1, 201, 234, 0) 100%); + cursor: pointer; } + nav.nav__primary > ul > li > a:hover, nav.nav__primary > ul > li > a.active, nav.nav__primary > ul > li > button:hover, nav.nav__primary > ul > li > button.active { + background: rgba(124, 77, 255, 0.2); + background: -webkit-linear-gradient(left, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); + background: linear-gradient(to right, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); + transition: all 0.5s ease-in-out; } + +nav.nav__toolbar { + margin-left: auto; } + nav.nav__toolbar > ul { + height: 60px; + display: flex; } + nav.nav__toolbar > ul > li { + height: 100%; + cursor: pointer; + transition: all 0.5s ease-in-out; + border-left: 1px solid rgba(8, 53, 117, 0.1); } + nav.nav__toolbar > ul > li.has-children.language-selector { + width: 70px; } + nav.nav__toolbar > ul > li > a, nav.nav__toolbar > ul > li > button { + appearance: none; + -webkit-appearance: none; + border: none; + background: none; + padding: 0; + padding-left: 1.5em; + padding-right: 1.5em; + width: 100%; + height: 100%; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + -o-border-radius: 0px; + border-radius: 0px; + cursor: pointer; } + nav.nav__toolbar > ul > li > a > span > img, nav.nav__toolbar > ul > li > button > span > img { + min-height: 25px; + min-width: 25px; } + nav.nav__toolbar > ul > li > a:hover, nav.nav__toolbar > ul > li > button:hover { + background: rgba(124, 77, 255, 0.2); + background: -webkit-linear-gradient(left, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); + background: linear-gradient(to right, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); + transition: all 0.5s ease-in-out; } + nav.nav__toolbar > ul > li.has-children > ul, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown { + display: none; + top: 60px; + min-width: 200px; + flex-direction: column; + right: 0; + height: auto; + background: #fff; + -webkit-border-radius: 0px 0px 4px 4px; + -moz-border-radius: 0px 0px 4px 4px; + -o-border-radius: 0px 0px 4px 4px; + border-radius: 0px 0px 4px 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + position: fixed; + list-style: none; + padding: 0; + transition: all 0.25s ease-in-out; } + nav.nav__toolbar > ul > li.has-children > ul > li, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown > li, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown > li, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul > li, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown > li, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown > li { + border-left: none; + border-top: 1px solid rgba(8, 53, 117, 0.1); + width: 100%; + background: #fff; + padding: 0; + text-align: left; } + nav.nav__toolbar > ul > li.has-children > ul > li > a, nav.nav__toolbar > ul > li.has-children > ul > li > button, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown > li > a, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown > li > button, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown > li > a, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown > li > button, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul > li > a, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul > li > button, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown > li > a, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown > li > button, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown > li > a, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown > li > button { + line-height: 2; + padding: 1rem; + text-align: left; + cursor: pointer; + width: 100%; + border: none; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + -o-border-radius: 0px; + border-radius: 0px; } + nav.nav__toolbar > ul > li.has-children > ul > li > a:hover, nav.nav__toolbar > ul > li.has-children > ul > li > button:hover, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown > li > a:hover, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown > li > button:hover, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown > li > a:hover, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown > li > button:hover, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul > li > a:hover, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul > li > button:hover, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown > li > a:hover, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown > li > button:hover, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown > li > a:hover, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown > li > button:hover { + background: rgba(124, 77, 255, 0.2); + background: -webkit-linear-gradient(left, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); + background: linear-gradient(to right, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); + transition: all 0.5s ease-in-out; + color: #083575; } + nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector { + right: 4rem; } + nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector li > a, nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector li button, nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector li .language-selector__item, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector li > a, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector li button, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector li .language-selector__item, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item { + display: flex; + justify-content: space-around; } + nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector li > a > span.icon, nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector li > a span.flag, nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector li button > span.icon, nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector li button span.flag, nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector li .language-selector__item > span.icon, nav.nav__toolbar > ul > li.has-children > ul nav.nav__toolbar > ul.language-selector li .language-selector__item span.flag, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a > span.icon, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a span.flag, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button > span.icon, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button span.flag, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item > span.icon, nav.nav__toolbar > ul > li.has-children > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item span.flag, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a > span.icon, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a span.flag, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button > span.icon, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button span.flag, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item > span.icon, nav.nav__toolbar > ul > li.has-children .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item span.flag, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector li > a > span.icon, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector li > a span.flag, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector li button > span.icon, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector li button span.flag, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector li .language-selector__item > span.icon, nav.nav__toolbar > ul > li .sub-nav-dropdown > ul nav.nav__toolbar > ul.language-selector li .language-selector__item span.flag, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a > span.icon, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a span.flag, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button > span.icon, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button span.flag, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item > span.icon, nav.nav__toolbar > ul > li .sub-nav-dropdown > .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item span.flag, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a > span.icon, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li > a span.flag, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button > span.icon, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li button span.flag, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item > span.icon, nav.nav__toolbar > ul > li .sub-nav-dropdown .sub-nav-dropdown nav.nav__toolbar > ul.language-selector li .language-selector__item span.flag { + width: 30px !important; + margin-right: 20px; } + nav.nav__toolbar > ul > li.has-children:hover > ul, nav.nav__toolbar > ul > li.has-children:hover > .sub-nav-dropdown, nav.nav__toolbar > ul > li .sub-nav-dropdown:hover > ul, nav.nav__toolbar > ul > li .sub-nav-dropdown:hover > .sub-nav-dropdown { + display: flex; } + nav.nav__toolbar > ul > li.has-children:hover > ul > li:hover, nav.nav__toolbar > ul > li.has-children:hover > .sub-nav-dropdown > li:hover, nav.nav__toolbar > ul > li .sub-nav-dropdown:hover > ul > li:hover, nav.nav__toolbar > ul > li .sub-nav-dropdown:hover > .sub-nav-dropdown > li:hover { + background: rgba(124, 77, 255, 0.2); + background: -webkit-linear-gradient(left, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); + background: linear-gradient(to right, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); } + +nav.nav span.icon.notification { + position: relative; + top: -.5em; } + +nav.nav span.badge { + display: block; + -webkit-border-radius: 300px; + -moz-border-radius: 300px; + -o-border-radius: 300px; + border-radius: 300px; + background: #01C9EA; + position: relative; + color: #fff; + font-size: .4rem; + font-weight: 300; + padding: .2rem 0.35rem; + margin-top: -2rem; + margin-left: 1.75em; } + +.mobile-navigation-panel { + display: grid; + position: fixed; + background: rgba(255, 255, 255, 0.95); + height: 100%; + top: 0; + right: 0; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + z-index: 6000; + transition: all .5s ease-in-out; + min-width: 300px; } + .mobile-navigation-panel.hidden, .mobile-navigation-panel[hidden] { + display: none; + visibility: hidden; + transform: translateX(150%); + transition: 0.75s ease-in-out; } + .mobile-navigation-panel.active, .mobile-navigation-panel[active] { + display: grid; + visibility: visible; + transform: translateX(0%); + animation: .75s ease-in-out slideLeft; } + .mobile-navigation-panel__wrap { + display: flex; + flex-direction: column; } + .mobile-navigation-panel__wrap > .close-panel__toggle { + align-self: flex-end; + margin-top: 1rem; + margin-right: 1rem; } + .mobile-navigation-panel__wrap > .close-panel__toggle > button { + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + -o-border-radius: 100px; + border-radius: 100px; + padding: .75rem 1rem; + font-size: 1rem; + border: 1px solid rgba(255, 255, 255, 0); + cursor: pointer; + opacity: .8; } + .mobile-navigation-panel__wrap > .close-panel__toggle > button:hover { + border: 1px solid rgba(68, 157, 245, 0.8); + opacity: 1; } + .mobile-navigation-panel__wrap > div, .mobile-navigation-panel__wrap > section { + height: auto; } + .mobile-navigation-panel__wrap nav.nav__primary { + margin-left: 0; + height: auto; } + .mobile-navigation-panel__wrap nav.nav > div, .mobile-navigation-panel__wrap nav.nav > section { + height: auto; } + .mobile-navigation-panel__wrap nav.nav > ul { + display: flex; + flex-direction: column; + width: 100%; + height: auto; } + .mobile-navigation-panel__wrap nav.nav > ul > li { + width: 100%; } + .mobile-navigation-panel__wrap nav.nav > ul > li > a, .mobile-navigation-panel__wrap nav.nav > ul > li > button { + width: 100%; + flex-direction: row; + justify-content: flex-start; + padding: 0; + align-items: center; + cursor: pointer; + text-indent: 1rem; + padding: 0; + height: 100%; + min-height: 50px; + box-sizing: border-box; } + .mobile-navigation-panel__wrap nav.nav > ul > li > a > span, .mobile-navigation-panel__wrap nav.nav > ul > li > button > span { + text-align: left; + margin: 0; } + .mobile-navigation-panel__wrap nav.nav > ul > li > a > span.icon, .mobile-navigation-panel__wrap nav.nav > ul > li > button > span.icon { + text-align: center; + max-width: 20px; + height: auto; } + .mobile-navigation-panel__wrap nav.nav > ul > li > a > span.label, .mobile-navigation-panel__wrap nav.nav > ul > li > button > span.label { + margin-left: 1.5rem; } + .mobile-navigation-panel__wrap nav.nav__toolbar { + margin-left: 0; + height: auto; + margin-top: auto; + bottom: 0; + position: relative; } + .mobile-navigation-panel__wrap nav.nav__toolbar > div, .mobile-navigation-panel__wrap nav.nav__toolbar > section { + height: auto; } + .mobile-navigation-panel__wrap nav.nav__toolbar > ul { + display: flex; + flex-direction: column; + width: 100%; + height: auto; } + .mobile-navigation-panel__wrap nav.nav__toolbar > ul > li { + width: 100%; } + .mobile-navigation-panel__wrap nav.nav__toolbar > ul > li > a, .mobile-navigation-panel__wrap nav.nav__toolbar > ul > li > button { + display: flex; + width: 100%; + flex-direction: row; + justify-content: flex-start; + align-items: center; + cursor: pointer; + text-indent: 1rem; + height: 100%; + min-height: 50px; + padding: 0; + box-sizing: border-box; } + .mobile-navigation-panel__wrap nav.nav__toolbar > ul > li > a > span, .mobile-navigation-panel__wrap nav.nav__toolbar > ul > li > button > span { + text-align: left; + margin: 0; } + .mobile-navigation-panel__wrap nav.nav__toolbar > ul > li > a > span.icon, .mobile-navigation-panel__wrap nav.nav__toolbar > ul > li > button > span.icon { + text-align: center; + max-width: 20px; + height: auto; } + .mobile-navigation-panel__wrap nav.nav__toolbar > ul > li > a > span.label, .mobile-navigation-panel__wrap nav.nav__toolbar > ul > li > button > span.label { + margin-left: 1.5rem; + color: #083575; + font-size: 0.7rem; } + +header.header__desktop .header-wrap .active nav.nav__primary, +header.header__desktop .header-wrap .active nav.nav__toolbar { + display: block; } + +/* Dropdown-Styles */ +.sub-nav-dropdown { + display: flex; + top: 60px; + min-width: 200px; + flex-direction: column; + right: 0; + height: auto; + background: #fff; + -webkit-border-radius: 0px 0px 4px 4px; + -moz-border-radius: 0px 0px 4px 4px; + -o-border-radius: 0px 0px 4px 4px; + border-radius: 0px 0px 4px 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + position: fixed; + list-style: none; + padding: 0; + transition: all 0.25s ease-in-out; } + .sub-nav-dropdown > li, .sub-nav-dropdown > .sub-nav-dropdown__item { + border-left: none; + border-top: 1px solid rgba(8, 53, 117, 0.1); + width: 100%; + background: #fff; + padding: 0; + text-align: left; } + .sub-nav-dropdown > li > a, .sub-nav-dropdown > li > button, .sub-nav-dropdown > .sub-nav-dropdown__item > a, .sub-nav-dropdown > .sub-nav-dropdown__item > button { + line-height: 2; + padding: 1rem; + text-align: left; + cursor: pointer; + width: 100%; + border: none; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + -o-border-radius: 0px; + border-radius: 0px; } + .sub-nav-dropdown > li > a:hover, .sub-nav-dropdown > li > button:hover, .sub-nav-dropdown > .sub-nav-dropdown__item > a:hover, .sub-nav-dropdown > .sub-nav-dropdown__item > button:hover { + background: rgba(124, 77, 255, 0.2); + background: -webkit-linear-gradient(left, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); + background: linear-gradient(to right, rgba(124, 77, 255, 0.2) 0%, rgba(24, 169, 230, 0.2) 50%, rgba(1, 201, 234, 0.2) 100%); + transition: all 0.5s ease-in-out; + color: #083575; } + +/*language-selector*/ +ul.language-selector { + margin-right: 12.5rem; } + +button.language-selector__item { + display: flex; + justify-content: flex-start; } + button.language-selector__item > span.icon, button.language-selector__item span.flag { + width: 30px !important; + margin-right: 1rem; } + +/* Footer Navigation Mobile - Specific Overrides */ +footer.footer { + /* position: fixed; //remove comments for fixed positioned footer */ + /* bottom: 0; //remove comments for bottom fixed positioned footer */ } + footer.footer .footer-wrap { + padding-left: 0; + height: 100%; } + footer.footer .footer-wrap > nav.nav__primary { + margin-left: 0; + width: 100%; } + footer.footer .footer-wrap > nav.nav__primary > ul { + width: 100%; } + footer.footer .footer-wrap > nav.nav__primary > ul > li { + width: 100%; + align-items: center; + justify-content: space-evenly; } + footer.footer .footer-wrap > nav.nav__primary > ul > li > a > span.label, footer.footer .footer-wrap > nav.nav__primary > ul > li > button > span.label { + color: #083575; + font-size: 0.7rem; } + +/* scss/Organisms/_Footers.scss */ +footer.solid-footer, +.solid-footer { + height: auto; + min-height: 60px; + display: flex; + align-items: center; + width: 100%; } + footer.solid-footer.fixed, footer.solid-footer[fixed], + .solid-footer.fixed, + .solid-footer[fixed] { + position: fixed; + bottom: 0; + z-index: 999999; + /* update to meet your z-index needs */ + width: 100%; + left: 0; + right: 0; + top: auto; + box-shadow: 0 -2px 2px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 0 -2px 2px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 0 -2px 2px rgba(0, 0, 0, 0.1); + -o-box-shadow: 0 -2px 2px rgba(0, 0, 0, 0.1); } + footer.solid-footer__content, + .solid-footer__content { + display: flex; + justify-content: space-between; + align-items: center; + height: 100%; + width: 100%; } + @media (max-width: 767px) { + footer.solid-footer__content, + .solid-footer__content { + display: flex; + justify-content: space-around; + flex-wrap: wrap; + flex-direction: column; + align-items: center; + height: 100%; + width: 100%; + padding: 1em 0; } + footer.solid-footer__content--copyright, footer.solid-footer__content--links, + .solid-footer__content--copyright, + .solid-footer__content--links { + width: 100%; + display: flex; + justify-content: center; + padding: 0.5rem 0; } } + footer.solid-footer ul, footer.solid-footer ol, + .solid-footer ul, + .solid-footer ol { + list-style: none; + padding-left: 0; + display: flex; } + footer.solid-footer ul li:not(:first-child), footer.solid-footer ol li:not(:first-child), + .solid-footer ul li:not(:first-child), + .solid-footer ol li:not(:first-child) { + border-left: 1px solid #DAE0E6; } + footer.solid-footer ul li, footer.solid-footer ol li, + .solid-footer ul li, + .solid-footer ol li { + padding: 0 1em; + font-size: 1em; } + footer.solid-footer ul li a img.link-icon, footer.solid-footer ul li a .link-icon, footer.solid-footer ol li a img.link-icon, footer.solid-footer ol li a .link-icon, + .solid-footer ul li a img.link-icon, + .solid-footer ul li a .link-icon, + .solid-footer ol li a img.link-icon, + .solid-footer ol li a .link-icon { + margin-right: .5em; } + footer.solid-footer img.link-icon, footer.solid-footer .link-icon, + .solid-footer img.link-icon, + .solid-footer .link-icon { + margin-right: .5em; } + +/*# sourceMappingURL=main.css.map */ + +body, html { + height: 100%!important; + width: 100%; + margin: 0; + padding: 0; +} + +.main-content { + background-image: linear-gradient(135deg, rgb(124, 77, 255) 0%, rgb(24, 169, 230) 50%, rgb(1, 201, 234) 100%); + box-sizing: border-box; + height: 100%; + width: 100%; + position: relative; + background-repeat: no-repeat; + padding-bottom: 60px; + padding-top: 60px; +} + +.main-content-section { + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + -webkit-box-pack: center; + justify-content: center; + -webkit-box-align: center; + align-items: center; + text-align: center; +} + +.login-panel { + display: flex; + flex-direction: column; + box-sizing: border-box; + box-shadow: rgba(0, 0, 0, 0.3) 0px 2px 4px 0px; + min-width: 310px; + position: relative; + text-align: center; + background: rgb(255, 255, 255); + border-radius: 4px; + margin: auto; + padding: 40px; + padding-top: 24px; +} + +.panel-body { + display: grid; + flex-direction: column; +} + +.space-between { + display: flex; + justify-content: space-between; +} + +hr { + margin-top: 16px; + margin-bottom: 16px; + border-bottom: none; + border-left: none; + border-right: none; + border-color: #DAE0E6; + filter: opacity(95%) +} + +.link { + color: #449df5; + font-family: "Raleway", "Roboto", sans-serif; + font-size: 12px; + font-weight: 600; + letter-spacing: .75px; + line-height: 14px; + text-align: center; + padding: 0; + text-decoration: none; + background-color: transparent; + border: none; +} diff --git a/templates/idp/email-password-interaction/message.ejs b/templates/idp/email-password-interaction/message.ejs new file mode 100644 index 000000000..6d9025035 --- /dev/null +++ b/templates/idp/email-password-interaction/message.ejs @@ -0,0 +1,23 @@ + + + + + <%= message %> + + + +
+
+
+
+
+

+ <%= message %> +

+
+
+
+
+
+ + diff --git a/templates/idp/email-password-interaction/register.ejs b/templates/idp/email-password-interaction/register.ejs new file mode 100644 index 000000000..f2f993846 --- /dev/null +++ b/templates/idp/email-password-interaction/register.ejs @@ -0,0 +1,62 @@ + + + + + Register + + + +
+
+
+

Register

+ +
+
+
+ + diff --git a/templates/idp/email-password-interaction/resetPassword.ejs b/templates/idp/email-password-interaction/resetPassword.ejs new file mode 100644 index 000000000..e48ae6cb0 --- /dev/null +++ b/templates/idp/email-password-interaction/resetPassword.ejs @@ -0,0 +1,45 @@ + + + + + Reset Password + + + +
+
+
+

Reset Password

+ +
+
+
+ + diff --git a/templates/idp/email-password-interaction/resetPasswordEmail.ejs b/templates/idp/email-password-interaction/resetPasswordEmail.ejs new file mode 100644 index 000000000..d052e0188 --- /dev/null +++ b/templates/idp/email-password-interaction/resetPasswordEmail.ejs @@ -0,0 +1,2 @@ +

Reset your password

+

Click here to reset your password.

diff --git a/templates/idp/email-password-interaction/resetPasswordEmailTemplate.ejs b/templates/idp/email-password-interaction/resetPasswordEmailTemplate.ejs new file mode 100644 index 000000000..15bf77a52 --- /dev/null +++ b/templates/idp/email-password-interaction/resetPasswordEmailTemplate.ejs @@ -0,0 +1,28 @@ + + + + + Sign-in + + + +
+
+
+

Reset Password

+ +
+
+
+ + diff --git a/test/assets/idp/noPropsTestHtml.ejs b/test/assets/idp/noPropsTestHtml.ejs new file mode 100644 index 000000000..2eff73663 --- /dev/null +++ b/test/assets/idp/noPropsTestHtml.ejs @@ -0,0 +1 @@ +

secret message

\ No newline at end of file diff --git a/test/assets/idp/testHtml.ejs b/test/assets/idp/testHtml.ejs new file mode 100644 index 000000000..3c3481f5d --- /dev/null +++ b/test/assets/idp/testHtml.ejs @@ -0,0 +1 @@ +

<%= message %>

\ No newline at end of file diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts new file mode 100644 index 000000000..34c48de19 --- /dev/null +++ b/test/integration/Identity.test.ts @@ -0,0 +1,262 @@ +import type { Server } from 'http'; +import { stringify } from 'querystring'; +import { URL } from 'url'; +import { load } from 'cheerio'; +import { fetch } from 'cross-fetch'; +import type { Initializer } from '../../src/init/Initializer'; +import type { HttpServerFactory } from '../../src/server/HttpServerFactory'; +import type { WrappedExpiringStorage } from '../../src/storage/keyvalue/WrappedExpiringStorage'; +import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; +import { joinFilePath } from '../../src/util/PathUtil'; +import { getPort } from '../util/Util'; +import { instantiateFromConfig } from './Config'; +import { IdentityTestState } from './IdentityTestState'; + +const port = getPort('Identity'); +const baseUrl = `http://localhost:${port}/`; + +// Undo the global identity token verifier mock +jest.unmock('@solid/identity-token-verifier'); + +// Don't send actual e-mails +jest.mock('nodemailer'); + +// Prevent panva/node-openid-client from emitting DraftWarning +jest.spyOn(process, 'emitWarning').mockImplementation(); + +// No way around the cookies https://github.com/panva/node-oidc-provider/issues/552 . +// They will be simulated by storing the values and passing them along. +// This is why the redirects are handled manually. +// We also need to parse the HTML in several steps since there is no API. +describe('A Solid server with IDP', (): void => { + let server: Server; + let initializer: Initializer; + let expiringStorage: WrappedExpiringStorage; + let factory: HttpServerFactory; + const redirectUrl = 'http://mockedredirect/'; + const oidcIssuer = baseUrl; + const card = new URL('profile/card', baseUrl).href; + const webId = `${card}#me`; + const email = 'test@test.com'; + const password = 'password!'; + const password2 = 'password2!'; + let sendMail: jest.Mock; + + beforeAll(async(): Promise => { + // Needs to happen before Components.js instantiation + sendMail = jest.fn(); + const nodemailer = jest.requireMock('nodemailer'); + Object.assign(nodemailer, { createTransport: (): any => ({ sendMail }) }); + + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', 'server-memory.json', { + 'urn:solid-server:default:variable:port': port, + 'urn:solid-server:default:variable:baseUrl': baseUrl, + 'urn:solid-server:default:variable:podTemplateFolder': joinFilePath(__dirname, '../assets/templates'), + 'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../templates/idp'), + }, + ) as Record; + ({ factory, initializer, expiringStorage } = instances); + await initializer.handleSafe(); + server = factory.startServer(port); + + // Create a simple webId + const turtle = `<${webId}> <${baseUrl}> .`; + await fetch(card, { + method: 'PUT', + headers: { 'content-type': 'text/turtle' }, + body: turtle, + }); + }); + + afterAll(async(): Promise => { + expiringStorage.finalize(); + await new Promise((resolve, reject): void => { + server.close((error): void => error ? reject(error) : resolve()); + }); + }); + + describe('doing registration', (): void => { + let state: IdentityTestState; + let nextUrl: string; + let formBody: string; + let registrationTriple: string; + + beforeAll(async(): Promise => { + state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); + + // We will need this twice + formBody = stringify({ email, webId, password, confirmPassword: password, remember: 'yes' }); + }); + + it('initializes the session and finds the registration URL.', async(): Promise => { + const url = await state.startSession(); + const { register } = await state.parseLoginPage(url); + expect(typeof register).toBe('string'); + nextUrl = (await state.extractFormUrl(register)).url; + }); + + it('sends the form once to receive the registration triple.', async(): Promise => { + const res = await state.fetchIdp(nextUrl, 'POST', formBody, APPLICATION_X_WWW_FORM_URLENCODED); + expect(res.status).toBe(200); + // eslint-disable-next-line newline-per-chained-call + registrationTriple = load(await res.text())('form div label').first().text().trim().split('\n')[0]; + expect(registrationTriple).toMatch(new RegExp( + `^<${webId}> "[^"]+"\\s*\\.\\s*$`, + 'u', + )); + }); + + it('updates the webId with the registration token.', async(): Promise => { + const patchBody = `INSERT DATA { ${registrationTriple} }`; + const res = await fetch(webId, { + method: 'PATCH', + headers: { 'content-type': 'application/sparql-update' }, + body: patchBody, + }); + expect(res.status).toBe(205); + }); + + it('sends the form again once the registration token was added.', async(): Promise => { + const res = await state.fetchIdp(nextUrl, 'POST', formBody, APPLICATION_X_WWW_FORM_URLENCODED); + expect(res.status).toBe(302); + nextUrl = res.headers.get('location')!; + }); + + it('will be redirected internally and logged in.', async(): Promise => { + await state.handleLoginRedirect(nextUrl); + expect(state.session.info?.webId).toBe(webId); + }); + }); + + describe('authenticating', (): void => { + let state: IdentityTestState; + const container = new URL('secret/', baseUrl).href; + + beforeAll(async(): Promise => { + state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); + + // Create container where only webId can write + const turtle = ` +@prefix acl: . +<#owner> a acl:Authorization; + acl:agent <${webId}>; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control. +`; + await fetch(`${container}.acl`, { + method: 'PUT', + headers: { 'content-type': 'text/turtle' }, + body: turtle, + }); + }); + + it('initializes the session and logs in.', async(): Promise => { + const url = await state.startSession(); + const { login } = await state.parseLoginPage(url); + expect(typeof login).toBe('string'); + await state.login(login, email, password); + expect(state.session.info?.webId).toBe(webId); + }); + + it('can only access the container when using the logged in session.', async(): Promise => { + let res = await fetch(container); + expect(res.status).toBe(401); + + res = await state.session.fetch(container); + expect(res.status).toBe(200); + }); + + it('can no longer access the container after logging out.', async(): Promise => { + await state.session.logout(); + const res = await state.session.fetch(container); + expect(res.status).toBe(401); + }); + + it('can log in again.', async(): Promise => { + const url = await state.startSession(); + + // For the following part it is debatable if this is correct but this might be a consequence of the authn client + const form = await state.extractFormUrl(url); + expect(form.url.endsWith('/confirm')).toBe(true); + + const res = await state.fetchIdp(form.url, 'POST'); + const nextUrl = res.headers.get('location'); + expect(typeof nextUrl).toBe('string'); + + await state.handleLoginRedirect(nextUrl!); + expect(state.session.info?.webId).toBe(webId); + }); + }); + + describe('resetting password', (): void => { + let state: IdentityTestState; + let nextUrl: string; + + beforeAll(async(): Promise => { + state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); + }); + + it('initializes the session and finds the forgot password URL.', async(): Promise => { + const url = await state.startSession(); + const { forgotPassword } = await state.parseLoginPage(url); + expect(typeof forgotPassword).toBe('string'); + nextUrl = (await state.extractFormUrl(forgotPassword)).url; + }); + + it('sends the corresponding email address through the form to get a mail.', async(): Promise => { + const res = await state.fetchIdp(nextUrl, 'POST', stringify({ email }), APPLICATION_X_WWW_FORM_URLENCODED); + expect(res.status).toBe(200); + expect(load(await res.text())('form div p').first().text().trim()) + .toBe('If your account exists, an email has been sent with a link to reset your password.'); + + const mail = sendMail.mock.calls[0][0]; + expect(mail.to).toBe(email); + const match = /(http:.*)$/u.exec(mail.text); + expect(match).toBeDefined(); + nextUrl = match![1]; + expect(nextUrl).toContain('resetpassword?rid='); + }); + + it('resets the password through the given link.', async(): Promise => { + const { url, body } = await state.extractFormUrl(nextUrl); + + const recordId = load(body)('input[name="recordId"]').attr('value'); + expect(typeof recordId).toBe('string'); + + const formData = stringify({ password: password2, confirmPassword: password2, recordId }); + const res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); + expect(res.status).toBe(200); + expect(await res.text()).toContain('Your password was successfully reset.'); + }); + }); + + describe('logging in after password reset', (): void => { + let state: IdentityTestState; + let nextUrl: string; + + beforeAll(async(): Promise => { + state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); + }); + + it('initializes the session.', async(): Promise => { + const url = await state.startSession(); + const { login } = await state.parseLoginPage(url); + expect(typeof login).toBe('string'); + nextUrl = login; + }); + + it('can not log in with the old password anymore.', async(): Promise => { + const formData = stringify({ email, password }); + const res = await state.fetchIdp(nextUrl, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); + expect(res.status).toBe(200); + expect(await res.text()).toContain('Incorrect password'); + }); + + it('can log in with the new password.', async(): Promise => { + await state.login(nextUrl, email, password2); + expect(state.session.info?.webId).toBe(webId); + }); + }); +}); diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts new file mode 100644 index 000000000..845cc52e6 --- /dev/null +++ b/test/integration/IdentityTestState.ts @@ -0,0 +1,149 @@ +import { stringify } from 'querystring'; +import { URL } from 'url'; +import { Session } from '@inrupt/solid-client-authn-node'; +import { load } from 'cheerio'; +import type { Response } from 'cross-fetch'; +import { fetch } from 'cross-fetch'; +import type { Cookie } from 'set-cookie-parser'; +import { parse, splitCookiesString } from 'set-cookie-parser'; +import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; + +/* eslint-disable jest/no-standalone-expect */ +/** + * Helper class to track the state while going through an IDP procedure + * and generalizes several common calls and checks. + */ +export class IdentityTestState { + private readonly baseUrl: string; + private readonly redirectUrl: string; + private readonly oidcIssuer: string; + + public readonly session: Session; + private readonly cookies: Map; + private cookie?: string; + + public constructor(baseUrl: string, redirectUrl: string, oidcIssuer: string) { + this.baseUrl = baseUrl; + this.redirectUrl = redirectUrl; + this.oidcIssuer = oidcIssuer; + this.session = new Session(); + this.cookies = new Map(); + } + + /** + * Performs a fetch call while keeping track of the stored cookies and preventing redirects. + * @param url - URL to call. + * @param method - Method to use. + * @param body - Body to send along. + * @param contentType - Content-Type of the body. + */ + // eslint-disable-next-line default-param-last + public async fetchIdp(url: string, method = 'GET', body?: string, contentType?: string): Promise { + const options = { method, headers: { cookie: this.cookie }, body, redirect: 'manual' } as any; + if (contentType) { + options.headers['content-type'] = contentType; + } + const res = await fetch(url, options); + + // Parse the cookies that need to be set and convert them to the corresponding header value + // Make sure we don't overwrite cookies that were already present + if (res.headers.get('set-cookie')) { + const newCookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); + for (const cookie of newCookies) { + this.cookies.set(cookie.name, cookie); + } + // eslint-disable-next-line unicorn/prefer-spread + this.cookie = Array.from(this.cookies, ([ , nom ]): string => `${nom.name}=${nom.value}`).join('; '); + } + return res; + } + + /** + * Uses the given jquery command to find a node in the given html body. + * The value from the given attribute field then gets extracted and combined with the base url. + * @param html - Body to parse. + * @param jquery - Query to run on the body. + * @param attr - Attribute to extract. + */ + public extractUrl(html: string, jquery: string, attr: string): string { + const url = load(html)(jquery).attr(attr); + expect(typeof url).toBe('string'); + return new URL(url!, this.baseUrl).href; + } + + /** + * Initializes an authentication session and stores the relevant cookies for later re-use. + * All te relevant links from the login page get extracted. + */ + public async startSession(): Promise { + let nextUrl = ''; + await this.session.login({ + redirectUrl: this.redirectUrl, + oidcIssuer: this.oidcIssuer, + handleRedirect(data): void { + nextUrl = data; + }, + }); + expect(nextUrl.length > 0).toBeTruthy(); + expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy(); + + // Need to catch the redirect so we can copy the cookies + const res = await this.fetchIdp(nextUrl); + expect(res.status).toBe(302); + nextUrl = res.headers.get('location')!; + + return nextUrl; + } + + public async parseLoginPage(url: string): Promise<{ register: string; login: string; forgotPassword: string }> { + const res = await this.fetchIdp(url); + expect(res.status).toBe(200); + const text = await res.text(); + const register = this.extractUrl(text, 'a:contains("Register")', 'href'); + const login = this.extractUrl(text, 'form', 'action'); + const forgotPassword = this.extractUrl(text, 'a:contains("Forgot Password")', 'href'); + + return { register, login, forgotPassword }; + } + + /** + * Logs in by sending the corresponding email and password to the given form action. + * The URL should be extracted from the login page. + */ + public async login(url: string, email: string, password: string): Promise { + const formData = stringify({ email, password }); + const res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); + expect(res.status).toBe(302); + const nextUrl = res.headers.get('location')!; + + return this.handleLoginRedirect(nextUrl); + } + + /** + * Calls the given URL and extracts the action URL from a form contained within the resulting body. + * Also returns the resulting body in case further parsing is needed. + */ + public async extractFormUrl(url: string): Promise<{ url: string; body: string }> { + const res = await this.fetchIdp(url); + expect(res.status).toBe(200); + const text = await res.text(); + const formUrl = this.extractUrl(text, 'form', 'action'); + return { + url: new URL(formUrl, this.baseUrl).href, + body: text, + }; + } + + /** + * Handles the redirect that happens after logging in. + */ + public async handleLoginRedirect(url: string): Promise { + const res = await this.fetchIdp(url); + expect(res.status).toBe(302); + const mockUrl = res.headers.get('location')!; + expect(mockUrl.startsWith(this.redirectUrl)).toBeTruthy(); + + const info = await this.session.handleIncomingRedirect(mockUrl); + expect(info?.isLoggedIn).toBe(true); + } +} diff --git a/test/integration/PodCreation.test.ts b/test/integration/PodCreation.test.ts index 74336d884..0572bc48b 100644 --- a/test/integration/PodCreation.test.ts +++ b/test/integration/PodCreation.test.ts @@ -23,7 +23,7 @@ describe('A server with a pod handler', (): void => { }); afterAll(async(): Promise => { - await new Promise((resolve, reject): void => { + await new Promise((resolve, reject): void => { server.close((error): void => error ? reject(error) : resolve()); }); }); diff --git a/test/integration/RedisResourceLockerIntegration.test.ts b/test/integration/RedisResourceLockerIntegration.test.ts index 51a461863..394ea3041 100644 --- a/test/integration/RedisResourceLockerIntegration.test.ts +++ b/test/integration/RedisResourceLockerIntegration.test.ts @@ -30,7 +30,7 @@ describeIf('docker', 'A server with a RedisResourceLocker as ResourceLocker', () }); afterAll(async(): Promise => { - await locker.quit(); + await locker.finalize(); await new Promise((resolve, reject): void => { server.close((error): void => error ? reject(error) : resolve()); }); diff --git a/test/integration/ServerFetch.test.ts b/test/integration/ServerFetch.test.ts index a061674d6..e591e2a22 100644 --- a/test/integration/ServerFetch.test.ts +++ b/test/integration/ServerFetch.test.ts @@ -2,6 +2,7 @@ import type { Server } from 'http'; import fetch from 'cross-fetch'; import type { Initializer } from '../../src/init/Initializer'; import type { HttpServerFactory } from '../../src/server/HttpServerFactory'; +import type { WrappedExpiringStorage } from '../../src/storage/keyvalue/WrappedExpiringStorage'; import { getPort } from '../util/Util'; import { instantiateFromConfig } from './Config'; @@ -12,6 +13,7 @@ const baseUrl = `http://localhost:${port}/`; describe('A Solid server', (): void => { let server: Server; let initializer: Initializer; + let expiringStorage: WrappedExpiringStorage; let factory: HttpServerFactory; beforeAll(async(): Promise => { @@ -19,15 +21,17 @@ describe('A Solid server', (): void => { 'urn:solid-server:test:Instances', 'server-memory.json', { 'urn:solid-server:default:variable:port': port, 'urn:solid-server:default:variable:baseUrl': baseUrl, + 'urn:solid-server:default:variable:idpTemplateFolder': '', }, ) as Record; - ({ factory, initializer } = instances); + ({ factory, initializer, expiringStorage } = instances); await initializer.handleSafe(); server = factory.startServer(port); }); afterAll(async(): Promise => { - await new Promise((resolve, reject): void => { + expiringStorage.finalize(); + await new Promise((resolve, reject): void => { server.close((error): void => error ? reject(error) : resolve()); }); }); diff --git a/test/integration/WebSocketsProtocol.test.ts b/test/integration/WebSocketsProtocol.test.ts index af4100ecd..1686dc6a1 100644 --- a/test/integration/WebSocketsProtocol.test.ts +++ b/test/integration/WebSocketsProtocol.test.ts @@ -23,7 +23,7 @@ describe('A server with the Solid WebSockets API behind a proxy', (): void => { }); afterAll(async(): Promise => { - await new Promise((resolve, reject): void => { + await new Promise((resolve, reject): void => { server.close((error): void => error ? reject(error) : resolve()); }); }); diff --git a/test/integration/config/run-with-redlock.json b/test/integration/config/run-with-redlock.json index a982d5205..228b8022b 100644 --- a/test/integration/config/run-with-redlock.json +++ b/test/integration/config/run-with-redlock.json @@ -126,10 +126,7 @@ "RedisResourceLocker:_redisClients": [ "6379" ] }, "GreedyReadWriteLocker:_storage": { - "@type": "ResourceIdentifierStorage", - "ResourceIdentifierStorage:_source": { - "@type": "MemoryMapStorage" - } + "@type": "MemoryMapStorage" }, "GreedyReadWriteLocker:_suffixes_count": "count", "GreedyReadWriteLocker:_suffixes_read": "read", @@ -138,7 +135,6 @@ "WrappedExpiringReadWriteLocker:_expiration": 3000 }, - { "@id": "urn:solid-server:default:ResourceStore_ToTurtle", "@type": "RepresentationConvertingStore", @@ -163,6 +159,11 @@ "RepresentationConvertingStore:_options_outConverter": { "@id": "urn:solid-server:default:RepresentationConverter" } + }, + + { + "@id": "urn:solid-server:default:IdentityProviderHandler", + "@type": "UnsupportedAsyncHandler" } ] } diff --git a/test/integration/config/server-dynamic-unsafe.json b/test/integration/config/server-dynamic-unsafe.json index f754d41bb..fe69c7499 100644 --- a/test/integration/config/server-dynamic-unsafe.json +++ b/test/integration/config/server-dynamic-unsafe.json @@ -71,6 +71,10 @@ "@id": "urn:solid-server:default:ResponseWriter" } }, + { + "@id": "urn:solid-server:default:IdentityProviderHandler", + "@type": "UnsupportedAsyncHandler" + }, { "@id": "urn:solid-server:default:BaseUrlRouterRule", "BaseUrlRouterRule:_baseStore": { diff --git a/test/integration/config/server-memory.json b/test/integration/config/server-memory.json index 716985dd7..0cb276eb2 100644 --- a/test/integration/config/server-memory.json +++ b/test/integration/config/server-memory.json @@ -19,7 +19,9 @@ "files-scs:config/presets/static.json", "files-scs:config/presets/storage/backend/storage-memory.json", "files-scs:config/presets/storage-wrapper.json", - "files-scs:config/presets/cli-params.json" + "files-scs:config/presets/cli-params.json", + "files-scs:config/presets/identity/identity-provider.json", + "files-scs:config/presets/identity/interaction-policy.json" ], "@graph": [ { @@ -39,9 +41,23 @@ { "RecordObject:_record_key": "factory", "RecordObject:_record_value": { "@id": "urn:solid-server:default:ServerFactory" } + }, + { + "comment": "Timer needs to be stopped when tests are finished.", + "RecordObject:_record_key": "expiringStorage", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:ExpiringIdpStorage" } } ] }, + { + "@id": "urn:solid-server:default:EmailSender", + "@type": "BaseEmailSender", + "args_senderName": "Solid Server", + "args_emailConfig_host": "smtp.example.email", + "args_emailConfig_port": 587, + "args_emailConfig_auth_user": "alice@example.email", + "args_emailConfig_auth_pass": "NYEaCsqV7aVStRCbmC" + }, { "@id": "urn:solid-server:default:RoutingResourceStore", "@type": "PassthroughStore", diff --git a/test/integration/config/server-middleware.json b/test/integration/config/server-middleware.json index 72a1ff5d0..d0f7a6781 100644 --- a/test/integration/config/server-middleware.json +++ b/test/integration/config/server-middleware.json @@ -7,6 +7,10 @@ "files-scs:config/presets/cli-params.json" ], "@graph": [ + { + "@id": "urn:solid-server:default:IdentityProviderHandler", + "@type": "UnsupportedAsyncHandler" + }, { "@id": "urn:solid-server:default:PodManagerHandler", "@type": "Variable" diff --git a/test/integration/config/server-subdomains-unsafe.json b/test/integration/config/server-subdomains-unsafe.json index 16bb74433..1a54f97fe 100644 --- a/test/integration/config/server-subdomains-unsafe.json +++ b/test/integration/config/server-subdomains-unsafe.json @@ -83,6 +83,10 @@ "@id": "urn:solid-server:default:ResourcesGenerator", "TemplatedResourcesGenerator:_templateFolder": "$PACKAGE_ROOT/test/assets/templates" }, + { + "@id": "urn:solid-server:default:IdentityProviderHandler", + "@type": "UnsupportedAsyncHandler" + }, { "@id": "urn:solid-server:default:variable:store", "@type": "Variable" diff --git a/test/integration/config/server-without-auth.json b/test/integration/config/server-without-auth.json index 60e8f5912..ee3ed245e 100644 --- a/test/integration/config/server-without-auth.json +++ b/test/integration/config/server-without-auth.json @@ -42,6 +42,10 @@ "@id": "urn:solid-server:default:ResponseWriter" } }, + { + "@id": "urn:solid-server:default:IdentityProviderHandler", + "@type": "UnsupportedAsyncHandler" + }, { "@id": "urn:solid-server:default:RoutingResourceStore", "@type": "PassthroughStore", diff --git a/test/unit/identity/IdentityProviderFactory.test.ts b/test/unit/identity/IdentityProviderFactory.test.ts new file mode 100644 index 000000000..626680db7 --- /dev/null +++ b/test/unit/identity/IdentityProviderFactory.test.ts @@ -0,0 +1,103 @@ +import type { interactionPolicy, Configuration, KoaContextWithOIDC } from 'oidc-provider'; +import type { ConfigurationFactory } from '../../../src/identity/configuration/ConfigurationFactory'; +import { IdentityProviderFactory } from '../../../src/identity/IdentityProviderFactory'; +import type { InteractionPolicy } from '../../../src/identity/interaction/InteractionPolicy'; +import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter'; + +jest.mock('oidc-provider', (): any => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + Provider: jest.fn().mockImplementation((issuer: string, config: Configuration): any => ({ issuer, config })), +})); + +describe('An IdentityProviderFactory', (): void => { + const issuer = 'http://test.com/'; + const idpPolicy: InteractionPolicy = { + policy: [ 'prompt' as unknown as interactionPolicy.Prompt ], + url: (ctx: KoaContextWithOIDC): string => `/idp/interaction/${ctx.oidc.uid}`, + }; + const webId = 'http://alice.test.com/card#me'; + let configuration: any; + let errorResponseWriter: ResponseWriter; + let factory: IdentityProviderFactory; + + beforeEach(async(): Promise => { + configuration = {}; + const configurationFactory: ConfigurationFactory = { + createConfiguration: async(): Promise => configuration, + }; + + errorResponseWriter = { + handleSafe: jest.fn(), + } as any; + + factory = new IdentityProviderFactory(issuer, configurationFactory, errorResponseWriter); + }); + + it('has fixed default values.', async(): Promise => { + const result = await factory.createProvider(idpPolicy) as any; + expect(result.issuer).toBe(issuer); + expect(result.config.interactions).toEqual(idpPolicy); + + const findResult = await result.config.findAccount({}, webId); + expect(findResult.accountId).toBe(webId); + await expect(findResult.claims()).resolves.toEqual({ sub: webId, webid: webId }); + + expect(result.config.claims).toEqual({ webid: [ 'webid', 'client_webid' ]}); + expect(result.config.conformIdTokenClaims).toBe(false); + expect(result.config.features).toEqual({ + registration: { enabled: true }, + dPoP: { enabled: true, ack: 'draft-01' }, + claimsParameter: { enabled: true }, + }); + expect(result.config.subjectTypes).toEqual([ 'public', 'pairwise' ]); + expect(result.config.formats).toEqual({ + // eslint-disable-next-line @typescript-eslint/naming-convention + AccessToken: 'jwt', + }); + expect(result.config.audiences()).toBe('solid'); + + expect(result.config.extraAccessTokenClaims({}, {})).toEqual({}); + expect(result.config.extraAccessTokenClaims({}, { accountId: webId })).toEqual({ + webid: webId, + // This will need to change once #718 is fixed + // eslint-disable-next-line @typescript-eslint/naming-convention + client_webid: 'http://localhost:3001/', + aud: 'solid', + }); + + await expect(result.config.renderError({ res: 'response!' }, null, 'error!')).resolves.toBeUndefined(); + expect(errorResponseWriter.handleSafe).toHaveBeenCalledTimes(1); + expect(errorResponseWriter.handleSafe).toHaveBeenLastCalledWith({ response: 'response!', result: 'error!' }); + }); + + it('overwrites fields from the factory config.', async(): Promise => { + configuration.dummy = 'value!'; + configuration.conformIdTokenClaims = true; + const result = await factory.createProvider(idpPolicy) as any; + expect(result.config.dummy).toBe('value!'); + expect(result.config.conformIdTokenClaims).toBe(false); + }); + + it('copies specific object values from the factory config.', async(): Promise => { + configuration.interactions = { dummy: 'interaction!' }; + configuration.claims = { dummy: 'claim!' }; + configuration.features = { dummy: 'feature!' }; + configuration.subjectTypes = [ 'dummy!' ]; + configuration.formats = { dummy: 'format!' }; + + const result = await factory.createProvider({ policy: 'policy!', url: 'url!' } as any) as any; + expect(result.config.interactions).toEqual({ policy: 'policy!', url: 'url!' }); + expect(result.config.claims).toEqual({ dummy: 'claim!', webid: [ 'webid', 'client_webid' ]}); + expect(result.config.features).toEqual({ + dummy: 'feature!', + registration: { enabled: true }, + dPoP: { enabled: true, ack: 'draft-01' }, + claimsParameter: { enabled: true }, + }); + expect(result.config.subjectTypes).toEqual([ 'public', 'pairwise' ]); + expect(result.config.formats).toEqual({ + // eslint-disable-next-line @typescript-eslint/naming-convention + AccessToken: 'jwt', + }); + }); +}); diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts new file mode 100644 index 000000000..5baeedcad --- /dev/null +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -0,0 +1,99 @@ +import type { interactionPolicy, KoaContextWithOIDC, Provider } from 'oidc-provider'; +import type { IdentityProviderFactory } from '../../../src/identity/IdentityProviderFactory'; +import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler'; +import type { InteractionHttpHandler } from '../../../src/identity/interaction/InteractionHttpHandler'; +import type { InteractionPolicy } from '../../../src/identity/interaction/InteractionPolicy'; +import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter'; +import type { HttpRequest } from '../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../src/server/HttpResponse'; + +describe('An IdentityProviderHttpHandler', (): void => { + const request: HttpRequest = {} as any; + const response: HttpResponse = {} as any; + let providerFactory: IdentityProviderFactory; + const idpPolicy: InteractionPolicy = { + policy: [ 'prompt' as unknown as interactionPolicy.Prompt ], + url: (ctx: KoaContextWithOIDC): string => `/idp/interaction/${ctx.oidc.uid}`, + }; + let interactionHttpHandler: InteractionHttpHandler; + let errorResponseWriter: ResponseWriter; + let provider: Provider; + let handler: IdentityProviderHttpHandler; + + beforeEach(async(): Promise => { + provider = { + callback: jest.fn(), + } as any; + + providerFactory = { + createProvider: jest.fn().mockResolvedValue(provider), + } as any; + + interactionHttpHandler = { + canHandle: jest.fn(), + handle: jest.fn(), + } as any; + + errorResponseWriter = { + handleSafe: jest.fn(), + } as any; + + handler = new IdentityProviderHttpHandler( + providerFactory, + idpPolicy, + interactionHttpHandler, + errorResponseWriter, + ); + }); + + it('calls the provider if there is no matching handler.', async(): Promise => { + (interactionHttpHandler.canHandle as jest.Mock).mockRejectedValueOnce(new Error('error!')); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(provider.callback).toHaveBeenCalledTimes(1); + expect(provider.callback).toHaveBeenLastCalledWith(request, response); + expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(0); + expect(errorResponseWriter.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('calls the interaction handler if it can handle the input.', async(): Promise => { + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(provider.callback).toHaveBeenCalledTimes(0); + expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(1); + expect(interactionHttpHandler.handle).toHaveBeenLastCalledWith({ request, response, provider }); + expect(errorResponseWriter.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('calls the errorResponseWriter if there was an issue with the interaction handler.', async(): Promise => { + const error = new Error('error!'); + (interactionHttpHandler.handle as jest.Mock).mockRejectedValueOnce(error); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(provider.callback).toHaveBeenCalledTimes(0); + expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(1); + expect(interactionHttpHandler.handle).toHaveBeenLastCalledWith({ request, response, provider }); + expect(errorResponseWriter.handleSafe).toHaveBeenCalledTimes(1); + expect(errorResponseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: error }); + }); + + it('re-throws the error if it is not a native Error.', async(): Promise => { + (interactionHttpHandler.handle as jest.Mock).mockRejectedValueOnce('apple!'); + await expect(handler.handle({ request, response })).rejects.toEqual('apple!'); + }); + + it('caches the provider after creating it.', async(): Promise => { + expect(providerFactory.createProvider).toHaveBeenCalledTimes(0); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(providerFactory.createProvider).toHaveBeenCalledTimes(1); + expect(providerFactory.createProvider).toHaveBeenLastCalledWith(idpPolicy); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(providerFactory.createProvider).toHaveBeenCalledTimes(1); + }); + + it('errors if there is an issue creating the provider.', async(): Promise => { + const error = new Error('error!'); + (providerFactory.createProvider as jest.Mock).mockRejectedValueOnce(error); + await expect(handler.handle({ request, response })).rejects.toThrow(error); + + (providerFactory.createProvider as jest.Mock).mockRejectedValueOnce('apple'); + await expect(handler.handle({ request, response })).rejects.toBe('apple'); + }); +}); diff --git a/test/unit/identity/configuration/KeyConfigurationFactory.test.ts b/test/unit/identity/configuration/KeyConfigurationFactory.test.ts new file mode 100644 index 000000000..24222e79d --- /dev/null +++ b/test/unit/identity/configuration/KeyConfigurationFactory.test.ts @@ -0,0 +1,93 @@ +import { KeyConfigurationFactory } from '../../../../src/identity/configuration/KeyConfigurationFactory'; +import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; +import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; + +/* eslint-disable @typescript-eslint/naming-convention */ +function getExpected(adapter: any, cookieKeys: any, jwks: any): any { + return { + adapter, + cookies: { + long: { signed: true, maxAge: 1 * 24 * 60 * 60 * 1000 }, + short: { signed: true }, + keys: cookieKeys, + }, + conformIdTokenClaims: false, + features: { + devInteractions: { enabled: false }, + deviceFlow: { enabled: true }, + introspection: { enabled: true }, + revocation: { enabled: true }, + registration: { enabled: true }, + claimsParameter: { enabled: true }, + }, + jwks, + ttl: { + AccessToken: 1 * 60 * 60, + AuthorizationCode: 10 * 60, + IdToken: 1 * 60 * 60, + DeviceCode: 10 * 60, + RefreshToken: 1 * 24 * 60 * 60, + }, + subjectTypes: [ 'public', 'pairwise' ], + routes: { + authorization: '/foo/idp/auth', + check_session: '/foo/idp/session/check', + code_verification: '/foo/idp/device', + device_authorization: '/foo/idp/device/auth', + end_session: '/foo/idp/session/end', + introspection: '/foo/idp/token/introspection', + jwks: '/foo/idp/jwks', + pushed_authorization_request: '/foo/idp/request', + registration: '/foo/idp/reg', + revocation: '/foo/idp/token/revocation', + token: '/foo/idp/token', + userinfo: '/foo/idp/me', + }, + }; +} + +describe('A KeyConfigurationFactory', (): void => { + let storageAdapterFactory: AdapterFactory; + const baseUrl = 'http://test.com/foo/'; + const idpPathName = 'idp'; + let storage: KeyValueStorage; + let generator: KeyConfigurationFactory; + + beforeEach(async(): Promise => { + storageAdapterFactory = { + createStorageAdapter: jest.fn().mockReturnValue('adapter!'), + }; + + const map = new Map(); + storage = { + get: jest.fn((id: string): any => map.get(id)), + set: jest.fn((id: string, value: any): any => map.set(id, value)), + } as any; + + generator = new KeyConfigurationFactory(storageAdapterFactory, baseUrl, idpPathName, storage); + }); + + it('creates a correct configuration.', async(): Promise => { + const result = await generator.createConfiguration(); + expect(result).toEqual(getExpected( + expect.any(Function), + [ expect.any(String) ], + { keys: [ expect.objectContaining({ kty: 'RSA' }) ]}, + )); + + (result.adapter as (name: string) => any)('test!'); + expect(storageAdapterFactory.createStorageAdapter).toHaveBeenCalledTimes(1); + expect(storageAdapterFactory.createStorageAdapter).toHaveBeenLastCalledWith('test!'); + }); + + it('stores cookie keys and jwks for re-use.', async(): Promise => { + const result = await generator.createConfiguration(); + const result2 = await generator.createConfiguration(); + expect(result.cookies).toEqual(result2.cookies); + expect(result.jwks).toEqual(result2.jwks); + expect(storage.get).toHaveBeenCalledTimes(4); + expect(storage.set).toHaveBeenCalledTimes(2); + expect(storage.set).toHaveBeenCalledWith('idp/jwks', result.jwks); + expect(storage.set).toHaveBeenCalledWith('idp/cookie-secret', result.cookies?.keys); + }); +}); diff --git a/test/unit/identity/interaction/SessionHttpHandler.test.ts b/test/unit/identity/interaction/SessionHttpHandler.test.ts new file mode 100644 index 000000000..6ca588943 --- /dev/null +++ b/test/unit/identity/interaction/SessionHttpHandler.test.ts @@ -0,0 +1,50 @@ +import type { Provider } from 'oidc-provider'; +import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; +import type { InteractionCompleter } from '../../../../src/identity/interaction/util/InteractionCompleter'; +import type { HttpRequest } from '../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../src/server/HttpResponse'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; + +describe('A SessionHttpHandler', (): void => { + const request: HttpRequest = {} as any; + const response: HttpResponse = {} as any; + const webId = 'http://test.com/id#me'; + let details: any = {}; + let provider: Provider; + let oidcInteractionCompleter: InteractionCompleter; + let handler: SessionHttpHandler; + + beforeEach(async(): Promise => { + details = { session: { accountId: webId }}; + provider = { + interactionDetails: jest.fn().mockResolvedValue(details), + } as any; + + oidcInteractionCompleter = { + handleSafe: jest.fn(), + } as any; + + handler = new SessionHttpHandler(oidcInteractionCompleter); + }); + + it('requires a session and accountId.', async(): Promise => { + details.session = undefined; + await expect(handler.handle({ request, response, provider })).rejects.toThrow(NotImplementedHttpError); + + details.session = { accountId: undefined }; + await expect(handler.handle({ request, response, provider })).rejects.toThrow(NotImplementedHttpError); + }); + + it('calls the oidc completer with the webId in the session.', async(): Promise => { + await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(provider.interactionDetails).toHaveBeenCalledTimes(1); + expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response); + expect(oidcInteractionCompleter.handleSafe).toHaveBeenCalledTimes(1); + expect(oidcInteractionCompleter.handleSafe).toHaveBeenLastCalledWith({ + request, + response, + provider, + webId, + }); + }); +}); diff --git a/test/unit/identity/interaction/email-password/AccountInteractionPolicy.test.ts b/test/unit/identity/interaction/email-password/AccountInteractionPolicy.test.ts new file mode 100644 index 000000000..f1c1470b4 --- /dev/null +++ b/test/unit/identity/interaction/email-password/AccountInteractionPolicy.test.ts @@ -0,0 +1,20 @@ +import { + AccountInteractionPolicy, +} from '../../../../../src/identity/interaction/email-password/AccountInteractionPolicy'; + +describe('An AccountInteractionPolicy', (): void => { + const idpPath = '/idp'; + const interactionPolicy = new AccountInteractionPolicy(idpPath); + + it('errors if the idpPath parameter does not start with a slash.', async(): Promise => { + expect((): any => new AccountInteractionPolicy('idp')).toThrow('idpPath needs to start with a /'); + }); + + it('has a select_account policy at index 0.', async(): Promise => { + expect(interactionPolicy.policy[0].name).toBe('select_account'); + }); + + it('creates URLs by prepending /idp/interaction/.', async(): Promise => { + expect(interactionPolicy.url({ oidc: { uid: 'valid-uid' }} as any)).toBe('/idp/interaction/valid-uid'); + }); +}); diff --git a/test/unit/identity/interaction/email-password/EmailPasswordUtil.test.ts b/test/unit/identity/interaction/email-password/EmailPasswordUtil.test.ts new file mode 100644 index 000000000..e2e6f1999 --- /dev/null +++ b/test/unit/identity/interaction/email-password/EmailPasswordUtil.test.ts @@ -0,0 +1,65 @@ +import { + assertPassword, + throwIdpInteractionError, +} from '../../../../../src/identity/interaction/email-password/EmailPasswordUtil'; +import { IdpInteractionError } from '../../../../../src/identity/interaction/util/IdpInteractionError'; +import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; + +describe('EmailPasswordUtil', (): void => { + describe('#throwIdpInteractionError', (): void => { + const prefilled = { test: 'data' }; + + it('copies the values of other IdpInteractionErrors.', async(): Promise => { + const error = new IdpInteractionError(404, 'Not found!', { test2: 'data2' }); + expect((): never => throwIdpInteractionError(error, prefilled)).toThrow(expect.objectContaining({ + statusCode: error.statusCode, + message: error.message, + prefilled: { ...error.prefilled, ...prefilled }, + })); + }); + + it('re-throws IdpInteractionErrors if there are no new prefilled values.', async(): Promise => { + const error = new IdpInteractionError(404, 'Not found!', { test2: 'data2' }); + expect((): never => throwIdpInteractionError(error)).toThrow(error); + }); + + it('copies status code and message for HttpErrors.', async(): Promise => { + const error = new NotFoundHttpError('Not found!'); + expect((): never => throwIdpInteractionError(error, prefilled)).toThrow(expect.objectContaining({ + statusCode: error.statusCode, + message: error.message, + prefilled, + })); + }); + + it('copies message for native Errors.', async(): Promise => { + const error = new Error('Error!'); + expect((): never => throwIdpInteractionError(error, prefilled)).toThrow(expect.objectContaining({ + statusCode: 500, + message: error.message, + prefilled, + })); + }); + + it('defaults all values in case a non-native Error object gets thrown.', async(): Promise => { + const error = 'Error!'; + expect((): never => throwIdpInteractionError(error, prefilled)).toThrow(expect.objectContaining({ + statusCode: 500, + message: 'Unknown Error', + prefilled, + })); + }); + }); + + describe('#assertPassword', (): void => { + it('validates the password against the confirmPassword.', async(): Promise => { + expect((): void => assertPassword(undefined, undefined)).toThrow('Password required'); + expect((): void => assertPassword([], undefined)).toThrow('Password required'); + expect((): void => assertPassword('password', undefined)).toThrow('Password confirmation required'); + expect((): void => assertPassword('password', [])).toThrow('Password confirmation required'); + expect((): void => assertPassword('password', 'confirmPassword')) + .toThrow('Password and confirmation do not match'); + expect(assertPassword('password', 'password')).toBeUndefined(); + }); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts new file mode 100644 index 000000000..2dc27095a --- /dev/null +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -0,0 +1,91 @@ +import type { Provider } from 'oidc-provider'; +import { + ForgotPasswordHandler, +} from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler'; +import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; +import type { IdpRenderHandler } from '../../../../../../src/identity/interaction/util/IdpRenderHandler'; +import type { TemplateRenderer } from '../../../../../../src/identity/interaction/util/TemplateRenderer'; +import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../../../src/server/HttpResponse'; +import { createPostFormRequest } from './Util'; + +describe('A ForgotPasswordHandler', (): void => { + let request: HttpRequest; + const response: HttpResponse = {} as any; + const email = 'test@test.email'; + const recordId = '123456'; + const html = `Reset Password`; + // `Interaction` type is not exposed + const details = {} as any; + const renderParams = { response, props: { details, errorMessage: '', prefilled: { email }}}; + let provider: Provider; + let messageRenderHandler: IdpRenderHandler; + let accountStore: AccountStore; + const baseUrl = 'http://test.com/base/'; + const idpPath = '/idp'; + let emailTemplateRenderer: TemplateRenderer<{ resetLink: string }>; + let emailSender: EmailSender; + let handler: ForgotPasswordHandler; + + beforeEach(async(): Promise => { + request = createPostFormRequest({ email }); + + provider = { + interactionDetails: jest.fn().mockResolvedValue(details), + } as any; + + messageRenderHandler = { + handleSafe: jest.fn(), + } as any; + + accountStore = { + generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId), + } as any; + + emailTemplateRenderer = { + handleSafe: jest.fn().mockResolvedValue(html), + } as any; + + emailSender = { + handleSafe: jest.fn(), + } as any; + + handler = new ForgotPasswordHandler({ + messageRenderHandler, + accountStore, + baseUrl, + idpPath, + emailTemplateRenderer, + emailSender, + }); + }); + + it('errors on non-string emails.', async(): Promise => { + request = createPostFormRequest({}); + await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required'); + request = createPostFormRequest({ email: [ 'email', 'email2' ]}); + await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required'); + }); + + it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise => { + (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error'); + await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); + expect(messageRenderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(messageRenderHandler.handleSafe).toHaveBeenLastCalledWith(renderParams); + }); + + it('sends a mail if a ForgotPassword record could be generated.', async(): Promise => { + await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); + expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ + recipient: email, + subject: 'Reset your password', + text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword?rid=${recordId}`, + html, + }); + expect(messageRenderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(messageRenderHandler.handleSafe).toHaveBeenLastCalledWith(renderParams); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts new file mode 100644 index 000000000..acf8523f5 --- /dev/null +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -0,0 +1,68 @@ +import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler'; +import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { InteractionHttpHandlerInput } from '../../../../../../src/identity/interaction/InteractionHttpHandler'; +import type { InteractionCompleter } from '../../../../../../src/identity/interaction/util/InteractionCompleter'; +import { createPostFormRequest } from './Util'; + +describe('A LoginHandler', (): void => { + const webId = 'http://alice.test.com/card#me'; + const email = 'alice@test.email'; + let input: InteractionHttpHandlerInput; + let storageAdapter: AccountStore; + let interactionCompleter: InteractionCompleter; + let handler: LoginHandler; + + beforeEach(async(): Promise => { + input = {} as any; + + storageAdapter = { + authenticate: jest.fn().mockResolvedValue(webId), + } as any; + + interactionCompleter = { + handleSafe: jest.fn(), + } as any; + + handler = new LoginHandler({ accountStore: storageAdapter, interactionCompleter }); + }); + + it('errors on invalid emails.', async(): Promise => { + input.request = createPostFormRequest({}); + let prom = handler.handle(input); + await expect(prom).rejects.toThrow('Email required'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: {}})); + input.request = createPostFormRequest({ email: [ 'a', 'b' ]}); + prom = handler.handle(input); + await expect(prom).rejects.toThrow('Email required'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { }})); + }); + + it('errors on invalid passwords.', async(): Promise => { + input.request = createPostFormRequest({ email }); + let prom = handler.handle(input); + await expect(prom).rejects.toThrow('Password required'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); + input.request = createPostFormRequest({ email, password: [ 'a', 'b' ]}); + prom = handler.handle(input); + await expect(prom).rejects.toThrow('Password required'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); + }); + + it('throws an IdpInteractionError if there is a problem.', async(): Promise => { + input.request = createPostFormRequest({ email, password: 'password!' }); + (storageAdapter.authenticate as jest.Mock).mockRejectedValueOnce(new Error('auth failed!')); + const prom = handler.handle(input); + await expect(prom).rejects.toThrow('auth failed!'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); + }); + + it('calls the OidcInteractionCompleter when done.', async(): Promise => { + input.request = createPostFormRequest({ email, password: 'password!' }); + await expect(handler.handle(input)).resolves.toBeUndefined(); + expect(storageAdapter.authenticate).toHaveBeenCalledTimes(1); + expect(storageAdapter.authenticate).toHaveBeenLastCalledWith(email, 'password!'); + expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionCompleter.handleSafe) + .toHaveBeenLastCalledWith({ ...input, webId, shouldRemember: false }); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts new file mode 100644 index 000000000..41e409111 --- /dev/null +++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts @@ -0,0 +1,96 @@ +import type { Provider } from 'oidc-provider'; +import { + RegistrationHandler, +} from '../../../../../../src/identity/interaction/email-password/handler/RegistrationHandler'; +import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { InteractionCompleter } from '../../../../../../src/identity/interaction/util/InteractionCompleter'; +import type { OwnershipValidator } from '../../../../../../src/identity/interaction/util/OwnershipValidator'; +import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../../../src/server/HttpResponse'; +import { createPostFormRequest } from './Util'; + +describe('A RegistrationHandler', (): void => { + const webId = 'http://alice.test.com/card#me'; + const email = 'alice@test.email'; + let request: HttpRequest; + const response: HttpResponse = {} as any; + let provider: Provider; + let ownershipValidator: OwnershipValidator; + let accountStore: AccountStore; + let interactionCompleter: InteractionCompleter; + let handler: RegistrationHandler; + + beforeEach(async(): Promise => { + provider = { + interactionDetails: jest.fn().mockResolvedValue({ uid: '123456' }), + } as any; + + ownershipValidator = { + handleSafe: jest.fn(), + } as any; + + accountStore = { + create: jest.fn(), + } as any; + + interactionCompleter = { + handleSafe: jest.fn(), + } as any; + + handler = new RegistrationHandler({ + ownershipValidator, + accountStore, + interactionCompleter, + }); + }); + + it('errors on non-string emails.', async(): Promise => { + request = createPostFormRequest({}); + await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required'); + request = createPostFormRequest({ email: [ 'email', 'email2' ]}); + await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required'); + }); + + it('errors on invalid emails.', async(): Promise => { + request = createPostFormRequest({ email: 'invalidEmail' }); + const prom = handler.handle({ request, response, provider }); + await expect(prom).rejects.toThrow('Invalid email'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { }})); + }); + + it('errors on non-string webIds.', async(): Promise => { + request = createPostFormRequest({ email }); + let prom = handler.handle({ request, response, provider }); + await expect(prom).rejects.toThrow('WebId required'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); + request = createPostFormRequest({ email, webId: [ 'a', 'b' ]}); + prom = handler.handle({ request, response, provider }); + await expect(prom).rejects.toThrow('WebId required'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); + }); + + it('errors on invalid passwords.', async(): Promise => { + request = createPostFormRequest({ email, webId, password: 'password!', confirmPassword: 'bad' }); + const prom = handler.handle({ request, response, provider }); + await expect(prom).rejects.toThrow('Password and confirmation do not match'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email, webId }})); + }); + + it('throws an IdpInteractionError if there is a problem.', async(): Promise => { + request = createPostFormRequest({ email, webId, password: 'password!', confirmPassword: 'password!' }); + (accountStore.create as jest.Mock).mockRejectedValueOnce(new Error('create failed!')); + const prom = handler.handle({ request, response, provider }); + await expect(prom).rejects.toThrow('create failed!'); + await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email, webId }})); + }); + + it('calls the OidcInteractionCompleter when done.', async(): Promise => { + request = createPostFormRequest({ email, webId, password: 'password!', confirmPassword: 'password!' }); + await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, 'password!'); + expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionCompleter.handleSafe) + .toHaveBeenLastCalledWith({ request, response, provider, webId, shouldRemember: false }); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts new file mode 100644 index 000000000..a8c49dc7a --- /dev/null +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts @@ -0,0 +1,96 @@ +import { + ResetPasswordHandler, +} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; +import type { + ResetPasswordRenderHandler, +} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordRenderHandler'; +import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../../../src/server/HttpResponse'; +import type { RenderHandler } from '../../../../../../src/server/util/RenderHandler'; +import { createPostFormRequest } from './Util'; + +describe('A ResetPasswordHandler', (): void => { + let request: HttpRequest; + const response: HttpResponse = {} as any; + const recordId = '123456'; + const email = 'alice@test.email'; + let accountStore: AccountStore; + let renderHandler: ResetPasswordRenderHandler; + let messageRenderHandler: RenderHandler<{ message: string }>; + let handler: ResetPasswordHandler; + + beforeEach(async(): Promise => { + accountStore = { + getForgotPasswordRecord: jest.fn().mockResolvedValue(email), + deleteForgotPasswordRecord: jest.fn(), + changePassword: jest.fn(), + } as any; + + renderHandler = { + handleSafe: jest.fn(), + } as any; + + messageRenderHandler = { + handleSafe: jest.fn(), + } as any; + + handler = new ResetPasswordHandler({ + accountStore, + renderHandler, + messageRenderHandler, + }); + }); + + it('renders errors for non-string recordIds.', async(): Promise => { + const errorMessage = 'Invalid request. Open the link from your email again'; + request = createPostFormRequest({}); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId: '' }}); + request = createPostFormRequest({ recordId: [ 'a', 'b' ]}); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(2); + expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId: '' }}); + }); + + it('renders errors for invalid passwords.', async(): Promise => { + const errorMessage = 'Password and confirmation do not match'; + request = createPostFormRequest({ recordId, password: 'password!', confirmPassword: 'otherPassword!' }); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId }}); + }); + + it('renders errors for invalid emails.', async(): Promise => { + const errorMessage = 'This reset password link is no longer valid.'; + request = createPostFormRequest({ recordId, password: 'password!', confirmPassword: 'password!' }); + (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId }}); + }); + + it('renders a message on success.', async(): Promise => { + request = createPostFormRequest({ recordId, password: 'password!', confirmPassword: 'password!' }); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); + expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); + expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); + expect(accountStore.deleteForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); + expect(accountStore.changePassword).toHaveBeenCalledTimes(1); + expect(accountStore.changePassword).toHaveBeenLastCalledWith(email, 'password!'); + expect(messageRenderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(messageRenderHandler.handleSafe) + .toHaveBeenLastCalledWith({ response, props: { message: 'Your password was successfully reset.' }}); + }); + + it('has a default error for non-native errors.', async(): Promise => { + const errorMessage = 'An unknown error occurred'; + request = createPostFormRequest({ recordId, password: 'password!', confirmPassword: 'password!' }); + (accountStore.getForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('not native'); + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId }}); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordViewHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordViewHandler.test.ts new file mode 100644 index 000000000..590e6e642 --- /dev/null +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordViewHandler.test.ts @@ -0,0 +1,48 @@ +import type { + ResetPasswordRenderHandler, +} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordRenderHandler'; +import { + ResetPasswordViewHandler, +} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordViewHandler'; +import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../../../src/server/HttpResponse'; + +describe('A ResetPasswordViewHandler', (): void => { + let request: HttpRequest; + const response: HttpResponse = {} as any; + let renderHandler: ResetPasswordRenderHandler; + let handler: ResetPasswordViewHandler; + + beforeEach(async(): Promise => { + request = {} as any; + + renderHandler = { + handleSafe: jest.fn(), + } as any; + + handler = new ResetPasswordViewHandler(renderHandler); + }); + + it('requires a URL.', async(): Promise => { + await expect(handler.handle({ request, response })).rejects.toThrow('The request must have a URL'); + }); + + it('requires a record ID.', async(): Promise => { + request.url = '/foo'; + await expect(handler.handle({ request, response })).rejects + .toThrow('A forgot password record ID must be provided. Use the link you have received by email.'); + request.url = '/foo?wrong=recordId'; + await expect(handler.handle({ request, response })).rejects + .toThrow('A forgot password record ID must be provided. Use the link you have received by email.'); + }); + + it('renders the response.', async(): Promise => { + request.url = '/foo?rid=recordId'; + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ + response, + props: { errorMessage: '', recordId: 'recordId' }, + }); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/Util.ts b/test/unit/identity/interaction/email-password/handler/Util.ts new file mode 100644 index 000000000..0348f17b7 --- /dev/null +++ b/test/unit/identity/interaction/email-password/handler/Util.ts @@ -0,0 +1,14 @@ +import { stringify } from 'querystring'; +import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; +import { guardedStreamFrom } from '../../../../../../src/util/StreamUtil'; + +/** + * Creates a mock HttpRequest which is a stream of an object encoded as application/x-www-form-urlencoded + * and a matching content-type header. + * @param data - Object to encode. + */ +export function createPostFormRequest(data: NodeJS.Dict): HttpRequest { + const request = guardedStreamFrom(stringify(data)) as HttpRequest; + request.headers = { 'content-type': 'application/x-www-form-urlencoded' }; + return request; +} diff --git a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts new file mode 100644 index 000000000..3ec216793 --- /dev/null +++ b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts @@ -0,0 +1,93 @@ +import type { + EmailPasswordData, +} from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore'; +import { BaseAccountStore } from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore'; +import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; + +describe('A BaseAccountStore', (): void => { + const storageName = '/mail/storage'; + let storage: KeyValueStorage; + const saltRounds = 11; + let store: BaseAccountStore; + const email = 'test@test.com'; + const webId = 'http://test.com/#webId'; + const password = 'password!'; + + beforeEach(async(): Promise => { + const map = new Map(); + storage = { + get: jest.fn((id: string): any => map.get(id)), + set: jest.fn((id: string, value: any): any => map.set(id, value)), + delete: jest.fn((id: string): any => map.delete(id)), + } as any; + + store = new BaseAccountStore({ storageName, storage, saltRounds }); + }); + + it('can create accounts.', async(): Promise => { + await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + }); + + it('errors when creating a second account for an email.', async(): Promise => { + await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, 'diffId', 'diffPass')).rejects.toThrow('Account already exists'); + }); + + it('errors when authenticating a non-existent account.', async(): Promise => { + await expect(store.authenticate(email, password)).rejects.toThrow('No account by that email'); + }); + + it('errors when authenticating with the wrong password.', async(): Promise => { + await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Incorrect password'); + }); + + it('can authenticate.', async(): Promise => { + await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.authenticate(email, password)).resolves.toBe(webId); + }); + + it('errors when changing the password of a non-existent account.', async(): Promise => { + await expect(store.changePassword(email, password)).rejects.toThrow('Account does not exist'); + }); + + it('can change the password.', async(): Promise => { + const newPassword = 'newPassword!'; + await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.changePassword(email, newPassword)).resolves.toBeUndefined(); + await expect(store.authenticate(email, newPassword)).resolves.toBe(webId); + }); + + it('can delete an account.', async(): Promise => { + await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.deleteAccount(email)).resolves.toBeUndefined(); + await expect(store.authenticate(email, password)).rejects.toThrow('No account by that email'); + }); + + it('errors when forgetting the password of an account that does not exist.', async(): Promise => { + await expect(store.generateForgotPasswordRecord(email)).rejects.toThrow('Account does not exist'); + }); + + it('generates a recordId when a password was forgotten.', async(): Promise => { + await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + const recordId = await store.generateForgotPasswordRecord(email); + expect(typeof recordId).toBe('string'); + }); + + it('returns undefined if there is no matching record to retrieve.', async(): Promise => { + await expect(store.getForgotPasswordRecord('unknownRecord')).resolves.toBeUndefined(); + }); + + it('returns the email matching the forgotten password record.', async(): Promise => { + await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + const recordId = await store.generateForgotPasswordRecord(email); + await expect(store.getForgotPasswordRecord(recordId)).resolves.toBe(email); + }); + + it('can delete stored forgotten password records.', async(): Promise => { + await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + const recordId = await store.generateForgotPasswordRecord(email); + await expect(store.deleteForgotPasswordRecord(recordId)).resolves.toBeUndefined(); + await expect(store.getForgotPasswordRecord('unknownRecord')).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/identity/interaction/util/BaseEmailSender.test.ts b/test/unit/identity/interaction/util/BaseEmailSender.test.ts new file mode 100644 index 000000000..a04fa4424 --- /dev/null +++ b/test/unit/identity/interaction/util/BaseEmailSender.test.ts @@ -0,0 +1,55 @@ +import type { EmailSenderArgs } from '../../../../../src/identity/interaction/util/BaseEmailSender'; +import { BaseEmailSender } from '../../../../../src/identity/interaction/util/BaseEmailSender'; +import type { EmailArgs } from '../../../../../src/identity/interaction/util/EmailSender'; +jest.mock('nodemailer'); + +describe('A BaseEmailSender', (): void => { + let constructorArgs: EmailSenderArgs; + const recipient = 'test@test.com'; + const args: EmailArgs = { recipient, subject: 'subject!', text: 'text!', html: '' }; + let sendMail: jest.Mock; + + beforeEach(async(): Promise => { + constructorArgs = { + emailConfig: { + host: 'smtp.example.email', + port: 587, + auth: { + user: 'alice@example.email', + pass: 'NYEaCsqV7aVStRCbmC', + }, + }, + }; + + sendMail = jest.fn(); + const nodemailer = jest.requireMock('nodemailer'); + Object.assign(nodemailer, { createTransport: (): any => ({ sendMail }) }); + }); + + it('sends a mail with the given settings.', async(): Promise => { + constructorArgs.senderName = 'My Solid Server'; + const sender = new BaseEmailSender(constructorArgs); + await expect(sender.handleSafe(args)).resolves.toBeUndefined(); + expect(sendMail).toHaveBeenCalledTimes(1); + expect(sendMail).toHaveBeenLastCalledWith({ + from: 'My Solid Server', + to: recipient, + subject: args.subject, + text: args.text, + html: args.html, + }); + }); + + it('defaults to the name Solid if none is provided.', async(): Promise => { + const sender = new BaseEmailSender(constructorArgs); + await expect(sender.handleSafe(args)).resolves.toBeUndefined(); + expect(sendMail).toHaveBeenCalledTimes(1); + expect(sendMail).toHaveBeenLastCalledWith({ + from: 'Solid', + to: recipient, + subject: args.subject, + text: args.text, + html: args.html, + }); + }); +}); diff --git a/test/unit/identity/interaction/util/EjsTemplateRenderer.test.ts b/test/unit/identity/interaction/util/EjsTemplateRenderer.test.ts new file mode 100644 index 000000000..47482b7ce --- /dev/null +++ b/test/unit/identity/interaction/util/EjsTemplateRenderer.test.ts @@ -0,0 +1,19 @@ +import { renderFile } from 'ejs'; +import { + EjsTemplateRenderer, +} from '../../../../../src/identity/interaction/util/EjsTemplateRenderer'; + +jest.mock('ejs'); + +describe('An EjsTemplateRenderer', (): void => { + const templatePath = '/var/templates/'; + const templateFile = 'template.ejs'; + const options: Record = { email: 'alice@test.email', webId: 'http://alice.test.com/card#me' }; + const renderer = new EjsTemplateRenderer>(templatePath, templateFile); + + it('renders the given file with the given options.', async(): Promise => { + await expect(renderer.handle(options)).resolves.toBeUndefined(); + expect(renderFile).toHaveBeenCalledTimes(1); + expect(renderFile).toHaveBeenLastCalledWith('/var/templates/template.ejs', options); + }); +}); diff --git a/test/unit/identity/interaction/util/FormDataUtil.test.ts b/test/unit/identity/interaction/util/FormDataUtil.test.ts new file mode 100644 index 000000000..e6957e445 --- /dev/null +++ b/test/unit/identity/interaction/util/FormDataUtil.test.ts @@ -0,0 +1,23 @@ +import { stringify } from 'querystring'; +import { + getFormDataRequestBody, +} from '../../../../../src/identity/interaction/util/FormDataUtil'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import { UnsupportedMediaTypeHttpError } from '../../../../../src/util/errors/UnsupportedMediaTypeHttpError'; +import { guardedStreamFrom } from '../../../../../src/util/StreamUtil'; + +describe('FormDataUtil', (): void => { + describe('#getFormDataRequestBody', (): void => { + it('only supports form data.', async(): Promise => { + await expect(getFormDataRequestBody({ headers: { 'content-type': 'text/turtle' }} as any)) + .rejects.toThrow(UnsupportedMediaTypeHttpError); + }); + + it('converts the body to an object.', async(): Promise => { + const data = { test: 'test!', moreTest: '!TEST!' }; + const stream = guardedStreamFrom(stringify(data)) as HttpRequest; + stream.headers = { 'content-type': 'application/x-www-form-urlencoded' }; + await expect(getFormDataRequestBody(stream)).resolves.toEqual(data); + }); + }); +}); diff --git a/test/unit/identity/interaction/util/IdpRouteController.test.ts b/test/unit/identity/interaction/util/IdpRouteController.test.ts new file mode 100644 index 000000000..7a867079e --- /dev/null +++ b/test/unit/identity/interaction/util/IdpRouteController.test.ts @@ -0,0 +1,93 @@ +import type { Provider } from 'oidc-provider'; +import { IdpInteractionError } from '../../../../../src/identity/interaction/util/IdpInteractionError'; +import type { IdpRenderHandler } from '../../../../../src/identity/interaction/util/IdpRenderHandler'; +import { + IdpRouteController, +} from '../../../../../src/identity/interaction/util/IdpRouteController'; +import type { HttpHandler } from '../../../../../src/server/HttpHandler'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../../src/server/HttpResponse'; + +describe('An IdpRouteController', (): void => { + let request: HttpRequest; + const response: HttpResponse = {} as any; + // `Interaction` type is not exposed + const details = {} as any; + let provider: Provider; + let renderHandler: IdpRenderHandler; + let postHandler: HttpHandler; + let controller: IdpRouteController; + + beforeEach(async(): Promise => { + request = { + randomData: 'data!', + method: 'GET', + } as any; + + provider = { + interactionDetails: jest.fn().mockResolvedValue(details), + } as any; + + renderHandler = { + handleSafe: jest.fn(), + } as any; + + postHandler = { + handleSafe: jest.fn(), + } as any; + + controller = new IdpRouteController('pathName', renderHandler, postHandler); + }); + + it('renders the renderHandler for GET requests.', async(): Promise => { + await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ + response, + props: { details, errorMessage: '', prefilled: {}}, + }); + expect(postHandler.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('calls the postHandler for POST requests.', async(): Promise => { + request.method = 'POST'; + await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(0); + expect(postHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(postHandler.handleSafe).toHaveBeenLastCalledWith({ request, response, provider }); + }); + + it('renders an error if the POST request failed.', async(): Promise => { + request.method = 'POST'; + const error = new IdpInteractionError(400, 'bad request!', { more: 'data!' }); + (postHandler.handleSafe as jest.Mock).mockRejectedValueOnce(error); + await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(postHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(postHandler.handleSafe).toHaveBeenLastCalledWith({ request, response, provider }); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ + response, + props: { details, errorMessage: 'bad request!', prefilled: { more: 'data!' }}, + }); + }); + + it('has a default error message if none is provided.', async(): Promise => { + request.method = 'POST'; + (postHandler.handleSafe as jest.Mock).mockRejectedValueOnce('apple!'); + await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(postHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(postHandler.handleSafe).toHaveBeenLastCalledWith({ request, response, provider }); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ + response, + props: { details, errorMessage: 'An unknown error occurred', prefilled: {}}, + }); + }); + + it('does nothing for other methods.', async(): Promise => { + request.method = 'DELETE'; + await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(postHandler.handleSafe).toHaveBeenCalledTimes(0); + expect(renderHandler.handleSafe).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts b/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts new file mode 100644 index 000000000..a8a306c61 --- /dev/null +++ b/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts @@ -0,0 +1,62 @@ +import type { Provider } from 'oidc-provider'; +import type { RenderHandlerMap } from '../../../../../src/identity/interaction/util/InitialInteractionHandler'; +import { InitialInteractionHandler } from '../../../../../src/identity/interaction/util/InitialInteractionHandler'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../../src/server/HttpResponse'; + +describe('An InitialInteractionHandler', (): void => { + const request: HttpRequest = {} as any; + const response: HttpResponse = {} as any; + let provider: Provider; + // `Interaction` type is not exposed + let details: any; + let map: RenderHandlerMap; + let handler: InitialInteractionHandler; + + beforeEach(async(): Promise => { + map = { + default: { handleSafe: jest.fn() }, + test: { handleSafe: jest.fn() }, + } as any; + + details = { prompt: { name: 'test' }}; + provider = { + interactionDetails: jest.fn().mockResolvedValue(details), + } as any; + + handler = new InitialInteractionHandler(map); + }); + + it('uses the named handler if it is found.', async(): Promise => { + await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(provider.interactionDetails).toHaveBeenCalledTimes(1); + expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response); + expect(map.default.handleSafe).toHaveBeenCalledTimes(0); + expect(map.test.handleSafe).toHaveBeenCalledTimes(1); + expect(map.test.handleSafe).toHaveBeenLastCalledWith({ + response, + props: { + details, + errorMessage: '', + prefilled: {}, + }, + }); + }); + + it('uses the default handler if there is no match.', async(): Promise => { + details.prompt.name = 'unknown'; + await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); + expect(provider.interactionDetails).toHaveBeenCalledTimes(1); + expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response); + expect(map.default.handleSafe).toHaveBeenCalledTimes(1); + expect(map.test.handleSafe).toHaveBeenCalledTimes(0); + expect(map.default.handleSafe).toHaveBeenLastCalledWith({ + response, + props: { + details, + errorMessage: '', + prefilled: {}, + }, + }); + }); +}); diff --git a/test/unit/identity/interaction/util/InteractionCompleter.test.ts b/test/unit/identity/interaction/util/InteractionCompleter.test.ts new file mode 100644 index 000000000..f90d350f2 --- /dev/null +++ b/test/unit/identity/interaction/util/InteractionCompleter.test.ts @@ -0,0 +1,52 @@ +import type { Provider } from 'oidc-provider'; +import { InteractionCompleter } from '../../../../../src/identity/interaction/util/InteractionCompleter'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../../src/server/HttpResponse'; + +describe('An InteractionCompleter', (): void => { + const request: HttpRequest = {} as any; + const response: HttpResponse = {} as any; + const webId = 'http://alice.test.com/#me'; + let provider: Provider; + const completer = new InteractionCompleter(); + + beforeEach(async(): Promise => { + const now = Date.now(); + Date.now = jest.fn().mockReturnValue(now); + provider = { + interactionFinished: jest.fn(), + } as any; + }); + + it('sends the correct data to the provider.', async(): Promise => { + await expect(completer.handle({ request, response, provider, webId, shouldRemember: true })) + .resolves.toBeUndefined(); + expect(provider.interactionFinished).toHaveBeenCalledTimes(1); + expect(provider.interactionFinished).toHaveBeenLastCalledWith(request, response, { + login: { + account: webId, + remember: true, + ts: Math.floor(Date.now() / 1000), + }, + consent: { + rejectedScopes: [], + }, + }); + }); + + it('rejects offline access if shouldRemember is false.', async(): Promise => { + await expect(completer.handle({ request, response, provider, webId, shouldRemember: false })) + .resolves.toBeUndefined(); + expect(provider.interactionFinished).toHaveBeenCalledTimes(1); + expect(provider.interactionFinished).toHaveBeenLastCalledWith(request, response, { + login: { + account: webId, + remember: false, + ts: Math.floor(Date.now() / 1000), + }, + consent: { + rejectedScopes: [ 'offline_access' ], + }, + }); + }); +}); diff --git a/test/unit/identity/interaction/util/IssuerOwnershipValidator.test.ts b/test/unit/identity/interaction/util/IssuerOwnershipValidator.test.ts new file mode 100644 index 000000000..31ef78646 --- /dev/null +++ b/test/unit/identity/interaction/util/IssuerOwnershipValidator.test.ts @@ -0,0 +1,70 @@ +import fetch from '@rdfjs/fetch'; +import type { DatasetResponse } from '@rdfjs/fetch-lite'; +import { DataFactory } from 'n3'; +import type { Quad } from 'n3'; +import type { DatasetCore } from 'rdf-js'; +import { IssuerOwnershipValidator } from '../../../../../src/identity/interaction/util/IssuerOwnershipValidator'; +import { SOLID } from '../../../../../src/util/Vocabularies'; +const { literal, namedNode, quad } = DataFactory; + +jest.mock('@rdfjs/fetch'); + +function quadToString(qq: Quad): string { + const subPred = `<${qq.subject.value}> <${qq.predicate.value}>`; + if (qq.object.termType === 'Literal') { + return `${subPred} "${qq.object.value}"`; + } + return `${subPred} <${qq.object.value}>`; +} + +describe('An IssuerOwnershipValidator', (): void => { + const fetchMock: jest.Mock = fetch as any; + const issuer = 'http://test.com/foo/'; + const webId = 'http://alice.test.com/#me'; + const interactionId = 'interaction!!'; + let rawResponse: DatasetResponse; + let dataset: DatasetCore; + let triples: Quad[]; + const issuerTriple = quad(namedNode(webId), SOLID.terms.oidcIssuer, namedNode(issuer)); + const tokenTriple = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(interactionId)); + let validator: IssuerOwnershipValidator; + + beforeEach(async(): Promise => { + triples = []; + + dataset = { + has: (qq: Quad): boolean => triples.some((triple): boolean => triple.equals(qq)), + } as any; + + rawResponse = { + dataset: async(): Promise => dataset, + } as any; + + fetchMock.mockReturnValue(rawResponse); + + validator = new IssuerOwnershipValidator(issuer); + }); + + it('errors if the expected triples are missing.', async(): Promise => { + const prom = validator.handle({ webId, interactionId }); + await expect(prom).rejects.toThrow(quadToString(issuerTriple)); + await expect(prom).rejects.toThrow(quadToString(tokenTriple)); + }); + + it('only requests the needed triples.', async(): Promise => { + triples = [ issuerTriple ]; + let prom = validator.handle({ webId, interactionId }); + await expect(prom).rejects.not.toThrow(quadToString(issuerTriple)); + await expect(prom).rejects.toThrow(quadToString(tokenTriple)); + + triples = [ tokenTriple ]; + prom = validator.handle({ webId, interactionId }); + await expect(prom).rejects.toThrow(quadToString(issuerTriple)); + await expect(prom).rejects.not.toThrow(quadToString(tokenTriple)); + }); + + it('resolves if all required triples are present.', async(): Promise => { + triples = [ issuerTriple, tokenTriple ]; + await expect(validator.handle({ webId, interactionId })).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/identity/storage/ExpiringAdapterFactory.test.ts b/test/unit/identity/storage/ExpiringAdapterFactory.test.ts new file mode 100644 index 000000000..c95d47701 --- /dev/null +++ b/test/unit/identity/storage/ExpiringAdapterFactory.test.ts @@ -0,0 +1,119 @@ +import type { AdapterPayload } from 'oidc-provider'; +import type { ExpiringAdapter } from '../../../../src/identity/storage/ExpiringAdapterFactory'; +import { ExpiringAdapterFactory } from '../../../../src/identity/storage/ExpiringAdapterFactory'; +import type { ExpiringStorage } from '../../../../src/storage/keyvalue/ExpiringStorage'; + +describe('An ExpiringAdapterFactory', (): void => { + const storageName = '/storage'; + const name = 'nnaammee'; + const id = 'http://alice.test.com/card#me'; + const grantId = 'grant123456'; + let payload: AdapterPayload; + let storage: ExpiringStorage; + let adapter: ExpiringAdapter; + let factory: ExpiringAdapterFactory; + // Make sure this stays consistent in tests + const now = Date.now(); + const expiresIn = 333; + const expireDate = new Date(now + (expiresIn * 1000)); + + beforeEach(async(): Promise => { + Date.now = jest.fn().mockReturnValue(now); + + payload = { data: 'data!' }; + + const map = new Map(); + storage = { + get: jest.fn().mockImplementation((key: string): any => map.get(key)), + set: jest.fn().mockImplementation((key: string, value: any): any => map.set(key, value)), + delete: jest.fn().mockImplementation((key: string): any => map.delete(key)), + } as any; + + factory = new ExpiringAdapterFactory({ storageName, storage }); + adapter = factory.createStorageAdapter(name); + }); + + it('can find payload by id.', async(): Promise => { + await expect(adapter.upsert(id, payload, 333)).resolves.toBeUndefined(); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenCalledWith(expect.anything(), payload, expireDate); + await expect(adapter.find(id)).resolves.toBe(payload); + }); + + it('can store payloads without expiration time.', async(): Promise => { + await expect(adapter.upsert(id, payload)).resolves.toBeUndefined(); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenCalledWith(expect.anything(), payload, undefined); + }); + + it('can find payload by userCode.', async(): Promise => { + const userCode = 'userCode!'; + payload.userCode = userCode; + await expect(adapter.upsert(id, payload, 333)).resolves.toBeUndefined(); + expect(storage.set).toHaveBeenCalledTimes(2); + expect(storage.set).toHaveBeenCalledWith(expect.anything(), payload, expireDate); + expect(storage.set).toHaveBeenCalledWith(expect.anything(), id, expireDate); + await expect(adapter.findByUserCode(userCode)).resolves.toBe(payload); + }); + + it('can find payload by uid.', async(): Promise => { + const uid = 'uid!'; + payload.uid = uid; + await expect(adapter.upsert(id, payload, 333)).resolves.toBeUndefined(); + expect(storage.set).toHaveBeenCalledTimes(2); + expect(storage.set).toHaveBeenCalledWith(expect.anything(), payload, expireDate); + expect(storage.set).toHaveBeenCalledWith(expect.anything(), id, expireDate); + await expect(adapter.findByUid(uid)).resolves.toBe(payload); + }); + + it('can revoke by grantId.', async(): Promise => { + payload.grantId = grantId; + await expect(adapter.upsert(id, payload, 333)).resolves.toBeUndefined(); + expect(storage.set).toHaveBeenCalledTimes(2); + expect(storage.set).toHaveBeenCalledWith(expect.anything(), payload, expireDate); + expect(storage.set).toHaveBeenCalledWith(expect.anything(), [ expect.anything() ], expireDate); + await expect(adapter.find(id)).resolves.toBe(payload); + await expect(adapter.revokeByGrantId(grantId)).resolves.toBeUndefined(); + expect(storage.delete).toHaveBeenCalledTimes(2); + await expect(adapter.find(id)).resolves.toBeUndefined(); + }); + + it('does not do anything if revokeByGrantId finds no matching grant.', async(): Promise => { + await expect(adapter.revokeByGrantId(grantId)).resolves.toBeUndefined(); + expect(storage.delete).toHaveBeenCalledTimes(0); + }); + + it('can store multiple ids for a single grant.', async(): Promise => { + payload.grantId = grantId; + const id2 = 'id2!'; + const payload2 = { data: 'data2!', grantId }; + await expect(adapter.upsert(id, payload, 333)).resolves.toBeUndefined(); + await expect(adapter.upsert(id2, payload2, 333)).resolves.toBeUndefined(); + await expect(adapter.find(id)).resolves.toBe(payload); + await expect(adapter.find(id2)).resolves.toBe(payload2); + await expect(adapter.revokeByGrantId(grantId)).resolves.toBeUndefined(); + await expect(adapter.find(id)).resolves.toBeUndefined(); + await expect(adapter.find(id2)).resolves.toBeUndefined(); + }); + + it('can destroy the payload.', async(): Promise => { + await expect(adapter.upsert(id, payload, 333)).resolves.toBeUndefined(); + await expect(adapter.find(id)).resolves.toBe(payload); + await expect(adapter.destroy(id)).resolves.toBeUndefined(); + await expect(adapter.find(id)).resolves.toBeUndefined(); + }); + + it('can consume the payload.', async(): Promise => { + // Caching since the object gets modified + const cachedPayload = { ...payload }; + await expect(adapter.upsert(id, payload, 333)).resolves.toBeUndefined(); + await expect(adapter.find(id)).resolves.toEqual(cachedPayload); + await expect(adapter.consume(id)).resolves.toBeUndefined(); + await expect(adapter.find(id)).resolves.toEqual({ ...cachedPayload, consumed: Math.floor(Date.now() / 1000) }); + }); + + it('does not do anything if consume finds no payload.', async(): Promise => { + await expect(adapter.consume(id)).resolves.toBeUndefined(); + await expect(adapter.find(id)).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/identity/storage/WrappedFetchAdapterFactory.test.ts b/test/unit/identity/storage/WrappedFetchAdapterFactory.test.ts new file mode 100644 index 000000000..db5ce8211 --- /dev/null +++ b/test/unit/identity/storage/WrappedFetchAdapterFactory.test.ts @@ -0,0 +1,142 @@ +import { literal, namedNode, quad } from '@rdfjs/data-model'; +import fetch from '@rdfjs/fetch'; +import type { DatasetResponse } from '@rdfjs/fetch-lite'; +import type { Adapter } from 'oidc-provider'; +import type { Dataset, Quad, Term } from 'rdf-js'; +import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; +import { WrappedFetchAdapterFactory } from '../../../../src/identity/storage/WrappedFetchAdapterFactory'; +import { SOLID } from '../../../../src/util/Vocabularies'; + +jest.mock('@rdfjs/fetch'); + +describe('A WrappedFetchAdapterFactory', (): void => { + const fetchMock: jest.Mock = fetch as any; + let triples: Quad[]; + const id = 'http://alice.test.com/card#me'; + let source: Adapter; + let sourceFactory: AdapterFactory; + let adapter: Adapter; + let factory: WrappedFetchAdapterFactory; + + beforeEach(async(): Promise => { + triples = []; + + const dataset: Dataset = { + match: (subject: Term, predicate: Term): Quad[] => triples.filter((triple): boolean => + triple.subject.equals(subject) && triple.predicate.equals(predicate)), + } as any; + + const rawResponse: DatasetResponse = { + dataset: async(): Promise => dataset, + } as any; + + fetchMock.mockReturnValue(rawResponse); + + source = { + upsert: jest.fn(), + find: jest.fn(), + findByUserCode: jest.fn(), + findByUid: jest.fn(), + destroy: jest.fn(), + revokeByGrantId: jest.fn(), + consume: jest.fn(), + }; + + sourceFactory = { + createStorageAdapter: jest.fn().mockReturnValue(source), + }; + + factory = new WrappedFetchAdapterFactory(sourceFactory); + adapter = factory.createStorageAdapter('Client'); + }); + + it('passes the call to the source for upsert.', async(): Promise => { + await expect(adapter.upsert('id', 'payload' as any, 5)).resolves.toBeUndefined(); + expect(source.upsert).toHaveBeenCalledTimes(1); + expect(source.upsert).toHaveBeenLastCalledWith('id', 'payload' as any, 5); + }); + + it('passes the call to the source for findByUserCode.', async(): Promise => { + await expect(adapter.findByUserCode('userCode')).resolves.toBeUndefined(); + expect(source.findByUserCode).toHaveBeenCalledTimes(1); + expect(source.findByUserCode).toHaveBeenLastCalledWith('userCode'); + }); + + it('passes the call to the source for findByUid.', async(): Promise => { + await expect(adapter.findByUid('uid')).resolves.toBeUndefined(); + expect(source.findByUid).toHaveBeenCalledTimes(1); + expect(source.findByUid).toHaveBeenLastCalledWith('uid'); + }); + + it('passes the call to the source for destroy.', async(): Promise => { + await expect(adapter.destroy('id')).resolves.toBeUndefined(); + expect(source.destroy).toHaveBeenCalledTimes(1); + expect(source.destroy).toHaveBeenLastCalledWith('id'); + }); + + it('passes the call to the source for revokeByGrantId.', async(): Promise => { + await expect(adapter.revokeByGrantId('grantId')).resolves.toBeUndefined(); + expect(source.revokeByGrantId).toHaveBeenCalledTimes(1); + expect(source.revokeByGrantId).toHaveBeenLastCalledWith('grantId'); + }); + + it('passes the call to the source for consume.', async(): Promise => { + await expect(adapter.consume('id')).resolves.toBeUndefined(); + expect(source.consume).toHaveBeenCalledTimes(1); + expect(source.consume).toHaveBeenLastCalledWith('id'); + }); + + it('returns the source find payload if there is one.', async(): Promise => { + (source.find as jest.Mock).mockResolvedValueOnce('payload!'); + await expect(adapter.find(id)).resolves.toBe('payload!'); + }); + + it('returns undefined if this is not a Client Adapter and there is no source payload.', async(): Promise => { + adapter = factory.createStorageAdapter('NotClient'); + await expect(adapter.find(id)).resolves.toBeUndefined(); + }); + + it('returns undefined if there was a problem accessing the id.', async(): Promise => { + fetchMock.mockRejectedValueOnce(new Error('bad data!')); + await expect(adapter.find(id)).resolves.toBeUndefined(); + }); + + it('returns undefined if there are no solid:oidcRegistration triples.', async(): Promise => { + triples = [ + quad(namedNode(id), namedNode('irrelevant'), literal('value')), + ]; + await expect(adapter.find(id)).resolves.toBeUndefined(); + }); + + it('returns undefined if there are no valid solid:oidcRegistration triples.', async(): Promise => { + triples = [ + quad(namedNode(id), namedNode('irrelevant'), literal('value')), + quad(namedNode(id), SOLID.terms.oidcRegistration, literal('}{')), + ]; + await expect(adapter.find(id)).resolves.toBeUndefined(); + }); + + it('returns undefined if there are no matching solid:oidcRegistration triples.', async(): Promise => { + triples = [ + quad(namedNode(id), namedNode('irrelevant'), literal('value')), + quad(namedNode(id), SOLID.terms.oidcRegistration, literal('}{')), + quad(namedNode(id), SOLID.terms.oidcRegistration, literal('{ "client_id": "invalid_id" }')), + ]; + await expect(adapter.find(id)).resolves.toBeUndefined(); + }); + + it('returns a new payload if there is a registration match.', async(): Promise => { + triples = [ + quad(namedNode(id), namedNode('irrelevant'), literal('value')), + quad(namedNode(id), SOLID.terms.oidcRegistration, literal('}{')), + quad(namedNode(id), SOLID.terms.oidcRegistration, literal('{ "client_id": "invalid_id" }')), + quad(namedNode(id), SOLID.terms.oidcRegistration, literal(`{ "client_id": "${id}" }`)), + ]; + + /* eslint-disable @typescript-eslint/naming-convention */ + await expect(adapter.find(id)).resolves.toEqual({ + client_id: id, + token_endpoint_auth_method: 'none', + }); + }); +}); diff --git a/test/unit/identity/util/FetchUtil.test.ts b/test/unit/identity/util/FetchUtil.test.ts new file mode 100644 index 000000000..83cd78246 --- /dev/null +++ b/test/unit/identity/util/FetchUtil.test.ts @@ -0,0 +1,44 @@ +import fetch from '@rdfjs/fetch'; +import type { DatasetResponse } from '@rdfjs/fetch-lite'; +import type { Dataset } from 'rdf-js'; +import { fetchDataset } from '../../../../src/identity/util/FetchUtil'; + +jest.mock('@rdfjs/fetch'); + +describe('FetchUtil', (): void => { + describe('#fetchDataset', (): void => { + const fetchMock: jest.Mock = fetch as any; + const url = 'http://test.com/foo'; + let datasetResponse: DatasetResponse; + const dataset: Dataset = {} as any; + + beforeEach(async(): Promise => { + datasetResponse = { + dataset: jest.fn().mockReturnValue(dataset), + } as any; + + fetchMock.mockResolvedValue(datasetResponse); + }); + + it('errors if there was an issue fetching.', async(): Promise => { + fetchMock.mockRejectedValueOnce(new Error('Invalid webId!')); + await expect(fetchDataset(url)).rejects.toThrow(`Cannot fetch ${url}: Invalid webId!`); + expect(fetchMock).toHaveBeenCalledWith(url); + + fetchMock.mockRejectedValueOnce('apple'); + await expect(fetchDataset(url)).rejects.toThrow(`Cannot fetch ${url}: Unknown error`); + }); + + it('errors if there was an issue parsing the returned RDF.', async(): Promise => { + (datasetResponse.dataset as jest.Mock).mockRejectedValueOnce(new Error('Invalid RDF!')); + await expect(fetchDataset(url)).rejects.toThrow(`Could not parse RDF in ${url}: Invalid RDF!`); + + (datasetResponse.dataset as jest.Mock).mockRejectedValueOnce('apple'); + await expect(fetchDataset(url)).rejects.toThrow(`Could not parse RDF in ${url}: Unknown error`); + }); + + it('returns the resulting Dataset.', async(): Promise => { + await expect(fetchDataset(url)).resolves.toBe(dataset); + }); + }); +}); diff --git a/test/unit/init/AppRunner.test.ts b/test/unit/init/AppRunner.test.ts index b48d6cc19..aa2f3b7a7 100644 --- a/test/unit/init/AppRunner.test.ts +++ b/test/unit/init/AppRunner.test.ts @@ -68,6 +68,7 @@ describe('AppRunner', (): void => { 'urn:solid-server:default:variable:sparqlEndpoint': undefined, 'urn:solid-server:default:variable:loggingLevel': 'info', 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', + 'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../../templates/idp'), }, }, ); @@ -107,6 +108,7 @@ describe('AppRunner', (): void => { 'urn:solid-server:default:variable:sparqlEndpoint': undefined, 'urn:solid-server:default:variable:loggingLevel': 'info', 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', + 'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../../templates/idp'), }, }, ); @@ -126,6 +128,7 @@ describe('AppRunner', (): void => { '-p', '4000', '-s', 'http://localhost:5000/sparql', '--podConfigJson', '/different-path.json', + '--idpTemplateFolder', 'templates/idp', ], }); @@ -152,6 +155,7 @@ describe('AppRunner', (): void => { 'urn:solid-server:default:variable:rootFilePath': '/root', 'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql', 'urn:solid-server:default:variable:podConfigJson': '/different-path.json', + 'urn:solid-server:default:variable:idpTemplateFolder': '/var/cwd/templates/idp', }, }, ); @@ -169,6 +173,7 @@ describe('AppRunner', (): void => { '--rootFilePath', 'root', '--sparqlEndpoint', 'http://localhost:5000/sparql', '--podConfigJson', '/different-path.json', + '--idpTemplateFolder', 'templates/idp', ], }); @@ -195,6 +200,7 @@ describe('AppRunner', (): void => { 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/root', 'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql', 'urn:solid-server:default:variable:podConfigJson': '/different-path.json', + 'urn:solid-server:default:variable:idpTemplateFolder': '/var/cwd/templates/idp', }, }, ); @@ -212,6 +218,7 @@ describe('AppRunner', (): void => { '-p', '4000', '-s', 'http://localhost:5000/sparql', '--podConfigJson', '/different-path.json', + '--idpTemplateFolder', 'templates/idp', ]; new AppRunner().runCli(); @@ -239,6 +246,7 @@ describe('AppRunner', (): void => { 'urn:solid-server:default:variable:rootFilePath': '/root', 'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql', 'urn:solid-server:default:variable:podConfigJson': '/different-path.json', + 'urn:solid-server:default:variable:idpTemplateFolder': '/var/cwd/templates/idp', }, }, ); diff --git a/test/unit/init/ConfigPodInitializer.test.ts b/test/unit/init/ConfigPodInitializer.test.ts index c36b5e6cf..3d494498b 100644 --- a/test/unit/init/ConfigPodInitializer.test.ts +++ b/test/unit/init/ConfigPodInitializer.test.ts @@ -1,5 +1,4 @@ import { ConfigPodInitializer } from '../../../src/init/ConfigPodInitializer'; -import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier'; import type { ComponentsJsFactory } from '../../../src/pods/generate/ComponentsJsFactory'; import { TEMPLATE, TEMPLATE_VARIABLE } from '../../../src/pods/generate/variables/Variables'; import type { KeyValueStorage } from '../../../src/storage/keyvalue/KeyValueStorage'; @@ -8,7 +7,7 @@ import type { ResourceStore } from '../../../src/storage/ResourceStore'; describe('A ConfigPodInitializer', (): void => { let storeFactory: ComponentsJsFactory; let configStorage: KeyValueStorage; - let routingStorage: KeyValueStorage; + let routingStorage: KeyValueStorage; let initializer: ConfigPodInitializer; const identifierA = { path: 'http://test.com/A' }; const identifierB = { path: 'http://test.com/B' }; @@ -26,8 +25,8 @@ describe('A ConfigPodInitializer', (): void => { const map = new Map(); routingStorage = { - get: async(identifier: ResourceIdentifier): Promise => map.get(identifier.path), - set: async(identifier: ResourceIdentifier, value: ResourceStore): Promise => map.set(identifier.path, value), + get: async(key: string): Promise => map.get(key), + set: async(key: string, value: ResourceStore): Promise => map.set(key, value), } as any; initializer = new ConfigPodInitializer(storeFactory, configStorage, routingStorage); @@ -38,7 +37,7 @@ describe('A ConfigPodInitializer', (): void => { expect(storeFactory.generate).toHaveBeenCalledTimes(2); expect(storeFactory.generate).toHaveBeenCalledWith('templateA', TEMPLATE.ResourceStore, configA); expect(storeFactory.generate).toHaveBeenCalledWith('templateB', TEMPLATE.ResourceStore, configB); - await expect(routingStorage.get(identifierA)).resolves.toBe('store'); - await expect(routingStorage.get(identifierB)).resolves.toBe('store'); + await expect(routingStorage.get(identifierA.path)).resolves.toBe('store'); + await expect(routingStorage.get(identifierB.path)).resolves.toBe('store'); }); }); diff --git a/test/unit/ldp/http/BasicResponseWriter.test.ts b/test/unit/ldp/http/BasicResponseWriter.test.ts index 4b83be61b..41b01673b 100644 --- a/test/unit/ldp/http/BasicResponseWriter.test.ts +++ b/test/unit/ldp/http/BasicResponseWriter.test.ts @@ -44,7 +44,7 @@ describe('A BasicResponseWriter', (): void => { const data = guardedStreamFrom([ ' .' ]); result = { statusCode: 201, data }; - const end = new Promise((resolve): void => { + const end = new Promise((resolve): void => { response.on('end', (): void => { expect(response._isEndCalled()).toBeTruthy(); expect(response._getStatusCode()).toBe(201); @@ -78,7 +78,7 @@ describe('A BasicResponseWriter', (): void => { response = new PassThrough(); response.writeHead = jest.fn(); - const end = new Promise((resolve): void => { + const end = new Promise((resolve): void => { response.on('error', (error: Error): void => { expect(error).toEqual(new Error('bad data!')); resolve(); diff --git a/test/unit/pods/ConfigPodManager.test.ts b/test/unit/pods/ConfigPodManager.test.ts index 23baed28f..74739f704 100644 --- a/test/unit/pods/ConfigPodManager.test.ts +++ b/test/unit/pods/ConfigPodManager.test.ts @@ -6,6 +6,7 @@ import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/Re import type { PodSettings } from '../../../src/pods/settings/PodSettings'; import type { KeyValueStorage } from '../../../src/storage/keyvalue/KeyValueStorage'; import type { ResourceStore } from '../../../src/storage/ResourceStore'; + describe('A ConfigPodManager', (): void => { let settings: PodSettings; const base = 'http://test.com/'; @@ -14,7 +15,7 @@ describe('A ConfigPodManager', (): void => { }; let store: ResourceStore; let podGenerator: PodGenerator; - let routingStorage: KeyValueStorage; + let routingStorage: KeyValueStorage; let generatorData: Resource[]; let resourcesGenerator: ResourcesGenerator; let manager: ConfigPodManager; @@ -45,8 +46,8 @@ describe('A ConfigPodManager', (): void => { const map = new Map(); routingStorage = { - get: async(identifier: ResourceIdentifier): Promise => map.get(identifier.path), - set: async(identifier: ResourceIdentifier, value: ResourceStore): Promise => map.set(identifier.path, value), + get: async(key: ResourceIdentifier): Promise => map.get(key), + set: async(key: ResourceIdentifier, value: ResourceStore): Promise => map.set(key, value), } as any; manager = new ConfigPodManager(idGenerator, podGenerator, resourcesGenerator, routingStorage); @@ -62,6 +63,6 @@ describe('A ConfigPodManager', (): void => { expect(store.setRepresentation).toHaveBeenCalledTimes(2); expect(store.setRepresentation).toHaveBeenCalledWith({ path: '/path/' }, '/'); expect(store.setRepresentation).toHaveBeenLastCalledWith({ path: '/path/foo' }, '/foo'); - await expect(routingStorage.get(identifier)).resolves.toBe(store); + await expect(routingStorage.get(identifier.path)).resolves.toBe(store); }); }); diff --git a/test/unit/server/util/RenderEjsHandler.test.ts b/test/unit/server/util/RenderEjsHandler.test.ts new file mode 100644 index 000000000..c13487eea --- /dev/null +++ b/test/unit/server/util/RenderEjsHandler.test.ts @@ -0,0 +1,77 @@ +import { createResponse } from 'node-mocks-http'; +import { joinFilePath } from '../../../../src'; +import type { HttpResponse } from '../../../../src'; +import { RenderEjsHandler } from '../../../../src/server/util/RenderEjsHandler'; + +describe('RenderEjsHandler', (): void => { + let response: HttpResponse; + let templatePath: string; + let templateFile: string; + + beforeEach((): void => { + response = createResponse(); + templatePath = joinFilePath(__dirname, '../../../assets/idp'); + templateFile = 'testHtml.ejs'; + }); + + it('throws an error if the path is not valid.', async(): Promise => { + const handler = new RenderEjsHandler<{ message: string }>('/bad/path', 'badFile.thing'); + await expect(handler.handle({ + response, + props: { + message: 'cool', + }, + })).rejects.toThrow(`ENOENT: no such file or directory, open '/bad/path/badFile.thing'`); + }); + + it('throws an error if valid parameters were not provided.', async(): Promise => { + const handler = new RenderEjsHandler(templatePath, templateFile); + await expect(handler.handle({ + response, + props: 'This is an invalid prop.', + })).rejects.toThrow(); + }); + + it('successfully renders a page.', async(): Promise => { + const handler = new RenderEjsHandler<{ message: string }>(templatePath, templateFile); + await handler.handle({ + response, + props: { + message: 'cool', + }, + }); + // Cast to any because mock-response depends on express, which this project doesn't have + const testResponse = response as any; + expect(testResponse._isEndCalled()).toBe(true); + expect(testResponse._getData()).toBe('

cool

'); + expect(testResponse._getStatusCode()).toBe(200); + }); + + it('successfully escapes html input.', async(): Promise => { + const handler = new RenderEjsHandler<{ message: string }>(templatePath, templateFile); + await handler.handle({ + response, + props: { + message: '', + }, + }); + // Cast to any because mock-response depends on express, which this project doesn't have + const testResponse = response as any; + expect(testResponse._isEndCalled()).toBe(true); + expect(testResponse._getData()).toBe('

<script>alert(1)</script>

'); + expect(testResponse._getStatusCode()).toBe(200); + }); + + it('successfully renders when no props are needed.', async(): Promise => { + const handler = new RenderEjsHandler(templatePath, 'noPropsTestHtml.ejs'); + await handler.handle({ + response, + props: undefined, + }); + // Cast to any because mock-response depends on express, which this project doesn't have + const testResponse = response as any; + expect(testResponse._isEndCalled()).toBe(true); + expect(testResponse._getData()).toBe('

secret message

'); + expect(testResponse._getStatusCode()).toBe(200); + }); +}); diff --git a/test/unit/server/util/RouterHandler.test.ts b/test/unit/server/util/RouterHandler.test.ts new file mode 100644 index 000000000..d00ba148a --- /dev/null +++ b/test/unit/server/util/RouterHandler.test.ts @@ -0,0 +1,93 @@ +import { createRequest, createResponse } from 'node-mocks-http'; +import type { AsyncHandler, HttpHandlerInput, HttpRequest, HttpResponse } from '../../../../src'; +import { guardStream } from '../../../../src'; +import { RouterHandler } from '../../../../src/server/util/RouterHandler'; +import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler'; + +describe('RouterHandler', (): void => { + let subHandler: AsyncHandler; + let genericRequest: HttpRequest; + let genericResponse: HttpResponse; + let genericInput: HttpHandlerInput; + + beforeEach((): void => { + subHandler = new StaticAsyncHandler(true, undefined); + genericRequest = guardStream(createRequest({ + url: '/test', + })); + genericResponse = createResponse(); + genericInput = { + request: genericRequest, + response: genericResponse, + }; + }); + + it('calls the sub handler when handle is called.', async(): Promise => { + const handler = new RouterHandler(subHandler, [ 'GET' ], [ '/test' ]); + expect(await handler.handle(genericInput)).toBeUndefined(); + }); + + it('throws an error if the request does not have a url.', async(): Promise => { + const handler = new RouterHandler(subHandler, [ 'GET' ], [ '/test' ]); + const request = guardStream(createRequest()); + await expect(handler.canHandle({ + request, + response: genericResponse, + })).rejects.toThrow('Cannot handle request without a url'); + }); + + it('throws an error if the request does not have a method.', async(): Promise => { + const handler = new RouterHandler(subHandler, [ 'GET' ], [ '/test' ]); + const request = guardStream(createRequest({ + url: '/test', + })); + // @ts-expect-error manually set the method + request.method = undefined; + await expect(handler.canHandle({ + request, + response: genericResponse, + })).rejects.toThrow('Cannot handle request without a method'); + }); + + it('throws an error if the request does not have a pathname.', async(): Promise => { + const handler = new RouterHandler(subHandler, [ 'GET' ], [ '/test' ]); + const request = guardStream(createRequest({ + url: '?bad=pathname', + })); + await expect(handler.canHandle({ + request, + response: genericResponse, + })).rejects.toThrow('Cannot handle request without pathname'); + }); + + it('throws an error when there are no allowed methods or pathnames.', async(): Promise => { + const handler = new RouterHandler(subHandler, [], []); + await expect(handler.canHandle(genericInput)).rejects.toThrow('GET is not allowed.'); + }); + + it('throws an error when there are no allowed methods.', async(): Promise => { + const handler = new RouterHandler(subHandler, [], [ '/test' ]); + await expect(handler.canHandle(genericInput)).rejects.toThrow('GET is not allowed.'); + }); + + it('throws an error when there are no allowed pathnames.', async(): Promise => { + const handler = new RouterHandler(subHandler, [ 'GET' ], []); + await expect(handler.canHandle(genericInput)).rejects.toThrow('Cannot handle route /test'); + }); + + it('throws an error if the RegEx string is not valid Regex.', async(): Promise => { + expect((): RouterHandler => new RouterHandler(subHandler, [ 'GET' ], [ '[' ])) + .toThrow('Invalid regular expression: /[/: Unterminated character class'); + }); + + it('throws an error if all else is successful, but the sub handler cannot handle.', async(): Promise => { + const rejectingHandler = new StaticAsyncHandler(false, undefined); + const handler = new RouterHandler(rejectingHandler, [ 'GET' ], [ '/test' ]); + await expect(handler.canHandle(genericInput)).rejects.toThrow('Not supported'); + }); + + it('does not throw an error if the sub handler is successful.', async(): Promise => { + const handler = new RouterHandler(subHandler, [ 'GET' ], [ '/test' ]); + expect(await handler.canHandle(genericInput)).toBeUndefined(); + }); +}); diff --git a/test/unit/storage/keyvalue/JsonResourceStorage.test.ts b/test/unit/storage/keyvalue/JsonResourceStorage.test.ts index 32fae6eec..07ea1d3b4 100644 --- a/test/unit/storage/keyvalue/JsonResourceStorage.test.ts +++ b/test/unit/storage/keyvalue/JsonResourceStorage.test.ts @@ -1,27 +1,37 @@ import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; import type { Representation } from '../../../../src/ldp/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; import { JsonResourceStorage } from '../../../../src/storage/keyvalue/JsonResourceStorage'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { readableToString } from '../../../../src/util/StreamUtil'; +import { LDP } from '../../../../src/util/Vocabularies'; describe('A JsonResourceStorage', (): void => { - const identifier1: ResourceIdentifier = { path: 'http://test.com/foo' }; - const identifier2: ResourceIdentifier = { path: 'http://test.com/bar' }; + const baseUrl = 'http://test.com/'; + const container = '/data/'; + const identifier1 = 'http://test.com/foo'; + const identifier2 = 'http://test.com/bar'; let store: ResourceStore; let storage: JsonResourceStorage; beforeEach(async(): Promise => { const data: Record = { }; store = { + async resourceExists(identifier: ResourceIdentifier): Promise { + return Boolean(data[identifier.path]); + }, async getRepresentation(identifier: ResourceIdentifier): Promise { + // Simulate container metadata + if (identifier.path === 'http://test.com/data/') { + const metadata = new RepresentationMetadata({ [LDP.contains]: Object.keys(data) }); + return new BasicRepresentation('', metadata); + } if (!data[identifier.path]) { throw new NotFoundHttpError(); - } else { - return new BasicRepresentation(data[identifier.path], identifier); } + return new BasicRepresentation(data[identifier.path], identifier); }, async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise { data[identifier.path] = await readableToString(representation.data); @@ -35,7 +45,7 @@ describe('A JsonResourceStorage', (): void => { }, } as any; - storage = new JsonResourceStorage(store); + storage = new JsonResourceStorage(store, baseUrl, container); }); it('returns undefined if there is no matching data.', async(): Promise => { @@ -45,7 +55,7 @@ describe('A JsonResourceStorage', (): void => { it('returns data if it was set beforehand.', async(): Promise => { await expect(storage.set(identifier1, 'apple')).resolves.toBe(storage); await expect(storage.get(identifier1)).resolves.toBe('apple'); - expect(storage.entries).toThrow(NotImplementedHttpError); + await expect(storage.entries().next()).resolves.toEqual({ done: false, value: [ identifier1, 'apple' ]}); }); it('can check if data is present.', async(): Promise => { @@ -73,11 +83,10 @@ describe('A JsonResourceStorage', (): void => { }); it('re-throws errors thrown by the store.', async(): Promise => { - store.getRepresentation = jest.fn().mockRejectedValue(new Error('bad GET')); + store.getRepresentation = jest.fn().mockRejectedValueOnce(new Error('bad GET')); await expect(storage.get(identifier1)).rejects.toThrow('bad GET'); - await expect(storage.has(identifier1)).rejects.toThrow('bad GET'); - store.deleteResource = jest.fn().mockRejectedValue(new Error('bad DELETE')); + store.deleteResource = jest.fn().mockRejectedValueOnce(new Error('bad DELETE')); await expect(storage.delete(identifier1)).rejects.toThrow('bad DELETE'); }); }); diff --git a/test/unit/storage/keyvalue/MemoryMapStorage.test.ts b/test/unit/storage/keyvalue/MemoryMapStorage.test.ts index 9a41e5b4a..b2a361af2 100644 --- a/test/unit/storage/keyvalue/MemoryMapStorage.test.ts +++ b/test/unit/storage/keyvalue/MemoryMapStorage.test.ts @@ -1,13 +1,12 @@ -import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; import { MemoryMapStorage } from '../../../../src/storage/keyvalue/MemoryMapStorage'; describe('A MemoryMapStorage', (): void => { - const identifier1: ResourceIdentifier = { path: 'http://test.com/foo' }; - const identifier2: ResourceIdentifier = { path: 'http://test.com/bar' }; - let storage: MemoryMapStorage; + const identifier1 = 'http://test.com/foo'; + const identifier2 = 'http://test.com/bar'; + let storage: MemoryMapStorage; beforeEach(async(): Promise => { - storage = new MemoryMapStorage(); + storage = new MemoryMapStorage(); }); it('returns undefined if there is no matching data.', async(): Promise => { diff --git a/test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts b/test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts deleted file mode 100644 index aaabc88c3..000000000 --- a/test/unit/storage/keyvalue/ResourceIdentifierStorage.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; -import { ResourceIdentifierStorage } from '../../../../src/storage/keyvalue/ResourceIdentifierStorage'; - -describe('A ResourceIdentifierStorage', (): void => { - const path = 'http://test.com/foo'; - const identifier = { path }; - let source: KeyValueStorage; - let storage: ResourceIdentifierStorage; - - beforeEach(async(): Promise => { - source = { - get: jest.fn(), - has: jest.fn(), - set: jest.fn(), - delete: jest.fn(), - entries: jest.fn(async function* (): any { - yield [ 'a', 1 ]; - }), - }; - storage = new ResourceIdentifierStorage(source); - }); - - it('calls the corresponding function on the source Storage.', async(): Promise => { - await storage.get(identifier); - expect(source.get).toHaveBeenCalledTimes(1); - expect(source.get).toHaveBeenLastCalledWith(path); - - await storage.has(identifier); - expect(source.has).toHaveBeenCalledTimes(1); - expect(source.has).toHaveBeenLastCalledWith(path); - - await storage.set(identifier, 5); - expect(source.set).toHaveBeenCalledTimes(1); - expect(source.set).toHaveBeenLastCalledWith(path, 5); - - await storage.delete(identifier); - expect(source.delete).toHaveBeenCalledTimes(1); - expect(source.delete).toHaveBeenLastCalledWith(path); - - await storage.entries().next(); - expect(source.entries).toHaveBeenCalledTimes(1); - }); -}); diff --git a/test/unit/storage/keyvalue/WrappedExpiringStorage.test.ts b/test/unit/storage/keyvalue/WrappedExpiringStorage.test.ts new file mode 100644 index 000000000..4ef088fe3 --- /dev/null +++ b/test/unit/storage/keyvalue/WrappedExpiringStorage.test.ts @@ -0,0 +1,155 @@ +import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; +import type { Expires } from '../../../../src/storage/keyvalue/WrappedExpiringStorage'; +import { WrappedExpiringStorage } from '../../../../src/storage/keyvalue/WrappedExpiringStorage'; +import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; +import clearAllTimers = jest.clearAllTimers; + +type Internal = Expires; + +function createExpires(payload: string, expires?: Date): Internal { + return { payload, expires: expires?.toISOString() }; +} + +jest.useFakeTimers(); + +describe('A WrappedExpiringStorage', (): void => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + let source: KeyValueStorage; + let storage: WrappedExpiringStorage; + + beforeEach(async(): Promise => { + source = { + get: jest.fn(), + has: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + entries: jest.fn(), + }; + storage = new WrappedExpiringStorage(source); + }); + + afterEach(async(): Promise => { + clearAllTimers(); + }); + + it('does not return data if there is no result.', async(): Promise => { + await expect(storage.get('key')).resolves.toBeUndefined(); + expect(source.get).toHaveBeenCalledTimes(1); + expect(source.get).toHaveBeenLastCalledWith('key'); + }); + + it('returns data if it has not expired.', async(): Promise => { + (source.get as jest.Mock).mockResolvedValueOnce(createExpires('data!', tomorrow)); + await expect(storage.get('key')).resolves.toEqual('data!'); + }); + + it('deletes expired data when trying to get it.', async(): Promise => { + (source.get as jest.Mock).mockResolvedValueOnce(createExpires('data!', yesterday)); + await expect(storage.get('key')).resolves.toBeUndefined(); + expect(source.delete).toHaveBeenCalledTimes(1); + expect(source.delete).toHaveBeenLastCalledWith('key'); + }); + + it('returns false on `has` checks if there is no data.', async(): Promise => { + await expect(storage.has('key')).resolves.toBe(false); + expect(source.get).toHaveBeenCalledTimes(1); + expect(source.get).toHaveBeenLastCalledWith('key'); + }); + + it('true on `has` checks if there is non-expired data.', async(): Promise => { + (source.get as jest.Mock).mockResolvedValueOnce(createExpires('data!', tomorrow)); + await expect(storage.has('key')).resolves.toBe(true); + }); + + it('deletes expired data when checking if it exists.', async(): Promise => { + (source.get as jest.Mock).mockResolvedValueOnce(createExpires('data!', yesterday)); + await expect(storage.has('key')).resolves.toBe(false); + expect(source.delete).toHaveBeenCalledTimes(1); + expect(source.delete).toHaveBeenLastCalledWith('key'); + }); + + it('converts the expiry date to a string when storing data.', async(): Promise => { + await storage.set('key', 'data!', tomorrow); + expect(source.set).toHaveBeenCalledTimes(1); + expect(source.set).toHaveBeenLastCalledWith('key', createExpires('data!', tomorrow)); + }); + + it('can store data without expiry date.', async(): Promise => { + await storage.set('key', 'data!'); + expect(source.set).toHaveBeenCalledTimes(1); + expect(source.set).toHaveBeenLastCalledWith('key', createExpires('data!')); + }); + + it('errors when trying to store expired data.', async(): Promise => { + await expect(storage.set('key', 'data!', yesterday)).rejects.toThrow(InternalServerError); + }); + + it('directly calls delete on the source when deleting.', async(): Promise => { + await expect(storage.delete('key')).resolves.toBeUndefined(); + expect(source.delete).toHaveBeenCalledTimes(1); + expect(source.delete).toHaveBeenLastCalledWith('key'); + }); + + it('only iterates over non-expired entries.', async(): Promise => { + const data = [ + [ 'key1', createExpires('data1', tomorrow) ], + [ 'key2', createExpires('data2', yesterday) ], + [ 'key3', createExpires('data3') ], + ]; + (source.entries as jest.Mock).mockImplementationOnce(function* (): any { + yield* data; + }); + const it = storage.entries(); + await expect(it.next()).resolves.toEqual( + expect.objectContaining({ value: [ 'key1', 'data1' ]}), + ); + await expect(it.next()).resolves.toEqual( + expect.objectContaining({ value: [ 'key3', 'data3' ]}), + ); + }); + + it('removes expired entries after a given time.', async(): Promise => { + // Timeout of 1 minute + storage = new WrappedExpiringStorage(source, 1); + const data = [ + [ 'key1', createExpires('data1', tomorrow) ], + [ 'key2', createExpires('data2', yesterday) ], + [ 'key3', createExpires('data3') ], + ]; + (source.entries as jest.Mock).mockImplementationOnce(function* (): any { + yield* data; + }); + + jest.advanceTimersByTime(60 * 1000); + + // Allow timer promise callback to resolve + await new Promise(setImmediate); + + expect(source.delete).toHaveBeenCalledTimes(1); + expect(source.delete).toHaveBeenLastCalledWith('key2'); + }); + + it('can stop the timer.', async(): Promise => { + // Timeout of 1 minute + storage = new WrappedExpiringStorage(source, 1); + const data = [ + [ 'key1', createExpires('data1', tomorrow) ], + [ 'key2', createExpires('data2', yesterday) ], + [ 'key3', createExpires('data3') ], + ]; + (source.entries as jest.Mock).mockImplementationOnce(function* (): any { + yield* data; + }); + + expect(storage.finalize()).toBeUndefined(); + jest.advanceTimersByTime(60 * 1000); + + // Allow timer promise callback to resolve + await new Promise(setImmediate); + + expect(source.delete).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/storage/routing/BaseUrlRouterRule.test.ts b/test/unit/storage/routing/BaseUrlRouterRule.test.ts index eb9db10e9..45f6f9927 100644 --- a/test/unit/storage/routing/BaseUrlRouterRule.test.ts +++ b/test/unit/storage/routing/BaseUrlRouterRule.test.ts @@ -1,25 +1,17 @@ -import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { BaseUrlRouterRule } from '../../../../src/storage/routing/BaseUrlRouterRule'; import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; describe('A BaseUrlRouterRule', (): void => { - let stores: KeyValueStorage; + let stores: KeyValueStorage; const baseStore = 'baseStore!' as any; const aliceIdentifier = { path: 'http://alice.test.com/' }; const aliceStore = 'aliceStore!' as any; let rule: BaseUrlRouterRule; beforeEach(async(): Promise => { - const map = new Map([[ aliceIdentifier.path, aliceStore ]]); - stores = { - * entries(): any { - for (const [ path, val ] of map.entries()) { - yield [{ path }, val ]; - } - }, - } as any; + stores = new Map([[ aliceIdentifier.path, aliceStore ]]) as any; rule = new BaseUrlRouterRule(stores, baseStore); }); diff --git a/test/unit/util/locking/GreedyReadWriteLocker.test.ts b/test/unit/util/locking/GreedyReadWriteLocker.test.ts index 900317f16..8833f475e 100644 --- a/test/unit/util/locking/GreedyReadWriteLocker.test.ts +++ b/test/unit/util/locking/GreedyReadWriteLocker.test.ts @@ -38,7 +38,7 @@ class MemoryLocker implements ResourceLocker { describe('A GreedyReadWriteLocker', (): void => { let sourceLocker: ResourceLocker; - let storage: KeyValueStorage; + let storage: KeyValueStorage; const resourceId = { path: 'http://test.com/resource' }; const resource2Id = { path: 'http://test.com/resource2' }; let locker: GreedyReadWriteLocker; @@ -46,13 +46,7 @@ describe('A GreedyReadWriteLocker', (): void => { beforeEach(async(): Promise => { sourceLocker = new MemoryLocker(); - const map = new Map(); - storage = { - get: async(identifier: ResourceIdentifier): Promise => map.get(identifier.path), - has: async(identifier: ResourceIdentifier): Promise => map.has(identifier.path), - set: async(identifier: ResourceIdentifier, value: number): Promise => map.set(identifier.path, value), - delete: async(identifier: ResourceIdentifier): Promise => map.delete(identifier.path), - } as any; + storage = new Map() as any; locker = new GreedyReadWriteLocker(sourceLocker, storage); }); @@ -166,7 +160,7 @@ describe('A GreedyReadWriteLocker', (): void => { // We want to make sure the write operation only starts while the read operation is busy // Otherwise the internal write lock might not be acquired yet - const delayedLockWrite = new Promise((resolve): void => { + const delayedLockWrite = new Promise((resolve): void => { emitter.on('readStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withWriteLock(resourceId, (): any => { @@ -201,7 +195,7 @@ describe('A GreedyReadWriteLocker', (): void => { emitter.on('releaseRead', resolve); }); - const delayedLockWrite = new Promise((resolve): void => { + const delayedLockWrite = new Promise((resolve): void => { emitter.on('readStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withWriteLock(resource2Id, (): any => { @@ -236,7 +230,7 @@ describe('A GreedyReadWriteLocker', (): void => { const promRead1 = new Promise((resolve): any => emitter.on('releaseRead1', resolve)); const promRead2 = new Promise((resolve): any => emitter.on('releaseRead2', resolve)); - const delayedLockWrite = new Promise((resolve): void => { + const delayedLockWrite = new Promise((resolve): void => { emitter.on('readStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withWriteLock(resourceId, (): any => { @@ -246,7 +240,7 @@ describe('A GreedyReadWriteLocker', (): void => { }); }); - const delayedLockRead2 = new Promise((resolve): void => { + const delayedLockRead2 = new Promise((resolve): void => { emitter.on('readStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withReadLock(resourceId, async(): Promise => { @@ -290,7 +284,7 @@ describe('A GreedyReadWriteLocker', (): void => { }); // We want to make sure the read operation only starts while the write operation is busy - const delayedLockRead = new Promise((resolve): void => { + const delayedLockRead = new Promise((resolve): void => { emitter.on('writeStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withReadLock(resourceId, (): any => { diff --git a/test/unit/util/locking/RedisResourceLocker.test.ts b/test/unit/util/locking/RedisResourceLocker.test.ts index dce5d80c5..2bfad0f3a 100644 --- a/test/unit/util/locking/RedisResourceLocker.test.ts +++ b/test/unit/util/locking/RedisResourceLocker.test.ts @@ -195,10 +195,10 @@ describe('A RedisResourceLocker', (): void => { }); }); - describe('quit()', (): void => { - it('should clear all locks and intervals when quit() is called.', async(): Promise => { + describe('finalize()', (): void => { + it('should clear all locks and intervals when finalize() is called.', async(): Promise => { await locker.acquire(identifier); - await locker.quit(); + await locker.finalize(); expect(redlock.quit).toHaveBeenCalledTimes(1); // This works since the Redlock is simply a mock and quit should have cleared the lockMap diff --git a/test/util/Util.ts b/test/util/Util.ts index 92a880333..11d4f0325 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -6,6 +6,7 @@ import type { SystemError } from '../../src/util/errors/SystemError'; /* eslint-disable @typescript-eslint/naming-convention */ const portNames = [ 'DynamicPods', + 'Identity', 'LpdHandlerWithAuth', 'LpdHandlerWithoutAuth', 'Middleware',