diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..b499b00a1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Ignore everything +* + +# Explicitly whitelist _necessary_ **source files** +!/package.json +!/package-lock.json +!/Makefile +!/lib/ +!/config/ +!/test/ + +!/oidc-tester/odk-central-backend-config.json +!/oidc-tester/certs/*.pem +!/oidc-tester/fake-oidc-server/accounts.json +!/oidc-tester/fake-oidc-server/index.js +!/oidc-tester/fake-oidc-server/package.json +!/oidc-tester/fake-oidc-server/package-lock.json +!/oidc-tester/playwright-tests/package.json +!/oidc-tester/playwright-tests/package-lock.json +!/oidc-tester/playwright-tests/playwright.config.js +!/oidc-tester/playwright-tests/src/**/*.js +!/oidc-tester/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..6f9192b4a --- /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-tester/playwright-results/**/*.png diff --git a/.github/workflows/oidc-integration.yml b/.github/workflows/oidc-integration.yml new file mode 100644 index 000000000..b56e27658 --- /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: TEST_AUTH=oidc NODE_CONFIG_ENV=oidc-integration-test make test-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 5ce36dcd9..955d9577e 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..c3bf68860 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,32 @@ default: base node_modules: package.json - npm install --legacy-peer-deps + npm clean-install --legacy-peer-deps touch node_modules +.PHONY: test-oidc-e2e +test-oidc-e2e: node_modules + cd oidc-tester && \ + 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-tester/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-tester/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..d5eafc317 --- /dev/null +++ b/config/oidc-development.json @@ -0,0 +1,14 @@ +{ + "default": { + "env": { + "oidcProviderName": "OpenID Connect" + }, + "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..819aa2258 --- /dev/null +++ b/config/oidc-example-auth0.json @@ -0,0 +1,14 @@ +{ + "default": { + "env": { + "oidcProviderName": "Auth0" + }, + "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..488ae7044 --- /dev/null +++ b/config/oidc-example-broken.json @@ -0,0 +1,14 @@ +{ + "default": { + "env": { + "oidcProviderName": "Broken provider" + }, + "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..0805e9806 --- /dev/null +++ b/config/oidc-example-google.json @@ -0,0 +1,14 @@ +{ + "default": { + "env": { + "oidcProviderName": "Google" + }, + "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..37a98c25b --- /dev/null +++ b/config/oidc-integration-test.json @@ -0,0 +1,14 @@ +{ + "default": { + "env": { + "oidcProviderName": "OpenID Connect" + }, + "oidc": { + "enabled": true, + "issuerUrl": "http://localhost:9898", + "clientId": "odk-central-backend-dev", + "clientSecret": "super-top-secret" + } + } +} + diff --git a/lib/bin/cli.js b/lib/bin/cli.js index 58fb57273..6a23814b5 100644 --- a/lib/bin/cli.js +++ b/lib/bin/cli.js @@ -31,7 +31,10 @@ const email = () => program.opts().email; program.requiredOption('-u, --email '); program.command('user-create') - .action(() => withPassword((password) => run(createUser(email(), password)))); + .option('--null-password', 'Create a user without a password.') + .action(({ nullPassword }) => (nullPassword ? + run(createUser(email(), null)) : + withPassword((password) => run(createUser(email(), password))))); program.command('user-promote') .action(() => run(promoteUser(email()))); diff --git a/lib/formats/mail.js b/lib/formats/mail.js index c07ed21c4..b087c8d3f 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. Your account was created for login with {{{oidcProviderName}}}. 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..17d9d694b 100644 --- a/lib/http/preprocessors.js +++ b/lib/http/preprocessors.js @@ -12,7 +12,7 @@ const { isTrue } = require('../util/http'); 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 +68,8 @@ const authHandler = ({ Sessions, Users, Auth, bcrypt }, context) => { // Basic Auth, which is allowed over HTTPS only: } else if (isPresent(authHeader) && authHeader.startsWith('Basic ')) { + // REVIEW: do web users still have legitimate reasons for accessing using BasicAuth? + // 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 +106,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..448c077a7 100644 --- a/lib/http/service.js +++ b/lib/http/service.js @@ -11,6 +11,16 @@ // defined elsewhere into an actual Express service. The only thing it needs in // order to do this is a valid dependency injection context container. +// REVIEW: per CONTRIBUTING.md, perhaps this should be rewritten? Specifically: +// +// > We do something a little bit odd: we only use actual Express middleware +// > sparingly. Besides the usual assortment of stock/packaged parsers and +// > loggers, only middleware that rewrites the incoming request URL are +// > actually implemented as Express middleware; everything else we run as what +// > we call a preprocessor. +// +const cookieParser = require('cookie-parser'); + module.exports = (container) => { const service = require('express')(); @@ -41,6 +51,8 @@ module.exports = (container) => { service.use(versionParser); service.use(fieldKeyParser); + service.use(cookieParser()); + //////////////////////////////////////////////////////////////////////////////// // PREPROCESSORS @@ -74,6 +86,8 @@ module.exports = (container) => { require('../resources/analytics')(service, endpoint); require('../resources/datasets')(service, endpoint); require('../resources/entities')(service, endpoint); + // REVIEW: should these definitely be at /v1/ URLs and using standard preprocessor? + 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..706239658 --- /dev/null +++ b/lib/resources/oidc.js @@ -0,0 +1,204 @@ +// 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 } = 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', +}; + +// FIXME remove logging before merge +// eslint-disable-next-line no-console +const log = (...args) => console.error('resources/oidc', ...args); // FIXME suppress all logs + +module.exports = (service, endpoint) => { + if (!isEnabled()) { + log('OIDC not enabled; routes will not be created.'); + return; + } + log('Initialising OIDC routes...'); + + service.get('/oidc/login', endpoint.html(async ({ Sentry }, _, req, res) => { + try { + const client = await getClient(); + const code_verifier = generators.codeVerifier(); // eslint-disable-line camelcase + + log('code_verifier:', code_verifier); // 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) { + Sentry.captureException(err); + // hack to override the defaultErrorWriter TODO should be fixed elsewhere + err.isProblem = true; + throw err; + } + })); + + 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 + + log('code_verifier:', code_verifier); + + const client = await getClient(); + + const params = client.callbackParams(req); + const tokenSet = await client.callback(getRedirectUri(), params, { code_verifier }); + log('received and validated tokens:', tokenSet); + log('validated ID Token claims:', tokenSet.claims()); + + 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, 'provider-misconfigured'); + } + if (!email_verified) return errorToFrontend(req, res, 'email-not-verified'); // eslint-disable-line camelcase + + log('userinfo:', userinfo); + + const user = await getUserByEmail(container, email); + if (!user) return errorToFrontend(req, res, 'auth-ok-user-not-found'); + + await initSession(container, req, res, user); + + // This redirect would be ideal, but breaks `SameSite: Secure` cookies. + // return res.redirect('/'); + // Instead, we need to render a page and then "browse" from that page to the normal frontend: + + const nextPath = safeNextPathFrom(next); + + // 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) { + container.Sentry.captureException(err); + // hack to override the defaultErrorWriter TODO should be fixed elsewhere + err.isProblem = true; + throw err; + } + })); + + log('OIDC routes initialised.'); +}; + +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); + + // REVIEW: here we append query string manually, because Central Frontend expects search/hash in the wrong order: + const redirectUrl = envDomain + loginUrl.pathname + loginUrl.hash + loginUrl.search; + + redirect(307, redirectUrl); +} + +async function getUserByEmail({ Users }, email) { + const userOption = await Users.getByEmail(email); + if (!userOption.isDefined()) return; + + const user = userOption.get(); + log('got user:', user); + + return user; +} + +async function initSession(container, req, res, user) { + const applySession = await createUserSession(container, req.headers, user); + applySession(req, res); +} + +// logic from login.vue in frontend +// REVIEW: how can we re-use frontend logic? E.g. pass as a query string to frontend, and forward there? +function safeNextPathFrom(next) { + log('safeNextPathFrom()', typeof next, 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(); + + // REVIEW: this is similar to code from frontend's src/components/account/login.vue, + // but its significance is unclear. + return '/#' + url.pathname + url.search + url.hash; +} 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..e14ecaa7b 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,23 @@ module.exports = (service, endpoint) => { Users.getByActorId(params.id) .then(getOrNotFound) .then((user) => auth.canOrReject('user.update', user.actor) - .then(() => User.fromApi(body)) + .then(() => { + if (oidc.isEnabled()) { + // don't allow modifying email or password for users when using OIDC + // REVIEW is there some official, lower-level place to do this, e.g. in lib/model/frames.js? + const { email, password, ...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 +154,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..4eb5684c3 --- /dev/null +++ b/lib/util/html.js @@ -0,0 +1,31 @@ +// 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} + + + +
ODK Central
+
+ ${body} +
+ + +`; + +module.exports = { frontendPage, html }; diff --git a/lib/util/oidc.js b/lib/util/oidc.js new file mode 100644 index 000000000..74cebd964 --- /dev/null +++ b/lib/util/oidc.js @@ -0,0 +1,144 @@ +// 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'); + +// FIXME remove console logging before merge +// eslint-disable-next-line no-console +const log = (...args) => console.error('resources/oidc', ...args); + +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.'); + + log('Initialising OIDC client...'); + + try { + assertHasAll('config keys', Object.keys(oidcConfig), ['issuerUrl', 'clientId', 'clientSecret']); + + const { issuerUrl } = oidcConfig; + log('Attempting discovery from:', issuerUrl); + const issuer = await Issuer.discover(issuerUrl); + log('Discovered issuer:', issuer.issuer, issuer.metadata); + + // 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, + }); + + log('OIDC client initialised OK.'); + + 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/sessions.js b/lib/util/sessions.js new file mode 100644 index 000000000..59584b951 --- /dev/null +++ b/lib/util/sessions.js @@ -0,0 +1,47 @@ +// 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://'); + +// TODO add some thoughts about why __Host is important in prod but impossible in dev +const SESSION_COOKIE = HTTPS_ENABLED ? '__Host-session' : 'session'; + +function createUserSession({ Audits, Sessions }, headers, user) { + return 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-tester.dockerfile b/oidc-tester.dockerfile new file mode 100644 index 000000000..6b64c585e --- /dev/null +++ b/oidc-tester.dockerfile @@ -0,0 +1,38 @@ +# 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 + +# 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-tester/fake-oidc-server +COPY oidc-tester/fake-oidc-server/package.json oidc-tester/fake-oidc-server/package-lock.json . +RUN npm clean-install + +WORKDIR /odk-central-backend/oidc-tester/playwright-tests +COPY oidc-tester/playwright-tests/package.json \ + oidc-tester/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 / . + +COPY oidc-tester/odk-central-backend-config.json config/local.json + +WORKDIR /odk-central-backend/oidc-tester +CMD ./scripts/docker-start.sh diff --git a/oidc-tester/.gitignore b/oidc-tester/.gitignore new file mode 100644 index 000000000..3a18785be --- /dev/null +++ b/oidc-tester/.gitignore @@ -0,0 +1 @@ +/playwright-results/ diff --git a/oidc-tester/README.md b/oidc-tester/README.md new file mode 100644 index 000000000..ae88a1a40 --- /dev/null +++ b/oidc-tester/README.md @@ -0,0 +1,9 @@ +oidc-tester +=========== + +Testing OpenID Connect / OAuth2 (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-tester/certs/fake-oidc-server.example.net-key.pem b/oidc-tester/certs/fake-oidc-server.example.net-key.pem new file mode 100644 index 000000000..6b5d5c1de --- /dev/null +++ b/oidc-tester/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-tester/certs/fake-oidc-server.example.net.pem b/oidc-tester/certs/fake-oidc-server.example.net.pem new file mode 100644 index 000000000..f920911ae --- /dev/null +++ b/oidc-tester/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-tester/certs/odk-central.example.org-key.pem b/oidc-tester/certs/odk-central.example.org-key.pem new file mode 100644 index 000000000..4e2eb4631 --- /dev/null +++ b/oidc-tester/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-tester/certs/odk-central.example.org.pem b/oidc-tester/certs/odk-central.example.org.pem new file mode 100644 index 000000000..a8d6970b1 --- /dev/null +++ b/oidc-tester/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-tester/docker-compose.yml b/oidc-tester/docker-compose.yml new file mode 100644 index 000000000..f03c6cb0b --- /dev/null +++ b/oidc-tester/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-tester/playwright-tests/results diff --git a/oidc-tester/fake-oidc-server/.eslintrc.cjs b/oidc-tester/fake-oidc-server/.eslintrc.cjs new file mode 100644 index 000000000..86546b7a0 --- /dev/null +++ b/oidc-tester/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-tester/fake-oidc-server/accounts.json b/oidc-tester/fake-oidc-server/accounts.json new file mode 100644 index 000000000..ccaece6ea --- /dev/null +++ b/oidc-tester/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-tester/fake-oidc-server/index.js b/oidc-tester/fake-oidc-server/index.js new file mode 100644 index 000000000..54829bcd0 --- /dev/null +++ b/oidc-tester/fake-oidc-server/index.js @@ -0,0 +1,116 @@ +// 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 (ctx, resourceIndicator, client) => { + log.info('getResourceServerInfo()', { ctx, resourceIndicator, client }); + return {}; + }, + }, + }, + + 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

+ +
+ [ 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-tester/fake-oidc-server/package-lock.json b/oidc-tester/fake-oidc-server/package-lock.json new file mode 100644 index 000000000..51b604d5d --- /dev/null +++ b/oidc-tester/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-tester/fake-oidc-server/package.json b/oidc-tester/fake-oidc-server/package.json new file mode 100644 index 000000000..cf673ac8d --- /dev/null +++ b/oidc-tester/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" + }, + "license": "SEE LICENSE IN ../LICENSE", + "dependencies": { + "oidc-provider": "^8.2.2" + } +} diff --git a/oidc-tester/odk-central-backend-config.json b/oidc-tester/odk-central-backend-config.json new file mode 100644 index 000000000..f0ce13a99 --- /dev/null +++ b/oidc-tester/odk-central-backend-config.json @@ -0,0 +1,20 @@ +{ + "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", + "oidcProviderName": "Fake OIDC Server" + }, + "oidc": { + "issuerUrl": "https://fake-oidc-server.example.net:9898", + "clientId": "odk-central-backend-dev", + "clientSecret": "super-top-secret", + "enabled": true + } + } +} diff --git a/oidc-tester/playwright-tests/.eslintrc.js b/oidc-tester/playwright-tests/.eslintrc.js new file mode 100644 index 000000000..eb184614e --- /dev/null +++ b/oidc-tester/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-tester/playwright-tests/package-lock.json b/oidc-tester/playwright-tests/package-lock.json new file mode 100644 index 000000000..fc4f6b264 --- /dev/null +++ b/oidc-tester/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-tester/playwright-tests/package.json b/oidc-tester/playwright-tests/package.json new file mode 100644 index 000000000..278e26bb2 --- /dev/null +++ b/oidc-tester/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" + }, + "license": "SEE LICENSE IN ../../LICENSE" +} diff --git a/oidc-tester/playwright-tests/playwright.config.js b/oidc-tester/playwright-tests/playwright.config.js new file mode 100644 index 000000000..9f156788b --- /dev/null +++ b/oidc-tester/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-tester/playwright-tests/src/authn-success-authz-fail.spec.js b/oidc-tester/playwright-tests/src/authn-success-authz-fail.spec.js new file mode 100644 index 000000000..a56fc54d9 --- /dev/null +++ b/oidc-tester/playwright-tests/src/authn-success-authz-fail.spec.js @@ -0,0 +1,29 @@ +// 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, + fillLoginForm, + initTest, +} = require('./utils'); // eslint-disable-line object-curly-newline + +test('successful authN, but user unknown by central', async ({ browserName, page }) => { + // given + await initTest({ browserName, page }); + + // when + await page.goto(`${frontendUrl}/v1/oidc/login`); + await fillLoginForm(page, { username: 'bob', password: 'topsecret!!!!!' }); + + // then + await assertErrorRedirect(page, 'auth-ok-user-not-found'); +}); diff --git a/oidc-tester/playwright-tests/src/authn-success-email-unverified.spec.js b/oidc-tester/playwright-tests/src/authn-success-email-unverified.spec.js new file mode 100644 index 000000000..0aea55c9e --- /dev/null +++ b/oidc-tester/playwright-tests/src/authn-success-email-unverified.spec.js @@ -0,0 +1,30 @@ +// 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, + fillLoginForm, + initTest, +} = require('./utils'); // eslint-disable-line object-curly-newline + +// eslint-disable-next-line quotes +test(`successful authN, but claim 'email_verified' has value false`, async ({ browserName, page }) => { + // given + await initTest({ browserName, page }); + + // when + await page.goto(`${frontendUrl}/v1/oidc/login`); + await fillLoginForm(page, { username: 'charlie', password: 'topsecret!!!!!' }); + + // then + await assertErrorRedirect(page, 'email-not-verified'); +}); diff --git a/oidc-tester/playwright-tests/src/authn-success-no-email-claim.spec.js b/oidc-tester/playwright-tests/src/authn-success-no-email-claim.spec.js new file mode 100644 index 000000000..66525ce1e --- /dev/null +++ b/oidc-tester/playwright-tests/src/authn-success-no-email-claim.spec.js @@ -0,0 +1,30 @@ +// 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, + fillLoginForm, + initTest, +} = require('./utils'); // eslint-disable-line object-curly-newline + +// eslint-disable-next-line quotes +test(`successful authN, but no 'email' claim provided`, async ({ browserName, page }) => { + // given + await initTest({ browserName, page }); + + // when + await page.goto(`${frontendUrl}/v1/oidc/login`); + await fillLoginForm(page, { username: 'dave', password: 'topsecret!!!!!' }); + + // then + await assertErrorRedirect(page, 'provider-misconfigured'); +}); diff --git a/oidc-tester/playwright-tests/src/config.js b/oidc-tester/playwright-tests/src/config.js new file mode 100644 index 000000000..1b5cbe6cf --- /dev/null +++ b/oidc-tester/playwright-tests/src/config.js @@ -0,0 +1,17 @@ +// 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; +// FIXME default to localhost and set env var in CI +const frontendUrl = process.env.ODK_CENTRAL_FRONTEND || `https://odk-central.example.org:${port}`; + +module.exports = { + frontendUrl, + port, +}; diff --git a/oidc-tester/playwright-tests/src/global-setup-teardown.js b/oidc-tester/playwright-tests/src/global-setup-teardown.js new file mode 100644 index 000000000..353e70b03 --- /dev/null +++ b/oidc-tester/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-tester/playwright-tests/src/happy-next-enketo.spec.js b/oidc-tester/playwright-tests/src/happy-next-enketo.spec.js new file mode 100644 index 000000000..e795e78a2 --- /dev/null +++ b/oidc-tester/playwright-tests/src/happy-next-enketo.spec.js @@ -0,0 +1,31 @@ +// 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 + assertLocation, + assertLoginSuccessful, + fillLoginForm, + initTest, +} = require('./utils'); // eslint-disable-line object-curly-newline + +test('can log in with next parameter pointing to enketo', async ({ browserName, page }) => { + // given + await initTest({ browserName, page }); + + // when + await page.goto(`${frontendUrl}/v1/oidc/login?next=/-/some/path`); + await fillLoginForm(page, { username: 'alice', password: 'topsecret!!!!!' }); + + // then + await assertLoginSuccessful(page, '/-/some/path'); + await assertLocation(page, frontendUrl + '/-/some/path'); +}); diff --git a/oidc-tester/playwright-tests/src/happy-next.spec.js b/oidc-tester/playwright-tests/src/happy-next.spec.js new file mode 100644 index 000000000..d53afd51d --- /dev/null +++ b/oidc-tester/playwright-tests/src/happy-next.spec.js @@ -0,0 +1,31 @@ +// 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 + assertLocation, + assertLoginSuccessful, + fillLoginForm, + initTest, +} = require('./utils'); // eslint-disable-line object-curly-newline + +test('can log in with next parameter', async ({ browserName, page }) => { + // given + await initTest({ browserName, page }); + + // when + await page.goto(`${frontendUrl}/v1/oidc/login?next=/some/path`); + await fillLoginForm(page, { username: 'alice', password: 'topsecret!!!!!' }); + + // then + await assertLoginSuccessful(page, '/'); // N.B. backend doesn't receive URL fragments + await assertLocation(page, frontendUrl + '/#/some/path'); +}); diff --git a/oidc-tester/playwright-tests/src/happy.spec.js b/oidc-tester/playwright-tests/src/happy.spec.js new file mode 100644 index 000000000..4d0b32bb8 --- /dev/null +++ b/oidc-tester/playwright-tests/src/happy.spec.js @@ -0,0 +1,31 @@ +// 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 + assertLocation, + assertLoginSuccessful, + fillLoginForm, + initTest, +} = require('./utils'); // eslint-disable-line object-curly-newline + +test('can log in', async ({ browserName, page }) => { + // given + await initTest({ browserName, page }); + + // when + await page.goto(`${frontendUrl}/v1/oidc/login`); + await fillLoginForm(page, { username: 'alice', password: 'topsecret!!!!!' }); + + // then + await assertLoginSuccessful(page, '/'); // N.B. backend doesn't receive URL fragments + await assertLocation(page, frontendUrl + '/#/'); +}); diff --git a/oidc-tester/playwright-tests/src/login-aborted.spec.js b/oidc-tester/playwright-tests/src/login-aborted.spec.js new file mode 100644 index 000000000..32e277f2f --- /dev/null +++ b/oidc-tester/playwright-tests/src/login-aborted.spec.js @@ -0,0 +1,30 @@ +// 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 + assertErrorPage, + initTest, +} = require('./utils'); // eslint-disable-line object-curly-newline + +test('handles aborted login', async ({ browserName, page }) => { + // given + await initTest({ browserName, page }); + + // 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 assertErrorPage(page, 'An unknown error occurred on the server.'); +}); diff --git a/oidc-tester/playwright-tests/src/utils.js b/oidc-tester/playwright-tests/src/utils.js new file mode 100644 index 000000000..f1095deb5 --- /dev/null +++ b/oidc-tester/playwright-tests/src/utils.js @@ -0,0 +1,98 @@ +// 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 = { + assertErrorPage, + 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'; + +// TODO assert status code? +async function assertErrorPage(page, expectedMessage) { + await assertTitle(page, 'Error!'); + await expect(page.locator('#error-message')).toHaveText(expectedMessage); +} + +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 }) { + console.log(` + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @ + @ Starting test in browser: ${browserName} + @ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + `); + + page.on('console', msg => { + const level = msg.type().toUpperCase(); + console.log(level, msg.text()); + }); +} diff --git a/oidc-tester/scripts/docker-start.sh b/oidc-tester/scripts/docker-start.sh new file mode 100755 index 000000000..e5bcff051 --- /dev/null +++ b/oidc-tester/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..." +./scripts/wait-for-it.sh 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..." +./scripts/wait-for-it.sh 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 --null-password +cd - +log "Test users created." + +log "Running playwright tests..." +cd playwright-tests +npx playwright test + +log "Tests completed OK!" diff --git a/oidc-tester/scripts/wait-for-it.sh b/oidc-tester/scripts/wait-for-it.sh new file mode 100755 index 000000000..b5725e4c8 --- /dev/null +++ b/oidc-tester/scripts/wait-for-it.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available +# +# Source: https://github.com/vishnubob/wait-for-it +# +# The MIT License (MIT) +# Copyright (c) 2016 Giles Hall +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +cmdname=$(basename $0) + +echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $TIMEOUT -gt 0 ]]; then + echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT" + else + echoerr "$cmdname: waiting for $HOST:$PORT without a timeout" + fi + start_ts=$(date +%s) + while : + do + if [[ $ISBUSY -eq 1 ]]; then + nc -z $HOST $PORT + result=$? + else + (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1 + result=$? + fi + if [[ $result -eq 0 ]]; then + end_ts=$(date +%s) + echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds" + break + fi + sleep 1 + done + return $result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $QUIET -eq 1 ]]; then + timeout $BUSYTIMEFLAG $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & + else + timeout $BUSYTIMEFLAG $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & + fi + PID=$! + trap "kill -INT -$PID" INT + wait $PID + RESULT=$? + if [[ $RESULT -ne 0 ]]; then + echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT" + fi + return $RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + hostport=(${1//:/ }) + HOST=${hostport[0]} + PORT=${hostport[1]} + shift 1 + ;; + --child) + CHILD=1 + shift 1 + ;; + -q | --quiet) + QUIET=1 + shift 1 + ;; + -s | --strict) + STRICT=1 + shift 1 + ;; + -h) + HOST="$2" + if [[ $HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + HOST="${1#*=}" + shift 1 + ;; + -p) + PORT="$2" + if [[ $PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + PORT="${1#*=}" + shift 1 + ;; + -t) + TIMEOUT="$2" + if [[ $TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$HOST" == "" || "$PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +TIMEOUT=${TIMEOUT:-15} +STRICT=${STRICT:-0} +CHILD=${CHILD:-0} +QUIET=${QUIET:-0} + +# check to see if timeout is from busybox? +# check to see if timeout is from busybox? +TIMEOUT_PATH=$(realpath $(which timeout)) +if [[ $TIMEOUT_PATH =~ "busybox" ]]; then + ISBUSY=1 + BUSYTIMEFLAG="-t" +else + ISBUSY=0 + BUSYTIMEFLAG="" +fi + +if [[ $CHILD -gt 0 ]]; then + wait_for + RESULT=$? + exit $RESULT +else + if [[ $TIMEOUT -gt 0 ]]; then + wait_for_wrapper + RESULT=$? + else + wait_for + RESULT=$? + fi +fi + +if [[ $CLI != "" ]]; then + if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then + echoerr "$cmdname: strict mode, refusing to execute subprocess" + exit $RESULT + fi + exec "${CLI[@]}" +else + exit $RESULT +fi 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/audits.js b/test/integration/api/audits.js index 5f0b5772f..5c37a1bed 100644 --- a/test/integration/api/audits.js +++ b/test/integration/api/audits.js @@ -46,7 +46,12 @@ describe('/audits', () => { Users.getByEmail('david@getodk.org').then((o) => o.get()) ])) .then(([ audits, project, alice, david ]) => { - audits.length.should.equal(4); + assertAuditActions(audits, [ // eslint-disable-line no-use-before-define + 'user.create', + 'project.update', + 'project.create', + 'user.session.create', + ]); audits.forEach((audit) => { audit.should.be.an.Audit(); }); audits[0].actorId.should.equal(alice.actor.id); @@ -101,7 +106,13 @@ describe('/audits', () => { Users.getByEmail('david@getodk.org').then((o) => o.get()) ])) .then(([ audits, [ project, form ], alice, david ]) => { - audits.length.should.equal(5); + assertAuditActions(audits, [ // eslint-disable-line no-use-before-define + 'user.create', + 'form.update.publish', + 'form.create', + 'project.create', + 'user.session.create', + ]); audits.forEach((audit) => { audit.should.be.an.Audit(); }); audits[0].actorId.should.equal(alice.actor.id); @@ -224,13 +235,14 @@ describe('/audits', () => { .then(() => asAlice.get('/v1/audits?action=user') .expect(200) .then(({ body }) => { - body.length.should.equal(6); - body[0].action.should.equal('user.delete'); - body[1].action.should.equal('user.assignment.delete'); - body[2].action.should.equal('user.assignment.create'); - body[3].action.should.equal('user.update'); - body[4].action.should.equal('user.create'); - body[5].action.should.equal('user.session.create'); + assertAuditActions(body, [ // eslint-disable-line no-use-before-define + 'user.delete', + 'user.assignment.delete', + 'user.assignment.create', + 'user.update', + 'user.create', + 'user.session.create', + ]); }))))); it('should filter by action category (project)', testService((service) => @@ -722,3 +734,6 @@ describe('/audits', () => { }); }); +function assertAuditActions(audits, expected) { + audits.map(a => a.action).should.deepEqual(expected); +} 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 bab3fe8a4..13349ccb9 100644 --- a/test/integration/api/users.js +++ b/test/integration/api/users.js @@ -3,6 +3,7 @@ const should = require('should'); // eslint-disable-next-line import/no-dynamic-require const { getOrNotFound } = require(appRoot + '/lib/util/promise'); const { testService } = require('../setup'); +const authenticateUser = require('../../util/authenticate-user'); describe('api: /users', () => { describe('GET', () => { @@ -86,279 +87,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', () => { @@ -472,13 +518,19 @@ describe('api: /users', () => { .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'); 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('alice@getodk.org'); + return authenticateUser(service, 'alice'); + } else { + after.body.email.should.equal('newalice@odk.org'); + return service.post('/v1/sessions') + .send({ email: 'newalice@odk.org', password: 'alice' }) + .expect(200); + } }))))); it('should allow nonadministrator users to update themselves', testService((service) => @@ -494,20 +546,27 @@ 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) => - 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' }) + // eslint-disable-next-line func-names + it('should send an email to the user\'s previous email when their email changes', function () { + // REVIEW or could exclude _outside_ it() as per larger blocks of tests in this file. + // mocha marks skipped tests as "pending", which doesn't seem appropriate in the case + // of uname/pword vs OIDC auth. + if (process.env.TEST_AUTH === 'oidc') return this.skip(); + return testService((service) => + service.login('alice', (asAlice) => + asAlice.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((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 data collection 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) => @@ -538,119 +597,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)))))); + .expect(404)))); - 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)))); - - 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', () => { @@ -718,9 +791,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/encryption.js b/test/integration/other/encryption.js index a1a109efc..66003a744 100644 --- a/test/integration/other/encryption.js +++ b/test/integration/other/encryption.js @@ -14,6 +14,7 @@ const { Form, Key, Submission } = require(appRoot + '/lib/model/frames'); const { mapSequential } = require(appRoot + '/test/util/util'); // eslint-disable-next-line import/no-dynamic-require const { exhaust } = require(appRoot + '/lib/worker/worker'); +const authenticateUser = require('../../util/authenticate-user'); describe('managed encryption', () => { describe('lock management', () => { @@ -286,14 +287,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 f9fb52ca8..8cd0bd51a 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -7,6 +7,7 @@ const request = require('supertest'); const { noop } = require(appRoot + '/lib/util/util'); // eslint-disable-next-line import/no-dynamic-require const { task } = require(appRoot + '/lib/task/task'); +const authenticateUser = require('../util/authenticate-user'); // knex things. const config = require('config'); @@ -133,15 +134,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 af6e9928b..bcd897e8f 100644 --- a/test/integration/task/account.js +++ b/test/integration/task/account.js @@ -10,7 +10,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'); @@ -18,6 +18,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([ @@ -37,6 +45,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 1feb478cd..74ddcdaad 100644 --- a/test/unit/http/preprocessors.js +++ b/test/unit/http/preprocessors.js @@ -153,7 +153,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) => { @@ -168,7 +168,7 @@ describe('preprocessors', () => { createRequest({ method: 'GET', headers: { 'X-Forwarded-Proto': 'https', Cookie: 'please just let me in' - } }), + }, cookies: {} }), { fieldKey: Option.none() } ) )).then((context) => { @@ -182,8 +182,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) => { @@ -200,8 +200,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) => { @@ -223,8 +223,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() } @@ -243,8 +244,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) => { @@ -257,8 +258,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) => { @@ -271,8 +272,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) => { @@ -292,8 +293,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 })); @@ -304,8 +305,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 })); @@ -316,8 +317,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) => { @@ -331,8 +332,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()); @@ -343,8 +344,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()); @@ -355,8 +356,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/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(/