From c9099c01d18689e3185fbccfe1c5a0be27150c84 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 26 Apr 2023 12:13:04 +0000 Subject: [PATCH 1/4] Support Single Sign-on with OpenID Connect --- .dockerignore | 21 + .eslintrc.json | 1 + .github/workflows/oidc-e2e.yml | 22 + .github/workflows/oidc-integration.yml | 36 + .github/workflows/standard-suite.yml | 32 + CONTRIBUTING.md | 6 + Makefile | 27 + config/oidc-development.json | 11 + config/oidc-example-auth0.json | 11 + config/oidc-example-broken.json | 11 + config/oidc-example-google.json | 11 + config/oidc-integration-test.json | 11 + config/oidc-tester-docker.json | 19 + lib/bin/cli.js | 35 +- lib/formats/mail.js | 3 + lib/http/endpoint.js | 22 + lib/http/preprocessors.js | 9 +- lib/http/service.js | 4 + lib/resources/oidc.js | 166 ++ lib/resources/sessions.js | 51 +- lib/resources/users.js | 151 +- lib/task/account.js | 4 +- lib/util/html.js | 68 + lib/util/oidc.js | 134 ++ lib/util/problem.js | 2 + lib/util/sessions.js | 46 + oidc-dev/.gitignore | 1 + oidc-dev/README.md | 11 + .../fake-oidc-server.example.net-key.pem | 28 + .../certs/fake-oidc-server.example.net.pem | 24 + .../certs/odk-central.example.org-key.pem | 28 + oidc-dev/certs/odk-central.example.org.pem | 24 + oidc-dev/docker-compose.yml | 18 + oidc-dev/fake-oidc-server/.eslintrc.cjs | 20 + oidc-dev/fake-oidc-server/accounts.json | 12 + oidc-dev/fake-oidc-server/index.js | 113 ++ oidc-dev/fake-oidc-server/package-lock.json | 1497 +++++++++++++++++ oidc-dev/fake-oidc-server/package.json | 14 + oidc-dev/playwright-tests/.eslintrc.js | 23 + oidc-dev/playwright-tests/package-lock.json | 1421 ++++++++++++++++ oidc-dev/playwright-tests/package.json | 16 + .../playwright-tests/playwright.config.js | 63 + oidc-dev/playwright-tests/src/config.js | 16 + .../src/global-setup-teardown.js | 81 + .../playwright-tests/src/oidc-login.spec.js | 78 + oidc-dev/playwright-tests/src/utils.js | 83 + oidc-dev/scripts/docker-start.sh | 34 + oidc-tester.dockerfile | 39 + package-lock.json | 235 +++ package.json | 3 + test/integration/api/app-users.js | 5 +- test/integration/api/public-links.js | 8 +- test/integration/api/sessions.js | 132 +- test/integration/api/users.js | 885 +++++----- test/integration/other/basic-auth.js | 18 +- test/integration/other/encryption.js | 8 +- test/integration/setup.js | 11 +- test/integration/task/account.js | 16 +- test/unit/http/preprocessors.js | 51 +- test/unit/util/html.js | 39 + test/util/authenticate-user.js | 97 ++ 61 files changed, 5426 insertions(+), 640 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/oidc-e2e.yml create mode 100644 .github/workflows/oidc-integration.yml create mode 100644 .github/workflows/standard-suite.yml create mode 100644 config/oidc-development.json create mode 100644 config/oidc-example-auth0.json create mode 100644 config/oidc-example-broken.json create mode 100644 config/oidc-example-google.json create mode 100644 config/oidc-integration-test.json create mode 100644 config/oidc-tester-docker.json create mode 100644 lib/resources/oidc.js create mode 100644 lib/util/html.js create mode 100644 lib/util/oidc.js create mode 100644 lib/util/sessions.js create mode 100644 oidc-dev/.gitignore create mode 100644 oidc-dev/README.md create mode 100644 oidc-dev/certs/fake-oidc-server.example.net-key.pem create mode 100644 oidc-dev/certs/fake-oidc-server.example.net.pem create mode 100644 oidc-dev/certs/odk-central.example.org-key.pem create mode 100644 oidc-dev/certs/odk-central.example.org.pem create mode 100644 oidc-dev/docker-compose.yml create mode 100644 oidc-dev/fake-oidc-server/.eslintrc.cjs create mode 100644 oidc-dev/fake-oidc-server/accounts.json create mode 100644 oidc-dev/fake-oidc-server/index.js create mode 100644 oidc-dev/fake-oidc-server/package-lock.json create mode 100644 oidc-dev/fake-oidc-server/package.json create mode 100644 oidc-dev/playwright-tests/.eslintrc.js create mode 100644 oidc-dev/playwright-tests/package-lock.json create mode 100644 oidc-dev/playwright-tests/package.json create mode 100644 oidc-dev/playwright-tests/playwright.config.js create mode 100644 oidc-dev/playwright-tests/src/config.js create mode 100644 oidc-dev/playwright-tests/src/global-setup-teardown.js create mode 100644 oidc-dev/playwright-tests/src/oidc-login.spec.js create mode 100644 oidc-dev/playwright-tests/src/utils.js create mode 100755 oidc-dev/scripts/docker-start.sh create mode 100644 oidc-tester.dockerfile create mode 100644 test/unit/util/html.js create mode 100644 test/util/authenticate-user.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..ff30a645b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Ignore everything +* + +# Explicitly whitelist _necessary_ **source files** +!/package.json +!/package-lock.json +!/Makefile +!/lib/ +!/config/ +!/test/ + +!/oidc-dev/certs/*.pem +!/oidc-dev/fake-oidc-server/accounts.json +!/oidc-dev/fake-oidc-server/index.js +!/oidc-dev/fake-oidc-server/package.json +!/oidc-dev/fake-oidc-server/package-lock.json +!/oidc-dev/playwright-tests/package.json +!/oidc-dev/playwright-tests/package-lock.json +!/oidc-dev/playwright-tests/playwright.config.js +!/oidc-dev/playwright-tests/src/**/*.js +!/oidc-dev/scripts/*.sh diff --git a/.eslintrc.json b/.eslintrc.json index 6924d26b6..3e1afa7a4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,6 +6,7 @@ "array-bracket-spacing": "off", "arrow-parens": "off", "class-methods-use-this": "off", + "camelcase": [ "error", { "ignoreDestructuring": true, "properties": "never" } ], "comma-dangle": "off", "consistent-return": "off", "curly": "off", diff --git a/.github/workflows/oidc-e2e.yml b/.github/workflows/oidc-e2e.yml new file mode 100644 index 000000000..1e109c973 --- /dev/null +++ b/.github/workflows/oidc-e2e.yml @@ -0,0 +1,22 @@ +name: OIDC e2e tests + +on: push + +jobs: + oidc-e2e-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 18 + uses: actions/setup-node@v3 + with: + node-version: 18.17.0 + cache: 'npm' + - run: sudo apt-get install -y curl + - run: make test-oidc-e2e + - name: Archive playwright screenshots + if: failure() + uses: actions/upload-artifact@v3 + with: + name: Playwright Screenshots + path: oidc-dev/playwright-results/**/*.png diff --git a/.github/workflows/oidc-integration.yml b/.github/workflows/oidc-integration.yml new file mode 100644 index 000000000..eb8367dbf --- /dev/null +++ b/.github/workflows/oidc-integration.yml @@ -0,0 +1,36 @@ +name: OIDC integration tests + +on: push + +jobs: + oidc-integration-test: + # TODO should we use the same container as circle & central? + runs-on: ubuntu-latest + services: + # see: https://docs.github.com/en/enterprise-server@3.5/actions/using-containerized-services/creating-postgresql-service-containers + postgres: + image: postgres:14.6 + env: + POSTGRES_PASSWORD: odktest + ports: + - 5432:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 18 + uses: actions/setup-node@v3 + with: + node-version: 18.17.0 + cache: 'npm' + - run: npm ci --legacy-peer-deps + - run: make fake-oidc-server-ci > fake-oidc-server.log & + - run: node lib/bin/create-docker-databases.js + - run: make test-oidc-integration + - name: Fake OIDC Server Logs + if: always() + run: "! [[ -f ./fake-oidc-server.log ]] || cat ./fake-oidc-server.log" diff --git a/.github/workflows/standard-suite.yml b/.github/workflows/standard-suite.yml new file mode 100644 index 000000000..4226943ef --- /dev/null +++ b/.github/workflows/standard-suite.yml @@ -0,0 +1,32 @@ +name: Full Standard Test Suite + +on: push + +jobs: + standard-tests: + # TODO should we use the same container as circle & central? + runs-on: ubuntu-latest + services: + # see: https://docs.github.com/en/enterprise-server@3.5/actions/using-containerized-services/creating-postgresql-service-containers + postgres: + image: postgres:14.6 + env: + POSTGRES_PASSWORD: odktest + ports: + - 5432:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 18 + uses: actions/setup-node@v3 + with: + node-version: 18.17.0 + cache: 'npm' + - run: npm ci --legacy-peer-deps + - run: node lib/bin/create-docker-databases.js + - run: make test-full diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f3025d21..a91f85d2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,12 @@ If you're looking for help or discussion on _how_ ODK Central Backend works inte Please see the [project README](https://github.com/getodk/central-backend#setting-up-a-development-environment) for instructions on how to set up your development environment. +### OpenID Connect + +If you want to use OpenID Connect instead of username/password for authentication in development: + +Instead of `make dev`, run both `make dev-oidc` and `make fake-oidc-server`. + ## Guidelines If you're starting work on an issue ticket, please leave a comment saying so. If you run into trouble or have to stop working on a ticket, please leave a comment as well. As you write code, the usual guidelines apply; please ensure you are following existing conventions: diff --git a/Makefile b/Makefile index a935a8f7f..7903847cf 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,33 @@ node_modules: package.json npm install --legacy-peer-deps touch node_modules +.PHONY: test-oidc-integration +test-oidc-integration: node_modules + TEST_AUTH=oidc NODE_CONFIG_ENV=oidc-integration-test make test-integration + +.PHONY: test-oidc-e2e +test-oidc-e2e: node_modules + cd oidc-dev && \ + docker compose down && \ + docker compose build && \ + docker compose up --exit-code-from odk-central-oidc-tester + +.PHONY: dev-oidc +dev-oidc: base + NODE_CONFIG_ENV=oidc-development npx nodemon --watch lib --watch config lib/bin/run-server.js + +.PHONY: fake-oidc-server +fake-oidc-server: + cd oidc-dev/fake-oidc-server && \ + npm clean-install && \ + FAKE_OIDC_ROOT_URL=http://localhost:9898 npx nodemon index.js + +.PHONY: fake-oidc-server-ci +fake-oidc-server-ci: + cd oidc-dev/fake-oidc-server && \ + npm clean-install && \ + FAKE_OIDC_ROOT_URL=http://localhost:9898 node index.js + .PHONY: node_version node_version: node_modules node lib/bin/enforce-node-version.js diff --git a/config/oidc-development.json b/config/oidc-development.json new file mode 100644 index 000000000..cd1ce645d --- /dev/null +++ b/config/oidc-development.json @@ -0,0 +1,11 @@ +{ + "default": { + "oidc": { + "_description": "local test server: from https://www.npmjs.com/package/oidc-provider", + "issuerUrl": "http://localhost:9898", + "clientId": "odk-central-backend-dev", + "clientSecret": "super-top-secret", + "enabled": true + } + } +} diff --git a/config/oidc-example-auth0.json b/config/oidc-example-auth0.json new file mode 100644 index 000000000..f0584267e --- /dev/null +++ b/config/oidc-example-auth0.json @@ -0,0 +1,11 @@ +{ + "default": { + "oidc": { + "_description": "auth0: https://manage.auth0.com/dashboard/us/odk-oidc-dev/", + "issuerUrl": "https://odk-oidc-dev.us.auth0.com", + "clientId": "ZKKpcW8TpKymVLbD1dbDVExj7SU4Zxbn", + "clientSecret": "7tuVT7OsjRHfmUiwYYyWNT8YArMNlmvvv70tqlChkjtVHW0Xsp0mvVAyKIfCgUn5", + "enabled": true + } + } +} diff --git a/config/oidc-example-broken.json b/config/oidc-example-broken.json new file mode 100644 index 000000000..f6c3804ef --- /dev/null +++ b/config/oidc-example-broken.json @@ -0,0 +1,11 @@ +{ + "default": { + "oidc": { + "_description": "broken config: fiddle with this config to test out different init failure modes", + "issuerUrl": "http://example.com", + "clientId": "this is required; should be reported during client init if this line commented out", + "clientSecret": "this is required; should be reported during client init if this line commented out", + "enabled": true + } + } +} diff --git a/config/oidc-example-google.json b/config/oidc-example-google.json new file mode 100644 index 000000000..2ed49cbbc --- /dev/null +++ b/config/oidc-example-google.json @@ -0,0 +1,11 @@ +{ + "default": { + "oidc": { + "_description": "google: from https://console.cloud.google.com/apis/credentials", + "issuerUrl": "https://accounts.google.com", + "clientId": "564021877275-o5q3i8j44190d93d9mldd3rti1fncn3u.apps.googleusercontent.com", + "clientSecret": "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", + "enabled": true + } + } +} diff --git a/config/oidc-integration-test.json b/config/oidc-integration-test.json new file mode 100644 index 000000000..188c29fef --- /dev/null +++ b/config/oidc-integration-test.json @@ -0,0 +1,11 @@ +{ + "default": { + "oidc": { + "enabled": true, + "issuerUrl": "http://localhost:9898", + "clientId": "odk-central-backend-dev", + "clientSecret": "super-top-secret" + } + } +} + diff --git a/config/oidc-tester-docker.json b/config/oidc-tester-docker.json new file mode 100644 index 000000000..0b92c01d9 --- /dev/null +++ b/config/oidc-tester-docker.json @@ -0,0 +1,19 @@ +{ + "default": { + "database": { + "host": "odk-central-oidc-tester-postgres", + "database": "oidc-tester", + "user": "odk-central-backend", + "password": "supertopsecret3000" + }, + "env": { + "domain": "https://odk-central.example.org:8989" + }, + "oidc": { + "issuerUrl": "https://fake-oidc-server.example.net:9898", + "clientId": "odk-central-backend-dev", + "clientSecret": "super-top-secret", + "enabled": true + } + } +} diff --git a/lib/bin/cli.js b/lib/bin/cli.js index 58fb57273..3d4d34a40 100644 --- a/lib/bin/cli.js +++ b/lib/bin/cli.js @@ -15,13 +15,7 @@ const { run } = require('../task/task'); const { createUser, promoteUser, setUserPassword } = require('../task/account'); - -// gets a password interactively if not supplied in cli args. -const prompt = require('prompt'); -const withPassword = (f) => { - prompt.start(); - prompt.get([{ name: 'password', hidden: true, replace: '*' }], (_, { password }) => f(password)); -}; +const oidc = require('../util/oidc'); const { Command } = require('commander'); const program = new Command('node lib/bin/cli.js'); @@ -30,13 +24,30 @@ const email = () => program.opts().email; program.requiredOption('-u, --email '); -program.command('user-create') - .action(() => withPassword((password) => run(createUser(email(), password)))); +if (oidc.isEnabled()) { + program.command('user-create') + .action(() => run(createUser(email(), null))); + + program.command('user-set-password') + .action(() => { + throw new Error(`You cannot set a user's password when OpenID Connect (OIDC) is enabled.`); // eslint-disable-line quotes + }); +} else { + // gets a password interactively if not supplied in cli args. + const prompt = require('prompt'); + const withPassword = (f) => { + prompt.start(); + prompt.get([{ name: 'password', hidden: true, replace: '*' }], (_, { password }) => f(password)); + }; + + program.command('user-create') + .action(() => (withPassword((password) => run(createUser(email(), password))))); + + program.command('user-set-password') + .action(() => withPassword((password) => run(setUserPassword(email(), password)))); +} program.command('user-promote') .action(() => run(promoteUser(email()))); -program.command('user-set-password') - .action(() => withPassword((password) => run(setUserPassword(email(), password)))); - program.parse(); diff --git a/lib/formats/mail.js b/lib/formats/mail.js index c07ed21c4..aaa28fe09 100644 --- a/lib/formats/mail.js +++ b/lib/formats/mail.js @@ -32,6 +32,9 @@ const messages = { // Notifies a user that an account has been created with a predetermined password. accountCreatedWithPassword: message('ODK Central account created', 'Hello!

An account has been provisioned for you on an ODK Central server.

If this message is unexpected, simply ignore it. Your account was created with an assigned password. Please use that password to sign in.

If you have not been given the password, or you cannot remember it, you can reset it at any time at this link:

{{{domain}}}/#/reset-password

'), + // Notifies a user that an account has been created for login exclusively with OIDC. + accountCreatedForOidc: message('ODK Central account created', 'Hello!

An account has been provisioned for you on an ODK Central data collection server.

If this message is unexpected, simply ignore it. Please go to {{{domain}}} to sign in.

'), + // Notifies a user that their account's email has been changed accountEmailChanged: message('ODK Central account email changed', 'Hello!

We are emailing because you have an ODK Central account, and somebody has just changed the email address associated with the account from this one you are reading right now ({{oldEmail}}) to a new address ({{newEmail}}).

If this was you, please feel free to ignore this email. Otherwise, please contact your local ODK system administrator immediately.

'), diff --git a/lib/http/endpoint.js b/lib/http/endpoint.js index c8565abb4..5c1ebf790 100644 --- a/lib/http/endpoint.js +++ b/lib/http/endpoint.js @@ -22,6 +22,7 @@ const { reduce } = require('ramda'); const { openRosaError } = require('../formats/openrosa'); const { odataXmlError } = require('../formats/odata'); const { noop, isPresent } = require('../util/util'); +const { frontendPage, html } = require('../util/html'); const { serialize, redirect } = require('../util/http'); const { resolve, reject } = require('../util/promise'); const { PartialPipe } = require('../util/stream'); @@ -89,6 +90,7 @@ const getRequestContext = (request) => ({ params: request.params, query: request.query, files: request.files, + cookies: request.cookies, apiVersion: request.apiVersion, fieldKey: request.fieldKey @@ -235,6 +237,25 @@ const defaultEndpoint = endpointBase({ errorWriter: defaultErrorWriter }); +// Render html content in the style of frontend +const htmlEndpoint = endpointBase({ + resultWriter: (result, request, response) => { + response.type('text/html'); + response.send(frontendPage(result)); + }, + errorWriter: (error, request, response) => { + response.type('text/html'); + response.status(500); + response.send(frontendPage({ + body: html` +

Error!

+
An unknown error occurred on the server.
+
Go home
+ `, + })); + }, +}); + //////////////////////////////////////// // OPENROSA @@ -355,6 +376,7 @@ const odataXmlEndpoint = endpointBase({ const builder = (container, preprocessors) => { const result = defaultEndpoint(container, preprocessors); + result.html = htmlEndpoint(container, preprocessors); result.openRosa = openRosaEndpoint(container, preprocessors); result.odata = { json: odataJsonEndpoint(container, preprocessors), diff --git a/lib/http/preprocessors.js b/lib/http/preprocessors.js index a4f84e062..0218657e1 100644 --- a/lib/http/preprocessors.js +++ b/lib/http/preprocessors.js @@ -9,10 +9,11 @@ const { isBlank, isPresent, noop, without } = require('../util/util'); const { isTrue } = require('../util/http'); +const oidc = require('../util/oidc'); const Problem = require('../util/problem'); const { QueryOptions } = require('../util/db'); const { reject, getOrReject } = require('../util/promise'); - +const { SESSION_COOKIE } = require('../util/sessions'); // injects an empty/anonymous auth object into the request context. const emptyAuthInjector = ({ Auth }, context) => context.with({ auth: Auth.by(null) }); @@ -68,6 +69,8 @@ const authHandler = ({ Sessions, Users, Auth, bcrypt }, context) => { // Basic Auth, which is allowed over HTTPS only: } else if (isPresent(authHeader) && authHeader.startsWith('Basic ')) { + if (oidc.isEnabled()) return reject(Problem.user.basicAuthNotSupportedWhenOidcEnabled()); + // fail the request unless we are under HTTPS. // this logic does mean that if we are not under nginx it is possible to fool the server. // but it is the user's prerogative to undertake this bypass, so their security is in their hands. @@ -104,13 +107,13 @@ const authHandler = ({ Sessions, Users, Auth, bcrypt }, context) => { return; // otherwise get the cookie contents. - const token = /session=([^;]+)(?:;|$)/.exec(context.headers.cookie); + const token = context.cookies[SESSION_COOKIE]; if (token == null) return; // actually try to authenticate with it. no Problem on failure. short circuit // out if we have a GET or HEAD request. - const maybeSession = authBySessionToken(decodeURIComponent(token[1])); + const maybeSession = authBySessionToken(decodeURIComponent(token)); if ((context.method === 'GET') || (context.method === 'HEAD')) return maybeSession; // if non-GET run authentication as usual but we'll have to check CSRF afterwards. diff --git a/lib/http/service.js b/lib/http/service.js index 600f36c4f..0a0188909 100644 --- a/lib/http/service.js +++ b/lib/http/service.js @@ -41,6 +41,9 @@ module.exports = (container) => { service.use(versionParser); service.use(fieldKeyParser); + const cookieParser = require('cookie-parser'); + service.use(cookieParser()); + //////////////////////////////////////////////////////////////////////////////// // PREPROCESSORS @@ -74,6 +77,7 @@ module.exports = (container) => { require('../resources/analytics')(service, endpoint); require('../resources/datasets')(service, endpoint); require('../resources/entities')(service, endpoint); + require('../resources/oidc')(service, endpoint); //////////////////////////////////////////////////////////////////////////////// // POSTRESOURCE HANDLERS diff --git a/lib/resources/oidc.js b/lib/resources/oidc.js new file mode 100644 index 000000000..677256351 --- /dev/null +++ b/lib/resources/oidc.js @@ -0,0 +1,166 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +// Allow declaring util functions at the end of the file: +/* eslint-disable no-use-before-define */ + +// OpenID Connect auth handling using Authorization Code Flow with PKCE. +// TODO document _why_ auth-code-flow, and not e.g. implicit flow? + +const { generators } = require('openid-client'); +const config = require('config'); + +const { html, safeNextPathFrom } = require('../util/html'); +const { redirect } = require('../util/http'); +const { createUserSession } = require('../util/sessions'); +const { // eslint-disable-line object-curly-newline + CODE_CHALLENGE_METHOD, + SCOPES, + getClient, + getRedirectUri, + isEnabled, +} = require('../util/oidc'); // eslint-disable-line camelcase,object-curly-newline + +// TODO use req.protocol? +const envDomain = config.get('default.env.domain'); +const HTTPS_ENABLED = envDomain.startsWith('https://'); +const ONE_HOUR = 60 * 60 * 1000; + +// Cannot use __Host- because cookie's Path is set +// Use __Secure- in production. But not in dev - even though firefox will +// support __Secure with localhost, chrome will not. Note that this behaviour +// is similar but distinct from the Secure attribute, which seems to send +// cookies to http://localhost on both Chrome and FireFox. +// See: +// * https://bugzilla.mozilla.org/show_bug.cgi?id=1648993 +// * https://bugs.chromium.org/p/chromium/issues/detail?id=1056543 +const CODE_VERIFIER_COOKIE = (HTTPS_ENABLED ? '__Secure-' : '') + 'ocv'; +const NEXT_COOKIE = (HTTPS_ENABLED ? '__Secure-' : '') + 'next'; // eslint-disable-line no-multi-spaces +const callbackCookieProps = { + httpOnly: true, + secure: HTTPS_ENABLED, + sameSite: 'Lax', // allow cookie to be sent on redirect from IdP + path: '/v1/oidc/callback', +}; + +module.exports = (service, endpoint) => { + if (!isEnabled()) return; + + service.get('/oidc/login', endpoint.html(async ({ Sentry }, _, req, res) => { + try { + const client = await getClient(); + const code_verifier = generators.codeVerifier(); // eslint-disable-line camelcase + + const code_challenge = generators.codeChallenge(code_verifier); // eslint-disable-line camelcase + + const authUrl = client.authorizationUrl({ + scope: SCOPES.join(' '), + resource: `${envDomain}/v1`, + code_challenge, + code_challenge_method: CODE_CHALLENGE_METHOD, + }); + + res.cookie(CODE_VERIFIER_COOKIE, code_verifier, { ...callbackCookieProps, maxAge: ONE_HOUR }); + + const { next } = req.query; + if (next) res.cookie(NEXT_COOKIE, next, { ...callbackCookieProps, maxAge: ONE_HOUR }); + + redirect(307, authUrl); + } catch (err) { + if (redirect.isRedirect(err)) { + throw err; + } else { + Sentry.captureException(err); + return errorToFrontend(req, res, 'internal-server-error'); + } + } + })); + + service.get('/oidc/callback', endpoint.html(async (container, _, req, res) => { + try { + const code_verifier = req.cookies[CODE_VERIFIER_COOKIE]; // eslint-disable-line camelcase + const next = req.cookies[NEXT_COOKIE]; // eslint-disable-line no-multi-spaces + res.clearCookie(CODE_VERIFIER_COOKIE, callbackCookieProps); + res.clearCookie(NEXT_COOKIE, callbackCookieProps); // eslint-disable-line no-multi-spaces + + const client = await getClient(); + + const params = client.callbackParams(req); + const tokenSet = await client.callback(getRedirectUri(), params, { code_verifier }); + + const { access_token } = tokenSet; + + const userinfo = await client.userinfo(access_token); + + const { email, email_verified } = userinfo; + if (!email) { + // eslint-disable-next-line quotes + container.Sentry.captureException(new Error(`Required claim not provided in UserInfo Response: 'email'`)); + return errorToFrontend(req, res, 'email-claim-not-provided'); + } + if (!email_verified) return errorToFrontend(req, res, 'email-not-verified'); // eslint-disable-line camelcase + + const user = await getUserByEmail(container, email); + if (!user) return errorToFrontend(req, res, 'auth-ok-user-not-found'); + + await initSession(container, req, res, user); + + const nextPath = safeNextPathFrom(next); + + // This redirect would be ideal, but breaks `SameSite: Secure` cookies. + // return redirect(303, nextPath); + // Instead, we need to render a page and then "browse" from that page to the normal frontend: + + // id=cl only set for playwright. Why can't it locate this anchor in any other way? + return { + head: html``, + body: html` +

Authentication Successful

+
Continue to ODK Central
+ `, + }; + } catch (err) { + if (redirect.isRedirect(err)) { + throw err; + } else { + container.Sentry.captureException(err); + return errorToFrontend(req, res, 'internal-server-error'); + } + } + })); +}; + +function errorToFrontend(req, res, errorCode) { + const loginUrl = new URL('/#/login', envDomain); + + loginUrl.searchParams.append('oidcError', errorCode); + + const next = req.cookies[NEXT_COOKIE]; + if (next && !Array.isArray(next)) loginUrl.searchParams.append('next', next); + + // Append query string manually, because Central Frontend expects search/hash + // in the wrong order (Vue hash-based routing). + const redirectUrl = envDomain + loginUrl.pathname + loginUrl.hash + loginUrl.search; + + redirect(303, redirectUrl); +} + +async function getUserByEmail({ Users }, email) { + const userOption = await Users.getByEmail(email); + if (!userOption.isDefined()) return; + + const user = userOption.get(); + + return user; +} + +async function initSession(container, req, res, user) { + const applySession = await createUserSession(container, req.headers, user); + applySession(req, res); +} diff --git a/lib/resources/sessions.js b/lib/resources/sessions.js index b6e7738d3..859ee563d 100644 --- a/lib/resources/sessions.js +++ b/lib/resources/sessions.js @@ -11,39 +11,34 @@ const Problem = require('../util/problem'); const { isBlank, noargs } = require('../util/util'); const { getOrReject, rejectIf } = require('../util/promise'); const { success } = require('../util/http'); - +const { SESSION_COOKIE, createUserSession } = require('../util/sessions'); +const oidc = require('../util/oidc'); module.exports = (service, endpoint) => { - service.post('/sessions', endpoint(({ Audits, Users, Sessions, bcrypt }, { body, userAgent }) => { - const { email, password } = body; + if (!oidc.isEnabled()) { + service.post('/sessions', endpoint(({ Audits, Users, Sessions, bcrypt }, { body, headers }) => { + // TODO if we're planning to offer multiple authN methods, we should be looking for + // any calls to bcrypt.verify(), and blocking them if that authN method is not + // appropriate for the current user. + // + // It may be useful to re-use the sessions resources for other authN methods. - if (isBlank(email) || isBlank(password)) - return Problem.user.missingParameters({ expected: [ 'email', 'password' ], got: { email, password } }); + const { email, password } = body; - return Users.getByEmail(email) - .then(getOrReject(Problem.user.authenticationFailed())) - .then((user) => bcrypt.verify(password, user.password) - .then(rejectIf( - (verified) => (verified !== true), - noargs(Problem.user.authenticationFailed) - )) - .then(() => Promise.all([ - Sessions.create(user.actor), - // Logging here rather than defining Sessions.create.audit, because - // Sessions.create.audit would require auth. Logging here also makes - // it easy to access `headers`. - Audits.log(user.actor, 'user.session.create', user.actor, { userAgent }) - ])) - .then(([ session ]) => (_, response) => { - response.cookie('__Host-session', session.token, { path: '/', expires: session.expiresAt, - httpOnly: true, secure: true, sameSite: 'strict' }); - response.cookie('__csrf', session.csrf, { expires: session.expiresAt, - secure: true, sameSite: 'strict' }); + if (isBlank(email) || isBlank(password)) + return Problem.user.missingParameters({ expected: [ 'email', 'password' ], got: { email, password } }); - return session; - })); - })); + return Users.getByEmail(email) + .then(getOrReject(Problem.user.authenticationFailed())) + .then((user) => bcrypt.verify(password, user.password) + .then(rejectIf( + (verified) => (verified !== true), + noargs(Problem.user.authenticationFailed) + )) + .then(() => createUserSession({ Audits, Sessions }, headers, user))); + })); + } service.get('/sessions/restore', endpoint((_, { auth }) => auth.session.orElse(Problem.user.notFound()))); @@ -56,7 +51,7 @@ module.exports = (service, endpoint) => { // terminate itself. // TODO: repetitive w above. if (session.token === auth.session.map((s) => s.token).orNull()) { - response.cookie('__Host-session', 'null', { path: '/', expires: new Date(0), + response.cookie(SESSION_COOKIE, 'null', { path: '/', expires: new Date(0), httpOnly: true, secure: true, sameSite: 'strict' }); response.cookie('__csrf', 'null', { expires: new Date(0), secure: true, sameSite: 'strict' }); diff --git a/lib/resources/users.js b/lib/resources/users.js index e474a71f6..468ff3990 100644 --- a/lib/resources/users.js +++ b/lib/resources/users.js @@ -14,6 +14,7 @@ const Option = require('../util/option'); const Problem = require('../util/problem'); const { resolve, reject, getOrNotFound } = require('../util/promise'); const { isPresent } = require('../util/util'); +const oidc = require('../util/oidc'); module.exports = (service, endpoint) => { @@ -31,52 +32,80 @@ module.exports = (service, endpoint) => { ]) .then(([ exact, list ]) => exact.map((x) => [ x ]).orElse(list.orElse([])))))); - service.post('/users', endpoint(({ Users, mail }, { body, auth }) => - auth.canOrReject('user.create', User.species) - .then(() => User.fromApi(body).forV1OnlyCopyEmailToDisplayName()) - .then(Users.create) - .then((savedUser) => (isPresent(body.password) - ? Users.updatePassword(savedUser, body.password) - .then(() => mail(savedUser.email, 'accountCreatedWithPassword')) - : Users.provisionPasswordResetToken(savedUser) - .then((token) => mail(savedUser.email, 'accountCreated', { token }))) - .then(always(savedUser))))); + if (oidc.isEnabled()) { + // Same as non-OIDC, except that password is random & unguessable + service.post('/users', endpoint(({ Users, mail }, { body, auth }) => + auth.canOrReject('user.create', User.species) + .then(() => User.fromApi(body).forV1OnlyCopyEmailToDisplayName()) + .then(Users.create) + .then((savedUser) => + mail(savedUser.email, 'accountCreatedForOidc') + .then(always(savedUser))))); + } else { + service.post('/users', endpoint(({ Users, mail }, { body, auth }) => + auth.canOrReject('user.create', User.species) + .then(() => User.fromApi(body).forV1OnlyCopyEmailToDisplayName()) + .then(Users.create) + .then((savedUser) => (isPresent(body.password) + ? Users.updatePassword(savedUser, body.password) + .then(() => mail(savedUser.email, 'accountCreatedWithPassword')) + : Users.provisionPasswordResetToken(savedUser) + .then((token) => mail(savedUser.email, 'accountCreated', { token }))) + .then(always(savedUser))))); - // TODO/SECURITY: subtle timing attack here. - service.post('/users/reset/initiate', endpoint(({ Users, mail }, { auth, body, query }) => - Users.getByEmail(body.email) - .then((maybeUser) => maybeUser - .map((user) => ((isTrue(query.invalidate)) - ? auth.canOrReject('user.password.invalidate', user.actor) - .then(() => Users.invalidatePassword(user)) - : resolve(user)) - .then(() => Users.provisionPasswordResetToken(user) - .then((token) => mail(body.email, 'accountReset', { token })))) - .orElseGet(() => ((isTrue(query.invalidate)) - ? auth.canOrReject('user.password.invalidate', User.species) - : resolve()) - .then(() => Users.emailEverExisted(body.email) - .then((existed) => ((existed === true) - ? mail(body.email, 'accountResetDeleted') - : resolve())))) - .then(success)))); + // TODO/SECURITY: subtle timing attack here. + service.post('/users/reset/initiate', endpoint(({ Users, mail }, { auth, body, query }) => + Users.getByEmail(body.email) + .then((maybeUser) => maybeUser + .map((user) => ((isTrue(query.invalidate)) + ? auth.canOrReject('user.password.invalidate', user.actor) + .then(() => Users.invalidatePassword(user)) + : resolve(user)) + .then(() => Users.provisionPasswordResetToken(user) + .then((token) => mail(body.email, 'accountReset', { token })))) + .orElseGet(() => ((isTrue(query.invalidate)) + ? auth.canOrReject('user.password.invalidate', User.species) + : resolve()) + .then(() => Users.emailEverExisted(body.email) + .then((existed) => ((existed === true) + ? mail(body.email, 'accountResetDeleted') + : resolve())))) + .then(success)))); + // TODO: some standard URL structure for RPC-style methods. + service.post('/users/reset/verify', endpoint(({ Actors, Sessions, Users }, { body, auth }) => + resolve(auth.actor) + .then(getOrNotFound) + .then((actor) => (((actor.meta == null) || (actor.meta.resetPassword == null)) + ? reject(Problem.user.insufficientRights()) + : Users.getByActorId(actor.meta.resetPassword) + .then(getOrNotFound) + .then((user) => auth.canOrReject('user.password.reset', user.actor) + .then(() => Promise.all([ + Users.updatePassword(user, body.new), + Sessions.terminateByActorId(user.actorId), + Actors.consume(actor) + ])) + .then(success)))))); - // TODO: some standard URL structure for RPC-style methods. - service.post('/users/reset/verify', endpoint(({ Actors, Sessions, Users }, { body, auth }) => - resolve(auth.actor) - .then(getOrNotFound) - .then((actor) => (((actor.meta == null) || (actor.meta.resetPassword == null)) - ? reject(Problem.user.insufficientRights()) - : Users.getByActorId(actor.meta.resetPassword) - .then(getOrNotFound) - .then((user) => auth.canOrReject('user.password.reset', user.actor) - .then(() => Promise.all([ - Users.updatePassword(user, body.new), - Sessions.terminateByActorId(user.actorId), - Actors.consume(actor) - ])) - .then(success)))))); + // TODO: infosec debate around 404 vs 403 if insufficient privs but record DNE. + // TODO: exact endpoint naming. + service.put('/users/:id/password', endpoint(async ({ Sessions, Users, mail, bcrypt }, { params, body, auth }) => { + const user = await Users.getByActorId(params.id).then(getOrNotFound); + await auth.canOrReject('user.update', user.actor); + const verified = await bcrypt.verify(body.old, user.password); + if (verified !== true) return Problem.user.authenticationFailed(); + await Promise.all([ + Users.updatePassword(user, body.new), + Sessions.terminateByActorId( + user.actorId, + auth.session.map(({ token }) => token).orNull() + ) + ]); + await mail(user.email, 'accountPasswordChanged'); + return success(); + })); + } // Returns the currently authed actor. service.get('/users/current', endpoint(({ Auth, Users }, { auth, queryOptions }) => @@ -101,31 +130,28 @@ module.exports = (service, endpoint) => { Users.getByActorId(params.id) .then(getOrNotFound) .then((user) => auth.canOrReject('user.update', user.actor) - .then(() => User.fromApi(body)) + .then(async () => { + if (oidc.isEnabled()) { + // Don't allow modifying own email or password for users when using OIDC. + // An admin _may_ change another user's password. + const canUpdateAnyUser = await auth.can('user.update', User.species); + if (canUpdateAnyUser) { + return body; + } else { + const { email, ...filtered } = body; + return filtered; + } + } else { + return body; + } + }) + .then((filteredBody) => User.fromApi(filteredBody)) .then((patchData) => Users.update(user, patchData) .then((result) => ((isPresent(patchData.email) && (patchData.email !== user.email)) ? mail(user.email, 'accountEmailChanged', { oldEmail: user.email, newEmail: patchData.email }) : resolve()) .then(always(result))))))); - // TODO: ditto infosec debate. - // TODO: exact endpoint naming. - service.put('/users/:id/password', endpoint(async ({ Sessions, Users, mail, bcrypt }, { params, body, auth }) => { - const user = await Users.getByActorId(params.id).then(getOrNotFound); - await auth.canOrReject('user.update', user.actor); - const verified = await bcrypt.verify(body.old, user.password); - if (verified !== true) return Problem.user.authenticationFailed(); - await Promise.all([ - Users.updatePassword(user, body.new), - Sessions.terminateByActorId( - user.actorId, - auth.session.map(({ token }) => token).orNull() - ) - ]); - await mail(user.email, 'accountPasswordChanged'); - return success(); - })); - service.delete('/users/:id', endpoint(({ Actors, Users }, { params, auth }) => Users.getByActorId(params.id) .then(getOrNotFound) @@ -133,4 +159,3 @@ module.exports = (service, endpoint) => { .then(Actors.del) .then(success))); }; - diff --git a/lib/task/account.js b/lib/task/account.js index 6eedca0fa..35989a4f4 100644 --- a/lib/task/account.js +++ b/lib/task/account.js @@ -19,8 +19,8 @@ const { getOrNotFound } = require('../util/promise'); // TODO: friendlier success/failure messages. const createUser = task.withContainer((container) => (email, password) => container.transacting(({ Users }) => Users.create(User.fromApi({ email }).forV1OnlyCopyEmailToDisplayName()) - .then((user) => Users.updatePassword(user, password) - .then(() => user)))); + .then((user) => (password === null ? user : Users.updatePassword(user, password) + .then(() => user))))); // Given a User email, finds and promotes that User to an Administrator. const promoteUser = task.withContainer((container) => (email) => container.transacting(({ Assignments, Users }) => diff --git a/lib/util/html.js b/lib/util/html.js new file mode 100644 index 000000000..0fac2f576 --- /dev/null +++ b/lib/util/html.js @@ -0,0 +1,68 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. +// +// Helper functions that relate to the HTML/frontend layer of the application. + +const config = require('config'); + +const envDomain = config.get('default.env.domain'); + +// handy dev function for enabling syntax hilighting of html +const html = ([ first, ...rest ], ...vars) => first + vars.map((v, idx) => [ v, rest[idx] ]).flat().join(''); + +// Style to look like odk-central-frontend +const frontendPage = ({ head='', body }) => html` + + + ${head} + + + + +
+ ${body} +
+ + +`; + +// Logic adapted from `login.vue` in frontend. +const safeNextPathFrom = next => { + if (!next) return '/#/'; + + let url; + try { + url = new URL(next, envDomain); + } catch (e) { + return '/#/'; + } + + if (url.origin !== envDomain || url.pathname === '/login') + return '/#/'; + + // Don't modify enketo URLs + if (url.pathname.startsWith('/-/')) return url.toString(); + + // Append query string manually, because Central Frontend expects search/hash + // in the wrong order (Vue hash-based routing). + return '/#' + url.pathname + url.search + url.hash; +}; + +module.exports = { frontendPage, html, safeNextPathFrom }; diff --git a/lib/util/oidc.js b/lib/util/oidc.js new file mode 100644 index 000000000..3653d5805 --- /dev/null +++ b/lib/util/oidc.js @@ -0,0 +1,134 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +// Allow aligning object properties and function arguments for readability: +/* eslint-disable key-spacing,no-multi-spaces */ + +// Allow defining utility functions at the bottom of the file: +/* eslint-disable no-use-before-define */ + +// OpenID settings, algorithms etc. +// Keep an eye on updates to recommendations in case these need updating. +// See: TODO add link to where to get up-to-date recommendations +const CODE_CHALLENGE_METHOD = 'S256'; // S256 PKCE +const REQUIRED_CLAIMS = ['email', 'email_verified']; +const RESPONSE_TYPE = 'code'; +const SCOPES = ['openid', 'email']; +const TOKEN_SIGNING_ALG = 'RS256'; +const TOKEN_ENDPOINT_AUTH_METHOD = 'client_secret_basic'; + +module.exports = { + CODE_CHALLENGE_METHOD, + SCOPES, + getClient, + getRedirectUri, + isEnabled, +}; + +const config = require('config'); +const { Issuer } = require('openid-client'); + +const oidcConfig = (config.has('default.oidc') && config.get('default.oidc')) || {}; + +function isEnabled() { + // This is AN EXPLICIT SETTING rather than derived from e.g. client init + // failing - we don't want to default to a different authN method to that + // requested by the system administrator. + return oidcConfig.enabled === true; +} + +function getRedirectUri() { + return `${config.get('default.env.domain')}/v1/oidc/callback`; +} + +let clientLoader; // single instance, initialised lazily +function getClient() { + if (!clientLoader) clientLoader = initClient(); + return clientLoader; +} +async function initClient() { + if (!isEnabled()) throw new Error('OIDC is not enabled.'); + + try { + assertHasAll('config keys', Object.keys(oidcConfig), ['issuerUrl', 'clientId', 'clientSecret']); + + const { issuerUrl } = oidcConfig; + const issuer = await Issuer.discover(issuerUrl); + + // eslint-disable-next-line object-curly-newline + const { + claims_supported, + code_challenge_methods_supported, + id_token_signing_alg_values_supported, + response_types_supported, + scopes_supported, + token_endpoint_auth_methods_supported, + } = issuer.metadata; // eslint-disable-line object-curly-newline + + // This code uses email to verify a user's identity. An unverified email + // address is not suitable for verification. + // + // For some providers, this may require explicit configuration[1]; for other providers, email may not be supported at all as a form of verification[2]. + // + // Iff a provider advertises the email_verified claim, we assume that the + // email claim is sufficient to verify the user's identity. + // + // [1]: https://developers.onelogin.com/openid-connect/guides/email-verified + // [2]: https://learn.microsoft.com/en-us/azure/active-directory/develop/claims-validation + + // In the spec: scopes_supported is optional, but recommended. + // In this code: scopes_supported is required. + // see: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + if (!scopes_supported) throw new Error('scopes_supported was not provided in issuer metadata.'); // eslint-disable-line camelcase + // In the spec: supported scopes may not be included in scopes_supported. + // In this code: required scopes *must* be included in scopes_supported. + // see: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + assertHasAll('scopes', scopes_supported, SCOPES); + + // In the spec: claims_supported is optional, but recommended. + // In this code: claims_supported is required. + // see: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + if (!claims_supported) throw new Error('claims_supported was not provided in issuer metadata.'); // eslint-disable-line camelcase + // In the spec: supported claims may not be included in claims_supported. + // In this code: supported claims *must* be included in claims_supported. + // see: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + assertHasAll('required claims', claims_supported, REQUIRED_CLAIMS); + + assertHas('response type', response_types_supported, RESPONSE_TYPE); + assertHas('code challenge method', code_challenge_methods_supported, CODE_CHALLENGE_METHOD); + assertHas('token signing alg', id_token_signing_alg_values_supported, TOKEN_SIGNING_ALG); + assertHas('token endpoint auth method', token_endpoint_auth_methods_supported, TOKEN_ENDPOINT_AUTH_METHOD); + + const client = new issuer.Client({ + client_id: oidcConfig.clientId, + client_secret: oidcConfig.clientSecret, + redirect_uris: [getRedirectUri()], + response_types: [RESPONSE_TYPE], + id_token_signed_response_alg: TOKEN_SIGNING_ALG, + token_endpoint_auth_method: TOKEN_ENDPOINT_AUTH_METHOD, + }); + + return client; + } catch (err) { + // N.B. don't include the config here - it might include the client secret, perhaps in the wrong place. + throw new Error(`Failed to configure OpenID Connect client: ${err}`); + } +} + +function assertHas(name, actual, required) { + if (!actual.includes(required)) { + throw new Error(`Missing required ${name}. Wanted: ${required}, but got ${actual}!`); + } +} + +function assertHasAll(name, actual, required) { + if (!required.every(v => actual.includes(v))) { + throw new Error(`Missing required ${name}. Wanted: ${required}, but got ${actual}!`); + } +} diff --git a/lib/util/problem.js b/lib/util/problem.js index 74ab0ddf1..0f5a2b247 100644 --- a/lib/util/problem.js +++ b/lib/util/problem.js @@ -129,6 +129,8 @@ const problems = { openRosaAuthenticationRequired: problem(401.4, () => 'This resource requires authentication.'), + basicAuthNotSupportedWhenOidcEnabled: problem(401.5, () => 'This authentication method is not available on this server.'), + // TODO: should have details but not sure what yet. insufficientRights: problem(403.1, () => 'The authentication you provided does not have rights to perform that action.'), diff --git a/lib/util/sessions.js b/lib/util/sessions.js new file mode 100644 index 000000000..231d28514 --- /dev/null +++ b/lib/util/sessions.js @@ -0,0 +1,46 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +const config = require('config'); + +// TODO use req.protocol? +const HTTPS_ENABLED = config.get('default.env.domain').startsWith('https://'); + +// We want the __Host prefix in production, but it's not possible when developing using http:// +// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#__host- +const SESSION_COOKIE = HTTPS_ENABLED ? '__Host-session' : 'session'; + +const createUserSession = ({ Audits, Sessions }, headers, user) => Promise.all([ + Sessions.create(user.actor), + // Logging here rather than defining Sessions.create.audit, because + // Sessions.create.audit would require auth. Logging here also makes + // it easy to access `headers`. + Audits.log(user.actor, 'user.session.create', user.actor, { + userAgent: headers['user-agent'] + }) +]) + .then(([ session ]) => (_, response) => { + response.cookie(SESSION_COOKIE, session.token, { + httpOnly: true, + path: '/', + expires: session.expiresAt, + secure: HTTPS_ENABLED, + sameSite: 'Strict', + }); + + response.cookie('__csrf', session.csrf, { + expires: session.expiresAt, + secure: HTTPS_ENABLED, + sameSite: 'Strict', + }); + + return session; + }); + +module.exports = { SESSION_COOKIE, createUserSession }; diff --git a/oidc-dev/.gitignore b/oidc-dev/.gitignore new file mode 100644 index 000000000..3a18785be --- /dev/null +++ b/oidc-dev/.gitignore @@ -0,0 +1 @@ +/playwright-results/ diff --git a/oidc-dev/README.md b/oidc-dev/README.md new file mode 100644 index 000000000..19a72921a --- /dev/null +++ b/oidc-dev/README.md @@ -0,0 +1,11 @@ +oidc-dev +======== + +Tools to help dev & testing of OpenID Connect / OAuth2 (OIDC) as identity provider for ODK Central. + +Testing OIDC is tricky because there are a number of requirements and moving parts. + +To properly test HTTP flows between servers and proper cookie handling, we need OIDC & ODK Central servers both: + +1. exposed on separate hosts/domains +2. serving over HTTPS diff --git a/oidc-dev/certs/fake-oidc-server.example.net-key.pem b/oidc-dev/certs/fake-oidc-server.example.net-key.pem new file mode 100644 index 000000000..6b5d5c1de --- /dev/null +++ b/oidc-dev/certs/fake-oidc-server.example.net-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZuekl4P+MuWl7 +j+vXCPJGTYLkcKeVRD4QmNb6BCPuZmtV9FeN2Y9U07Nu2UQt/xqWNjqGoluq9SLd +HegudFERSyglvYq71faPIZrclbGue+vXL34k4kjeYO7LLrdxrueiVvZVquqN5abd +S3XetVJsg/DIZsoDgihvtBvg2YQ9/Zgu8hLjincQ1rTHUT61+S+JezqbiOh40Uzl +F3reMQBPi31YyMk7bBMQ4b1F7MH9V+bFMlY4HmbnlD3eKBIYR0D8QWTc6QwBq6Lr +zV0J/cJW6jk++c4KG2ER7HaazRPLuJID8CuiDSUKK1wA5HMbjThzESiS4+6wCwV9 +HzHDG9S/AgMBAAECggEBANlFZRyfw2UzQchEfx0/mEX/47cDlLioOSdm3mDw8Mpe ++o30H8s2aIpGGLFtr1QXVvi/dPgV3VRk/D2cMq7o9F1FmvLOizuW8U00Q84MtBtj +Hp7GjiNQjVcddC7el8GiwRSHo5spzJd9rV74hs+QMoiHwii6Kq4FnUSbf5aKeiVA +rBLVxIiBwO3T9PyaQHYQ9bc6l9yeKBWFK6Phql1Tl7aEGq61WoJkLcYDMys+n7Ae +c4W7Zthqv+ZQMw3Dd7y1AHMX1pf6kYNf+v2xqUCvkJILRSrHwUJCeuxPD23t3Cym +B/sKJyyv6sPm+16UPOuR7JCoESPyb/I64gAdkEC/9sECgYEA+3VBJ1O5gW44zW3s +YWO7hKenYs2CvrGhsZX6hALOYhuiSlI0XopJfNr+JunRblXTuv0WnLZ8BUdoNwQL +X9drpve1Rg1XoJ2ZX+XCqpZS0efjqd4DUWiZoa1eu/8nXt0j4+KcZPzqJDzSZb1r +zETJWD6XLtwJ2UoS7zkLflob0tUCgYEA3aiuCqoQngR9Aw+OZlK0fjnqVKkkjThW +dsqpSykt/TRFkuazcfVAFbdvb3kA4qzA0FtbBo6/wOm7AXFGevNNqXSnY5xNBZAI +H0VfsKheHl3RlAVg6SEhzz4vXLzKBh80Tk1ZBziaQLN5eEL4x2hCUz8C8K4J0t31 +AGV13zHyi0MCgYEAl9VRJgHz/SckvUYmeRfTXmItPAeDbsmrLKO2xIc9PxgYgm/o +lz1A6lcBJ1X/03OXiUzQnofBkx5u2uliRNi6c/MWTdo4kw8WUUVWqdJi58PxP9yC +fGGAgpNApJuIlktJJIzsij380yy2jiA2Ov095j7E4tKST9XeYPw86GpYapECgYBw +sdoKwfxA2rdUXwxfKZ1qr7db48MZqZMMQm1gMUeYfIMC9Rg20CIM6H5XhoXUuVAu +nsPgyaLkSfEyAo165UiO5yhTlJv0QA5hF7xW7MMtXTW4tCNZY+b0nwElfTaZdjP5 +u4mQCk8iph0T77jcaT2PZXHxPArykraFxQ/wskxGUQKBgHsCvoYl6kxHPhGNPpYf +qpwZyO//ga3KHi9XP4t58SMGdiRcWTzj4EgLy2/K29QU+q8sjMdOox+zzhGzHC4W +4+Me3+lqgykE0bbeha5zOTrsnyQJpdoIrmKk20/F168pcoStkkOHpN6GiIlAvrq6 +ACU934SA8Tk62igX4SJsBlEm +-----END PRIVATE KEY----- diff --git a/oidc-dev/certs/fake-oidc-server.example.net.pem b/oidc-dev/certs/fake-oidc-server.example.net.pem new file mode 100644 index 000000000..f920911ae --- /dev/null +++ b/oidc-dev/certs/fake-oidc-server.example.net.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIECDCCAnCgAwIBAgIRAIsf8ncG9T7uiS/T313hDAUwDQYJKoZIhvcNAQELBQAw +TzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMRIwEAYDVQQLDAl1c2Vy +QHNsaWsxGTAXBgNVBAMMEG1rY2VydCB1c2VyQHNsaWswHhcNMjMwNTI0MTgzMzA4 +WhcNMjUwODI0MTgzMzA4WjA9MScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQg +Y2VydGlmaWNhdGUxEjAQBgNVBAsMCXVzZXJAc2xpazCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANm56SXg/4y5aXuP69cI8kZNguRwp5VEPhCY1voEI+5m +a1X0V43Zj1TTs27ZRC3/GpY2OoaiW6r1It0d6C50URFLKCW9irvV9o8hmtyVsa57 +69cvfiTiSN5g7ssut3Gu56JW9lWq6o3lpt1Ldd61UmyD8MhmygOCKG+0G+DZhD39 +mC7yEuOKdxDWtMdRPrX5L4l7OpuI6HjRTOUXet4xAE+LfVjIyTtsExDhvUXswf1X +5sUyVjgeZueUPd4oEhhHQPxBZNzpDAGrouvNXQn9wlbqOT75zgobYRHsdprNE8u4 +kgPwK6INJQorXADkcxuNOHMRKJLj7rALBX0fMcMb1L8CAwEAAaNxMG8wDgYDVR0P +AQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFFyO4PTd +NPlpJui7syJqQM/WRyQIMCcGA1UdEQQgMB6CHGZha2Utb2lkYy1zZXJ2ZXIuZXhh +bXBsZS5uZXQwDQYJKoZIhvcNAQELBQADggGBAHRAP3SoSQNGWKZt4EB1m5wc0UGL +A2M8Ir89JxeJzJc1xYfKAncCw6b0a/HYV5WZymavyQUzzuSBSmKRbKRWrSDrexL9 +FhQ+8gh4fZ5RfrBG3kNK1fxI+7E7G3pzA/7uOK2Vj7EPq3mtTQSQfwe/1K2oEIQu +V0A2gYfDUmIqmT80h/iUmftVlXmfwQq/zWYhq+FVlP7pLo1yOG0qEjHF5baAOACs +Ns1FWUgaduUxjr5MCcnV8YkmeSQSA32A8BRNAqxxY9o4wIR+JdNiD5rgFjiBZMp/ +LKvDMsiJhPMeuD4lPFeAnm7Pp85P58lMYW7KyQXYVOAGC4e/aJ5Ukld/GFbkk5Yf +wrrWfffHul5nfB1Ig8eeF75ekaUSIrmduthMRFhX6I2GijFPuHj53wKI+uGiFqId +BNjr70AGgUO1IDS++3bfBBla+OS1scUWagf7IauP/ORKO/5Aiz0isr+Qk5lpubyW +xC4hUSfgyCFQvvS97GZvcpLEEhHSECXa4jIhOg== +-----END CERTIFICATE----- diff --git a/oidc-dev/certs/odk-central.example.org-key.pem b/oidc-dev/certs/odk-central.example.org-key.pem new file mode 100644 index 000000000..4e2eb4631 --- /dev/null +++ b/oidc-dev/certs/odk-central.example.org-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDHERg7lsor4OQ4 +w9WoLBDFuNoSQb8ogShUok2rOFKXdaL5Ev+8nPINJBk7XZLu5UdZBLM2unM5r4M0 +rKxy+FyEVrCO1ow80wg08B1cuygA0fweDIkb7w83j+DdOaEzGex3clTgTb05n1m9 +V76wATMtqVid0TnotdJ/ql1eSsaigyDabfD6BkBRnpljiU+U6wF+d49gPZzekF5H +xWGXEDhsyTt4ZO7ib6Ru/9qnGEGd/lJIAafYH0IKh/Y9XvNGXmhs0xlhBNh1lW2d +rNnjfw0IlymhIulYLb3xKKc28VGQmRTqmY5QU/x0M2hWitU4p/XS4ING8t0YEKLt +BYinVwzDAgMBAAECggEAFX4/A5AQXBR4D7j1RKcdWHvQ78+xeAoZ9KlhgW8fW8MT +yZjHi/HWIJ0ZLFO6HZkbzvy27N3Muxm8LN/H7tHqC0/g2EtJ8PdIF48lXHHIq+4A +Lq5jz2RMXE9ok/o48W+HHI53o5BBMguGgO8MY6x9fhyeuMtyWTHofGhElH22XK1i +4Rr3WTnsh+MIXCOTV69637QQdwKgBm065szccihnAZ/XdHV3Owb1Ag7yLFQNc/Wd +1WJCNCdl8UAhLpnTEJAbauyaDmZ7gVcVn9ilJKxTXF5gnsYESYO6Y2WwHYxLMgxY +j+F+CIpgggEjP655KbuKBgA2st71Bt/+tQMYgI4a4QKBgQD1EEi6y1a8K9kiP93N +FaBj0iMeT3b6YtoGWe7JpTwGsYVepohvKXNdWXj0IX5lCz7ajE+//PlgnDg06901 +5PxE6ofOt4EE2utt4ku8yYQhbIRk+oZFgiNGL/5wnU3PZPwz/4wM6T968vRA+MyZ +skIwLud6roEUYmxF8jMvvxye1wKBgQDP81J9qhTAt6jNkFpX+ldnJTR0I1URlgFB +7mz6rOoBCLqiWB+DUpfYMi6dwbjEHbUgy8FCpkTrGnJmDKibk1b/E3Er38NumY6v +dD4OlRQ+gYcVNxYW5q646GE48Xcy7UHnRwRRt9Di4kpla0ixmsbiYgv3Q0rcCPXs +yiHisCEf9QKBgQCmwTzsNn8/rgqjfpf7/JJWOmCBOIt6V5eKKNoOxmvxFgzt2h4O +nkMNK1vdq4jpUtyjNET0HDzJG6Q3hqPRD48Fih19cWrOlfULoaftv6Y0ZDY2zC5f +z+0WzoOxt6iBznK7I1H2WyVCEV5Zc7Mthpn5VYFX/rSA3XRVqDhibgYYowKBgAZ+ +xCHWsSU/107sZlX/JMG9AMFr5RlShSGJD/BYfEqh+ipd9EYGy2VeU+Rri5jckK7A +jn3FcbuiLNaRKKcLWBlJgyxqpdELjNBgIhwUffhh1VVNTixS8jwmTfsYV6/Ih1lw +92qSAj1D8izux+t8OSATDeqgOHNc+El4GszY0YANAoGBAIcFYakH//Lf7wjxHuXN +EsyMD4QIkbCy0RMGmt5lF3cN1GBG7EcbxeBUcpLb8yNsbLBYUYyDM+iFPmDhXcfy +g0R2zCH21RC9Zuzhobpk2oUt0bn9wr/q0zxwquvSwcxJ/k/Vi88jWE1VrErBnQzP +xE2lCwHcZkVo7k4xoXSmraG8 +-----END PRIVATE KEY----- diff --git a/oidc-dev/certs/odk-central.example.org.pem b/oidc-dev/certs/odk-central.example.org.pem new file mode 100644 index 000000000..a8d6970b1 --- /dev/null +++ b/oidc-dev/certs/odk-central.example.org.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEAjCCAmqgAwIBAgIQQ/KsJKcYj5qk6WWGoX/8cDANBgkqhkiG9w0BAQsFADBP +MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExEjAQBgNVBAsMCXVzZXJA +c2xpazEZMBcGA1UEAwwQbWtjZXJ0IHVzZXJAc2xpazAeFw0yMzA1MjQxODMzMzJa +Fw0yNTA4MjQxODMzMzJaMD0xJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBj +ZXJ0aWZpY2F0ZTESMBAGA1UECwwJdXNlckBzbGlrMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAxxEYO5bKK+DkOMPVqCwQxbjaEkG/KIEoVKJNqzhSl3Wi ++RL/vJzyDSQZO12S7uVHWQSzNrpzOa+DNKyscvhchFawjtaMPNMINPAdXLsoANH8 +HgyJG+8PN4/g3TmhMxnsd3JU4E29OZ9ZvVe+sAEzLalYndE56LXSf6pdXkrGooMg +2m3w+gZAUZ6ZY4lPlOsBfnePYD2c3pBeR8VhlxA4bMk7eGTu4m+kbv/apxhBnf5S +SAGn2B9CCof2PV7zRl5obNMZYQTYdZVtnazZ438NCJcpoSLpWC298SinNvFRkJkU +6pmOUFP8dDNoVorVOKf10uCDRvLdGBCi7QWIp1cMwwIDAQABo2wwajAOBgNVHQ8B +Af8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHwYDVR0jBBgwFoAUXI7g9N00 ++Wkm6LuzImpAz9ZHJAgwIgYDVR0RBBswGYIXb2RrLWNlbnRyYWwuZXhhbXBsZS5v +cmcwDQYJKoZIhvcNAQELBQADggGBAHet6zVttQZghR24toLehj32RACnAHCFUMXQ +lyj3yELVjR6T60rS1aomyuTMCu3BMoV4SpRZ+haLUFr3DTxa4Z1QdCHFL1YHDdmZ +urPNtWUv7U99gMPUJrDYEgpslXEE0kBBKZCFzadBx4ts1KnUtTnyivxhoGcQlMIY +i0sm2UnWm6ZiMfOn/RzvvR+N5fc8L/7bBoVgjZZtEQ9x7eU3GSi3szbJD90/kfh8 +79Dlo8sTJPNUpjAYu1aSDGX50WCGfAK3eU60r0sqiA3Q9/Ad9lbvJTFP5HU2EPg/ +8u1qENZT+R132aKdjY+hajyWqZXkwHnSIwHKaezRJfPypHmh4X6xXoMcUwHbojg7 +RjijuzOOp60gmbYEX9X6i7dxlySyhxTsWHA1rzG4K+rUsVuv3v9vERf2rLLbosIo +Ot6u1WG3ZJuhU3U/ynQWOCc7UHRusSKIYIEHnZRx/PIWOw3Yovj/gX9CmLN414Ce +YmcqoI/lk5p5LE3pjq9Zv5aGVwoLFQ== +-----END CERTIFICATE----- diff --git a/oidc-dev/docker-compose.yml b/oidc-dev/docker-compose.yml new file mode 100644 index 000000000..21718b0fe --- /dev/null +++ b/oidc-dev/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3" +services: + odk-central-oidc-tester-postgres: + image: postgres:14 + environment: + POSTGRES_USER: odk-central-backend + POSTGRES_PASSWORD: supertopsecret3000 + POSTGRES_DB: oidc-tester + odk-central-oidc-tester: + build: + dockerfile: oidc-tester.dockerfile + context: ../ + # expose playwright results + environment: + DEBUG: pw:api + ODK_PLAYWRIGHT_BROWSERS: chromium,firefox,webkit + volumes: + - ./playwright-results:/odk-central-backend/oidc-dev/playwright-tests/results diff --git a/oidc-dev/fake-oidc-server/.eslintrc.cjs b/oidc-dev/fake-oidc-server/.eslintrc.cjs new file mode 100644 index 000000000..86546b7a0 --- /dev/null +++ b/oidc-dev/fake-oidc-server/.eslintrc.cjs @@ -0,0 +1,20 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +const rules = {}; + +// This rule does not work if the node_modules directory has not been populated. +// If this rule is enabled here, `npm clean-install` will need to be run in this +// directory before eslint is run. +if (process.env.CI) rules['import/no-unresolved'] = 'off'; + +module.exports = { + extends: '../../.eslintrc.json', + rules, +}; diff --git a/oidc-dev/fake-oidc-server/accounts.json b/oidc-dev/fake-oidc-server/accounts.json new file mode 100644 index 000000000..ccaece6ea --- /dev/null +++ b/oidc-dev/fake-oidc-server/accounts.json @@ -0,0 +1,12 @@ +{ + "alice": { "email":"alice@getodk.org", "email_verified":true }, + "bob": { "email":"bob@getodk.org", "email_verified":true }, + "chelsea": { "email":"chelsea@getodk.org", "email_verified":true }, + "david": { "email":"david@getodk.org", "email_verified":true }, + "eleanor": { "email":"eleanor@getodk.org", "email_verified":true }, + + "playwright-alice": { "email":"alice@example.com", "email_verified":true }, + "playwright-bob": { "email":"bob@example.com", "email_verified":true }, + "playwright-charlie": { "email":"charlie@example.com", "email_verified":false }, + "playwright-dave": {} +} diff --git a/oidc-dev/fake-oidc-server/index.js b/oidc-dev/fake-oidc-server/index.js new file mode 100644 index 000000000..f734d1786 --- /dev/null +++ b/oidc-dev/fake-oidc-server/index.js @@ -0,0 +1,113 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +import Provider from 'oidc-provider'; +import Path from 'node:path'; +import fs from 'node:fs'; +import https from 'node:https'; + +const port = 9898; +const rootUrl = process.env.FAKE_OIDC_ROOT_URL || 'https://fake-oidc-server.example.net:9898'; + +const loadJson = path => JSON.parse(fs.readFileSync(path, { encoding: 'utf8' })); + +const ACCOUNTS_JSON_PATH = Path.resolve('./accounts.json'); +const ACCOUNTS = loadJson(ACCOUNTS_JSON_PATH); + +const pkg = loadJson('./package.json'); +// eslint-disable-next-line no-console +const log = (...args) => console.error(pkg.name, new Date().toISOString(), 'INFO', ...args); +log.info = log; + +function forHumans(o) { + if (o == null) return o; + if (typeof o === 'object') return JSON.stringify(o, null, 2); + return o; +} + +const oidc = new Provider(rootUrl, { + scopes: ['email'], + claims: { email: ['email', 'email_verified'] }, + + clients: [{ + client_id: 'odk-central-backend-dev', + client_secret: 'super-top-secret', + redirect_uris: ['http://localhost:8989/v1/oidc/callback', 'https://odk-central.example.org:8989/v1/oidc/callback'], + }], + + features: { + resourceIndicators: { + enabled: true, + getResourceServerInfo: () => ({}), + }, + }, + + async findAccount(ctx, id) { + const account = ACCOUNTS[id]; + if (!account) { + log.info(`findAccount() :: User account '${id}' not found! Check ${ACCOUNTS_JSON_PATH}!`); + throw new Error(`User account '${id}' not found! Check ${ACCOUNTS_JSON_PATH}!`); + } + + const ret = { + accountId: id, + async claims(use, scope) { + log.info('findAccount.claims()', { this: this, use, scope }); + const claims = { sub: id, ...account }; + log.info('findAccount.claims()', 'returning:', claims); + return claims; + }, + }; + log.info('findAccount()', 'found:', ret); + return ret; + }, + + async renderError(ctx, out, err) { + log('renderError()', err); + ctx.type = 'html'; + ctx.body = ` + + Error + +
+

Error

+
${err}
+

Stack

+
${err.stack}
+

Info

+ ${Object.entries(out).map(([key, value]) => `
${key}: ${forHumans(value)}
`).join('')} +

Configured Accounts

+
${forHumans(ACCOUNTS)}
+

Tips

+
    +
  • If you restarted the fake-oidc-server while viewing the login page, you'll need to restart your auth flow. Click "back to login" below.
  • +
  • To delete an auth session with fake-oidc-server, restart it! If running with nodemon/make dev-oidc you can do this by typing rs and pressing <enter>.
  • +
  • Note that the login form expects the account's username, not email address. This is to highlight that auth servers can choose their own authentication mechanisms, but will share the user's email back to the odk-central-backend server.
  • +
  • If your user exists in the OIDC server, but not in odk-central-backend's database, try running node lib/bin/cli.js --email <your email here> user-create
  • +
+
+ [ back to login ] +
+ + + `; + }, +}); + +(async () => { + if (rootUrl.startsWith('https://')) { + const key = fs.readFileSync('../certs/fake-oidc-server.example.net-key.pem', 'utf8'); // eslint-disable-line no-multi-spaces + const cert = fs.readFileSync('../certs/fake-oidc-server.example.net.pem', 'utf8'); + const httpsServer = https.createServer({ key, cert }, oidc.callback()); + await httpsServer.listen(port); + } else { + await oidc.listen(port); + } + log(`oidc-provider listening on port ${port}, check ${rootUrl}/.well-known/openid-configuration`); +})(); diff --git a/oidc-dev/fake-oidc-server/package-lock.json b/oidc-dev/fake-oidc-server/package-lock.json new file mode 100644 index 000000000..51b604d5d --- /dev/null +++ b/oidc-dev/fake-oidc-server/package-lock.json @@ -0,0 +1,1497 @@ +{ + "name": "fake-oidc-server", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "fake-oidc-server", + "version": "1.0.0", + "license": "SEE LICENSE IN ../LICENSE", + "dependencies": { + "oidc-provider": "^8.2.2" + } + }, + "node_modules/@koa/cors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-4.0.0.tgz", + "integrity": "sha512-Y4RrbvGTlAaa04DBoPBWJqDR5gPj32OOz827ULXfgB1F7piD1MB/zwn8JR2LAnvdILhxUbXbkXGWuNVsFuVFCQ==", + "dependencies": { + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@koa/router": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-12.0.0.tgz", + "integrity": "sha512-cnnxeKHXlt7XARJptflGURdJaO+ITpNkOHmQu7NHmCoRinPbyvFzce/EG/E8Zy81yQ1W9MoSdtklc3nyaDReUw==", + "dependencies": { + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "methods": "^1.1.2", + "path-to-regexp": "^6.2.1" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/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==", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.13", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.13.tgz", + "integrity": "sha512-3SD4rrMu1msNGEtNSt8Od6enwdo//U9s4ykmXfA2TD58kcLkCobtCDiby7kNyj7a/Q7lz/mAesAFI54rTdnvBA==", + "dependencies": { + "@types/http-cache-semantics": "^4.0.1", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookies": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", + "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==" + }, + "node_modules/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==", + "engines": { + "node": ">=10" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/eta": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-2.2.0.tgz", + "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.0.tgz", + "integrity": "sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/http2-wrapper/node_modules/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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jose": { + "version": "4.14.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", + "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/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==" + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/keyv": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", + "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/koa": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.14.2.tgz", + "integrity": "sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==", + "dependencies": { + "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": "^4.3.2", + "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": "^2.0.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" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/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==" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-url": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz", + "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oidc-provider": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.2.2.tgz", + "integrity": "sha512-zHXW8vzTuB0mJO3F/m+dz62/HII+qqMqgLGCQ5W/9Ojz6Jqe5voqA67ytvvHGkhoqgXCuYigLg9TBvbVnZQhGw==", + "dependencies": { + "@koa/cors": "^4.0.0", + "@koa/router": "^12.0.0", + "debug": "^4.3.4", + "eta": "^2.2.0", + "got": "^13.0.0", + "jose": "^4.14.4", + "jsesc": "^3.0.2", + "koa": "^2.14.2", + "nanoid": "^4.0.2", + "object-hash": "^3.0.0", + "oidc-token-hash": "^5.0.3", + "quick-lru": "^6.1.1", + "raw-body": "^2.5.2" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" + }, + "node_modules/quick-lru": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.1.tgz", + "integrity": "sha512-S27GBT+F0NTRiehtbrgaSE1idUAJ5bX8dPAQTdylEyNlrdcH5X4Lz7Edz3DYzecbsCluD5zO8ZNEe04z3D3u6Q==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ylru": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.3.2.tgz", + "integrity": "sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==", + "engines": { + "node": ">= 4.0.0" + } + } + }, + "dependencies": { + "@koa/cors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-4.0.0.tgz", + "integrity": "sha512-Y4RrbvGTlAaa04DBoPBWJqDR5gPj32OOz827ULXfgB1F7piD1MB/zwn8JR2LAnvdILhxUbXbkXGWuNVsFuVFCQ==", + "requires": { + "vary": "^1.1.2" + } + }, + "@koa/router": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-12.0.0.tgz", + "integrity": "sha512-cnnxeKHXlt7XARJptflGURdJaO+ITpNkOHmQu7NHmCoRinPbyvFzce/EG/E8Zy81yQ1W9MoSdtklc3nyaDReUw==", + "requires": { + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "methods": "^1.1.2", + "path-to-regexp": "^6.2.1" + } + }, + "@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==" + }, + "@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "requires": { + "defer-to-connect": "^2.0.1" + } + }, + "@types/http-cache-semantics": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==" + }, + "cacheable-request": { + "version": "10.2.13", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.13.tgz", + "integrity": "sha512-3SD4rrMu1msNGEtNSt8Od6enwdo//U9s4ykmXfA2TD58kcLkCobtCDiby7kNyj7a/Q7lz/mAesAFI54rTdnvBA==", + "requires": { + "@types/http-cache-semantics": "^4.0.1", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + } + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==" + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "requires": { + "safe-buffer": "5.2.1" + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "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" + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "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==", + "requires": { + "mimic-response": "^3.1.0" + }, + "dependencies": { + "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==" + } + } + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==" + }, + "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==" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "eta": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-2.2.0.tgz", + "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==" + }, + "form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" + }, + "got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "requires": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + } + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "requires": { + "has-symbols": "^1.0.2" + } + }, + "http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "requires": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "dependencies": { + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" + }, + "http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" + } + } + }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, + "http2-wrapper": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.0.tgz", + "integrity": "sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==", + "requires": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.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==" + } + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "jose": { + "version": "4.14.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", + "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==" + }, + "jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==" + }, + "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==" + }, + "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": "4.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", + "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "requires": { + "json-buffer": "3.0.1" + } + }, + "koa": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.14.2.tgz", + "integrity": "sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==", + "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": "^4.3.2", + "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": "^2.0.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": { + "http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "dependencies": { + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" + } + } + }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "requires": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + } + }, + "lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==" + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, + "normalize-url": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz", + "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==" + }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" + }, + "oidc-provider": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.2.2.tgz", + "integrity": "sha512-zHXW8vzTuB0mJO3F/m+dz62/HII+qqMqgLGCQ5W/9Ojz6Jqe5voqA67ytvvHGkhoqgXCuYigLg9TBvbVnZQhGw==", + "requires": { + "@koa/cors": "^4.0.0", + "@koa/router": "^12.0.0", + "debug": "^4.3.4", + "eta": "^2.2.0", + "got": "^13.0.0", + "jose": "^4.14.4", + "jsesc": "^3.0.2", + "koa": "^2.14.2", + "nanoid": "^4.0.2", + "object-hash": "^3.0.0", + "oidc-token-hash": "^5.0.3", + "quick-lru": "^6.1.1", + "raw-body": "^2.5.2" + } + }, + "oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, + "only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + }, + "p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==" + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" + }, + "quick-lru": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.1.tgz", + "integrity": "sha512-S27GBT+F0NTRiehtbrgaSE1idUAJ5bX8dPAQTdylEyNlrdcH5X4Lz7Edz3DYzecbsCluD5zO8ZNEe04z3D3u6Q==" + }, + "raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, + "responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "requires": { + "lowercase-keys": "^3.0.0" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, + "ylru": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.3.2.tgz", + "integrity": "sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==" + } + } +} diff --git a/oidc-dev/fake-oidc-server/package.json b/oidc-dev/fake-oidc-server/package.json new file mode 100644 index 000000000..0ecc08bc0 --- /dev/null +++ b/oidc-dev/fake-oidc-server/package.json @@ -0,0 +1,14 @@ +{ + "name": "fake-oidc-server", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": {}, + "volta": { + "node": "18.17.0" + }, + "license": "SEE LICENSE IN ../LICENSE", + "dependencies": { + "oidc-provider": "^8.2.2" + } +} diff --git a/oidc-dev/playwright-tests/.eslintrc.js b/oidc-dev/playwright-tests/.eslintrc.js new file mode 100644 index 000000000..eb184614e --- /dev/null +++ b/oidc-dev/playwright-tests/.eslintrc.js @@ -0,0 +1,23 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +const rules = {}; + +// This rule does not work if the node_modules directory has not been populated. +// Downloading playwright is quite slow, so it's probably better we don't have +// to do that before linting. +if (process.env.CI) rules['import/no-unresolved'] = 'off'; + +module.exports = { + extends: '../../.eslintrc.json', + env: { + browser: true, // for page.waitForFunction() code + }, + rules, +}; diff --git a/oidc-dev/playwright-tests/package-lock.json b/oidc-dev/playwright-tests/package-lock.json new file mode 100644 index 000000000..fc4f6b264 --- /dev/null +++ b/oidc-dev/playwright-tests/package-lock.json @@ -0,0 +1,1421 @@ +{ + "name": "playwright-tests", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "playwright-tests", + "version": "1.0.0", + "license": "SEE LICENSE IN ../../LICENSE", + "dependencies": { + "@playwright/test": "^1.37.1", + "cookie-parser": "^1.4.6", + "express": "^4.18.2", + "http-proxy-middleware": "^2.0.6" + } + }, + "node_modules/@playwright/test": { + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.37.1.tgz", + "integrity": "sha512-bq9zTli3vWJo8S3LwB91U0qDNQDpEXnw7knhxLM0nwDvexQAwx9tO8iKDZSqqneVq+URd/WIoz+BALMqUTgdSg==", + "dependencies": { + "@types/node": "*", + "playwright-core": "1.37.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@types/http-proxy": { + "version": "1.17.11", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz", + "integrity": "sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.5.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.6.tgz", + "integrity": "sha512-Gi5wRGPbbyOTX+4Y2iULQ27oUPrefaB0PxGQJnfyWN3kvEDGM3mIB5M/gQLmitZf7A9FmLeaqxD3L1CXpm3VKQ==" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "node_modules/get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright-core": { + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.1.tgz", + "integrity": "sha512-17EuQxlSIYCmEMwzMqusJ2ztDgJePjrbttaefgdsiqeLWidjYz9BxXaTaZWxH1J95SHGk6tjE+dwgWILJoUZfA==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + }, + "dependencies": { + "@playwright/test": { + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.37.1.tgz", + "integrity": "sha512-bq9zTli3vWJo8S3LwB91U0qDNQDpEXnw7knhxLM0nwDvexQAwx9tO8iKDZSqqneVq+URd/WIoz+BALMqUTgdSg==", + "requires": { + "@types/node": "*", + "fsevents": "2.3.2", + "playwright-core": "1.37.1" + } + }, + "@types/http-proxy": { + "version": "1.17.11", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz", + "integrity": "sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA==", + "requires": { + "@types/node": "*" + } + }, + "@types/node": { + "version": "20.5.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.6.tgz", + "integrity": "sha512-Gi5wRGPbbyOTX+4Y2iULQ27oUPrefaB0PxGQJnfyWN3kvEDGM3mIB5M/gQLmitZf7A9FmLeaqxD3L1CXpm3VKQ==" + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "requires": { + "fill-range": "^7.0.1" + } + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "requires": { + "safe-buffer": "5.2.1" + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + } + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + } + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + } + }, + "follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==" + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "requires": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, + "object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "playwright-core": { + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.1.tgz", + "integrity": "sha512-17EuQxlSIYCmEMwzMqusJ2ztDgJePjrbttaefgdsiqeLWidjYz9BxXaTaZWxH1J95SHGk6tjE+dwgWILJoUZfA==" + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "requires": { + "side-channel": "^1.0.4" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, + "serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + } + } +} diff --git a/oidc-dev/playwright-tests/package.json b/oidc-dev/playwright-tests/package.json new file mode 100644 index 000000000..84ede81c0 --- /dev/null +++ b/oidc-dev/playwright-tests/package.json @@ -0,0 +1,16 @@ +{ + "name": "playwright-tests", + "version": "1.0.0", + "main": "index.js", + "scripts": {}, + "dependencies": { + "@playwright/test": "^1.37.1", + "cookie-parser": "^1.4.6", + "express": "^4.18.2", + "http-proxy-middleware": "^2.0.6" + }, + "volta": { + "node": "18.17.0" + }, + "license": "SEE LICENSE IN ../../LICENSE" +} diff --git a/oidc-dev/playwright-tests/playwright.config.js b/oidc-dev/playwright-tests/playwright.config.js new file mode 100644 index 000000000..9f156788b --- /dev/null +++ b/oidc-dev/playwright-tests/playwright.config.js @@ -0,0 +1,63 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +const { devices } = require('@playwright/test'); + +const availableProjects = { + 'chrome-desktop': { channel: 'chrome' }, + 'chrome-mobile': { ...devices['Pixel 5'] }, // eslint-disable-line key-spacing,no-multi-spaces + 'chromium': { ...devices['Desktop Chrome'] }, // eslint-disable-line key-spacing,no-multi-spaces,quote-props + 'edge': { channel: 'msedge' }, // eslint-disable-line key-spacing,no-multi-spaces,quote-props + 'firefox': { ...devices['Desktop Firefox'] }, // eslint-disable-line key-spacing,no-multi-spaces,quote-props + 'safari-mobile': { ...devices['iPhone 12'] }, // eslint-disable-line key-spacing,no-multi-spaces + 'webkit': { ...devices['Desktop Safari'] }, // eslint-disable-line key-spacing,no-multi-spaces,quote-props +}; +const requestedBrowsers = process.env.ODK_PLAYWRIGHT_BROWSERS || 'firefox'; +const projects = requestedBrowsers + .split(',') + .map(name => { + if (!Object.prototype.hasOwnProperty.call(availableProjects, name)) { + throw new Error(`No project config available with name '${name}'!`); + } + const use = availableProjects[name]; + return { name, use }; + }); + +/** + * @see https://playwright.dev/docs/test-configuration + * @type {import('@playwright/test').PlaywrightTestConfig} + */ +const config = { + testDir: 'src', + /* Maximum time one test can run for. */ + timeout: 10 * 1000, + expect: { timeout: 2000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, // retries mean failure + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'line', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + actionTimeout: 0, + baseURL: 'https://odk-central.example.org:8989', + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + // desperate debug options - fiddle with these when you're confused what's going on: + video: 'retain-on-failure', + headless: true, + }, + projects, + outputDir: 'results/', + globalSetup: require.resolve('./src/global-setup-teardown'), +}; + +module.exports = config; diff --git a/oidc-dev/playwright-tests/src/config.js b/oidc-dev/playwright-tests/src/config.js new file mode 100644 index 000000000..9d8f0f79c --- /dev/null +++ b/oidc-dev/playwright-tests/src/config.js @@ -0,0 +1,16 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +const port = 8989; +const frontendUrl = `https://odk-central.example.org:${port}`; + +module.exports = { + frontendUrl, + port, +}; diff --git a/oidc-dev/playwright-tests/src/global-setup-teardown.js b/oidc-dev/playwright-tests/src/global-setup-teardown.js new file mode 100644 index 000000000..353e70b03 --- /dev/null +++ b/oidc-dev/playwright-tests/src/global-setup-teardown.js @@ -0,0 +1,81 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +/* eslint-disable no-multi-spaces,template-curly-spacing */ + +// globalSetup() returns globalTeardown() +// See: https://playwright.dev/docs/test-global-setup-teardown#configure-globalsetup-and-globalteardown +module.exports = async function globalSetup() { + const fakeFrontend = await startFakeFrontend(); // eslint-disable-line no-use-before-define + return function globalTeardown() { + fakeFrontend.close(); + }; +}; + +const express = require('express'); +const cookieParser = require('cookie-parser'); +const { createProxyMiddleware } = require('http-proxy-middleware'); + +const { port, frontendUrl } = require('./config'); +const backendUrl = 'http://localhost:8383'; + +async function startFakeFrontend() { + console.log('Starting fake frontend proxy...'); // eslint-disable-line no-console + const fakeFrontend = express(); + fakeFrontend.use(cookieParser()); + fakeFrontend.get('/', successHandler); // eslint-disable-line no-use-before-define + fakeFrontend.get('/-/*', successHandler); // eslint-disable-line no-use-before-define + fakeFrontend.use(createProxyMiddleware('/v1', { target: backendUrl })); + + if (frontendUrl.startsWith('http://')) { + return fakeFrontend.listen(port); + } else { + const fs = require('node:fs'); + const https = require('node:https'); + const key = fs.readFileSync('../certs/odk-central.example.org-key.pem', 'utf8'); + const cert = fs.readFileSync('../certs/odk-central.example.org.pem', 'utf8'); + const httpsServer = https.createServer({ key, cert }, fakeFrontend); + await httpsServer.listen(port); + return httpsServer; + } +} + +function html([ first, ...rest ], ...vars) { + return (` + + + ${first + vars.map((v, idx) => [ v, rest[idx] ]).flat().join('')} + + + `); +} + +function successHandler(req, res) { + // include request details in response body to allow for: + // + // * testing values in playwright + // * getting helpful debug info in screenshots + const reqDetails = { + url: req.url, + originalUrl: req.originalUrl, + hostname: req.hostname, + }; + + res.send(html` + +

${req.url} success!

+

Request Details

+

Path

${     JSON.stringify(reqDetails,  null, 2)}
+

Headers

${     JSON.stringify(req.headers, null, 2)}
+

Query Params

${JSON.stringify(req.query,   null, 2)}
+

Cookies

${     JSON.stringify(req.cookies, null, 2)}
+

location.href

+ + `); +} diff --git a/oidc-dev/playwright-tests/src/oidc-login.spec.js b/oidc-dev/playwright-tests/src/oidc-login.spec.js new file mode 100644 index 000000000..fbfad2aa6 --- /dev/null +++ b/oidc-dev/playwright-tests/src/oidc-login.spec.js @@ -0,0 +1,78 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +const { test } = require('@playwright/test'); + +const { frontendUrl } = require('./config'); +const { // eslint-disable-line object-curly-newline + assertErrorRedirect, + assertLocation, + assertLoginSuccessful, + fillLoginForm, + initTest, +} = require('./utils'); // eslint-disable-line object-curly-newline + +const password = 'topsecret123'; // fake-oidc-server will accept any non-empty password + +test.describe.configure({ mode: 'parallel' }); + +test.describe('happy', () => { + [ + [ 'no next param', '', '/', '/#/' ], // eslint-disable-line no-multi-spaces + [ 'internal next param', '?next=/some/path', '/', '/#/some/path' ], // eslint-disable-line no-multi-spaces + [ 'enketo next param', '?next=/-/some/path', '/-/some/path', '/-/some/path' ], // eslint-disable-line no-multi-spaces + ].forEach(([ description, initialQueryString, expectedBackendPath, expectedFrontendPath ]) => { + test(`can log in (${description})`, async ({ browserName, page }, testInfo) => { + // given + await initTest({ browserName, page }, testInfo); + + // when + await page.goto(`${frontendUrl}/v1/oidc/login${initialQueryString}`); + await fillLoginForm(page, { username: 'alice', password }); + + // then + await assertLoginSuccessful(page, expectedBackendPath); // N.B. backend doesn't receive URL fragments + await assertLocation(page, frontendUrl + expectedFrontendPath); + }); + }); +}); + +test.describe('redirected errors', () => { + [ + [ 'user unknown by central', 'bob', 'auth-ok-user-not-found' ], // eslint-disable-line no-multi-spaces + [ `no 'email' claim provided`, 'dave', 'email-claim-not-provided' ], // eslint-disable-line no-multi-spaces, quotes + [ `claim 'email_verified' has value false`, 'charlie', 'email-not-verified' ], // eslint-disable-line no-multi-spaces, quotes + ].forEach(([ description, username, expectedError ]) => { + test(`successful authN, but ${description}`, async ({ browserName, page }, testInfo) => { + // given + await initTest({ browserName, page }, testInfo); + + // when + await page.goto(`${frontendUrl}/v1/oidc/login`); + await fillLoginForm(page, { username, password }); + + // then + await assertErrorRedirect(page, expectedError); + }); + }); +}); + +test('aborted login', async ({ browserName, page }, testInfo) => { + // given + await initTest({ browserName, page }, testInfo); + + // when + await page.goto(`${frontendUrl}/v1/oidc/login`); + await page.getByText('Cancel').click(); + + // then + // Upstream error message is not exposed to the client, but would be: + // > access_denied (End-User aborted interaction) + await assertErrorRedirect(page, 'internal-server-error'); +}); diff --git a/oidc-dev/playwright-tests/src/utils.js b/oidc-dev/playwright-tests/src/utils.js new file mode 100644 index 000000000..bfbaae35d --- /dev/null +++ b/oidc-dev/playwright-tests/src/utils.js @@ -0,0 +1,83 @@ +// Copyright 2023 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +/* eslint-disable no-console,no-use-before-define */ + +module.exports = { + assertErrorRedirect, + assertLocation, + assertLoginSuccessful, + assertTitle, + fillLoginForm, + initTest, +}; + +const assert = require('node:assert'); +const { expect } = require('@playwright/test'); + +const { frontendUrl } = require('./config'); + +const SESSION_COOKIE = (frontendUrl.startsWith('https://') ? '__Host-' : '') + 'session'; + +async function assertErrorRedirect(page, expectedErrorCode) { + await page.waitForFunction(expected => { + const { href, hash } = window.location; + const fakeSearch = hash.replace(/[^?]*\?/, ''); // hash & search exchanged in odk-central-frontend + const actual = new URLSearchParams(fakeSearch).get('oidcError'); + + console.log(` + assertErrorRedirect() + window.location.href: ${href} + window.location.hash: ${hash} + expected error code: ${expected} + actual error code: ${actual} + `); + return actual === expected; + }, expectedErrorCode); +} + +function assertLocation(page, expectedLocation) { + console.log(' assertLocation()'); + console.log(` expected: '${expectedLocation}'`); + return page.waitForFunction(expected => { + const actualLocation = window.location.href; + console.log(`actual: '${actualLocation}'`); + return actualLocation === expected; + }, expectedLocation); +} + +async function assertLoginSuccessful(page, expectedPath) { + await expect(page.locator('h1')).toHaveText(`${expectedPath} success!`); + + const requestCookies = JSON.parse(await page.locator('#request-cookies').textContent()); + + console.log('requestCookies:', JSON.stringify(requestCookies, null, 2)); + + assert(requestCookies[SESSION_COOKIE], 'No session cookie found!'); + assert(requestCookies['__csrf'], 'No CSRF cookie found!'); // eslint-disable-line dot-notation,no-multi-spaces + assert.equal(Object.keys(requestCookies).length, 2, 'Unexpected requestCookie count!'); +} + +function assertTitle(page, expectedTitle) { + return expect(page.locator('h1')).toHaveText(expectedTitle); +} + +async function fillLoginForm(page, { username, password }) { + await page.locator('input[name=login]').fill('playwright-' + username); + await page.locator('input[name=password]').fill(password); + await page.locator('button[type=submit]').click(); + await page.getByRole('button', { name: 'Continue' }).click(); +} + +function initTest({ browserName, page }, testInfo) { + page.on('console', msg => { + const level = msg.type().toUpperCase(); + console.log(level, `[${browserName}:${testInfo.title}]`, msg.text()); + }); +} diff --git a/oidc-dev/scripts/docker-start.sh b/oidc-dev/scripts/docker-start.sh new file mode 100755 index 000000000..b65006dc7 --- /dev/null +++ b/oidc-dev/scripts/docker-start.sh @@ -0,0 +1,34 @@ +#!/bin/bash -eu + +log() { + echo "[oidc-tester] $*" +} + +log "Configuring DNS..." +# N.B. configuring DNS is done at runtime because Docker prevents write access before then. +echo '127.0.0.1 fake-oidc-server.example.net' >> /etc/hosts +echo '127.0.0.1 odk-central.example.org' >> /etc/hosts + +log "DNS configured." + +log "Waiting for postgres to start..." +wait-for-it odk-central-oidc-tester-postgres:5432 --strict --timeout=60 -- echo '[oidc-tester] postgres is UP!' + +log "Starting services..." +(cd fake-oidc-server && node index.js) & +(cd .. && make base && NODE_TLS_REJECT_UNAUTHORIZED=0 node lib/bin/run-server.js) & + +log "Waiting for odk-central-backend to start..." +wait-for-it localhost:8383 --strict --timeout=60 -- echo '[oidc-tester] odk-central-backend is UP!' + +log "Creating test users..." # _after_ migrations have been run +cd .. +node lib/bin/cli.js --email alice@example.com user-create +cd - +log "Test users created." + +log "Running playwright tests..." +cd playwright-tests +npx playwright test + +log "Tests completed OK!" diff --git a/oidc-tester.dockerfile b/oidc-tester.dockerfile new file mode 100644 index 000000000..117d34183 --- /dev/null +++ b/oidc-tester.dockerfile @@ -0,0 +1,39 @@ +# Some of the most fiddly stuff WRT cookie settings are around Secure, SameSite, +# __Host, __Secure, and we cannot fully test this without both HTTPS and a non- +# localhost domain. +# See: https://web.dev/when-to-use-local-https/#when-to-use-https-for-local-development + +# Make sure base image is compatible with Playwright system requirements. +# See: https://playwright.dev/docs/intro#system-requirements +# See: https://hub.docker.com/_/node +# See: https://wiki.debian.org/DebianReleases#Codenames +# See: https://en.wikipedia.org/wiki/Debian_version_history +FROM node:18.17.0-bullseye + +RUN apt-get update && apt-get install wait-for-it && rm -rf /var/lib/apt/lists/* + +# Set up main project dependencies - this layer is slow, but should be cached most of the time. +WORKDIR /odk-central-backend +COPY Makefile package.json package-lock.json . +RUN npm clean-install --legacy-peer-deps + +WORKDIR /odk-central-backend/oidc-dev/fake-oidc-server +COPY oidc-dev/fake-oidc-server/package.json oidc-dev/fake-oidc-server/package-lock.json . +RUN npm clean-install + +WORKDIR /odk-central-backend/oidc-dev/playwright-tests +COPY oidc-dev/playwright-tests/package.json \ + oidc-dev/playwright-tests/package-lock.json \ + . +RUN npm clean-install && echo -n 'Playwright: ' && npx playwright --version && npx playwright install --with-deps + +# Copy ALL files whitelisted in .dockerignore. Note that this means there is no +# isolation at the Docker level between code or dependencies of the various +# servers that will run. This is very convenient and probably allows for faster +# builds, but care should be taken to avoid interdependencies. +WORKDIR /odk-central-backend +COPY / . + +ENV NODE_CONFIG_ENV=oidc-tester-docker +WORKDIR /odk-central-backend/oidc-dev +CMD ./scripts/docker-start.sh diff --git a/package-lock.json b/package-lock.json index 40f15ddda..ff273a419 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "cloneable-readable": "~2", "commander": "^10.0.1", "config": "~1.31", + "cookie-parser": "^1.4.6", "csv-parse": "~4", "csv-stringify": "~5", "digest-stream": "~2", @@ -31,6 +32,7 @@ "mustache": "~2.3", "nodemailer": "~6", "odata-v4-parser": "~0.1", + "openid-client": "^5.4.3", "pg": "~8", "pg-query-stream": "~4", "pm2": "^5.2.2", @@ -49,6 +51,7 @@ "eslint": "^8.44.0", "eslint-config-airbnb-base": "~14", "eslint-plugin-import": "~2.25", + "fetch-cookie": "^2.1.0", "mocha": "^10.2.0", "nock": "^13.3.1", "node-mocks-http": "^1.12.2", @@ -2482,6 +2485,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -3686,6 +3709,16 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-cookie": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.1.0.tgz", + "integrity": "sha512-39+cZRbWfbibmj22R2Jy6dmTbAWC+oqun1f1FzQaNurkPDUP4C38jpeZbiXCR88RKRVDp8UcDrbFXkNhN+NjYg==", + "dev": true, + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5464,6 +5497,14 @@ "node": ">=8" } }, + "node_modules/jose": { + "version": "4.14.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", + "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-git": { "version": "0.7.8", "resolved": "https://registry.npmjs.org/js-git/-/js-git-0.7.8.tgz", @@ -6743,6 +6784,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -6866,6 +6915,14 @@ "resolved": "https://registry.npmjs.org/odata-v4-parser/-/odata-v4-parser-0.1.29.tgz", "integrity": "sha512-7ZsqxcMbGAqKSBme0+lul7g9K52RadGYU3WvYwDdduJNfnduSL59w4OEskvEzWHZMcPRqPnD9XtuO7Rpl+B3jA==" }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -6893,6 +6950,20 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.3.tgz", + "integrity": "sha512-sVQOvjsT/sbSfYsQI/9liWQGVZH/Pp3rrtlGEwgk/bbHfrUDZ24DN57lAagIwFtuEu+FM9Ev7r85s8S/yPjimQ==", + "dependencies": { + "jose": "^4.14.4", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -7864,6 +7935,12 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -7892,6 +7969,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8131,6 +8214,12 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -8418,6 +8507,12 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "node_modules/set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -9585,6 +9680,30 @@ "node": "*" } }, + "node_modules/tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -9855,6 +9974,16 @@ "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==", "deprecated": "Please see https://github.com/lydell/urix#deprecated" }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -12050,6 +12179,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -12983,6 +13128,16 @@ "pend": "~1.2.0" } }, + "fetch-cookie": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.1.0.tgz", + "integrity": "sha512-39+cZRbWfbibmj22R2Jy6dmTbAWC+oqun1f1FzQaNurkPDUP4C38jpeZbiXCR88RKRVDp8UcDrbFXkNhN+NjYg==", + "dev": true, + "requires": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -14295,6 +14450,11 @@ "istanbul-lib-report": "^3.0.0" } }, + "jose": { + "version": "4.14.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", + "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==" + }, "js-git": { "version": "0.7.8", "resolved": "https://registry.npmjs.org/js-git/-/js-git-0.7.8.tgz", @@ -15273,6 +15433,11 @@ } } }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" + }, "object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -15363,6 +15528,11 @@ "resolved": "https://registry.npmjs.org/odata-v4-parser/-/odata-v4-parser-0.1.29.tgz", "integrity": "sha512-7ZsqxcMbGAqKSBme0+lul7g9K52RadGYU3WvYwDdduJNfnduSL59w4OEskvEzWHZMcPRqPnD9XtuO7Rpl+B3jA==" }, + "oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==" + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -15384,6 +15554,17 @@ "wrappy": "1" } }, + "openid-client": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.3.tgz", + "integrity": "sha512-sVQOvjsT/sbSfYsQI/9liWQGVZH/Pp3rrtlGEwgk/bbHfrUDZ24DN57lAagIwFtuEu+FM9Ev7r85s8S/yPjimQ==", + "requires": { + "jose": "^4.14.4", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + } + }, "optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -16108,6 +16289,12 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, "pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -16127,6 +16314,12 @@ "side-channel": "^1.0.4" } }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -16299,6 +16492,12 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -16504,6 +16703,12 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, "set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -17398,6 +17603,26 @@ } } }, + "tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "dev": true, + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "dependencies": { + "universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true + } + } + }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -17608,6 +17833,16 @@ "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==" }, + "url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", diff --git a/package.json b/package.json index 76a80ddb9..79fc5954f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "cloneable-readable": "~2", "commander": "^10.0.1", "config": "~1.31", + "cookie-parser": "^1.4.6", "csv-parse": "~4", "csv-stringify": "~5", "digest-stream": "~2", @@ -32,6 +33,7 @@ "mustache": "~2.3", "nodemailer": "~6", "odata-v4-parser": "~0.1", + "openid-client": "^5.4.3", "pg": "~8", "pg-query-stream": "~4", "pm2": "^5.2.2", @@ -50,6 +52,7 @@ "eslint": "^8.44.0", "eslint-config-airbnb-base": "~14", "eslint-plugin-import": "~2.25", + "fetch-cookie": "^2.1.0", "mocha": "^10.2.0", "nock": "^13.3.1", "node-mocks-http": "^1.12.2", diff --git a/test/integration/api/app-users.js b/test/integration/api/app-users.js index 55d79e06f..8746ae688 100644 --- a/test/integration/api/app-users.js +++ b/test/integration/api/app-users.js @@ -1,6 +1,7 @@ const should = require('should'); const { testService } = require('../setup'); const testData = require('../../data/xml'); +const authenticateUser = require('../../util/authenticate-user'); describe('api: /projects/:id/app-users', () => { describe('POST', () => { @@ -234,8 +235,8 @@ describe('api: /key/:key', () => { .expect(403))); it('should reject non-field tokens', testService((service) => - service.post('/v1/sessions').send({ email: 'alice@getodk.org', password: 'alice' }) - .then(({ body }) => service.get(`/v1/key/${body.token}/users/current`) + authenticateUser(service, 'alice') + .then((token) => service.get(`/v1/key/${token}/users/current`) .expect(403)))); it('should passthrough to the appropriate route with successful auth', testService((service) => diff --git a/test/integration/api/public-links.js b/test/integration/api/public-links.js index f95e9a036..c08a35b08 100644 --- a/test/integration/api/public-links.js +++ b/test/integration/api/public-links.js @@ -1,6 +1,7 @@ const should = require('should'); const { testService } = require('../setup'); const testData = require('../../data/xml'); +const authenticateUser = require('../../util/authenticate-user'); describe('api: /projects/:id/forms/:id/public-links', () => { describe('POST', () => { @@ -195,16 +196,13 @@ describe('api: /key/:key', () => { .expect(403))); it('should allow cookie+public-link', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => body.token) + authenticateUser(service, 'alice') .then((aliceToken) => service.login('alice', (asAlice) => asAlice.post('/v1/projects/1/forms/simple/public-links') .send({ displayName: 'linktest' }) .then(({ body }) => body.token) .then((linkToken) => service.get(`/v1/key/${linkToken}/projects/1/forms/simple.xml`) - .set('Cookie', `__Host-session=${aliceToken}`) + .set('Cookie', `session=${aliceToken}`) .set('X-Forwarded-Proto', 'https') .expect(200)))))); diff --git a/test/integration/api/sessions.js b/test/integration/api/sessions.js index 505334e2b..8c5e32cdc 100644 --- a/test/integration/api/sessions.js +++ b/test/integration/api/sessions.js @@ -1,9 +1,12 @@ const should = require('should'); const { DateTime } = require('luxon'); const { testService } = require('../setup'); +const authenticateUser = require('../../util/authenticate-user'); describe('api: /sessions', () => { describe('POST', () => { + if (process.env.TEST_AUTH === 'oidc') return; // no this.skip() available at Suite-level + it('should return a new session if the information is valid', testService((service) => service.post('/v1/sessions') .send({ email: 'chelsea@getodk.org', password: 'chelsea' }) @@ -36,12 +39,12 @@ describe('api: /sessions', () => { // i don't know how this becomes an array but i think superagent does it. const cookie = headers['set-cookie']; - const session = /__Host-session=([^;]+); Path=\/; Expires=([^;]+); HttpOnly; Secure; SameSite=Strict/.exec(cookie[0]); + const session = /^session=([^;]+); Path=\/; Expires=([^;]+); HttpOnly; SameSite=Strict$/.exec(cookie[0]); should.exist(session); decodeURIComponent(session[1]).should.equal(body.token); session[2].should.equal(DateTime.fromISO(body.expiresAt).toHTTP()); - const csrf = /__csrf=([^;]+); Path=\/; Expires=([^;]+); Secure; SameSite=Strict/.exec(cookie[1]); + const csrf = /^__csrf=([^;]+); Path=\/; Expires=([^;]+); SameSite=Strict$/.exec(cookie[1]); should.exist(csrf); decodeURIComponent(csrf[1]).should.equal(body.csrf); csrf[2].should.equal(DateTime.fromISO(body.expiresAt).toHTTP()); @@ -87,20 +90,18 @@ describe('api: /sessions', () => { it('should fail if no valid session exists', testService((service) => service.get('/v1/sessions/restore') .set('X-Forwarded-Proto', 'https') - .set('Cookie', '__Host-session: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') + .set('Cookie', 'session: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') .expect(404))); it('should return the active session if it exists', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => service.get('/v1/sessions/restore') + authenticateUser(service, 'alice') + .then((token) => service.get('/v1/sessions/restore') .set('X-Forwarded-Proto', 'https') - .set('Cookie', '__Host-session=' + body.token) + .set('Cookie', 'session=' + token) .expect(200) .then((restore) => { restore.body.should.be.a.Session(); - restore.body.token.should.equal(body.token); + restore.body.token.should.equal(token); })))); }); @@ -110,30 +111,18 @@ describe('api: /sessions', () => { .expect(403))); it('should return a 403 if the user cannot delete the given token', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => { - // eslint-disable-next-line prefer-destructuring - const token = body.token; - return service.login('chelsea', (asChelsea) => - asChelsea.delete('/v1/sessions/' + token).expect(403)); - }))); + authenticateUser(service, 'alice') + .then((token) => service.login('chelsea', (asChelsea) => + asChelsea.delete('/v1/sessions/' + token).expect(403))))); it('should invalidate the token if successful', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => { - // eslint-disable-next-line prefer-destructuring - const token = body.token; - return service.delete('/v1/sessions/' + token) + authenticateUser(service, 'alice') + .then((token) => service.delete('/v1/sessions/' + token) + .set('Authorization', 'Bearer ' + token) + .expect(200) + .then(() => service.get('/v1/users/current') // actually doesn't matter which route; we get 401 due to broken auth. .set('Authorization', 'Bearer ' + token) - .expect(200) - .then(() => service.get('/v1/users/current') // actually doesn't matter which route; we get 401 due to broken auth. - .set('Authorization', 'Bearer ' + token) - .expect(401)); - }))); + .expect(401))))); it('should log the action in the audit log if it is a field key', testService((service) => service.login('alice', (asAlice) => @@ -150,19 +139,13 @@ describe('api: /sessions', () => { }))))); it('should allow non-admins to delete their own sessions', testService((service) => - service.post('/v1/sessions') - .send({ email: 'chelsea@getodk.org', password: 'chelsea' }) - .expect(200) - .then(({ body }) => { - // eslint-disable-next-line prefer-destructuring - const token = body.token; - return service.delete('/v1/sessions/' + token) + authenticateUser(service, 'chelsea') + .then((token) => service.delete('/v1/sessions/' + token) + .set('Authorization', 'Bearer ' + token) + .expect(200) + .then(() => service.get('/v1/users/current') // actually doesn't matter which route; we get 401 due to broken auth. .set('Authorization', 'Bearer ' + token) - .expect(200) - .then(() => service.get('/v1/users/current') // actually doesn't matter which route; we get 401 due to broken auth. - .set('Authorization', 'Bearer ' + token) - .expect(401)); - }))); + .expect(401))))); it('should allow managers to delete project app user sessions', testService((service) => service.login('bob', (asBob) => @@ -196,28 +179,19 @@ describe('api: /sessions', () => { .expect(403))))); it('should clear cookies if successful for the current session', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => { - // eslint-disable-next-line prefer-destructuring - const token = body.token; - return service.delete('/v1/sessions/' + token) - .set('Authorization', 'Bearer ' + token) - .expect(200) - .then(({ headers }) => { - headers['set-cookie'].should.eql([ - '__Host-session=null; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict', - '__csrf=null; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict' - ]); - }); - }))); + authenticateUser(service, 'alice') + .then((token) => service.delete('/v1/sessions/' + token) + .set('Authorization', 'Bearer ' + token) + .expect(200) + .then(({ headers }) => { + headers['set-cookie'].should.eql([ + 'session=null; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict', + '__csrf=null; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict' + ]); + })))); it('should not clear cookies if using some other session', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => body.token) + authenticateUser(service, 'alice') .then((token) => service.login('alice', (asAlice) => asAlice.delete('/v1/sessions/' + token) .expect(200) @@ -226,10 +200,7 @@ describe('api: /sessions', () => { }))))); it('should not log the action in the audit log for users', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => body.token) + authenticateUser(service, 'alice') .then((token) => service.delete('/v1/sessions/' + token) .set('Authorization', 'Bearer ' + token) .expect(200) @@ -277,10 +248,7 @@ describe('api: /sessions', () => { .expect(404))); it('should invalidate the token if successful', testService(async (service) => { - const { body: session } = await service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200); - const { token } = session; + const token = await authenticateUser(service, 'alice'); const { body } = await service.delete('/v1/sessions/current') .set('Authorization', `Bearer ${token}`) .expect(200); @@ -304,33 +272,27 @@ describe('api: /sessions', () => { // whole stack in addition to the unit tests. describe('cookie CSRF auth', () => { it('should reject if the CSRF token is missing', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => service.post('/v1/projects') + authenticateUser(service, 'alice') + .then((token) => service.post('/v1/projects') .send({ name: 'my project' }) .set('X-Forwarded-Proto', 'https') - .set('Cookie', '__Host-session=' + body.token) + .set('Cookie', 'session=' + token) .expect(401)))); it('should reject if the CSRF token is wrong', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => service.post('/v1/projects') + authenticateUser(service, 'alice') + .then((token) => service.post('/v1/projects') .send({ name: 'my project', __csrf: 'nope' }) .set('X-Forwarded-Proto', 'https') - .set('Cookie', '__Host-session=' + body.token) + .set('Cookie', 'session=' + token) .expect(401)))); it('should succeed if the CSRF token is correct', testService((service) => - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => service.post('/v1/projects') + authenticateUser(service, 'alice', 'includeCsrf') + .then((body) => service.post('/v1/projects') .send({ name: 'my project', __csrf: body.csrf }) .set('X-Forwarded-Proto', 'https') - .set('Cookie', '__Host-session=' + body.token) + .set('Cookie', 'session=' + body.token) .expect(200)))); }); }); diff --git a/test/integration/api/users.js b/test/integration/api/users.js index 9b006a063..dd36a5c26 100644 --- a/test/integration/api/users.js +++ b/test/integration/api/users.js @@ -2,6 +2,7 @@ const appRoot = require('app-root-path'); const should = require('should'); const { getOrNotFound } = require(appRoot + '/lib/util/promise'); const { testService } = require('../setup'); +const authenticateUser = require('../../util/authenticate-user'); describe('api: /users', () => { describe('GET', () => { @@ -85,279 +86,324 @@ describe('api: /users', () => { .send({ email: 'david@getodk.org' }) .expect(403)))); - it('should hash and store passwords if provided', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users') - .send({ email: 'david@getodk.org', password: 'alongpassword' }) - .expect(200) - .then(() => service.login({ email: 'david@getodk.org', password: 'alongpassword' }, (asDavid) => - asDavid.get('/v1/users/current').expect(200)))))); + if (process.env.TEST_AUTH === 'oidc') { + describe('with OIDC auth', () => { + it('should send an email to provisioned users', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org' }) + .expect(200) + .then(() => { + const email = global.inbox.pop(); + global.inbox.length.should.equal(0); + email.to.should.eql([{ address: 'david@getodk.org', name: '' }]); + email.subject.should.equal('ODK Central account created'); + })))); + + it('should not send a token which can reset the new user password', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org' }) + .expect(200) + .then(() => { + const tokenMatch = /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html); + should(tokenMatch).be.null(); + })))); + }); + } else { + describe('with standard uname/password auth', () => { + it('should hash and store passwords if provided', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org', password: 'alongpassword' }) + .expect(200) + .then(() => service.login({ email: 'david@getodk.org', password: 'alongpassword' }, (asDavid) => + asDavid.get('/v1/users/current').expect(200)))))); - it('should not accept and hash blank passwords', testService((service, { Users }) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users') - .send({ email: 'david@getodk.org', password: '' }) - .expect(200) // treats a blank password as no password provided - .then(() => Promise.all([ - service.post('/v1/sessions') + it('should not accept and hash blank passwords', testService((service, { Users }) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') .send({ email: 'david@getodk.org', password: '' }) - .expect(400), - Users.getByEmail('david@getodk.org') - .then(getOrNotFound) - .then(({ password }) => { should.not.exist(password); }) - ]))))); - - it('should not accept a password that is too short', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users') - .send({ email: 'david@getodk.org', password: 'short' }) - .expect(400)))); - - it('should send an email to provisioned users', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users') - .send({ email: 'david@getodk.org', password: 'daviddavid' }) - .expect(200) - .then(() => { - const email = global.inbox.pop(); - global.inbox.length.should.equal(0); - email.to.should.eql([{ address: 'david@getodk.org', name: '' }]); - email.subject.should.equal('ODK Central account created'); - })))); - - it('should send a token which can reset the new user password', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users') - .send({ email: 'david@getodk.org' }) - .expect(200) - .then(() => { - const token = /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]; - return service.post('/v1/users/reset/verify') - .send({ new: 'testresetpassword' }) - .set('Authorization', 'Bearer ' + token) + .expect(200) // treats a blank password as no password provided + .then(() => Promise.all([ + service.post('/v1/sessions') + .send({ email: 'david@getodk.org', password: '' }) + .expect(400), + Users.getByEmail('david@getodk.org') + .then(getOrNotFound) + .then(({ password }) => { should.not.exist(password); }) + ]))))); + + it('should not accept a password that is too short', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org', password: 'short' }) + .expect(400)))); + + it('should send an email to provisioned users', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org', password: 'daviddavid' }) .expect(200) - .then(() => service.login({ email: 'david@getodk.org', password: 'testresetpassword' }, (asDavid) => - asDavid.get('/v1/users/current').expect(200))); - })))); - - it('should not allow a too-short password when resetting via token', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users') - .send({ email: 'david@getodk.org' }) - .expect(200) - .then(() => { - const token = /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]; - return service.post('/v1/users/reset/verify') - .send({ new: 'tooshort' }) - .set('Authorization', 'Bearer ' + token) - .expect(400); - })))); - - it('should send a message explaining a pre-assigned password if given', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users') - .send({ email: 'david@getodk.org', password: 'daviddavid' }) - .expect(200) - .then(() => { - /Your account was created with an assigned password\./ - .test(global.inbox.pop().html) - .should.equal(true); - })))); - - it('should duplicate the email into the display name if not given', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users') - .send({ email: 'david@getodk.org' }) - .then(({ body }) => body.displayName.should.equal('david@getodk.org'))))); - - it('should log the action in the audit log', testService((service, { Audits, Users }) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users') - .send({ email: 'david@getodk.org' }) - .expect(200) - .then(() => Promise.all([ - Users.getByEmail('alice@getodk.org').then((o) => o.get()), - Users.getByEmail('david@getodk.org').then((o) => o.get()), - Audits.getLatestByAction('user.create').then((o) => o.get()) - ]) - .then(([ alice, david, log ]) => { - log.actorId.should.equal(alice.actor.id); - log.acteeId.should.equal(david.actor.acteeId); - log.details.data.actorId.should.be.a.Number(); - // eslint-disable-next-line no-param-reassign - delete log.details.data.actorId; - log.details.should.eql({ - data: { - email: 'david@getodk.org', - password: null - } - }); - }))))); + .then(() => { + const email = global.inbox.pop(); + global.inbox.length.should.equal(0); + email.to.should.eql([{ address: 'david@getodk.org', name: '' }]); + email.subject.should.equal('ODK Central account created'); + })))); + + it('should send a token which can reset the new user password', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org' }) + .expect(200) + .then(() => { + const token = /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]; + return service.post('/v1/users/reset/verify') + .send({ new: 'testresetpassword' }) + .set('Authorization', 'Bearer ' + token) + .expect(200) + .then(() => service.login({ email: 'david@getodk.org', password: 'testresetpassword' }, (asDavid) => + asDavid.get('/v1/users/current').expect(200))); + })))); + + it('should not allow a too-short password when resetting via token', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org' }) + .expect(200) + .then(() => { + const token = /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]; + return service.post('/v1/users/reset/verify') + .send({ new: 'tooshort' }) + .set('Authorization', 'Bearer ' + token) + .expect(400); + })))); + + it('should send a message explaining a pre-assigned password if given', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org', password: 'daviddavid' }) + .expect(200) + .then(() => { + /Your account was created with an assigned password\./ + .test(global.inbox.pop().html) + .should.equal(true); + })))); + + it('should duplicate the email into the display name if not given', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org' }) + .then(({ body }) => body.displayName.should.equal('david@getodk.org'))))); + + it('should log the action in the audit log', testService((service, { Audits, Users }) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users') + .send({ email: 'david@getodk.org' }) + .expect(200) + .then(() => Promise.all([ + Users.getByEmail('alice@getodk.org').then((o) => o.get()), + Users.getByEmail('david@getodk.org').then((o) => o.get()), + Audits.getLatestByAction('user.create').then((o) => o.get()) + ]) + .then(([ alice, david, log ]) => { + log.actorId.should.equal(alice.actor.id); + log.acteeId.should.equal(david.actor.acteeId); + log.details.data.actorId.should.be.a.Number(); + // eslint-disable-next-line no-param-reassign + delete log.details.data.actorId; + log.details.should.eql({ + data: { + email: 'david@getodk.org', + password: null + } + }); + }))))); + }); + } }); describe('/reset/initiate POST, /reset/verify POST', () => { - it('should not send any email if no account exists', testService((service) => - service.post('/v1/users/reset/initiate') - .send({ email: 'winnifred@getodk.org' }) - .expect(200) - .then(() => { - global.inbox.length.should.equal(0); - }))); - - it('should send a specific email if an account existed but was deleted', testService((service) => - service.login('alice', (asAlice) => - service.login('chelsea', (asChelsea) => - asChelsea.get('/v1/users/current') - .then(({ body }) => body.id) - .then((chelseaId) => asAlice.delete('/v1/users/' + chelseaId) - .expect(200) - .then(() => service.post('/v1/users/reset/initiate') - .send({ email: 'chelsea@getodk.org' }) - .expect(200) - .then(() => { - const email = global.inbox.pop(); - global.inbox.length.should.equal(0); - email.to.should.eql([{ address: 'chelsea@getodk.org', name: '' }]); - email.subject.should.equal('ODK Central account password reset'); - email.html.should.match(/account has been deleted/); - }))))))); - - it('should send an email with a token which can reset the user password', testService((service) => - service.post('/v1/users/reset/initiate') - .send({ email: 'alice@getodk.org' }) - .expect(200) - .then(() => { - const email = global.inbox.pop(); - global.inbox.length.should.equal(0); - email.to.should.eql([{ address: 'alice@getodk.org', name: '' }]); - email.subject.should.equal('ODK Central account password reset'); - const token = /token=([a-z0-9!$]+)/i.exec(email.html)[1]; - - return service.post('/v1/users/reset/verify') + if (process.env.TEST_AUTH === 'oidc') { + describe('with OIDC auth', () => { + it('should not expose /reset/initiate', testService((service) => + service.post('/v1/users/reset/initiate') + .send({ email: 'winnifred@getodk.org' }) + .expect(404))); + + it('should not expose /reset/verify', testService((service) => + service.post('/v1/users/reset/verify') .send({ new: 'resetthis!' }) - .set('Authorization', 'Bearer ' + token) + .set('Authorization', 'Bearer asdf') + .expect(404))); + }); + } else { + describe('with standard uname/password auth', () => { + it('should not send any email if no account exists', testService((service) => + service.post('/v1/users/reset/initiate') + .send({ email: 'winnifred@getodk.org' }) .expect(200) - .then(() => service.login({ email: 'alice@getodk.org', password: 'resetthis!' }, (asAlice) => - asAlice.get('/v1/users/current').expect(200))); - }))); - - it('should delete sessions after password reset', testService(async (service) => { - const asAlice = await service.login('alice'); - await service.post('/v1/users/reset/initiate') - .send({ email: 'alice@getodk.org' }) - .expect(200); - // The session has not been deleted yet. - await asAlice.get('/v1/users/current').expect(200); - - const token = /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]; - await service.post('/v1/users/reset/verify') - .send({ new: 'resetpassword' }) - .set('Authorization', `Bearer ${token}`) - .expect(200); - // The session has been deleted. - await asAlice.get('/v1/users/current').expect(401); - })); - - it('should not allow password reset token replay', testService((service) => - service.post('/v1/users/reset/initiate') - .send({ email: 'alice@getodk.org' }) - .expect(200) - .then(() => /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]) - .then((token) => service.post('/v1/users/reset/verify') - .send({ new: 'reset the first time!' }) - .set('Authorization', 'Bearer ' + token) - .expect(200) - .then(() => service.post('/v1/users/reset/verify') - .send({ new: 'reset again!' }) - .set('Authorization', 'Bearer ' + token) - .expect(401))))); - - it('should not log single use token deletion in the audit log', testService((service) => - service.post('/v1/users/reset/initiate') - .send({ email: 'alice@getodk.org' }) - .expect(200) - .then(() => /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]) - .then((token) => service.post('/v1/users/reset/verify') - .send({ new: 'resetpassword' }) - .set('Authorization', 'Bearer ' + token) - .expect(200)) - .then(() => service.get('/v1/audits') - .auth('alice@getodk.org', 'resetpassword') // cheap way to work around that we just changed the pw - .set('x-forwarded-proto', 'https') - .then(({ body }) => { - body[0].action.should.equal('user.update'); - body[0].details.data.should.eql({ password: true }); - })))); + .then(() => { + global.inbox.length.should.equal(0); + }))); + + it('should send a specific email if an account existed but was deleted', testService((service) => + service.login('alice', (asAlice) => + service.login('chelsea', (asChelsea) => + asChelsea.get('/v1/users/current') + .then(({ body }) => body.id) + .then((chelseaId) => asAlice.delete('/v1/users/' + chelseaId) + .expect(200) + .then(() => service.post('/v1/users/reset/initiate') + .send({ email: 'chelsea@getodk.org' }) + .expect(200) + .then(() => { + const email = global.inbox.pop(); + global.inbox.length.should.equal(0); + email.to.should.eql([{ address: 'chelsea@getodk.org', name: '' }]); + email.subject.should.equal('ODK Central account password reset'); + email.html.should.match(/account has been deleted/); + }))))))); + + it('should send an email with a token which can reset the user password', testService((service) => + service.post('/v1/users/reset/initiate') + .send({ email: 'alice@getodk.org' }) + .expect(200) + .then(() => { + const email = global.inbox.pop(); + global.inbox.length.should.equal(0); + email.to.should.eql([{ address: 'alice@getodk.org', name: '' }]); + email.subject.should.equal('ODK Central account password reset'); + const token = /token=([a-z0-9!$]+)/i.exec(email.html)[1]; - it('should fail the request if invalidation is requested but not allowed', testService((service) => - service.post('/v1/users/reset/initiate?invalidate=true') - .send({ email: 'alice@getodk.org' }) - .expect(403))); + return service.post('/v1/users/reset/verify') + .send({ new: 'resetthis!' }) + .set('Authorization', 'Bearer ' + token) + .expect(200) + .then(() => service.login({ email: 'alice@getodk.org', password: 'resetthis!' }, (asAlice) => + asAlice.get('/v1/users/current').expect(200))); + }))); - it('should invalidate the existing password if requested', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users/reset/initiate?invalidate=true') - .send({ email: 'bob@getodk.org' }) - .expect(200) - .then(() => { - // should still send the email. - const email = global.inbox.pop(); - global.inbox.length.should.equal(0); - email.to.should.eql([{ address: 'bob@getodk.org', name: '' }]); - email.subject.should.equal('ODK Central account password reset'); - - return service.post('/v1/sessions') - .send({ email: 'bob@getodk.org', password: 'bob' }) - .expect(401); - })))); + it('should delete sessions after password reset', testService(async (service) => { + const asAlice = await service.login('alice'); + await service.post('/v1/users/reset/initiate') + .send({ email: 'alice@getodk.org' }) + .expect(200); + // The session has not been deleted yet. + await asAlice.get('/v1/users/current').expect(200); + + const token = /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]; + await service.post('/v1/users/reset/verify') + .send({ new: 'resetpassword' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + // The session has been deleted. + await asAlice.get('/v1/users/current').expect(401); + })); + + it('should not allow password reset token replay', testService((service) => + service.post('/v1/users/reset/initiate') + .send({ email: 'alice@getodk.org' }) + .expect(200) + .then(() => /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]) + .then((token) => service.post('/v1/users/reset/verify') + .send({ new: 'reset the first time!' }) + .set('Authorization', 'Bearer ' + token) + .expect(200) + .then(() => service.post('/v1/users/reset/verify') + .send({ new: 'reset again!' }) + .set('Authorization', 'Bearer ' + token) + .expect(401))))); - it('should clear sessions if password is invalidated', testService(async (service) => { - // Log in as Bob twice. - const [asAlice, ...asBobs] = await service.login(['alice', 'bob', 'bob']); - await Promise.all(asBobs.map(asBob => asBob.get('/v1/users/current') - .expect(200))); - await asAlice.post('/v1/users/reset/initiate?invalidate=true') - .send({ email: 'bob@getodk.org' }) - .expect(200); - await Promise.all(asBobs.map(asBob => asBob.get('/v1/users/current') - .expect(401))); - })); - - it('should log action in audit log if password is invalidated', testService(async (service) => { - const asAlice = await service.login('alice'); - await asAlice.post('/v1/users/reset/initiate?invalidate=true') - .send({ email: 'bob@getodk.org' }) - .expect(200); - const { body: audits } = await asAlice.get('/v1/audits?action=user.update') - .set('X-Extended-Metadata', 'true') - .expect(200); - audits.length.should.equal(1); - const audit = audits[0]; - audit.actor.displayName.should.equal('Alice'); - audit.actee.displayName.should.equal('Bob'); - audit.details.should.eql({ data: { password: null } }); - })); - - it('should fail the request if invalidation is not allowed and email doesn\'t exist', testService((service) => - service.login('chelsea', (asChelsea) => - asChelsea.post('/v1/users/reset/initiate?invalidate=true') - .send({ email: 'winnifred@getodk.org' }) - .expect(403)))); + it('should not log single use token deletion in the audit log', testService((service) => + service.post('/v1/users/reset/initiate') + .send({ email: 'alice@getodk.org' }) + .expect(200) + .then(() => /token=([a-z0-9!$]+)/i.exec(global.inbox.pop().html)[1]) + .then((token) => service.post('/v1/users/reset/verify') + .send({ new: 'resetpassword' }) + .set('Authorization', 'Bearer ' + token) + .expect(200)) + .then(() => service.get('/v1/audits') + .auth('alice@getodk.org', 'resetpassword') // cheap way to work around that we just changed the pw + .set('x-forwarded-proto', 'https') + .then(({ body }) => { + body[0].action.should.equal('user.update'); + body[0].details.data.should.eql({ password: true }); + })))); - it('should return 200 if user has rights to invalidate but account doesn\'nt exist', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users/reset/initiate?invalidate=true') - .send({ email: 'winnifred@getodk.org' }) - .expect(200) - .then(() => { - global.inbox.length.should.equal(0); - })))); + it('should fail the request if invalidation is requested but not allowed', testService((service) => + service.post('/v1/users/reset/initiate?invalidate=true') + .send({ email: 'alice@getodk.org' }) + .expect(403))); - it('should not allow a user to reset their own password directly', testService((service) => - service.login('alice', (asAlice) => - asAlice.post('/v1/users/reset/verify') - .send({ new: 'coolpassword' }) - .expect(403)))); + it('should invalidate the existing password if requested', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users/reset/initiate?invalidate=true') + .send({ email: 'bob@getodk.org' }) + .expect(200) + .then(() => { + // should still send the email. + const email = global.inbox.pop(); + global.inbox.length.should.equal(0); + email.to.should.eql([{ address: 'bob@getodk.org', name: '' }]); + email.subject.should.equal('ODK Central account password reset'); + + return service.post('/v1/sessions') + .send({ email: 'bob@getodk.org', password: 'bob' }) + .expect(401); + })))); + + it('should clear sessions if password is invalidated', testService(async (service) => { + // Log in as Bob twice. + const [asAlice, ...asBobs] = await service.login(['alice', 'bob', 'bob']); + await Promise.all(asBobs.map(asBob => asBob.get('/v1/users/current') + .expect(200))); + await asAlice.post('/v1/users/reset/initiate?invalidate=true') + .send({ email: 'bob@getodk.org' }) + .expect(200); + await Promise.all(asBobs.map(asBob => asBob.get('/v1/users/current') + .expect(401))); + })); + + it('should log action in audit log if password is invalidated', testService(async (service) => { + const asAlice = await service.login('alice'); + await asAlice.post('/v1/users/reset/initiate?invalidate=true') + .send({ email: 'bob@getodk.org' }) + .expect(200); + const { body: audits } = await asAlice.get('/v1/audits?action=user.update') + .set('X-Extended-Metadata', 'true') + .expect(200); + audits.length.should.equal(1); + const audit = audits[0]; + audit.actor.displayName.should.equal('Alice'); + audit.actee.displayName.should.equal('Bob'); + audit.details.should.eql({ data: { password: null } }); + })); + + it('should fail the request if invalidation is not allowed and email doesn\'t exist', testService((service) => + service.login('chelsea', (asChelsea) => + asChelsea.post('/v1/users/reset/initiate?invalidate=true') + .send({ email: 'winnifred@getodk.org' }) + .expect(403)))); + + it('should return 200 if user has rights to invalidate but account doesn\'nt exist', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users/reset/initiate?invalidate=true') + .send({ email: 'winnifred@getodk.org' }) + .expect(200) + .then(() => { + global.inbox.length.should.equal(0); + })))); + + it('should not allow a user to reset their own password directly', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/users/reset/verify') + .send({ new: 'coolpassword' }) + .expect(403)))); + }); + } }); describe('/users/current GET', () => { @@ -420,7 +466,7 @@ describe('api: /users', () => { body.email.should.equal('alice@getodk.org'); }))))); - it('should allow nonadministrator users to get themselves', testService((service) => + it('should allow non-admins to get themselves', testService((service) => service.login('chelsea', (asChelsea) => asChelsea.get('/v1/users/current').expect(200).then(({ body }) => body.id) .then((chelseaId) => asChelsea.get('/v1/users/' + chelseaId) @@ -436,7 +482,7 @@ describe('api: /users', () => { }); describe('/users/:id PATCH', () => { - it('should reject if the authed user cannot update', testService((service) => + it('should reject non-admins from updating a different user', testService((service) => service.login('alice', (asAlice) => asAlice.get('/v1/users/current') .expect(200) @@ -452,16 +498,16 @@ describe('api: /users', () => { .expect(404)))); it('should update only the allowed fields', testService((service) => - service.login('alice', (asAlice) => - asAlice.get('/v1/users/current') + service.login('bob', (asBob) => + asBob.get('/v1/users/current') .expect(200) - .then((before) => asAlice.patch(`/v1/users/${before.body.id}`) + .then((before) => asBob.patch(`/v1/users/${before.body.id}`) .send({ id: 9999, type: 'exahacker', password: 'password', - email: 'newalice@odk.org', - displayName: 'new alice', + email: 'newbob@odk.org', + displayName: 'new bob', meta: { test: 'new meta' }, createdAt: '2006-01-01T00:00:00', updatedAt: '2006-01-01T00:00:00', @@ -470,17 +516,23 @@ describe('api: /users', () => { .expect(200) .then((after) => { before.body.id.should.equal(after.body.id); - after.body.displayName.should.equal('new alice'); - after.body.email.should.equal('newalice@odk.org'); + after.body.displayName.should.equal('new bob'); should.not.exist(after.body.meta); before.body.createdAt.should.equal(after.body.createdAt); after.body.updatedAt.should.be.a.recentIsoDate(); - return service.post('/v1/sessions') - .send({ email: 'newalice@odk.org', password: 'alice' }) - .expect(200); + + if (process.env.TEST_AUTH === 'oidc') { + after.body.email.should.equal('bob@getodk.org'); + return authenticateUser(service, 'bob'); + } else { + after.body.email.should.equal('newbob@odk.org'); + return service.post('/v1/sessions') + .send({ email: 'newbob@odk.org', password: 'bob' }) + .expect(200); + } }))))); - it('should allow nonadministrator users to update themselves', testService((service) => + it('should allow non-admins to update themselves', testService((service) => service.login('chelsea', (asChelsea) => asChelsea.get('/v1/users/current').expect(200).then(({ body }) => body.id) .then((chelseaId) => asChelsea.patch('/v1/users/' + chelseaId) @@ -493,20 +545,38 @@ describe('api: /users', () => { body.displayName.should.equal('a new display name'); })))))); - it('should send an email to the user\'s previous email when their email changes', testService((service) => + it('should allow admins to update another user', testService((service) => service.login('alice', (asAlice) => - asAlice.get('/v1/users/current') - .expect(200) - .then((before) => asAlice.patch(`/v1/users/${before.body.id}`) - .send({ email: 'david123@getodk.org' }) + service.login('chelsea', (asChelsea) => + asChelsea.get('/v1/users/current') .expect(200) - .then(() => { - const email = global.inbox.pop(); - global.inbox.length.should.equal(0); - email.to.should.eql([{ address: 'alice@getodk.org', name: '' }]); - email.subject.should.equal('ODK Central account email changed'); - email.html.should.equal('Hello!

We are emailing because you have an ODK Central account, and somebody has just changed the email address associated with the account from this one you are reading right now (alice@getodk.org) to a new address (david123@getodk.org).

If this was you, please feel free to ignore this email. Otherwise, please contact your local ODK system administrator immediately.

'); - }))))); + .then(({ body }) => body.id) + .then((chelseaId) => asAlice.patch('/v1/users/' + chelseaId) + .send({ displayName: 'a new display name', email: 'millwall@getodk.org' }) + .expect(200) + .then(() => asChelsea.get('/v1/users/' + chelseaId) + .then(({ body }) => { + body.should.be.a.User(); + body.email.should.equal('millwall@getodk.org'); + body.displayName.should.equal('a new display name'); + }))))))); + + if (process.env.TEST_AUTH !== 'oidc') { + it('should send an email to the user\'s previous email when their email changes', testService((service) => + service.login('alice', (asAlice) => + asAlice.get('/v1/users/current') + .expect(200) + .then((before) => asAlice.patch(`/v1/users/${before.body.id}`) + .send({ email: 'david123@getodk.org' }) + .expect(200) + .then(() => { + const email = global.inbox.pop(); + global.inbox.length.should.equal(0); + email.to.should.eql([{ address: 'alice@getodk.org', name: '' }]); + email.subject.should.equal('ODK Central account email changed'); + email.html.should.equal('Hello!

We are emailing because you have an ODK Central account, and somebody has just changed the email address associated with the account from this one you are reading right now (alice@getodk.org) to a new address (david123@getodk.org).

If this was you, please feel free to ignore this email. Otherwise, please contact your local ODK system administrator immediately.

'); + }))))); + } it('should not send an email to a user when their email does not change', testService((service) => service.login('alice', (asAlice) => @@ -537,119 +607,133 @@ describe('api: /users', () => { }); describe('/users/:id/password PUT', () => { - it('should reject if the authed user cannot update', testService((service) => - service.login('alice', (asAlice) => - asAlice.get('/v1/users/current') - .expect(200) - .then(({ body }) => service.login('chelsea', (asChelsea) => - asChelsea.put(`/v1/users/${body.id}/password`) + if (process.env.TEST_AUTH === 'oidc') { + describe('with OIDC auth', () => { + it('should not expose this endpoint', testService((service) => + service.login('alice', (asAlice) => + asAlice.get('/v1/users/current') + .expect(200) + .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) + .send({ old: 'alice', new: 'newpassword' }) + .expect(404))))); + }); + } else { + describe('with standard uname/password auth', () => { + it('should reject if the authed user cannot update', testService((service) => + service.login('alice', (asAlice) => + asAlice.get('/v1/users/current') + .expect(200) + .then(({ body }) => service.login('chelsea', (asChelsea) => + asChelsea.put(`/v1/users/${body.id}/password`) + .send({ old: 'alice', new: 'chelsea' }) + .expect(403)))))); + + it('should reject if the user does not exist', testService((service) => + service.login('alice', (asAlice) => + asAlice.put('/v1/users/9999/password') .send({ old: 'alice', new: 'chelsea' }) - .expect(403)))))); - - it('should reject if the user does not exist', testService((service) => - service.login('alice', (asAlice) => - asAlice.put('/v1/users/9999/password') - .send({ old: 'alice', new: 'chelsea' }) - .expect(404)))); + .expect(404)))); - it('should reject if the old password is not correct', testService((service) => - service.login('alice', (asAlice) => - asAlice.get('/v1/users/current') - .expect(200) - .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) - .send({ old: 'notalice', new: 'newpassword' }) - .expect(401))))); + it('should reject if the old password is not correct', testService((service) => + service.login('alice', (asAlice) => + asAlice.get('/v1/users/current') + .expect(200) + .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) + .send({ old: 'notalice', new: 'newpassword' }) + .expect(401))))); - it('should change the password', testService((service) => - service.login('alice', (asAlice) => - asAlice.get('/v1/users/current') - .expect(200) - .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) + it('should change the password', testService((service) => + service.login('alice', (asAlice) => + asAlice.get('/v1/users/current') + .expect(200) + .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) + .send({ old: 'alice', new: 'newpassword' }) + .expect(200)) + .then(({ body }) => { + body.success.should.equal(true); + return service.post('/v1/sessions') + .send({ email: 'alice@getodk.org', password: 'newpassword' }) + .expect(200); + })))); + + it('should disallow a password that is too short (<10 chars)', testService((service) => + service.login('alice', (asAlice) => + asAlice.get('/v1/users/current') + .expect(200) + .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) + .send({ old: 'alice', new: '123456789' }) + .expect(400))))); // 400.21 + + it('should allow nonadministrator users to set their own password', testService((service) => + service.login('chelsea', (asChelsea) => + asChelsea.get('/v1/users/current').expect(200).then(({ body }) => body.id) + .then((chelseaId) => asChelsea.put(`/v1/users/${chelseaId}/password`) + .send({ old: 'chelsea', new: 'newchelsea' }) + .expect(200) + .then(() => service.post('/v1/sessions') + .send({ email: 'chelsea@getodk.org', password: 'newchelsea' }) + .expect(200)))))); + + it('should delete other sessions', testService(async (service) => { + const asAlice = await service.login('alice'); + const anotherAlice = await service.login('alice'); + const { body: { id } } = await asAlice.get('/v1/users/current') + .expect(200); + await anotherAlice.get('/v1/users/current').expect(200); + await asAlice.put(`/v1/users/${id}/password`) .send({ old: 'alice', new: 'newpassword' }) - .expect(200)) - .then(({ body }) => { - body.success.should.equal(true); - return service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'newpassword' }) - .expect(200); - })))); - - it('should disallow a password that is too short (<10 chars)', testService((service) => - service.login('alice', (asAlice) => - asAlice.get('/v1/users/current') - .expect(200) - .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) - .send({ old: 'alice', new: '123456789' }) - .expect(400))))); // 400.21 - - it('should allow nonadministrator users to set their own password', testService((service) => - service.login('chelsea', (asChelsea) => - asChelsea.get('/v1/users/current').expect(200).then(({ body }) => body.id) - .then((chelseaId) => asChelsea.put(`/v1/users/${chelseaId}/password`) - .send({ old: 'chelsea', new: 'newchelsea' }) - .expect(200) - .then(() => service.post('/v1/sessions') - .send({ email: 'chelsea@getodk.org', password: 'newchelsea' }) - .expect(200)))))); - - it('should delete other sessions', testService(async (service) => { - const asAlice = await service.login('alice'); - const anotherAlice = await service.login('alice'); - const { body: { id } } = await asAlice.get('/v1/users/current') - .expect(200); - await anotherAlice.get('/v1/users/current').expect(200); - await asAlice.put(`/v1/users/${id}/password`) - .send({ old: 'alice', new: 'newpassword' }) - .expect(200); - // The other session has been deleted. - await anotherAlice.get('/v1/users/current').expect(401); - // The current session has not. - await asAlice.get('/v1/users/current').expect(200); - })); - - it('should delete sessions if Basic auth is used', testService(async (service) => { - const asAlice = await service.login('alice'); - const { body: { id } } = await asAlice.get('/v1/users/current') - .expect(200); - const basic = Buffer.from('alice@getodk.org:alice').toString('base64'); - await service.put(`/v1/users/${id}/password`) - .set('Authorization', `Basic ${basic}`) - .set('X-Forwarded-Proto', 'https') - .send({ old: 'alice', new: 'newpassword' }) - .expect(200); - await asAlice.get('/v1/users/current').expect(401); - })); - - it('should send an email to a user when their password changes', testService((service) => - service.login('alice', (asAlice) => - asAlice.get('/v1/users/current') - .expect(200) - .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) + .expect(200); + // The other session has been deleted. + await anotherAlice.get('/v1/users/current').expect(401); + // The current session has not. + await asAlice.get('/v1/users/current').expect(200); + })); + + it('should delete sessions if Basic auth is used', testService(async (service) => { + const asAlice = await service.login('alice'); + const { body: { id } } = await asAlice.get('/v1/users/current') + .expect(200); + const basic = Buffer.from('alice@getodk.org:alice').toString('base64'); + await service.put(`/v1/users/${id}/password`) + .set('Authorization', `Basic ${basic}`) + .set('X-Forwarded-Proto', 'https') .send({ old: 'alice', new: 'newpassword' }) - .expect(200) - .then(() => { - const email = global.inbox.pop(); - global.inbox.length.should.equal(0); - email.to.should.eql([{ address: 'alice@getodk.org', name: '' }]); - email.subject.should.equal('ODK Central account password change'); - }))))); + .expect(200); + await asAlice.get('/v1/users/current').expect(401); + })); - it('should log an audit on password change', testService((service, { Audits, Users }) => - service.login('alice', (asAlice) => - asAlice.get('/v1/users/current') - .expect(200) - .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) - .send({ old: 'alice', new: 'newpassword' }) - .expect(200) - .then(() => Promise.all([ - Users.getByEmail('alice@getodk.org').then((o) => o.get()), - Audits.getLatestByAction('user.update').then((o) => o.get()) - ])) - .then(([ alice, log ]) => { - log.actorId.should.equal(alice.actor.id); - log.details.should.eql({ data: { password: true } }); - log.acteeId.should.equal(alice.actor.acteeId); - }))))); + it('should send an email to a user when their password changes', testService((service) => + service.login('alice', (asAlice) => + asAlice.get('/v1/users/current') + .expect(200) + .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) + .send({ old: 'alice', new: 'newpassword' }) + .expect(200) + .then(() => { + const email = global.inbox.pop(); + global.inbox.length.should.equal(0); + email.to.should.eql([{ address: 'alice@getodk.org', name: '' }]); + email.subject.should.equal('ODK Central account password change'); + }))))); + + it('should log an audit on password change', testService((service, { Audits, Users }) => + service.login('alice', (asAlice) => + asAlice.get('/v1/users/current') + .expect(200) + .then(({ body }) => asAlice.put(`/v1/users/${body.id}/password`) + .send({ old: 'alice', new: 'newpassword' }) + .expect(200) + .then(() => Promise.all([ + Users.getByEmail('alice@getodk.org').then((o) => o.get()), + Audits.getLatestByAction('user.update').then((o) => o.get()) + ])) + .then(([ alice, log ]) => { + log.actorId.should.equal(alice.actor.id); + log.details.should.eql({ data: { password: true } }); + log.acteeId.should.equal(alice.actor.acteeId); + }))))); + }); + } }); describe('/users/:id DELETE', () => { @@ -717,9 +801,20 @@ describe('api: /users', () => { .then(({ body }) => body.id) .then((chelseaId) => asAlice.delete('/v1/users/' + chelseaId) .expect(200) - .then(() => service.post('/v1/sessions') - .send({ email: 'chelsea@getodk.org', password: 'chelsea' }) - .expect(401))))))); + .then(async () => { + if (process.env.TEST_AUTH === 'oidc') { + try { + await authenticateUser(service, 'chelsea'); + should.fail(); + } catch (err) { + err.message.should.equal('expected 200 "OK", got 307 "Temporary Redirect"'); + } + } else { + return service.post('/v1/sessions') + .send({ email: 'chelsea@getodk.org', password: 'chelsea' }) + .expect(401); + } + })))))); it('should disable active sessions', testService((service) => service.login('alice', (asAlice) => diff --git a/test/integration/other/basic-auth.js b/test/integration/other/basic-auth.js index 11406cd6f..62c0714ea 100644 --- a/test/integration/other/basic-auth.js +++ b/test/integration/other/basic-auth.js @@ -1,10 +1,18 @@ const { testService } = require('../setup'); describe('basic authentication', () => { - it('should accept email and password', testService((service) => - service.get('/v1/users/current') - .set('x-forwarded-proto', 'https') - .auth('alice@getodk.org', 'alice') - .expect(200))); + if (process.env.TEST_AUTH === 'oidc') { + it('should not accept email and password', testService((service) => + service.get('/v1/users/current') + .set('x-forwarded-proto', 'https') + .auth('alice@getodk.org', 'alice') + .expect(401))); + } else { + it('should accept email and password', testService((service) => + service.get('/v1/users/current') + .set('x-forwarded-proto', 'https') + .auth('alice@getodk.org', 'alice') + .expect(200))); + } }); diff --git a/test/integration/other/encryption.js b/test/integration/other/encryption.js index fcaacb60b..542df9ddd 100644 --- a/test/integration/other/encryption.js +++ b/test/integration/other/encryption.js @@ -8,6 +8,7 @@ const { pZipStreamToFiles } = require(appRoot + '/test/util/zip'); const { Form, Key, Submission } = require(appRoot + '/lib/model/frames'); const { mapSequential } = require(appRoot + '/test/util/util'); const { exhaust } = require(appRoot + '/lib/worker/worker'); +const authenticateUser = require('../../util/authenticate-user'); describe('managed encryption', () => { describe('lock management', () => { @@ -276,14 +277,11 @@ describe('managed encryption', () => { asAlice.get('/v1/projects/1/forms/simple/submissions/keys') .expect(200) .then(({ body }) => body[0].id), - service.post('/v1/sessions') - .send({ email: 'alice@getodk.org', password: 'alice' }) - .expect(200) - .then(({ body }) => body) + authenticateUser(service, 'alice', 'include-csrf'), ])) .then(([ keyId, session ]) => pZipStreamToFiles(service.post('/v1/projects/1/forms/simple/submissions.csv.zip') .send(`${keyId}=supersecret&__csrf=${session.csrf}`) - .set('Cookie', `__Host-session=${session.token}`) + .set('Cookie', `session=${session.token}`) .set('X-Forwarded-Proto', 'https') .set('Content-Type', 'application/x-www-form-urlencoded')) .then((result) => { diff --git a/test/integration/setup.js b/test/integration/setup.js index 6315f7fb8..f0fcd6b00 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -6,6 +6,7 @@ const { join } = require('path'); const request = require('supertest'); const { noop } = require(appRoot + '/lib/util/util'); const { task } = require(appRoot + '/lib/task/task'); +const authenticateUser = require('../util/authenticate-user'); // knex things. const config = require('config'); @@ -122,15 +123,7 @@ const augment = (service) => { // eslint-disable-next-line no-param-reassign service.login = async (userOrUsers, test = undefined) => { const users = Array.isArray(userOrUsers) ? userOrUsers : [userOrUsers]; - const tokens = await Promise.all(users.map(async (user) => { - const credentials = (typeof user === 'string') - ? { email: `${user}@getodk.org`, password: user } - : user; - const { body } = await service.post('/v1/sessions') - .send(credentials) - .expect(200); - return body.token; - })); + const tokens = await Promise.all(users.map(user => authenticateUser(service, user))); const proxies = tokens.map((token) => new Proxy(service, authProxy(token))); return test != null ? test(...proxies) diff --git a/test/integration/task/account.js b/test/integration/task/account.js index f4664e2aa..3ab383173 100644 --- a/test/integration/task/account.js +++ b/test/integration/task/account.js @@ -7,7 +7,7 @@ const { User } = require(appRoot + '/lib/model/frames'); describe('task: accounts', () => { describe('createUser', () => { - it('should create a user account', testTask(({ Users }) => + it('should create a user account with a password', testTask(({ Users }) => createUser('testuser@getodk.org', 'aoeuidhtns') .then((result) => { result.email.should.equal('testuser@getodk.org'); @@ -15,6 +15,14 @@ describe('task: accounts', () => { .then((user) => user.isDefined().should.equal(true)); }))); + it('should create a user account with a null password', testTask(({ Users }) => + createUser('testuser@getodk.org', null) + .then((result) => { + result.email.should.equal('testuser@getodk.org'); + return Users.getByEmail('testuser@getodk.org') + .then((user) => user.isDefined().should.equal(true)); + }))); + it('should log an audit entry', testTask(({ Audits, Users }) => createUser('testuser@getodk.org', 'aoeuidhtns') .then(() => Promise.all([ @@ -34,6 +42,12 @@ describe('task: accounts', () => { .then((user) => bcrypt.verify('aoeuidhtns', user.password)) .then((verified) => verified.should.equal(true)))); + it('should not verify a null password', testTask(({ Users, bcrypt }) => + createUser('testuser@getodk.org', null) + .then(() => Users.getByEmail('testuser@getodk.org')) + .then(getOrNotFound) + .then((user) => bcrypt.verify(null, user.password)) + .then((verified) => verified.should.equal(false)))); it('should complain if the password is too short', testTask(() => createUser('testuser@getodk.org', 'short') diff --git a/test/unit/http/preprocessors.js b/test/unit/http/preprocessors.js index 289e5ca60..6aabdff34 100644 --- a/test/unit/http/preprocessors.js +++ b/test/unit/http/preprocessors.js @@ -146,7 +146,7 @@ describe('preprocessors', () => { Promise.resolve(authHandler( { Auth, Sessions: mockSessions('alohomora') }, new Context( - createRequest({ method: 'GET', headers: { Cookie: '__Host-session=alohomora' } }), + createRequest({ method: 'GET', headers: { Cookie: 'session=alohomora' }, cookies: { session: 'alohomora' } }), { fieldKey: Option.none() } ) )).then((context) => { @@ -161,7 +161,7 @@ describe('preprocessors', () => { createRequest({ method: 'GET', headers: { 'X-Forwarded-Proto': 'https', Cookie: 'please just let me in' - } }), + }, cookies: {} }), { fieldKey: Option.none() } ) )).then((context) => { @@ -175,8 +175,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'GET', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=letmein' - } }), + Cookie: 'session=letmein' + }, cookies: { session: 'letmein' } }), { fieldKey: Option.none() } ) )).then((context) => { @@ -193,8 +193,8 @@ describe('preprocessors', () => { // eslint-disable-next-line quote-props 'Authorization': 'Bearer abc', 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=alohomora' - } }), + Cookie: 'session=alohomora' + }, cookies: { session: 'alohomora' } }), { auth: { isAuthenticated() { return false; } }, fieldKey: Option.none() } ) )).catch((err) => { @@ -216,8 +216,9 @@ describe('preprocessors', () => { // eslint-disable-next-line quote-props 'Authorization': 'Bearer abc', 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=alohomora' + Cookie: 'session=alohomora' }, + cookies: { session: 'alohomora' }, url: '/key/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }), { auth: { isAuthenticated() { return false; } }, fieldKey: Option.none() } @@ -236,8 +237,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'GET', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=alohomora' - } }), + Cookie: 'session=alohomora' + }, cookies: { session: 'alohomora' } }), { fieldKey: Option.none() } ) )).then((context) => { @@ -250,8 +251,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'HEAD', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=alohomora' - } }), + Cookie: 'session=alohomora' + }, cookies: { session: 'alohomora' } }), { fieldKey: Option.none() } ) )).then((context) => { @@ -264,8 +265,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'GET', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=aloho%24mora' - } }), + Cookie: 'session=aloho%24mora' + }, cookies: { session: 'aloho$mora' } }), { fieldKey: Option.none() } ) )).then((context) => { @@ -285,8 +286,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'POST', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=alohomora' - } }), + Cookie: 'session=alohomora' + }, cookies: { session: 'alohomora' } }), { fieldKey: Option.none() } ) )).should.be.rejectedWith(Problem, { problemCode: 401.2 })); @@ -297,8 +298,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'POST', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=alohomora' - }, body: { __csrf: 'notsecretcsrf' } }), + Cookie: 'session=alohomora' + }, body: { __csrf: 'notsecretcsrf' }, cookies: { session: 'alohomora' } }), { fieldKey: Option.none() } ) )).should.be.rejectedWith(Problem, { problemCode: 401.2 })); @@ -309,8 +310,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'POST', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=notalohomora' - }, body: { __csrf: 'secretcsrf' } }), + Cookie: 'session=notalohomora' + }, body: { __csrf: 'secretcsrf' }, cookies: { session: 'notalohomora' } }), { fieldKey: Option.none() } ) )).then((context) => { @@ -324,8 +325,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'POST', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=alohomora' - }, body: { __csrf: 'secretcsrf' } }), + Cookie: 'session=alohomora' + }, body: { __csrf: 'secretcsrf' }, cookies: { session: 'alohomora' } }), { fieldKey: Option.none() } ) )).should.be.fulfilled()); @@ -336,8 +337,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'POST', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=alohomora' - }, body: { __csrf: 'secret%24csrf' } }), + Cookie: 'session=alohomora' + }, body: { __csrf: 'secret%24csrf' }, cookies: { session: 'alohomora' } }), { fieldKey: Option.none() } ) )).should.be.fulfilled()); @@ -348,8 +349,8 @@ describe('preprocessors', () => { new Context( createRequest({ method: 'POST', headers: { 'X-Forwarded-Proto': 'https', - Cookie: '__Host-session=alohomora' - }, body: { __csrf: 'secretcsrf', other: 'data' } }), + Cookie: 'session=alohomora' + }, body: { __csrf: 'secretcsrf', other: 'data' }, cookies: { session: 'alohomora' } }), { fieldKey: Option.none() } ) )).then((context) => { diff --git a/test/unit/util/html.js b/test/unit/util/html.js new file mode 100644 index 000000000..31b946abc --- /dev/null +++ b/test/unit/util/html.js @@ -0,0 +1,39 @@ +const appRoot = require('app-root-path'); +// eslint-disable-next-line import/no-dynamic-require +const { html, safeNextPathFrom } = require(appRoot + '/lib/util/html'); + +describe('util/html', () => { + describe('html()', () => { + it('should return a simple string unmodified', () => { + // when + const output = html`a string without references to vars`; + + // then + output.should.equal('a string without references to vars'); + }); + + it('should return input with vars substituted', () => { + // given + const x = 1; + const y = 'helo'; + + // when + const output = html`
${x}
${y}${x}`; + + // then + output.should.equal('
1
helo1'); + }); + }); + + describe('safeNextPathFrom()', () => { + [ + [ '/-/xyz', 'http://localhost:8989/-/xyz' ], // eslint-disable-line no-multi-spaces + [ '/account/edit', '/#/account/edit' ], // eslint-disable-line no-multi-spaces + [ '/users', '/#/users' ], // eslint-disable-line no-multi-spaces + ].forEach(([next, expected]) => { + it(`should convert next=${next} to ${expected}`, () => { + safeNextPathFrom(next).should.equal(expected); + }); + }); + }); +}); diff --git a/test/util/authenticate-user.js b/test/util/authenticate-user.js new file mode 100644 index 000000000..80a78328a --- /dev/null +++ b/test/util/authenticate-user.js @@ -0,0 +1,97 @@ +// Allow main functionality to stay at top of file: +/* eslint-disable no-use-before-define */ + +const makeFetchCookie = require('fetch-cookie'); + +module.exports = async (service, user, includeCsrf) => { + if (!user) throw new Error('Did you forget the **service** arg?'); + if (process.env.TEST_AUTH === 'oidc') { + if (user.password) throw new Error('Password supplied but OIDC is enabled.'); + + const username = typeof user === 'string' ? user : user.email.split('@')[0]; + const body = await oidcAuthFor(service, username); + + if (includeCsrf) return body; + return body.token; + } else { + const credentials = (typeof user === 'string') + ? { email: `${user}@getodk.org`, password: user } + : user; + const { body } = await service.post('/v1/sessions') + .send(credentials) + .expect(200); + + if (includeCsrf) return body; + return body.token; + } +}; + +async function oidcAuthFor(service, user) { + const res1 = await service.get('/v1/oidc/login'); + + // custom cookie jar probably not important, but we will need these cookies + // for the final redirect + const cookieJar = new makeFetchCookie.toughCookie.CookieJar(); + res1.headers['set-cookie'].forEach(cookieString => { + cookieJar.setCookie(cookieString, 'http://localhost:8383/v1/oidc/login'); + }); + + const location1 = res1.headers.location; + + const fetchC = makeFetchCookie(fetch, cookieJar); + const res2 = await fetchC(location1); + if (res2.status !== 200) throw new Error('Non-200 response'); + + const location2 = await formActionFrom(res2); + + // TODO try replacing with FormData + const body = require('querystring').encode({ + prompt: 'login', + login: user, + password: 'topSecret123', + }); + const res3 = await fetchC(location2, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + const location3 = await formActionFrom(res3); + const body2 = require('querystring').encode({ prompt: 'consent' }); + const res4 = await fetchC(location3, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body2, + redirect: 'manual', + }); + if (res4.status !== 303) throw new Error('Expected 303!'); + + const location4 = res4.headers.get('location'); + const res5 = await fetchC(location4, { redirect: 'manual' }); + const location5 = res5.headers.get('location'); + + const u5 = new URL(location5); + const servicePath = u5.pathname + u5.search; + //const res6 = await service.get(servicePath, { headers:{ cookie:cookieJar.getCookieStringSync(location5) } }); + const res6 = await service.get(servicePath) + .set('Cookie', cookieJar.getCookieStringSync(location5)) + .expect(200); + + const sessionId = getSetCookie(res6, 'session'); + const csrfToken = getSetCookie(res6, '__csrf'); + + return { token: sessionId, csrf: csrfToken }; +} + +function getSetCookie(res, cookieName) { + const setCookieHeader = res.headers['set-cookie']; + if (!setCookieHeader) throw new Error(`Requested cookie '${cookieName}' was not found in Set-Cookie header!`); + + const prefix = `${cookieName}=`; + return decodeURIComponent(setCookieHeader.find(h => h.startsWith(prefix)).substring(prefix.length).split(';')[0]); +} + +async function formActionFrom(res) { + const text = await res.text(); + return text.match(/ Date: Fri, 8 Sep 2023 15:04:26 +0000 Subject: [PATCH 2/4] rebuild --- rebuild | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 rebuild diff --git a/rebuild b/rebuild new file mode 100644 index 000000000..e69de29bb From 48e587fb10d80a74ef866daa64052cd98b4b7c53 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Fri, 8 Sep 2023 15:04:33 +0000 Subject: [PATCH 3/4] Revert "rebuild" This reverts commit 6ae7a3f957fed386d47e96fc54f2a50202469bff. --- rebuild | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 rebuild diff --git a/rebuild b/rebuild deleted file mode 100644 index e69de29bb..000000000 From 26f50a72a2b55b7236a56e6cb745fd05b1193975 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Fri, 8 Sep 2023 15:37:53 +0000 Subject: [PATCH 4/4] Fix failing test --- test/integration/api/users.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/api/users.js b/test/integration/api/users.js index dd36a5c26..33d2217a2 100644 --- a/test/integration/api/users.js +++ b/test/integration/api/users.js @@ -807,7 +807,7 @@ describe('api: /users', () => { await authenticateUser(service, 'chelsea'); should.fail(); } catch (err) { - err.message.should.equal('expected 200 "OK", got 307 "Temporary Redirect"'); + err.message.should.equal('expected 200 "OK", got 303 "See Other"'); } } else { return service.post('/v1/sessions')