diff --git a/.gitignore b/.gitignore index f577330c9..71584e76b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ plugin_packages /src/test-results playwright-report state.json +/src/static/oidc diff --git a/Dockerfile b/Dockerfile index 06f182bf5..00b3f4b67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,9 +7,9 @@ FROM node:alpine as adminBuild WORKDIR /opt/etherpad-lite -COPY ./admin ./admin -COPY ./src/locales ./src/locales +COPY ./ ./ RUN cd ./admin && npm install -g pnpm && pnpm install && pnpm run build --outDir ./dist +RUN cd ./ui && pnpm install && pnpm run build --outDir ./dist FROM node:alpine as build @@ -116,6 +116,7 @@ FROM build as development COPY --chown=etherpad:etherpad ./src/package.json .npmrc ./src/ COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin +COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/ui/dist ./src/static/oidc RUN bin/installDeps.sh && \ if [ ! -z "${ETHERPAD_PLUGINS}" ] || [ ! -z "${ETHERPAD_LOCAL_PLUGINS}" ]; then \ @@ -130,6 +131,7 @@ ENV ETHERPAD_PRODUCTION=true COPY --chown=etherpad:etherpad ./src ./src COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin +COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/ui/dist ./src/static/oidc RUN bin/installDeps.sh && rm -rf ~/.npm && rm -rf ~/.local && rm -rf ~/.cache && \ if [ ! -z "${ETHERPAD_PLUGINS}" ] || [ ! -z "${ETHERPAD_LOCAL_PLUGINS}" ]; then \ diff --git a/admin/vite.config.ts b/admin/vite.config.ts index ff329032f..d90386ca8 100644 --- a/admin/vite.config.ts +++ b/admin/vite.config.ts @@ -15,7 +15,8 @@ export default defineConfig({ })], base: '/admin', build:{ - outDir: '../src/templates/admin' + outDir: '../src/templates/admin', + emptyOutDir: true, }, server:{ proxy: { diff --git a/bin/installOnWindows.bat b/bin/installOnWindows.bat index 5b04919bd..97cddce24 100644 --- a/bin/installOnWindows.bat +++ b/bin/installOnWindows.bat @@ -19,6 +19,15 @@ IF EXIST admin ( cd /D .. ) +:: Install ui only if available +IF EXIST ui ( + cd /D .\ui + dir + cmd /C pnpm i || exit /B 1 + cmd /C pnpm run build || exit /B 1 + cd /D .. +) + cmd /C pnpm i || exit /B 1 diff --git a/bin/run.sh b/bin/run.sh index 654897fa4..c6c4c92c9 100755 --- a/bin/run.sh +++ b/bin/run.sh @@ -22,7 +22,7 @@ Please type 'Etherpad rocks my socks' (or restart with the '--root' argument) if you still want to start it as root: EOF printf "> " >&2 - read rocks + read -r rocks [ "$rocks" = "Etherpad rocks my socks" ] || fatal "Your input was incorrect" fi @@ -32,9 +32,11 @@ bin/installDeps.sh "$@" || exit 1 ## Create the admin ui if [ -z "$NODE_ENV" ] || [ "$NODE_ENV" = "development" ]; then - ADMIN_UI_PATH="$(dirname $0)/../admin" + ADMIN_UI_PATH="$(dirname "$0")/../admin" + UI_PATH="$(dirname "$0")/../ui" log "Creating the admin UI..." - (cd $ADMIN_UI_PATH && pnpm run build) + (cd "$ADMIN_UI_PATH" && pnpm run build) + (cd "$UI_PATH" && pnpm run build) else log "Cannot create the admin UI in production mode" fi diff --git a/package.json b/package.json index 40557a772..dba9a0dd3 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ }, "devDependencies": { "admin": "workspace:./admin", - "docs": "workspace:./doc" + "docs": "workspace:./doc", + "ui": "workspace:./ui" }, "engines": { "node": ">=18.18.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6675f0e5..a4a31ace5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: docs: specifier: workspace:./doc version: link:doc + ui: + specifier: workspace:./ui + version: link:ui admin: devDependencies: @@ -178,6 +181,9 @@ importers: http-errors: specifier: ^2.0.0 version: 2.0.0 + jose: + specifier: ^5.2.3 + version: 5.2.3 js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -187,6 +193,9 @@ importers: jsonminify: specifier: 0.4.2 version: 0.4.2 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 languages4translatewiki: specifier: 0.1.3 version: 0.1.3 @@ -199,12 +208,18 @@ importers: log4js: specifier: ^6.9.1 version: 6.9.1 + lru-cache: + specifier: ^10.2.0 + version: 10.2.0 measured-core: specifier: ^2.0.0 version: 2.0.0 mime-types: specifier: ^2.1.35 version: 2.1.35 + oidc-provider: + specifier: ^8.4.5 + version: 8.4.5 openapi-backend: specifier: ^5.10.6 version: 5.10.6 @@ -272,18 +287,27 @@ importers: '@types/express': specifier: ^4.17.21 version: 4.17.21 + '@types/formidable': + specifier: ^3.4.5 + version: 3.4.5 '@types/http-errors': specifier: ^2.0.4 version: 2.0.4 '@types/jsdom': specifier: ^21.1.6 version: 21.1.6 + '@types/jsonwebtoken': + specifier: ^9.0.6 + version: 9.0.6 '@types/mocha': specifier: ^10.0.6 version: 10.0.6 '@types/node': specifier: ^20.11.30 version: 20.11.30 + '@types/oidc-provider': + specifier: ^8.4.4 + version: 8.4.4 '@types/semver': specifier: ^7.5.8 version: 7.5.8 @@ -333,6 +357,15 @@ importers: specifier: ^5.4.3 version: 5.4.3 + ui: + devDependencies: + typescript: + specifier: ^5.2.2 + version: 5.4.3 + vite: + specifier: ^5.2.0 + version: 5.2.6 + packages: /@aashutoshrathi/word-wrap@1.2.6: @@ -1274,6 +1307,26 @@ packages: resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} dev: false + /@koa/cors@5.0.0: + resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} + engines: {node: '>= 14.0.0'} + dependencies: + vary: 1.1.2 + dev: false + + /@koa/router@12.0.1: + resolution: {integrity: sha512-ribfPYfHb+Uw3b27Eiw6NPqjhIhTpVFzEWLwyc/1Xp+DCdwRRyIlAUODX+9bPARF6aQtUu1+/PHzdNvRzcs/+Q==} + engines: {node: '>= 12'} + dependencies: + debug: 4.3.4(supports-color@8.1.1) + http-errors: 2.0.0 + koa-compose: 4.1.0 + methods: 1.1.2 + path-to-regexp: 6.2.1 + transitivePeerDependencies: + - supports-color + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1799,6 +1852,11 @@ packages: shiki: 1.2.0 dev: true + /@sindresorhus/is@5.6.0: + resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} + engines: {node: '>=14.16'} + dev: false + /@sinonjs/commons@2.0.0: resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} dependencies: @@ -2081,6 +2139,19 @@ packages: resolution: {integrity: sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==} dev: true + /@szmarczak/http-timer@5.0.1: + resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} + engines: {node: '>=14.16'} + dependencies: + defer-to-connect: 2.0.1 + dev: false + + /@types/accepts@1.3.7: + resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} + dependencies: + '@types/node': 20.11.30 + dev: true + /@types/async@3.2.24: resolution: {integrity: sha512-8iHVLHsCCOBKjCF2KwFe0p9Z3rfM9mL+sSP8btyR5vTjJRAqpBYD28/ZLgXPf0pjG1VxOvtCV/BgXkQbpSe8Hw==} dev: true @@ -2098,6 +2169,10 @@ packages: '@types/node': 20.11.30 dev: true + /@types/content-disposition@0.5.8: + resolution: {integrity: sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==} + dev: true + /@types/cookie@0.4.1: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} dev: false @@ -2106,6 +2181,15 @@ packages: resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} dev: true + /@types/cookies@0.9.0: + resolution: {integrity: sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==} + dependencies: + '@types/connect': 3.4.38 + '@types/express': 4.17.21 + '@types/keygrip': 1.0.6 + '@types/node': 20.11.30 + dev: true + /@types/cors@2.8.17: resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} dependencies: @@ -2140,6 +2224,12 @@ packages: '@types/serve-static': 1.15.5 dev: true + /@types/formidable@3.4.5: + resolution: {integrity: sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ==} + dependencies: + '@types/node': 20.11.30 + dev: true + /@types/fs-extra@9.0.13: resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} dependencies: @@ -2152,6 +2242,14 @@ packages: '@types/unist': 3.0.2 dev: false + /@types/http-assert@1.5.5: + resolution: {integrity: sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==} + dev: true + + /@types/http-cache-semantics@4.0.4: + resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + dev: false + /@types/http-errors@2.0.4: resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} dev: true @@ -2171,6 +2269,35 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/jsonwebtoken@9.0.6: + resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} + dependencies: + '@types/node': 20.11.30 + dev: true + + /@types/keygrip@1.0.6: + resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + dev: true + + /@types/koa-compose@3.2.8: + resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==} + dependencies: + '@types/koa': 2.15.0 + dev: true + + /@types/koa@2.15.0: + resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==} + dependencies: + '@types/accepts': 1.3.7 + '@types/content-disposition': 0.5.8 + '@types/cookies': 0.9.0 + '@types/http-assert': 1.5.5 + '@types/http-errors': 2.0.4 + '@types/keygrip': 1.0.6 + '@types/koa-compose': 3.2.8 + '@types/node': 20.11.30 + dev: true + /@types/linkify-it@3.0.5: resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} dev: true @@ -2238,6 +2365,13 @@ packages: dependencies: undici-types: 5.26.5 + /@types/oidc-provider@8.4.4: + resolution: {integrity: sha512-+SlmKc4qlCJLjpw6Du/8cXw18JsPEYyQwoy+xheLkiuNsCz1mPEYI/lRXLQHvfJD9TH6+2/WDTLZQ2UUJ5G4bw==} + dependencies: + '@types/koa': 2.15.0 + '@types/node': 20.11.30 + dev: true + /@types/prop-types@15.7.11: resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} dev: true @@ -2977,6 +3111,10 @@ packages: update-browserslist-db: 1.0.13(browserslist@4.23.0) dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: false @@ -2997,6 +3135,32 @@ packages: engines: {node: '>= 0.8'} dev: false + /cache-content-type@1.0.1: + resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==} + engines: {node: '>= 6.0.0'} + dependencies: + mime-types: 2.1.35 + ylru: 1.3.2 + dev: false + + /cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + dev: false + + /cacheable-request@10.2.14: + resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} + engines: {node: '>=14.16'} + dependencies: + '@types/http-cache-semantics': 4.0.4 + get-stream: 6.0.1 + http-cache-semantics: 4.1.1 + keyv: 4.5.4 + mimic-response: 4.0.0 + normalize-url: 8.0.1 + responselike: 3.0.0 + dev: false + /call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} @@ -3083,6 +3247,11 @@ packages: wrap-ansi: 7.0.0 dev: true + /co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -3173,6 +3342,14 @@ packages: /cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + /cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + dev: false + /cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -3272,10 +3449,26 @@ packages: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} dev: false + /decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dependencies: + mimic-response: 3.1.0 + dev: false + + /deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + dev: false + /define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -3297,6 +3490,15 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + /delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + dev: false + + /depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + dev: false + /depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3370,6 +3572,12 @@ packages: tslib: 2.6.2 dev: true + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false @@ -3970,6 +4178,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /eta@3.4.0: + resolution: {integrity: sha512-tCsc7WXTjrTx4ZjYLplcqrI3o4mYJ+Z6YspeuGL8tbt/hHoMchwBwtKfwM09svEY86iRapY93vUqQttcNuIO5Q==} + engines: {node: '>=6.0.0'} + dev: false + /etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -4170,6 +4383,11 @@ packages: is-callable: 1.2.7 dev: true + /form-data-encoder@2.1.4: + resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} + engines: {node: '>= 14.17'} + dev: false + /form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} @@ -4299,6 +4517,11 @@ packages: engines: {node: '>=6'} dev: true + /get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + dev: false + /get-symbol-description@1.0.2: resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} @@ -4385,6 +4608,23 @@ packages: dependencies: get-intrinsic: 1.2.4 + /got@13.0.0: + resolution: {integrity: sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==} + engines: {node: '>=16'} + dependencies: + '@sindresorhus/is': 5.6.0 + '@szmarczak/http-timer': 5.0.1 + cacheable-lookup: 7.0.0 + cacheable-request: 10.2.14 + decompress-response: 6.0.0 + form-data-encoder: 2.1.4 + get-stream: 6.0.1 + http2-wrapper: 2.2.1 + lowercase-keys: 3.0.0 + p-cancelable: 3.0.0 + responselike: 3.0.0 + dev: false + /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -4423,7 +4663,6 @@ packages: engines: {node: '>= 0.4'} dependencies: has-symbols: 1.0.3 - dev: true /hasown@2.0.1: resolution: {integrity: sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==} @@ -4567,6 +4806,29 @@ packages: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} dev: false + /http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + dev: false + + /http-cache-semantics@4.1.1: + resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + dev: false + + /http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + dev: false + /http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -4588,6 +4850,14 @@ packages: - supports-color dev: false + /http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + dev: false + /https-proxy-agent@7.0.4: resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==} engines: {node: '>= 14'} @@ -4739,6 +5009,13 @@ packages: engines: {node: '>=8'} dev: true + /is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: false + /is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -4855,6 +5132,10 @@ packages: minimatch: 3.1.2 dev: false + /jose@5.2.3: + resolution: {integrity: sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==} + dev: false + /js-cookie@3.0.5: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} @@ -4912,9 +5193,14 @@ packages: hasBin: true dev: true + /jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + dev: false + /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - dev: true /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -4971,19 +5257,99 @@ packages: resolution: {integrity: sha512-lz1nOH69GbsVHeVgEdvyavc/33oymY1AZwtePMiMj4HZPMbP5OIKK3zT9INMWjwua/V4Z4yq7wSlBbSG+g4AEw==} dev: true + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.0 + dev: false + /just-extend@6.2.0: resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} dev: true + /jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + dev: false + /kebab-case@1.0.2: resolution: {integrity: sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q==} dev: true + /keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + dependencies: + tsscmp: 1.0.6 + dev: false + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: json-buffer: 3.0.1 - dev: true + + /koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + dev: false + + /koa-convert@2.0.0: + resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==} + engines: {node: '>= 10'} + dependencies: + co: 4.6.0 + koa-compose: 4.1.0 + dev: false + + /koa@2.15.2: + resolution: {integrity: sha512-MXTeZH3M6AJ8ukW2QZ8wqO3Dcdfh2WRRmjCBkEP+NhKNCiqlO5RDqHmSnsyNrbRJrdjyvIGSJho4vQiWgQJSVA==} + engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} + dependencies: + accepts: 1.3.8 + cache-content-type: 1.0.1 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookies: 0.9.1 + debug: 4.3.4(supports-color@8.1.1) + delegates: 1.0.0 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 1.8.1 + is-generator-function: 1.0.10 + koa-compose: 4.1.0 + koa-convert: 2.0.0 + on-finished: 2.4.1 + only: 0.0.2 + parseurl: 1.3.3 + statuses: 1.5.0 + type-is: 1.6.18 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: false /languages4translatewiki@0.1.3: resolution: {integrity: sha512-Z7+IM3FF+VyRbWl2CPWQoRf498zGy/qnolP5wJhMny4W3v0SL9rUCIPDHPao9rrw2yg2KIKnHIzopuWvGdnooQ==} @@ -5044,9 +5410,37 @@ packages: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} dev: true + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: false + + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: false + + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: false + + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: false + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: false + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: false + /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: false @@ -5085,6 +5479,16 @@ packages: tslib: 2.6.2 dev: true + /lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + + /lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} + dev: false + /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} dependencies: @@ -5212,6 +5616,16 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + /mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + dev: false + + /mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -5333,6 +5747,12 @@ packages: hasBin: true dev: true + /nanoid@5.0.6: + resolution: {integrity: sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==} + engines: {node: ^18 || >=20} + hasBin: true + dev: false + /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -5387,6 +5807,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /normalize-url@8.0.1: + resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==} + engines: {node: '>=14.16'} + dev: false + /nwsapi@2.2.7: resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==} dev: false @@ -5396,6 +5821,11 @@ packages: engines: {node: '>=0.10.0'} dev: false + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + dev: false + /object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} @@ -5446,6 +5876,31 @@ packages: resolution: {integrity: sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==} dev: false + /oidc-provider@8.4.5: + resolution: {integrity: sha512-2NsPrvIAX1W4ZR41cGbz2Lt2Ci8iXvECh+x+LcKcM115s/h8iB1pwnNlCdIrvAA2iBGM4/TkO75Xg7xb2FCzWA==} + dependencies: + '@koa/cors': 5.0.0 + '@koa/router': 12.0.1 + debug: 4.3.4(supports-color@8.1.1) + eta: 3.4.0 + got: 13.0.0 + jose: 5.2.3 + jsesc: 3.0.2 + koa: 2.15.2 + nanoid: 5.0.6 + object-hash: 3.0.0 + oidc-token-hash: 5.0.3 + quick-lru: 7.0.0 + raw-body: 2.5.2 + transitivePeerDependencies: + - supports-color + dev: false + + /oidc-token-hash@5.0.3: + resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} + engines: {node: ^10.13.0 || >=12.0.0} + dev: false + /on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -5463,6 +5918,10 @@ packages: dependencies: wrappy: 1.0.2 + /only@0.0.2: + resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} + dev: false + /openapi-backend@5.10.6: resolution: {integrity: sha512-vTjBRys/O4JIHdlRHUKZ7pxS+gwIJreAAU9dvYRFrImtPzQ5qxm5a6B8BTVT9m6I8RGGsShJv35MAc3Tu2/y/A==} engines: {node: '>=12.0.0'} @@ -5516,6 +5975,11 @@ packages: type-check: 0.4.0 dev: true + /p-cancelable@3.0.0: + resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} + engines: {node: '>=12.20'} + dev: false + /p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5580,7 +6044,6 @@ packages: /path-to-regexp@6.2.1: resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} - dev: true /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -5690,6 +6153,16 @@ packages: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true + /quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + dev: false + + /quick-lru@7.0.0: + resolution: {integrity: sha512-MX8gB7cVYTrYcFfAnfLlhRd0+Toyl8yX8uBx1MrX7K0jegiz9TumwOK27ldXrgDlHRdVi+MqU9Ssw6dr4BNreg==} + engines: {node: '>=18'} + dev: false + /rambda@7.5.0: resolution: {integrity: sha512-y/M9weqWAH4iopRd7EHDEQQvpFPHj1AA3oHozE9tfITHUtTR7Z9PSlIRRG2l1GuW7sefC1cXFfIcF+cgnShdBA==} dev: true @@ -5915,6 +6388,10 @@ packages: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} dev: false + /resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + dev: false + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5931,6 +6408,13 @@ packages: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + /responselike@3.0.0: + resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} + engines: {node: '>=14.16'} + dependencies: + lowercase-keys: 3.0.0 + dev: false + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6234,6 +6718,11 @@ packages: resolution: {integrity: sha512-ELtFtxc3r5we5GZfe6Fi0BFFxIi2M6BY1YEntBscKRDD3zx4JVHqx2VnTRSQu1BixCYSTH3MTjKd4esI2R7EgQ==} dev: true + /statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + dev: false + /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -6499,6 +6988,11 @@ packages: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} dev: true + /tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + dev: false + /tsx@4.7.1: resolution: {integrity: sha512-8d6VuibXHtlN5E3zFkgY8u4DX7Y3Z27zvvPKVmLon/D4AjuKzarkUBTLDBgj9iTQ0hg5xM7c/mYiRVM+HETf0g==} engines: {node: '>=18.0.0'} @@ -7102,6 +7596,11 @@ packages: yargs-parser: 20.2.4 dev: true + /ylru@1.3.2: + resolution: {integrity: sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==} + engines: {node: '>= 4.0.0'} + dev: false + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 53cf76272..9ebd5c672 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,4 @@ packages: - admin - bin - doc + - ui diff --git a/settings.json.docker b/settings.json.docker index 7fbda1aef..d96931822 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -650,5 +650,24 @@ /* * Enable/Disable case-insensitive pad names. */ - "lowerCasePadIds": "${LOWER_CASE_PAD_IDS:false}" + "lowerCasePadIds": "${LOWER_CASE_PAD_IDS:false}", + "sso": { + "issuer": "${SSO_ISSUER:http://localhost:9001}", + "clients": [ + { + "client_id": "${ADMIN_CLIENT:admin_client}", + "client_secret": "${ADMIN_SECRET:admin}", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["${ADMIN_REDIRECT:http://localhost:9001/admin/}"] + }, + { + "client_id": "${USER_CLIENT:user_client}", + "client_secret": "${USER_SECRET:user}", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["${USER_REDIRECT:http://localhost:9001/}"] + } + ] + } } diff --git a/settings.json.template b/settings.json.template index 9c9150394..85165b2f0 100644 --- a/settings.json.template +++ b/settings.json.template @@ -650,5 +650,25 @@ /* * Enable/Disable case-insensitive pad names. */ - "lowerCasePadIds": false + "lowerCasePadIds": false, + + "sso": { + "issuer": "${SSO_ISSUER:http://localhost:9001}", + "clients": [ + { + "client_id": "${ADMIN_CLIENT:admin_client}", + "client_secret": "${ADMIN_SECRET:admin}", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["${ADMIN_REDIRECT:http://localhost:9001/admin/}"] + }, + { + "client_id": "${USER_CLIENT:user_client}", + "client_secret": "${USER_SECRET:user}", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["${USER_REDIRECT:http://localhost:9001/}"] + } + ] + } } diff --git a/src/ep.json b/src/ep.json index f6d41e203..95cb4135e 100644 --- a/src/ep.json +++ b/src/ep.json @@ -45,6 +45,12 @@ "expressPreSession": "ep_etherpad-lite/node/hooks/express/specialpages" } }, + { + "name": "oauth2", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/security/OAuth2Provider" + } + }, { "name": "padurlsanitize", "hooks": { diff --git a/src/node/handler/APIHandler.ts b/src/node/handler/APIHandler.ts index 616d20675..17346b791 100644 --- a/src/node/handler/APIHandler.ts +++ b/src/node/handler/APIHandler.ts @@ -21,30 +21,12 @@ import {MapArrayType} from "../types/MapType"; -const absolutePaths = require('../utils/AbsolutePaths'); -import fs from 'fs'; const api = require('../db/API'); -import log4js from 'log4js'; const padManager = require('../db/PadManager'); -const randomString = require('../utils/randomstring'); -const argv = require('../utils/Cli').argv; import createHTTPError from 'http-errors'; - -const apiHandlerLogger = log4js.getLogger('APIHandler'); - -// ensure we have an apikey -let apikey:string|null = null; -const apikeyFilename = absolutePaths.makeAbsolute(argv.apikey || './APIKEY.txt'); - -try { - apikey = fs.readFileSync(apikeyFilename, 'utf8'); - apiHandlerLogger.info(`Api key file read from: "${apikeyFilename}"`); -} catch (e) { - apiHandlerLogger.info( - `Api key file "${apikeyFilename}" not found. Creating with random contents.`); - apikey = randomString(32); - fs.writeFileSync(apikeyFilename, apikey!, 'utf8'); -} +import {Http2ServerRequest, Http2ServerResponse} from "node:http2"; +import {publicKeyExported} from "../security/OAuth2Provider"; +import {jwtVerify} from "jose"; // a list of all functions const version:MapArrayType = {}; @@ -167,21 +149,20 @@ exports.version = version; type APIFields = { - apikey: string; api_key: string; padID: string; padName: string; } /** - * Handles a HTTP API call + * Handles an HTTP API call * @param {String} apiVersion the version of the api * @param {String} functionName the name of the called function * @param fields the params of the called function - * @req express request object - * @res express response object + * @param req express request object + * @param res express response object */ -exports.handle = async function (apiVersion: string, functionName: string, fields: APIFields) { +exports.handle = async function (apiVersion: string, functionName: string, fields: APIFields, req: Http2ServerRequest, res: Http2ServerResponse) { // say goodbye if this is an unknown API version if (!(apiVersion in version)) { throw new createHTTPError.NotFound('no such api version'); @@ -192,13 +173,20 @@ exports.handle = async function (apiVersion: string, functionName: string, field throw new createHTTPError.NotFound('no such function'); } - // check the api key! - fields.apikey = fields.apikey || fields.api_key; - - if (fields.apikey !== apikey!.trim()) { + if(!req.headers.authorization) { throw new createHTTPError.Unauthorized('no or wrong API Key'); } + try { + await jwtVerify(req.headers.authorization!.replace("Bearer ", ""), publicKeyExported!, {algorithms: ['RS256'], + requiredClaims: ["admin"]}) + + } catch (e) { + throw new createHTTPError.Unauthorized('no or wrong API Key'); + } + + + // sanitize any padIDs before continuing if (fields.padID) { fields.padID = await padManager.sanitizePadId(fields.padID); @@ -217,7 +205,3 @@ exports.handle = async function (apiVersion: string, functionName: string, field // call the api function return api[functionName].apply(this, functionParams); }; - -exports.exportedForTestingOnly = { - apiKey: apikey, -}; diff --git a/src/node/hooks/express/openapi.ts b/src/node/hooks/express/openapi.ts index aa2f1e483..a55e67871 100644 --- a/src/node/hooks/express/openapi.ts +++ b/src/node/hooks/express/openapi.ts @@ -483,14 +483,24 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT) ...defaultResponses, }, securitySchemes: { - ApiKey: { - type: 'apiKey', - in: 'query', - name: 'apikey', + openid: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: settings.sso.issuer+"/oidc/auth", + tokenUrl: settings.sso.issuer+"/oidc/token", + scopes: { + openid: "openid", + profile: "profile", + email: "email", + admin: "admin" + } + } + }, }, }, }, - security: [{ApiKey: []}], + security: [{openid: []}], }; // build operations @@ -657,7 +667,7 @@ exports.expressPreSession = async (hookName:string, {app}:any) => { } // start and bind to express - api.init(); + await api.init(); app.use(apiRoot, async (req:any, res:any) => { let response = null; try { diff --git a/src/node/security/OAuth2Provider.ts b/src/node/security/OAuth2Provider.ts new file mode 100644 index 000000000..3c6583e62 --- /dev/null +++ b/src/node/security/OAuth2Provider.ts @@ -0,0 +1,274 @@ +import {ArgsExpressType} from "../types/ArgsExpressType"; +import Provider, {Account, Configuration} from 'oidc-provider'; +import {generateKeyPair, exportJWK, KeyLike} from 'jose' +import MemoryAdapter from "./OIDCAdapter"; +import path from "path"; +const settings = require('../utils/Settings'); +import {IncomingForm} from 'formidable' +import express, {Request, Response} from 'express'; +import {format} from 'url' +import {ParsedUrlQuery} from "node:querystring"; +import {Http2ServerRequest, Http2ServerResponse} from "node:http2"; + +const configuration: Configuration = { + scopes: ['openid', 'profile', 'email'], + findAccount: async (ctx, id) => { + const users = settings.users as { + [username: string]: { + password: string; + is_admin: boolean; + } + } + + const usersArray1 = Object.keys(users).map((username) => ({ + username, + ...users[username] + })); + + const account = usersArray1.find((user) => user.username === id); + + if(account === undefined) { + return undefined + } + if (account.is_admin) { + return { + accountId: id, + claims: () => ({ + sub: id, + admin: true + }) + } as Account + } else { + return { + accountId: id, + claims: () => ({ + sub: id, + }) + } as Account + } + }, + ttl:{ + AccessToken: 1 * 60 * 60, // 1 hour in seconds + AuthorizationCode: 10 * 60, // 10 minutes in seconds + ClientCredentials: 1 * 60 * 60, // 1 hour in seconds + IdToken: 1 * 60 * 60, // 1 hour in seconds + RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds + }, + claims: { + openid: ['sub'], + email: ['email'], + profile: ['name'], + admin: ['admin'] + }, + cookies: { + keys: ['oidc'], + }, + features:{ + devInteractions: {enabled: false}, + }, + adapter: MemoryAdapter +}; + + +export let publicKeyExported: KeyLike|null +export let privateKeyExported: KeyLike|null + +/* +This function is used to initialize the OAuth2 provider + */ +export const expressCreateServer = async (hookName: string, args: ArgsExpressType, cb: Function) => { + const {privateKey, publicKey} = await generateKeyPair('RS256'); + const privateKeyJWK = await exportJWK(privateKey); + publicKeyExported = publicKey + privateKeyExported = privateKey + + const oidc = new Provider(settings.sso.issuer, { + ...configuration, jwks: { + keys: [ + privateKeyJWK + ], + }, + conformIdTokenClaims: false, + claims: { + address: ['address'], + email: ['email', 'email_verified'], + phone: ['phone_number', 'phone_number_verified'], + profile: ['birthdate', 'family_name', 'gender', 'given_name', 'locale', 'middle_name', 'name', + 'nickname', 'picture', 'preferred_username', 'profile', 'updated_at', 'website', 'zoneinfo'], + }, + features:{ + userinfo: {enabled: true}, + claimsParameter: {enabled: true}, + devInteractions: {enabled: false}, + resourceIndicators: {enabled: true, defaultResource(ctx) { + return ctx.origin; + }, + getResourceServerInfo(ctx, resourceIndicator, client) { + return { + scope: client.scope as string, + audience: 'account', + accessTokenFormat: 'jwt', + }; + }, + useGrantedResource(ctx, model) { + return true; + },}, + jwtResponseModes: {enabled: true}, + }, + clientBasedCORS: (ctx, origin, client) => { + return true + }, + extraTokenClaims: async (ctx, token) => { + + + if(token.kind === 'AccessToken') { + // Add your custom claims here. For example: + const users = settings.users as { + [username: string]: { + password: string; + is_admin: boolean; + } + } + + const usersArray1 = Object.keys(users).map((username) => ({ + username, + ...users[username] + })); + + const account = usersArray1.find((user) => user.username === token.accountId); + return { + admin: account?.is_admin + }; + } + }, + clients: settings.sso.clients + }); + + + args.app.post('/interaction/:uid', async (req: Http2ServerRequest, res: Http2ServerResponse, next:Function) => { + const formid = new IncomingForm(); + try { + // @ts-ignore + const {login, password} = (await formid.parse(req))[0] + const {prompt, jti, session,cid, params, grantId} = await oidc.interactionDetails(req, res); + + const client = await oidc.Client.find(params.client_id as string); + + switch (prompt.name) { + case 'login': { + const users = settings.users as { + [username: string]: { + password: string; + admin: boolean; + } + } + const usersArray1 = Object.keys(users).map((username) => ({ + username, + ...users[username] + })); + const account = usersArray1.find((user) => user.username === login as unknown as string && user.password === password as unknown as string); + if (!account) { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({error: "Invalid login"})); + } + + if (account) { + await oidc.interactionFinished(req, res, { + login: {accountId: account.username} + }, {mergeWithLastSubmission: false}); + } + break; + } + case 'consent': { + let grant; + if (grantId) { + // we'll be modifying existing grant in existing session + grant = await oidc.Grant.find(grantId); + } else { + // we're establishing a new grant + grant = new oidc.Grant({ + accountId: session!.accountId, + clientId: params.client_id as string, + }); + } + + if (prompt.details.missingOIDCScope) { + // @ts-ignore + grant!.addOIDCScope(prompt.details.missingOIDCScope.join(' ')); + } + if (prompt.details.missingOIDCClaims) { + grant!.addOIDCClaims(prompt.details.missingOIDCClaims as string[]); + } + if (prompt.details.missingResourceScopes) { + for (const [indicator, scope] of Object.entries(prompt.details.missingResourceScopes)) { + grant!.addResourceScope(indicator, scope.join(' ')); + } + } + const result = {consent: {grantId: await grant!.save()}}; + await oidc.interactionFinished(req, res, result, { + mergeWithLastSubmission: true, + }); + break; + } + } + await next(); + } catch (err:any) { + return res.writeHead(500).end(err.message); + } + }) + + + args.app.get('/interaction/:uid', async (req: Request, res: Response, next: Function) => { + try { + const { + uid, prompt, params, session, + } = await oidc.interactionDetails(req, res); + + params["state"] = uid + + switch (prompt.name) { + case 'login': { + res.redirect(format({ + pathname: '/views/login.html', + query: params as ParsedUrlQuery + })) + break + } + case 'consent': { + res.redirect(format({ + pathname: '/views/consent.html', + query: params as ParsedUrlQuery + })) + break + } + default: + return res.sendFile(path.join(settings.root,'src','static', 'oidc','login.html')); + } + } catch (err) { + return next(err); + } + }); + + + args.app.use('/views/', express.static(path.join(settings.root,'src','static', 'oidc'), {maxAge: 1000 * 60 * 60 * 24})); + + /* + oidc.on('authorization.error', (ctx, error) => { + console.log('authorization.error', error); + }) + + oidc.on('server_error', (ctx, error) => { + console.log('server_error', error); + }) + oidc.on('grant.error', (ctx, error) => { + console.log('grant.error', error); + }) + oidc.on('introspection.error', (ctx, error) => { + console.log('introspection.error', error); + }) + oidc.on('revocation.error', (ctx, error) => { + console.log('revocation.error', error); + })*/ + args.app.use("/oidc", oidc.callback()); + //cb(); +} diff --git a/src/node/security/OAuth2User.ts b/src/node/security/OAuth2User.ts new file mode 100644 index 000000000..b4305c401 --- /dev/null +++ b/src/node/security/OAuth2User.ts @@ -0,0 +1,5 @@ +export type OAuth2User = { + username: string; + password: string; + admin: boolean; +} diff --git a/src/node/security/OIDCAdapter.ts b/src/node/security/OIDCAdapter.ts new file mode 100644 index 000000000..7fb907776 --- /dev/null +++ b/src/node/security/OIDCAdapter.ts @@ -0,0 +1,115 @@ +import {LRUCache} from 'lru-cache'; +import type {Adapter, AdapterPayload} from "oidc-provider"; + + +const options = { + max: 500, + sizeCalculation: (item:any, key:any) => { + return 1 + }, + // for use with tracking overall storage size + maxSize: 5000, + + // how long to live in ms + ttl: 1000 * 60 * 5, + + // return stale items before removing from cache? + allowStale: false, + + updateAgeOnGet: false, + updateAgeOnHas: false, +} + +const epochTime = (date = Date.now()) => Math.floor(date / 1000); + +const storage = new LRUCache(options); + +function grantKeyFor(id: string) { + return `grant:${id}`; +} + +function userCodeKeyFor(userCode:string) { + return `userCode:${userCode}`; +} + +class MemoryAdapter implements Adapter{ + private readonly name: string; + constructor(name:string) { + this.name = name; + } + + key(id:string) { + return `${this.name}:${id}`; + } + + destroy(id:string) { + const key = this.key(id); + + const found = storage.get(key) as AdapterPayload; + const grantId = found && found.grantId; + + storage.delete(key); + + if (grantId) { + const grantKey = grantKeyFor(grantId); + (storage.get(grantKey) as string[])!.forEach(token => storage.delete(token)); + storage.delete(grantKey); + } + + return Promise.resolve(); + } + + consume(id: string) { + (storage.get(this.key(id)) as AdapterPayload)!.consumed = epochTime(); + return Promise.resolve(); + } + + find(id: string): Promise { + if (storage.has(this.key(id))){ + return Promise.resolve(storage.get(this.key(id)) as AdapterPayload); + } + return Promise.resolve(undefined) + } + + findByUserCode(userCode: string) { + const id = storage.get(userCodeKeyFor(userCode)) as string; + return this.find(id); + } + + upsert(id: string, payload: { + iat: number; + exp: number; + uid: string; + kind: string; + jti: string; + accountId: string; + loginTs: number; + }, expiresIn: number) { + const key = this.key(id); + + storage.set(key, payload, {ttl: expiresIn * 1000}); + + return Promise.resolve(); + } + + findByUid(uid: string): Promise { + for(const [_, value] of storage.entries()){ + if(typeof value ==="object" && "uid" in value && value.uid === uid){ + return Promise.resolve(value); + } + } + return Promise.resolve(undefined); + } + + revokeByGrantId(grantId: string): Promise { + const grantKey = grantKeyFor(grantId); + const grant = storage.get(grantKey) as string[]; + if (grant) { + grant.forEach((token) => storage.delete(token)); + storage.delete(grantKey); + } + return Promise.resolve(); + } +} + +export default MemoryAdapter diff --git a/src/node/utils/Cli.ts b/src/node/utils/Cli.ts index 1579dd3ce..1b2938196 100644 --- a/src/node/utils/Cli.ts +++ b/src/node/utils/Cli.ts @@ -45,10 +45,5 @@ for (let i = 0; i < argv.length; i++) { exports.argv.sessionkey = arg; } - // Override location of APIKEY.txt file - if (prevArg === '--apikey') { - exports.argv.apikey = arg; - } - prevArg = arg; } diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 0fd964872..426ed555c 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -342,6 +342,11 @@ exports.requireAuthentication = false; exports.requireAuthorization = false; exports.users = {}; +/* + * This setting is used for configuring sso + */ +exports.sso = {} + /* * Show settings in admin page, by default it is true */ diff --git a/src/package.json b/src/package.json index 9652b1ff9..8a9f8e259 100644 --- a/src/package.json +++ b/src/package.json @@ -45,15 +45,18 @@ "find-root": "1.1.0", "formidable": "^3.5.1", "http-errors": "^2.0.0", + "jose": "^5.2.3", "js-cookie": "^3.0.5", "jsdom": "^24.0.0", "jsonminify": "0.4.2", + "jsonwebtoken": "^9.0.2", "languages4translatewiki": "0.1.3", "live-plugin-manager-pnpm": "^0.18.1", "lodash.clonedeep": "4.5.0", "log4js": "^6.9.1", "measured-core": "^2.0.0", "mime-types": "^2.1.35", + "oidc-provider": "^8.4.5", "openapi-backend": "^5.10.6", "proxy-addr": "^2.0.7", "rate-limiter-flexible": "^5.0.0", @@ -72,22 +75,27 @@ "ueberdb2": "^4.2.63", "underscore": "1.13.6", "unorm": "1.6.0", - "wtfnode": "^0.9.1" + "wtfnode": "^0.9.1", + "lru-cache": "^10.2.0" }, "bin": { "etherpad-healthcheck": "../bin/etherpad-healthcheck", "etherpad-lite": "node/server.ts" }, "devDependencies": { + "@playwright/test": "^1.42.1", "@types/async": "^3.2.24", "@types/express": "^4.17.21", + "@types/formidable": "^3.4.5", "@types/http-errors": "^2.0.4", "@types/jsdom": "^21.1.6", + "@types/jsonwebtoken": "^9.0.6", "@types/mocha": "^10.0.6", "@types/node": "^20.11.30", + "@types/oidc-provider": "^8.4.4", + "@types/semver": "^7.5.8", "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", - "@types/semver": "^7.5.8", "@types/underscore": "^1.11.15", "eslint": "^8.57.0", "eslint-config-etherpad": "^4.0.4", @@ -96,7 +104,6 @@ "mocha-froth": "^0.2.10", "nodeify": "^1.0.1", "openapi-schema-validation": "^0.4.2", - "@playwright/test": "^1.42.1", "set-cookie-parser": "^2.6.0", "sinon": "^17.0.1", "split-grid": "^1.0.11", diff --git a/src/tests/backend/common.ts b/src/tests/backend/common.ts index c0cfd1377..21fb01e2f 100644 --- a/src/tests/backend/common.ts +++ b/src/tests/backend/common.ts @@ -13,18 +13,20 @@ const server = require('../../node/server'); const setCookieParser = require('set-cookie-parser'); const settings = require('../../node/utils/Settings'); import supertest from 'supertest'; +import TestAgent from "supertest/lib/agent"; +import {Http2Server} from "node:http2"; +import {SignJWT} from "jose"; +import {privateKeyExported} from "../../node/security/OAuth2Provider"; const webaccess = require('../../node/hooks/express/webaccess'); const backups:MapArrayType = {}; let agentPromise:Promise|null = null; -exports.apiKey = apiHandler.exportedForTestingOnly.apiKey; -exports.agent = null; -exports.baseUrl = null; -exports.httpServer = null; -exports.logger = log4js.getLogger('test'); +export let agent: TestAgent|null = null; +export let baseUrl:string|null = null; +export let httpServer: Http2Server|null = null; +export const logger = log4js.getLogger('test'); -const logger = exports.logger; const logLevel = logger.level; // Mocha doesn't monitor unhandled Promise rejections, so convert them to uncaught exceptions. @@ -33,10 +35,37 @@ process.on('unhandledRejection', (reason: string) => { throw reason; }); before(async function () { this.timeout(60000); - await exports.init(); + await init(); }); -exports.init = async function () { + +export const generateJWTToken = () => { + const jwt = new SignJWT({ + sub: 'admin', + jti: '123', + exp: Math.floor(Date.now() / 1000) + 60 * 60, + aud: 'account', + iss: 'http://localhost:9001', + admin: true + }) + jwt.setProtectedHeader({alg: 'RS256'}) + return jwt.sign(privateKeyExported!) +} + + +export const generateJWTTokenUser = () => { + const jwt = new SignJWT({ + sub: 'admin', + jti: '123', + exp: Math.floor(Date.now() / 1000) + 60 * 60, + aud: 'account', + iss: 'http://localhost:9001', + }) + jwt.setProtectedHeader({alg: 'RS256'}) + return jwt.sign(privateKeyExported!) +} + +export const init = async function () { if (agentPromise != null) return await agentPromise; let agentResolve; agentPromise = new Promise((resolve) => { agentResolve = resolve; }); @@ -53,11 +82,13 @@ exports.init = async function () { settings.ip = 'localhost'; settings.importExportRateLimiting = {max: 999999}; settings.commitRateLimiting = {duration: 0.001, points: 1e6}; - exports.httpServer = await server.start(); - exports.baseUrl = `http://localhost:${exports.httpServer.address().port}`; - logger.debug(`HTTP server at ${exports.baseUrl}`); + httpServer = await server.start(); + // @ts-ignore + baseUrl = `http://localhost:${httpServer!.address()!.port}`; + logger.debug(`HTTP server at ${baseUrl}`); // Create a supertest user agent for the HTTP server. - exports.agent = supertest(exports.baseUrl); + agent = supertest(baseUrl) + //.set('Authorization', `Bearer ${await generateJWTToken()}`); // Speed up authn tests. backups.authnFailureDelayMs = webaccess.authnFailureDelayMs; webaccess.authnFailureDelayMs = 0; @@ -69,8 +100,8 @@ exports.init = async function () { await server.exit(); }); - agentResolve!(exports.agent); - return exports.agent; + agentResolve!(agent); + return agent; }; /** @@ -81,7 +112,7 @@ exports.init = async function () { * @param {string} event - The socket.io Socket event to listen for. * @returns The argument(s) passed to the event handler. */ -exports.waitForSocketEvent = async (socket: any, event:string) => { +export const waitForSocketEvent = async (socket: any, event:string) => { const errorEvents = [ 'error', 'connect_error', @@ -136,7 +167,7 @@ exports.waitForSocketEvent = async (socket: any, event:string) => { * nullish, no cookies are passed to the server. * @returns {io.Socket} A socket.io client Socket object. */ -exports.connect = async (res:any = null) => { +export const connect = async (res:any = null) => { // Convert the `set-cookie` header(s) into a `cookie` header. const resCookies = (res == null) ? {} : setCookieParser.parse(res, {map: true}); const reqCookieHdr = Object.entries(resCookies).map( @@ -148,14 +179,14 @@ exports.connect = async (res:any = null) => { if (res) { padId = res.req.path.split('/p/')[1]; } - const socket = io(`${exports.baseUrl}/`, { + const socket = io(`${baseUrl}/`, { forceNew: true, // Different tests will have different query parameters. // socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the // express_sid cookie must be passed as a query parameter. query: {cookie: reqCookieHdr, padId}, }); try { - await exports.waitForSocketEvent(socket, 'connect'); + await waitForSocketEvent(socket, 'connect'); } catch (e) { socket.close(); throw e; @@ -173,7 +204,7 @@ exports.connect = async (res:any = null) => { * @param token * @returns The CLIENT_VARS message from the server. */ -exports.handshake = async (socket: any, padId:string, token = padutils.generateAuthorToken()) => { +export const handshake = async (socket: any, padId:string, token = padutils.generateAuthorToken()) => { logger.debug('sending CLIENT_READY...'); socket.emit('message', { component: 'pad', @@ -183,7 +214,7 @@ exports.handshake = async (socket: any, padId:string, token = padutils.generateA token, }); logger.debug('waiting for CLIENT_VARS response...'); - const msg = await exports.waitForSocketEvent(socket, 'message'); + const msg = await waitForSocketEvent(socket, 'message'); logger.debug('received CLIENT_VARS message'); return msg; }; @@ -191,7 +222,7 @@ exports.handshake = async (socket: any, padId:string, token = padutils.generateA /** * Convenience wrapper around `socket.send()` that waits for acknowledgement. */ -exports.sendMessage = async (socket: any, message:any) => await new Promise((resolve, reject) => { +export const sendMessage = async (socket: any, message:any) => await new Promise((resolve, reject) => { socket.emit('message', message, (errInfo:{ name: string, message: string, @@ -210,7 +241,7 @@ exports.sendMessage = async (socket: any, message:any) => await new Promise await exports.sendMessage(socket, { +export const sendUserChanges = async (socket:any, data:any) => await sendMessage(socket, { type: 'COLLABROOM', component: 'pad', data: { @@ -232,8 +263,8 @@ exports.sendUserChanges = async (socket:any, data:any) => await exports.sendMess * common.sendUserChanges(socket, {baseRev: rev, changeset}), * ]); */ -exports.waitForAcceptCommit = async (socket:any, wantRev: number) => { - const msg = await exports.waitForSocketEvent(socket, 'message'); +export const waitForAcceptCommit = async (socket:any, wantRev: number) => { + const msg = await waitForSocketEvent(socket, 'message'); assert.deepEqual(msg, { type: 'COLLABROOM', data: { @@ -252,7 +283,7 @@ const alphabet = 'abcdefghijklmnopqrstuvwxyz'; * @param {string} [charset] - Characters to pick from. * @returns {string} */ -exports.randomString = (len: number = 10, charset: string = `${alphabet}${alphabet.toUpperCase()}0123456789`): string => { +export const randomString = (len: number = 10, charset: string = `${alphabet}${alphabet.toUpperCase()}0123456789`): string => { let ret = ''; while (ret.length < len) ret += charset[Math.floor(Math.random() * charset.length)]; return ret; diff --git a/src/tests/backend/fuzzImportTest.ts b/src/tests/backend/fuzzImportTest.ts index 8366e62d8..5b959bbc2 100644 --- a/src/tests/backend/fuzzImportTest.ts +++ b/src/tests/backend/fuzzImportTest.ts @@ -7,13 +7,12 @@ const common = require('./common'); const host = `http://${settings.ip}:${settings.port}`; const froth = require('mocha-froth'); const axios = require('axios'); -const apiKey = common.apiKey; const apiVersion = 1; const testPadId = `TEST_fuzz${makeid()}`; const endPoint = function (point: string, version?:number) { version = version || apiVersion; - return `/api/${version}/${point}?apikey=${apiKey}`; + return `/api/${version}/${point}}`; }; console.log('Testing against padID', testPadId); @@ -29,7 +28,12 @@ setTimeout(() => { }, 5000); // wait 5 seconds async function runTest(number: number) { - await axios.get(`${host + endPoint('createPad')}&padID=${testPadId}`) + await axios + .get(`${host + endPoint('createPad')}?padID=${testPadId}`, { + headers: { + Authorization: await common.generateJWTToken(), + } + }) .then(() => { const req = axios.post(`${host}/p/${testPadId}/import`) .then(() => { diff --git a/src/tests/backend/specs/api/api.ts b/src/tests/backend/specs/api/api.ts index 32681e5c9..bab70b47c 100644 --- a/src/tests/backend/specs/api/api.ts +++ b/src/tests/backend/specs/api/api.ts @@ -12,7 +12,6 @@ const common = require('../../common'); const validateOpenAPI = require('openapi-schema-validation').validate; let agent: any; -const apiKey = common.apiKey; let apiVersion = 1; const makeid = () => { @@ -27,7 +26,7 @@ const makeid = () => { const testPadId = makeid(); -const endPoint = (point:string) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point:string) => `/api/${apiVersion}/${point}`; describe(__filename, function () { before(async function () { agent = await common.init(); }); diff --git a/src/tests/backend/specs/api/characterEncoding.ts b/src/tests/backend/specs/api/characterEncoding.ts index 7c2202a09..80093437b 100644 --- a/src/tests/backend/specs/api/characterEncoding.ts +++ b/src/tests/backend/specs/api/characterEncoding.ts @@ -6,17 +6,18 @@ * TODO: maybe unify those two files and merge in a single one. */ +import {generateJWTToken, generateJWTTokenUser} from "../../common"; + const assert = require('assert').strict; const common = require('../../common'); const fs = require('fs'); const fsp = fs.promises; let agent:any; -const apiKey = common.apiKey; let apiVersion = 1; const testPadId = makeid(); -const endPoint = (point:string, version?:number) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point:string, version?:number) => `/api/${version || apiVersion}/${point}`; describe(__filename, function () { before(async function () { agent = await common.init(); }); @@ -24,28 +25,38 @@ describe(__filename, function () { describe('Sanity checks', function () { it('can connect', async function () { await agent.get('/api/') + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/); }); it('finds the version tag', async function () { const res = await agent.get('/api/') + .set("Authorization", await generateJWTToken()) .expect(200); apiVersion = res.body.currentVersion; assert(apiVersion); }); - it('errors with invalid APIKey', async function () { + it('errors with invalid OAuth token', async function () { // This is broken because Etherpad doesn't handle HTTP codes properly see #2343 - // If your APIKey is password you deserve to fail all tests anyway - await agent.get(`/api/${apiVersion}/createPad?apikey=password&padID=test`) + await agent.get(`/api/${apiVersion}/createPad?padID=test`) + .set("Authorization", (await generateJWTToken()).substring(0,10)) + .expect(401); + }); + + it('errors with unprivileged OAuth token', async function () { + // This is broken because Etherpad doesn't handle HTTP codes properly see #2343 + await agent.get(`/api/${apiVersion}/createPad?padID=test`) + .set("Authorization", (await generateJWTTokenUser()).substring(0,10)) .expect(401); }); }); describe('Tests', function () { it('creates a new Pad', async function () { - const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -53,6 +64,7 @@ describe(__filename, function () { it('Sets the HTML of a Pad attempting to weird utf8 encoded content', async function () { const res = await agent.post(endPoint('setHTML')) + .set("Authorization", await generateJWTToken()) .send({ padID: testPadId, html: await fsp.readFile('tests/backend/specs/api/emojis.html', 'utf8'), @@ -63,7 +75,8 @@ describe(__filename, function () { }); it('get the HTML of Pad with emojis', async function () { - const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/); assert.match(res.body.data.html, /🇼/); diff --git a/src/tests/backend/specs/api/chat.ts b/src/tests/backend/specs/api/chat.ts index dc61402bf..d2c0ba8a8 100644 --- a/src/tests/backend/specs/api/chat.ts +++ b/src/tests/backend/specs/api/chat.ts @@ -1,17 +1,18 @@ 'use strict'; +import {generateJWTToken} from "../../common"; + const common = require('../../common'); import {strict as assert} from "assert"; let agent:any; -const apiKey = common.apiKey; let apiVersion = 1; let authorID = ''; const padID = makeid(); const timestamp = Date.now(); -const endPoint = (point:string) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point:string) => `/api/${apiVersion}/${point}`; describe(__filename, function () { before(async function () { agent = await common.init(); }); @@ -42,16 +43,18 @@ describe(__filename, function () { describe('Chat functionality', function () { it('creates a new Pad', async function () { - await agent.get(`${endPoint('createPad')}&padID=${padID}`) + await agent.get(`${endPoint('createPad')}?padID=${padID}`) + .set("authorization", await generateJWTToken()) + .expect(200) .expect((res:any) => { if (res.body.code !== 0) throw new Error('Unable to create new Pad'); }) - .expect('Content-Type', /json/) - .expect(200); + .expect('Content-Type', /json/); }); it('Creates an author with a name set', async function () { await agent.get(endPoint('createAuthor')) + .set("authorization", await generateJWTToken()) .expect((res:any) => { if (res.body.code !== 0 || !res.body.data.authorID) { throw new Error('Unable to create author'); @@ -63,7 +66,8 @@ describe(__filename, function () { }); it('Gets the head of chat before the first chat msg', async function () { - await agent.get(`${endPoint('getChatHead')}&padID=${padID}`) + await agent.get(`${endPoint('getChatHead')}?padID=${padID}`) + .set("authorization", await generateJWTToken()) .expect((res:any) => { if (res.body.data.chatHead !== -1) throw new Error('Chat Head Length is wrong'); if (res.body.code !== 0) throw new Error('Unable to get chat head'); @@ -73,8 +77,9 @@ describe(__filename, function () { }); it('Adds a chat message to the pad', async function () { - await agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` + + await agent.get(`${endPoint('appendChatMessage')}?padID=${padID}&text=blalblalbha` + `&authorID=${authorID}&time=${timestamp}`) + .set("authorization", await generateJWTToken()) .expect((res:any) => { if (res.body.code !== 0) throw new Error('Unable to create chat message'); }) @@ -83,7 +88,8 @@ describe(__filename, function () { }); it('Gets the head of chat', async function () { - await agent.get(`${endPoint('getChatHead')}&padID=${padID}`) + await agent.get(`${endPoint('getChatHead')}?padID=${padID}`) + .set("authorization", await generateJWTToken()) .expect((res:any) => { if (res.body.data.chatHead !== 0) throw new Error('Chat Head Length is wrong'); @@ -94,7 +100,8 @@ describe(__filename, function () { }); it('Gets Chat History of a Pad', async function () { - await agent.get(`${endPoint('getChatHistory')}&padID=${padID}`) + await agent.get(`${endPoint('getChatHistory')}?padID=${padID}`) + .set("authorization", await generateJWTToken()) .expect('Content-Type', /json/) .expect(200) .expect((res:any) => { diff --git a/src/tests/backend/specs/api/fuzzImportTest.ts b/src/tests/backend/specs/api/fuzzImportTest.ts index 85f4c81f2..3caa185da 100644 --- a/src/tests/backend/specs/api/fuzzImportTest.ts +++ b/src/tests/backend/specs/api/fuzzImportTest.ts @@ -9,7 +9,6 @@ const settings = require('../../../container/loadSettings.js').loadSettings(); const host = "http://" + settings.ip + ":" + settings.port; -const apiKey = common.apiKey; var apiVersion = 1; var testPadId = "TEST_fuzz" + makeid(); diff --git a/src/tests/backend/specs/api/importexport.ts b/src/tests/backend/specs/api/importexport.ts index a1ef64d87..5b93ea346 100644 --- a/src/tests/backend/specs/api/importexport.ts +++ b/src/tests/backend/specs/api/importexport.ts @@ -11,10 +11,9 @@ import {MapArrayType} from "../../../../node/types/MapType"; const common = require('../../common'); let agent:any; -const apiKey = common.apiKey; const apiVersion = 1; -const endPoint = (point: string, version?:string) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point: string, version?:string) => `/api/${version || apiVersion}/${point}`; const testImports:MapArrayType = { 'malformed': { @@ -243,29 +242,33 @@ describe(__filename, function () { } it('createPad', async function () { - const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('setHTML', async function () { - const res = await agent.get(`${endPoint('setHTML')}&padID=${testPadId}` + + const res = await agent.get(`${endPoint('setHTML')}?padID=${testPadId}` + `&html=${encodeURIComponent(test.input)}`) + .set("authorization", await common.generateJWTToken()) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('getHTML', async function () { - const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.html, test.wantHTML); }); it('getText', async function () { - const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.text, test.wantText); diff --git a/src/tests/backend/specs/api/importexportGetPost.ts b/src/tests/backend/specs/api/importexportGetPost.ts index 40bfb5552..355699bc2 100644 --- a/src/tests/backend/specs/api/importexportGetPost.ts +++ b/src/tests/backend/specs/api/importexportGetPost.ts @@ -5,6 +5,8 @@ */ import {MapArrayType} from "../../../../node/types/MapType"; +import {SuperTestStatic} from "supertest"; +import TestAgent from "supertest/lib/agent"; const assert = require('assert').strict; const common = require('../../common'); @@ -21,8 +23,7 @@ const wordXDoc = fs.readFileSync(`${__dirname}/test.docx`); const odtDoc = fs.readFileSync(`${__dirname}/test.odt`); const pdfDoc = fs.readFileSync(`${__dirname}/test.pdf`); -let agent:any; -const apiKey = common.apiKey; +let agent: TestAgent; const apiVersion = 1; const testPadId = makeid(); const testPadIdEnc = encodeURIComponent(testPadId); @@ -41,6 +42,7 @@ describe(__filename, function () { describe('Connectivity', function () { it('can connect', async function () { await agent.get('/api/') + .set("authorization", await common.generateJWTToken()) .expect(200) .expect('Content-Type', /json/); }); @@ -49,6 +51,7 @@ describe(__filename, function () { describe('API Versioning', function () { it('finds the version tag', async function () { await agent.get('/api/') + .set("authorization", await common.generateJWTToken()) .expect(200) .expect((res:any) => assert(res.body.currentVersion)); }); @@ -103,14 +106,17 @@ describe(__filename, function () { }); it('creates a new Pad, imports content to it, checks that content', async function () { - await agent.get(`${endPoint('createPad')}&padID=${testPadId}`) + await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => assert.equal(res.body.code, 0)); await agent.post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(200); - await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + await agent.get(`${endPoint('getText')}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) .expect(200) .expect((res:any) => assert.equal(res.body.data.text, padText.toString())); }); @@ -122,9 +128,11 @@ describe(__filename, function () { beforeEach(async function () { if (readOnlyId != null) return; await agent.post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(200); - const res = await agent.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getReadOnlyID')}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => assert.equal(res.body.code, 0)); @@ -145,7 +153,8 @@ describe(__filename, function () { // This ought to be before(), but it must run after the top-level beforeEach() above. beforeEach(async function () { if (text != null) return; - let req = agent.get(`/p/${readOnlyId}/export/${exportType}`); + let req = agent.get(`/p/${readOnlyId}/export/${exportType}`) + .set("authorization", await common.generateJWTToken()); if (authn) req = req.auth('user', 'user-password'); const res = await req .expect(200) @@ -163,6 +172,7 @@ describe(__filename, function () { it('re-import to read-only pad ID gives 403 forbidden', async function () { let req = agent.post(`/p/${readOnlyId}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', Buffer.from(text), { filename: `/test.${exportType}`, contentType: 'text/plain', @@ -175,6 +185,7 @@ describe(__filename, function () { // The new pad ID must differ from testPadId because Etherpad refuses to import // .etherpad files on top of a pad that already has edits. let req = agent.post(`/p/${testPadId}_import/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', Buffer.from(text), { filename: `/test.${exportType}`, contentType: 'text/plain', @@ -200,6 +211,7 @@ describe(__filename, function () { // TODO: fix support for .doc files.. it('Tries to import .doc that uses soffice or abiword', async function () { await agent.post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', wordDoc, {filename: '/test.doc', contentType: 'application/msword'}) .expect(200) .expect('Content-Type', /json/) @@ -212,6 +224,7 @@ describe(__filename, function () { it('exports DOC', async function () { await agent.get(`/p/${testPadId}/export/doc`) + .set("authorization", await common.generateJWTToken()) .buffer(true).parse(superagent.parse['application/octet-stream']) .expect(200) .expect((res:any) => assert(res.body.length >= 9000)); @@ -219,6 +232,7 @@ describe(__filename, function () { it('Tries to import .docx that uses soffice or abiword', async function () { await agent.post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', wordXDoc, { filename: '/test.docx', contentType: @@ -235,6 +249,7 @@ describe(__filename, function () { it('exports DOC from imported DOCX', async function () { await agent.get(`/p/${testPadId}/export/doc`) + .set("authorization", await common.generateJWTToken()) .buffer(true).parse(superagent.parse['application/octet-stream']) .expect(200) .expect((res:any) => assert(res.body.length >= 9100)); @@ -242,6 +257,7 @@ describe(__filename, function () { it('Tries to import .pdf that uses soffice or abiword', async function () { await agent.post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', pdfDoc, {filename: '/test.pdf', contentType: 'application/pdf'}) .expect(200) .expect('Content-Type', /json/) @@ -254,6 +270,7 @@ describe(__filename, function () { it('exports PDF', async function () { await agent.get(`/p/${testPadId}/export/pdf`) + .set("authorization", await common.generateJWTToken()) .buffer(true).parse(superagent.parse['application/octet-stream']) .expect(200) .expect((res:any) => assert(res.body.length >= 1000)); @@ -261,6 +278,7 @@ describe(__filename, function () { it('Tries to import .odt that uses soffice or abiword', async function () { await agent.post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', odtDoc, {filename: '/test.odt', contentType: 'application/odt'}) .expect(200) .expect('Content-Type', /json/) @@ -273,6 +291,7 @@ describe(__filename, function () { it('exports ODT', async function () { await agent.get(`/p/${testPadId}/export/odt`) + .set("authorization", await common.generateJWTToken()) .buffer(true).parse(superagent.parse['application/octet-stream']) .expect(200) .expect((res:any) => assert(res.body.length >= 7000)); @@ -282,6 +301,7 @@ describe(__filename, function () { it('Tries to import .etherpad', async function () { this.timeout(3000); await agent.post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', etherpadDoc, { filename: '/test.etherpad', contentType: 'application/etherpad', @@ -298,6 +318,7 @@ describe(__filename, function () { it('exports Etherpad', async function () { this.timeout(3000); await agent.get(`/p/${testPadId}/export/etherpad`) + .set("authorization", await common.generateJWTToken()) .buffer(true).parse(superagent.parse.text) .expect(200) .expect(/hello/); @@ -306,6 +327,7 @@ describe(__filename, function () { it('exports HTML for this Etherpad file', async function () { this.timeout(3000); await agent.get(`/p/${testPadId}/export/html`) + .set("authorization", await common.generateJWTToken()) .expect(200) .expect('content-type', 'text/html; charset=utf-8') .expect(/
    • hello<\/ul><\/li><\/ul>/); @@ -315,6 +337,7 @@ describe(__filename, function () { this.timeout(3000); settings.allowUnknownFileEnds = false; await agent.post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', padText, {filename: '/test.xasdasdxx', contentType: 'weirdness/jobby'}) .expect(400) .expect('Content-Type', /json/) @@ -380,6 +403,8 @@ describe(__filename, function () { // that a buggy makeGoodExport() doesn't cause checks to accidentally pass. const records = makeGoodExport(); await deleteTestPad(); + const importedPads = await importEtherpad(records) + console.log(importedPads) await importEtherpad(records) .expect(200) .expect('Content-Type', /json/) @@ -389,6 +414,7 @@ describe(__filename, function () { data: {directDatabaseAccess: true}, })); await agent.get(`/p/${testPadId}/export/txt`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.match(res.text, /foo/)); @@ -397,19 +423,19 @@ describe(__filename, function () { it('missing rev', async function () { const records:MapArrayType = makeGoodExport(); delete records['pad:testing:revs:0']; - await importEtherpad(records).expect(500); + importEtherpad(records).expect(500); }); it('bad changeset', async function () { const records = makeGoodExport(); records['pad:testing:revs:0'].changeset = 'garbage'; - await importEtherpad(records).expect(500); + importEtherpad(records).expect(500); }); it('missing attrib in pool', async function () { const records = makeGoodExport(); records['pad:testing'].pool.nextNum++; - await importEtherpad(records).expect(500); + (importEtherpad(records)).expect(500); }); it('extra attrib in pool', async function () { @@ -417,7 +443,7 @@ describe(__filename, function () { const pool = records['pad:testing'].pool; // @ts-ignore pool.numToAttrib[pool.nextNum] = ['key', 'value']; - await importEtherpad(records).expect(500); + (importEtherpad(records)).expect(500); }); it('changeset refers to non-existent attrib', async function () { @@ -434,19 +460,19 @@ describe(__filename, function () { text: 'asdffoo\n', attribs: '*1+4|1+4', }; - await importEtherpad(records).expect(500); + (importEtherpad(records)).expect(500); }); it('pad atext does not match', async function () { const records = makeGoodExport(); records['pad:testing'].atext.attribs = `*0${records['pad:testing'].atext.attribs}`; - await importEtherpad(records).expect(500); + (importEtherpad(records)).expect(500); }); it('missing chat message', async function () { const records:MapArrayType = makeGoodExport(); delete records['pad:testing:chat:0']; - await importEtherpad(records).expect(500); + importEtherpad(records).expect(500); }); }); @@ -523,7 +549,7 @@ describe(__filename, function () { }, }); - const importEtherpad = (records:MapArrayType) => agent.post(`/p/${testPadId}/import`) + const importEtherpad = (records: MapArrayType) => agent.post(`/p/${testPadId}/import`) .attach('file', Buffer.from(JSON.stringify(records), 'utf8'), { filename: '/test.etherpad', contentType: 'application/etherpad', @@ -543,6 +569,7 @@ describe(__filename, function () { data: {directDatabaseAccess: true}, })); await agent.get(`/p/${testPadId}/export/txt`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.equal(res.text, 'oofoo\n')); @@ -550,6 +577,7 @@ describe(__filename, function () { it('txt request rev 1', async function () { await agent.get(`/p/${testPadId}/1/export/txt`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.equal(res.text, 'ofoo\n')); @@ -557,6 +585,7 @@ describe(__filename, function () { it('txt request rev 2', async function () { await agent.get(`/p/${testPadId}/2/export/txt`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.equal(res.text, 'oofoo\n')); @@ -564,6 +593,7 @@ describe(__filename, function () { it('txt request rev 1test returns rev 1', async function () { await agent.get(`/p/${testPadId}/1test/export/txt`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.equal(res.text, 'ofoo\n')); @@ -571,6 +601,7 @@ describe(__filename, function () { it('txt request rev test1 is 403', async function () { await agent.get(`/p/${testPadId}/test1/export/txt`) + .set("authorization", await common.generateJWTToken()) .expect(500) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.match(res.text, /rev is not a number/)); @@ -578,6 +609,7 @@ describe(__filename, function () { it('txt request rev 5 returns head rev', async function () { await agent.get(`/p/${testPadId}/5/export/txt`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.equal(res.text, 'oofoo\n')); @@ -585,6 +617,7 @@ describe(__filename, function () { it('html request rev 1', async function () { await agent.get(`/p/${testPadId}/1/export/html`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.match(res.text, /ofoo
      /)); @@ -592,6 +625,7 @@ describe(__filename, function () { it('html request rev 2', async function () { await agent.get(`/p/${testPadId}/2/export/html`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.match(res.text, /oofoo
      /)); @@ -599,6 +633,7 @@ describe(__filename, function () { it('html request rev 1test returns rev 1', async function () { await agent.get(`/p/${testPadId}/1test/export/html`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.match(res.text, /ofoo
      /)); @@ -606,6 +641,7 @@ describe(__filename, function () { it('html request rev test1 results in 500 response', async function () { await agent.get(`/p/${testPadId}/test1/export/html`) + .set("authorization", await common.generateJWTToken()) .expect(500) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.match(res.text, /rev is not a number/)); @@ -613,6 +649,7 @@ describe(__filename, function () { it('html request rev 5 returns head rev', async function () { await agent.get(`/p/${testPadId}/5/export/html`) + .set("authorization", await common.generateJWTToken()) .expect(200) .buffer(true).parse(superagent.parse.text) .expect((res:any) => assert.match(res.text, /oofoo
      /)); @@ -643,6 +680,7 @@ describe(__filename, function () { it('!authn !exist -> create', async function () { await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(200); assert(await padManager.doesPadExist(testPadId)); @@ -653,6 +691,7 @@ describe(__filename, function () { it('!authn exist -> replace', async function () { const pad = await createTestPad('before import'); await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(200); assert(await padManager.doesPadExist(testPadId)); @@ -662,6 +701,7 @@ describe(__filename, function () { it('authn anonymous !exist -> fail', async function () { settings.requireAuthentication = true; await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(401); assert(!(await padManager.doesPadExist(testPadId))); @@ -671,6 +711,7 @@ describe(__filename, function () { settings.requireAuthentication = true; const pad = await createTestPad('before import\n'); await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(401); assert.equal(pad.text(), 'before import\n'); @@ -679,6 +720,7 @@ describe(__filename, function () { it('authn user create !exist -> create', async function () { settings.requireAuthentication = true; await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .auth('user', 'user-password') .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(200); @@ -691,6 +733,7 @@ describe(__filename, function () { settings.requireAuthentication = true; authorize = () => 'modify'; await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .auth('user', 'user-password') .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(403); @@ -701,6 +744,7 @@ describe(__filename, function () { settings.requireAuthentication = true; authorize = () => 'readOnly'; await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .auth('user', 'user-password') .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(403); @@ -711,6 +755,7 @@ describe(__filename, function () { settings.requireAuthentication = true; const pad = await createTestPad('before import\n'); await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .auth('user', 'user-password') .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(200); @@ -722,6 +767,7 @@ describe(__filename, function () { authorize = () => 'modify'; const pad = await createTestPad('before import\n'); await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .auth('user', 'user-password') .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(200); @@ -733,6 +779,7 @@ describe(__filename, function () { settings.requireAuthentication = true; authorize = () => 'readOnly'; await agent.post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) .auth('user', 'user-password') .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) .expect(403); @@ -744,7 +791,7 @@ describe(__filename, function () { const endPoint = (point: string, version?:string) => { - return `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; + return `/api/${version || apiVersion}/${point}`; }; function makeid() { diff --git a/src/tests/backend/specs/api/instance.ts b/src/tests/backend/specs/api/instance.ts index fc348e5af..2bf51bf86 100644 --- a/src/tests/backend/specs/api/instance.ts +++ b/src/tests/backend/specs/api/instance.ts @@ -8,10 +8,9 @@ const common = require('../../common'); let agent:any; -const apiKey = common.apiKey; const apiVersion = '1.2.14'; -const endPoint = (point: string, version?: number) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point: string, version?: number) => `/api/${version || apiVersion}/${point}`; describe(__filename, function () { before(async function () { agent = await common.init(); }); @@ -27,6 +26,7 @@ describe(__filename, function () { describe('getStats', function () { it('Gets the stats of a running instance', async function () { await agent.get(endPoint('getStats')) + .set("Authorization", await common.generateJWTToken()) .expect((res:any) => { if (res.body.code !== 0) throw new Error('getStats() failed'); diff --git a/src/tests/backend/specs/api/pad.ts b/src/tests/backend/specs/api/pad.ts index 180494bb2..f4d081ef4 100644 --- a/src/tests/backend/specs/api/pad.ts +++ b/src/tests/backend/specs/api/pad.ts @@ -12,7 +12,6 @@ const common = require('../../common'); const padManager = require('../../../../node/db/PadManager'); let agent:any; -const apiKey = common.apiKey; let apiVersion = 1; const testPadId = makeid(); const newPadId = makeid(); @@ -21,7 +20,7 @@ const anotherPadId = makeid(); let lastEdited = ''; const text = generateLongText(); -const endPoint = (point: string, version?: string) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point: string, version?: string) => `/api/${version || apiVersion}/${point}`; /* * Html document with nested lists of different types, to test its import and @@ -60,10 +59,10 @@ describe(__filename, function () { }); describe('Sanity checks', function () { - it('errors with invalid APIKey', async function () { + it('errors with invalid oauth token', async function () { // This is broken because Etherpad doesn't handle HTTP codes properly see #2343 - // If your APIKey is password you deserve to fail all tests anyway - await agent.get(`/api/${apiVersion}/createPad?apikey=password&padID=test`) + await agent.get(`/api/${apiVersion}/createPad?padID=test`) + .set("Authorization", (await common.generateJWTToken()).substring(0, 10)) .expect(401); }); }); @@ -113,20 +112,23 @@ describe(__filename, function () { describe('Tests', function () { it('deletes a Pad that does not exist', async function () { - await agent.get(`${endPoint('deletePad')}&padID=${testPadId}`) + await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) // @TODO: we shouldn't expect 200 here since the pad may not exist .expect('Content-Type', /json/); }); it('creates a new Pad', async function () { - const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('gets revision count of Pad', async function () { - const res = await agent.get(`${endPoint('getRevisionsCount')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -134,7 +136,8 @@ describe(__filename, function () { }); it('gets saved revisions count of Pad', async function () { - const res = await agent.get(`${endPoint('getSavedRevisionsCount')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getSavedRevisionsCount')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -142,7 +145,8 @@ describe(__filename, function () { }); it('gets saved revision list of Pad', async function () { - const res = await agent.get(`${endPoint('listSavedRevisions')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('listSavedRevisions')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -150,7 +154,8 @@ describe(__filename, function () { }); it('get the HTML of Pad', async function () { - const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert(res.body.data.html.length > 1); @@ -158,13 +163,15 @@ describe(__filename, function () { it('list all pads', async function () { const res = await agent.get(endPoint('listAllPads')) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert(res.body.data.padIDs.includes(testPadId)); }); it('deletes the Pad', async function () { - const res = await agent.get(`${endPoint('deletePad')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -172,27 +179,31 @@ describe(__filename, function () { it('list all pads again', async function () { const res = await agent.get(endPoint('listAllPads')) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert(!res.body.data.padIDs.includes(testPadId)); }); it('get the HTML of a Pad -- Should return a failure', async function () { - const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 1); }); it('creates a new Pad with text', async function () { - const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}&text=testText`) + const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}&text=testText`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('gets the Pad text and expect it to be testText with trailing \\n', async function () { - const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.text, 'testText\n'); @@ -200,6 +211,7 @@ describe(__filename, function () { it('set text', async function () { const res = await agent.post(endPoint('setText')) + .set("Authorization", (await common.generateJWTToken())) .send({ padID: testPadId, text: 'testTextTwo', @@ -210,28 +222,32 @@ describe(__filename, function () { }); it('gets the Pad text', async function () { - const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.text, 'testTextTwo\n'); }); it('gets Revision Count of a Pad', async function () { - const res = await agent.get(`${endPoint('getRevisionsCount')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.revisions, 1); }); it('saves Revision', async function () { - const res = await agent.get(`${endPoint('saveRevision')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('saveRevision')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('gets saved revisions count of Pad again', async function () { - const res = await agent.get(`${endPoint('getSavedRevisionsCount')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getSavedRevisionsCount')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -239,7 +255,8 @@ describe(__filename, function () { }); it('gets saved revision list of Pad again', async function () { - const res = await agent.get(`${endPoint('listSavedRevisions')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('listSavedRevisions')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -247,28 +264,32 @@ describe(__filename, function () { }); it('gets User Count of a Pad', async function () { - const res = await agent.get(`${endPoint('padUsersCount')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('padUsersCount')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.padUsersCount, 0); }); it('Gets the Read Only ID of a Pad', async function () { - const res = await agent.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getReadOnlyID')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert(res.body.data.readOnlyID); }); it('Get Authors of the Pad', async function () { - const res = await agent.get(`${endPoint('listAuthorsOfPad')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('listAuthorsOfPad')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.authorIDs.length, 0); }); it('Get When Pad was left Edited', async function () { - const res = await agent.get(`${endPoint('getLastEdited')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert(res.body.data.lastEdited); @@ -277,6 +298,7 @@ describe(__filename, function () { it('set text again', async function () { const res = await agent.post(endPoint('setText')) + .set("Authorization", (await common.generateJWTToken())) .send({ padID: testPadId, text: 'testTextThree', @@ -287,35 +309,40 @@ describe(__filename, function () { }); it('Get When Pad was left Edited again', async function () { - const res = await agent.get(`${endPoint('getLastEdited')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert(res.body.data.lastEdited > lastEdited); }); it('gets User Count of a Pad again', async function () { - const res = await agent.get(`${endPoint('padUsers')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('padUsers')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.padUsers.length, 0); }); it('deletes a Pad', async function () { - const res = await agent.get(`${endPoint('deletePad')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('creates the Pad again', async function () { - const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('Sets text on a pad Id', async function () { - const res = await agent.post(`${endPoint('setText')}&padID=${testPadId}`) + const res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .field({text}) .expect(200) .expect('Content-Type', /json/); @@ -323,7 +350,8 @@ describe(__filename, function () { }); it('Gets text on a pad Id', async function () { - const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -331,7 +359,8 @@ describe(__filename, function () { }); it('Sets text on a pad Id including an explicit newline', async function () { - const res = await agent.post(`${endPoint('setText')}&padID=${testPadId}`) + const res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .field({text: `${text}\n`}) .expect(200) .expect('Content-Type', /json/); @@ -339,7 +368,8 @@ describe(__filename, function () { }); it("Gets text on a pad Id and doesn't have an excess newline", async function () { - const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -347,7 +377,8 @@ describe(__filename, function () { }); it('Gets when pad was last edited', async function () { - const res = await agent.get(`${endPoint('getLastEdited')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.notEqual(res.body.lastEdited, 0); @@ -355,14 +386,16 @@ describe(__filename, function () { it('Move a Pad to a different Pad ID', async function () { const res = await agent.get( - `${endPoint('movePad')}&sourceID=${testPadId}&destinationID=${newPadId}&force=true`) + `${endPoint('movePad')}?sourceID=${testPadId}&destinationID=${newPadId}&force=true`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('Gets text from new pad', async function () { - const res = await agent.get(`${endPoint('getText')}&padID=${newPadId}`) + const res = await agent.get(`${endPoint('getText')}?padID=${newPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.text, `${text}\n`); @@ -370,21 +403,24 @@ describe(__filename, function () { it('Move pad back to original ID', async function () { const res = await agent.get( - `${endPoint('movePad')}&sourceID=${newPadId}&destinationID=${testPadId}&force=false`) + `${endPoint('movePad')}?sourceID=${newPadId}&destinationID=${testPadId}&force=false`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('Get text using original ID', async function () { - const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.text, `${text}\n`); }); it('Get last edit of original ID', async function () { - const res = await agent.get(`${endPoint('getLastEdited')}&padID=${testPadId}`) + const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.notEqual(res.body.lastEdited, 0); @@ -392,11 +428,13 @@ describe(__filename, function () { it('Append text to a pad Id', async function () { let res = await agent.get( - `${endPoint('appendText', '1.2.13')}&padID=${testPadId}&text=hello`) + `${endPoint('appendText', '1.2.13')}?padID=${testPadId}&text=hello`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -404,7 +442,8 @@ describe(__filename, function () { }); it('getText of old revision', async function () { - let res = await agent.get(`${endPoint('getRevisionsCount')}&padID=${testPadId}`) + let res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -412,7 +451,8 @@ describe(__filename, function () { assert(rev != null); assert(Number.isInteger(rev)); assert(rev > 0); - res = await agent.get(`${endPoint('getText')}&padID=${testPadId}&rev=${rev - 1}`) + res = await agent.get(`${endPoint('getText')}?padID=${testPadId}&rev=${rev - 1}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -422,6 +462,7 @@ describe(__filename, function () { it('Sets the HTML of a Pad attempting to pass ugly HTML', async function () { const html = '
      Hello HTML
      '; const res = await agent.post(endPoint('setHTML')) + .set("Authorization", (await common.generateJWTToken())) .send({ padID: testPadId, html, @@ -433,6 +474,7 @@ describe(__filename, function () { it('Pad with complex nested lists of different types', async function () { let res = await agent.post(endPoint('setHTML')) + .set("Authorization", (await common.generateJWTToken())) .send({ padID: testPadId, html: ulHtml, @@ -440,7 +482,8 @@ describe(__filename, function () { .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`) + res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); const receivedHtml = res.body.data.html.replace('
      ', '').toLowerCase(); @@ -448,11 +491,13 @@ describe(__filename, function () { }); it('Pad with white space between list items', async function () { - let res = await agent.get(`${endPoint('setHTML')}&padID=${testPadId}&html=${ulSpaceHtml}`) + let res = await agent.get(`${endPoint('setHTML')}?padID=${testPadId}&html=${ulSpaceHtml}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`) + res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); const receivedHtml = res.body.data.html.replace('
      ', '').toLowerCase(); @@ -461,7 +506,8 @@ describe(__filename, function () { it('errors if pad can be created', async function () { await Promise.all(['/', '%23', '%3F', '%26'].map(async (badUrlChar) => { - const res = await agent.get(`${endPoint('createPad')}&padID=${badUrlChar}`) + const res = await agent.get(`${endPoint('createPad')}?padID=${badUrlChar}`) + .set("Authorization", (await common.generateJWTToken())) .expect('Content-Type', /json/); assert.equal(res.body.code, 1); })); @@ -469,49 +515,57 @@ describe(__filename, function () { it('copies the content of a existent pad', async function () { const res = await agent.get( - `${endPoint('copyPad')}&sourceID=${testPadId}&destinationID=${copiedPadId}&force=true`) + `${endPoint('copyPad')}?sourceID=${testPadId}&destinationID=${copiedPadId}&force=true`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); }); it('does not add an useless revision', async function () { - let res = await agent.post(`${endPoint('setText')}&padID=${testPadId}`) + let res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .field({text: 'identical text\n'}) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.text, 'identical text\n'); - res = await agent.get(`${endPoint('getRevisionsCount')}&padID=${testPadId}`) + res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); const revCount = res.body.data.revisions; - res = await agent.post(`${endPoint('setText')}&padID=${testPadId}`) + res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .field({text: 'identical text\n'}) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getRevisionsCount')}&padID=${testPadId}`) + res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.data.revisions, revCount); }); it('creates a new Pad with empty text', async function () { - await agent.get(`${endPoint('createPad')}&padID=${anotherPadId}&text=`) + await agent.get(`${endPoint('createPad')}?padID=${anotherPadId}&text=`) + .set("Authorization", (await common.generateJWTToken())) .expect('Content-Type', /json/) .expect(200) .expect((res:any) => { assert.equal(res.body.code, 0, 'Unable to create new Pad'); }); - await agent.get(`${endPoint('getText')}&padID=${anotherPadId}`) + await agent.get(`${endPoint('getText')}?padID=${anotherPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect('Content-Type', /json/) .expect(200) .expect((res:any) => { @@ -521,7 +575,8 @@ describe(__filename, function () { }); it('deletes with empty text', async function () { - await agent.get(`${endPoint('deletePad')}&padID=${anotherPadId}`) + await agent.get(`${endPoint('deletePad')}?padID=${anotherPadId}`) + .set("Authorization", (await common.generateJWTToken())) .expect('Content-Type', /json/) .expect(200) .expect((res: any) => { @@ -543,8 +598,9 @@ describe(__filename, function () { }); it('returns a successful response', async function () { - const res = await agent.get(`${endPoint('copyPadWithoutHistory')}&sourceID=${sourcePadId}` + + const res = await agent.get(`${endPoint('copyPadWithoutHistory')}?sourceID=${sourcePadId}` + `&destinationID=${newPad}&force=false`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -552,10 +608,12 @@ describe(__filename, function () { // this test validates if the source pad's text and attributes are kept it('creates a new pad with the same content as the source pad', async function () { - let res = await agent.get(`${endPoint('copyPadWithoutHistory')}&sourceID=${sourcePadId}` + - `&destinationID=${newPad}&force=false`); + let res = await agent.get(`${endPoint('copyPadWithoutHistory')}?sourceID=${sourcePadId}` + + `&destinationID=${newPad}&force=false`) + .set("Authorization", (await common.generateJWTToken())); assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getHTML')}&padID=${newPad}`) + res = await agent.get(`${endPoint('getHTML')}?padID=${newPad}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200); const receivedHtml = res.body.data.html.replace('

      ', '').toLowerCase(); assert.equal(receivedHtml, expectedHtml); @@ -564,8 +622,9 @@ describe(__filename, function () { it('copying to a non-existent group throws an error', async function () { const padWithNonExistentGroup = `notExistentGroup$${newPad}`; const res = await agent.get(`${endPoint('copyPadWithoutHistory')}` + - `&sourceID=${sourcePadId}` + + `?sourceID=${sourcePadId}` + `&destinationID=${padWithNonExistentGroup}&force=true`) + .set("Authorization", (await common.generateJWTToken())) .expect(200); assert.equal(res.body.code, 1); }); @@ -577,16 +636,18 @@ describe(__filename, function () { it('force=false fails', async function () { const res = await agent.get(`${endPoint('copyPadWithoutHistory')}` + - `&sourceID=${sourcePadId}` + + `?sourceID=${sourcePadId}` + `&destinationID=${newPad}&force=false`) + .set("Authorization", (await common.generateJWTToken())) .expect(200); assert.equal(res.body.code, 1); }); it('force=true succeeds', async function () { const res = await agent.get(`${endPoint('copyPadWithoutHistory')}` + - `&sourceID=${sourcePadId}` + + `?sourceID=${sourcePadId}` + `&destinationID=${newPad}&force=true`) + .set("Authorization", (await common.generateJWTToken())) .expect(200); assert.equal(res.body.code, 0); }); @@ -613,7 +674,8 @@ describe(__filename, function () { // state between the two attribute pools caused corruption. const getHtml = async (padId:string) => { - const res = await agent.get(`${endPoint('getHTML')}&padID=${padId}`) + const res = await agent.get(`${endPoint('getHTML')}?padID=${padId}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); @@ -622,6 +684,7 @@ describe(__filename, function () { const setBody = async (padId: string, bodyHtml: string) => { await agent.post(endPoint('setHTML')) + .set("Authorization", (await common.generateJWTToken())) .send({padID: padId, html: `${bodyHtml}`}) .expect(200) .expect('Content-Type', /json/) @@ -631,8 +694,9 @@ describe(__filename, function () { const origHtml = await getHtml(sourcePadId); assert.doesNotMatch(origHtml, //); assert.doesNotMatch(origHtml, //); - await agent.get(`${endPoint('copyPadWithoutHistory')}&sourceID=${sourcePadId}` + + await agent.get(`${endPoint('copyPadWithoutHistory')}?sourceID=${sourcePadId}` + `&destinationID=${newPad}&force=false`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => assert.equal(res.body.code, 0)); @@ -672,8 +736,10 @@ describe(__filename, function () { */ const createNewPadWithHtml = async (padId: string, html: string) => { - await agent.get(`${endPoint('createPad')}&padID=${padId}`); + await agent.get(`${endPoint('createPad')}?padID=${padId}`) + .set("Authorization", (await common.generateJWTToken())); await agent.post(endPoint('setHTML')) + .set("Authorization", (await common.generateJWTToken())) .send({ padID: padId, html, diff --git a/src/tests/backend/specs/api/restoreRevision.ts b/src/tests/backend/specs/api/restoreRevision.ts index 28a509012..e30c8aa25 100644 --- a/src/tests/backend/specs/api/restoreRevision.ts +++ b/src/tests/backend/specs/api/restoreRevision.ts @@ -14,13 +14,14 @@ describe(__filename, function () { let pad: PadType; const restoreRevision = async (v:string, padId: string, rev: number, authorId:string|null = null) => { + // @ts-ignore const p = new URLSearchParams(Object.entries({ - apikey: common.apiKey, padID: padId, rev, ...(authorId == null ? {} : {authorId}), })); const res = await agent.get(`/api/${v}/restoreRevision?${p}`) + .set("Authorization", (await common.generateJWTToken())) .expect(200) .expect('Content-Type', /json/); assert.equal(res.body.code, 0); diff --git a/src/tests/backend/specs/api/sessionsAndGroups.ts b/src/tests/backend/specs/api/sessionsAndGroups.ts index 1c3196214..a7e85fbe9 100644 --- a/src/tests/backend/specs/api/sessionsAndGroups.ts +++ b/src/tests/backend/specs/api/sessionsAndGroups.ts @@ -1,25 +1,33 @@ 'use strict'; +import {agent, generateJWTToken, init, logger} from "../../common"; + +import TestAgent from "supertest/lib/agent"; +import supertest from "supertest"; const assert = require('assert').strict; -const common = require('../../common'); const db = require('../../../../node/db/DB'); -let agent:any; -const apiKey = common.apiKey; let apiVersion = 1; let groupID = ''; let authorID = ''; let sessionID = ''; let padID = makeid(); -const endPoint = (point:string) => `/api/${apiVersion}/${point}?apikey=${apiKey}`; +const endPoint = (point:string) => { + return `/api/${apiVersion}/${point}`; +} + +let preparedAgent: TestAgent describe(__filename, function () { - before(async function () { agent = await common.init(); }); + before(async function () { + preparedAgent = await init(); + }); describe('API Versioning', function () { it('errors if can not connect', async function () { - await agent.get('/api/') + await agent!.get('/api/') + .set('Accept', 'application/json') .expect(200) .expect((res:any) => { assert(res.body.currentVersion); @@ -60,7 +68,8 @@ describe(__filename, function () { describe('API: Group creation and deletion', function () { it('createGroup', async function () { - await agent.get(endPoint('createGroup')) + await agent!.get(endPoint('createGroup')) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -71,7 +80,8 @@ describe(__filename, function () { }); it('listSessionsOfGroup for empty group', async function () { - await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`) + await agent!.get(`${endPoint('listSessionsOfGroup')}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -81,7 +91,9 @@ describe(__filename, function () { }); it('deleteGroup', async function () { - await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`) + await agent! + .get(`${endPoint('deleteGroup')}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -92,7 +104,8 @@ describe(__filename, function () { it('createGroupIfNotExistsFor', async function () { const mapper = makeid(); let groupId: string; - await agent.get(`${endPoint('createGroupIfNotExistsFor')}&groupMapper=${mapper}`) + await preparedAgent.get(`${endPoint('createGroupIfNotExistsFor')}?groupMapper=${mapper}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -101,7 +114,8 @@ describe(__filename, function () { assert(groupId); }); // Passing the same mapper should return the same group ID. - await agent.get(`${endPoint('createGroupIfNotExistsFor')}&groupMapper=${mapper}`) + await preparedAgent.get(`${endPoint('createGroupIfNotExistsFor')}?groupMapper=${mapper}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -110,7 +124,8 @@ describe(__filename, function () { }); // Deleting the group should clean up the mapping. assert.equal(await db.get(`mapper2group:${mapper}`), groupId!); - await agent.get(`${endPoint('deleteGroup')}&groupID=${groupId!}`) + await preparedAgent.get(`${endPoint('deleteGroup')}?groupID=${groupId!}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -122,7 +137,8 @@ describe(__filename, function () { // Test coverage for https://github.com/ether/etherpad-lite/issues/4227 // Creates a group, creates 2 sessions, 2 pads and then deletes the group. it('createGroup', async function () { - await agent.get(endPoint('createGroup')) + await preparedAgent.get(endPoint('createGroup')) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -133,7 +149,8 @@ describe(__filename, function () { }); it('createAuthor', async function () { - await agent.get(endPoint('createAuthor')) + await preparedAgent.get(endPoint('createAuthor')) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -144,8 +161,9 @@ describe(__filename, function () { }); it('createSession', async function () { - await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` + + await preparedAgent.get(`${endPoint('createSession')}?authorID=${authorID}&groupID=${groupID}` + '&validUntil=999999999999') + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -156,8 +174,9 @@ describe(__filename, function () { }); it('createSession', async function () { - await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` + + await preparedAgent.get(`${endPoint('createSession')}?authorID=${authorID}&groupID=${groupID}` + '&validUntil=999999999999') + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -168,7 +187,8 @@ describe(__filename, function () { }); it('createGroupPad', async function () { - await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x1234567`) + await preparedAgent.get(`${endPoint('createGroupPad')}?groupID=${groupID}&padName=x1234567`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -177,7 +197,8 @@ describe(__filename, function () { }); it('createGroupPad', async function () { - await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x12345678`) + await preparedAgent.get(`${endPoint('createGroupPad')}?groupID=${groupID}&padName=x12345678`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -186,7 +207,8 @@ describe(__filename, function () { }); it('deleteGroup', async function () { - await agent.get(`${endPoint('deleteGroup')}&groupID=${groupID}`) + await preparedAgent.get(`${endPoint('deleteGroup')}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -198,7 +220,8 @@ describe(__filename, function () { describe('API: Author creation', function () { it('createGroup', async function () { - await agent.get(endPoint('createGroup')) + await preparedAgent.get(endPoint('createGroup')) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -209,7 +232,8 @@ describe(__filename, function () { }); it('createAuthor', async function () { - await agent.get(endPoint('createAuthor')) + await preparedAgent.get(endPoint('createAuthor')) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -219,7 +243,8 @@ describe(__filename, function () { }); it('createAuthor with name', async function () { - await agent.get(`${endPoint('createAuthor')}&name=john`) + await preparedAgent.get(`${endPoint('createAuthor')}?name=john`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -230,7 +255,8 @@ describe(__filename, function () { }); it('createAuthorIfNotExistsFor', async function () { - await agent.get(`${endPoint('createAuthorIfNotExistsFor')}&authorMapper=chris`) + await preparedAgent.get(`${endPoint('createAuthorIfNotExistsFor')}?authorMapper=chris`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -240,7 +266,8 @@ describe(__filename, function () { }); it('getAuthorName', async function () { - await agent.get(`${endPoint('getAuthorName')}&authorID=${authorID}`) + await preparedAgent.get(`${endPoint('getAuthorName')}?authorID=${authorID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -252,8 +279,9 @@ describe(__filename, function () { describe('API: Sessions', function () { it('createSession', async function () { - await agent.get(`${endPoint('createSession')}&authorID=${authorID}&groupID=${groupID}` + + await preparedAgent.get(`${endPoint('createSession')}?authorID=${authorID}&groupID=${groupID}` + '&validUntil=999999999999') + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -264,7 +292,8 @@ describe(__filename, function () { }); it('getSessionInfo', async function () { - await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`) + await preparedAgent.get(`${endPoint('getSessionInfo')}?sessionID=${sessionID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -276,7 +305,8 @@ describe(__filename, function () { }); it('listSessionsOfGroup', async function () { - await agent.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`) + await preparedAgent.get(`${endPoint('listSessionsOfGroup')}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -286,7 +316,8 @@ describe(__filename, function () { }); it('deleteSession', async function () { - await agent.get(`${endPoint('deleteSession')}&sessionID=${sessionID}`) + await preparedAgent.get(`${endPoint('deleteSession')}?sessionID=${sessionID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -295,7 +326,8 @@ describe(__filename, function () { }); it('getSessionInfo of deleted session', async function () { - await agent.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`) + await preparedAgent.get(`${endPoint('getSessionInfo')}?sessionID=${sessionID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -306,7 +338,8 @@ describe(__filename, function () { describe('API: Group pad management', function () { it('listPads', async function () { - await agent.get(`${endPoint('listPads')}&groupID=${groupID}`) + await preparedAgent.get(`${endPoint('listPads')}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -316,7 +349,8 @@ describe(__filename, function () { }); it('createGroupPad', async function () { - await agent.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=${padID}`) + await preparedAgent.get(`${endPoint('createGroupPad')}?groupID=${groupID}&padName=${padID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -326,10 +360,11 @@ describe(__filename, function () { }); it('listPads after creating a group pad', async function () { - await agent.get(`${endPoint('listPads')}&groupID=${groupID}`) + await preparedAgent.get(`${endPoint('listPads')}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) - .expect((res:any) => { + .expect((res) => { assert.equal(res.body.code, 0); assert.equal(res.body.data.padIDs.length, 1); }); @@ -338,7 +373,8 @@ describe(__filename, function () { describe('API: Pad security', function () { it('getPublicStatus', async function () { - await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`) + await preparedAgent.get(`${endPoint('getPublicStatus')}?padID=${padID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -348,7 +384,8 @@ describe(__filename, function () { }); it('setPublicStatus', async function () { - await agent.get(`${endPoint('setPublicStatus')}&padID=${padID}&publicStatus=true`) + await preparedAgent.get(`${endPoint('setPublicStatus')}?padID=${padID}&publicStatus=true`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -357,7 +394,8 @@ describe(__filename, function () { }); it('getPublicStatus after changing public status', async function () { - await agent.get(`${endPoint('getPublicStatus')}&padID=${padID}`) + await preparedAgent.get(`${endPoint('getPublicStatus')}?padID=${padID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { @@ -373,7 +411,8 @@ describe(__filename, function () { describe('API: Misc', function () { it('listPadsOfAuthor', async function () { - await agent.get(`${endPoint('listPadsOfAuthor')}&authorID=${authorID}`) + await preparedAgent.get(`${endPoint('listPadsOfAuthor')}?authorID=${authorID}`) + .set("Authorization", await generateJWTToken()) .expect(200) .expect('Content-Type', /json/) .expect((res:any) => { diff --git a/src/tests/container/specs/api/pad.js b/src/tests/container/specs/api/pad.js index 04067f0e3..f6ff8ebf5 100644 --- a/src/tests/container/specs/api/pad.js +++ b/src/tests/container/specs/api/pad.js @@ -31,8 +31,8 @@ describe('API Versioning', function () { }); describe('Permission', function () { - it('errors with invalid APIKey', function (done) { - api.get(`/api/${apiVersion}/createPad?apikey=wrong_password&padID=test`) + it('errors with invalid OAuth token', function (done) { + api.get(`/api/${apiVersion}/createPad?padID=test`) .expect(401, done); }); }); diff --git a/src/tests/settings.json b/src/tests/settings.json index c8064176b..5585a1900 100644 --- a/src/tests/settings.json +++ b/src/tests/settings.json @@ -650,5 +650,24 @@ /* * Enable/Disable case-insensitive pad names. */ - "lowerCasePadIds": false + "lowerCasePadIds": false, + "sso": { + "issuer": "${SSO_ISSUER:http://localhost:9001}", + "clients": [ + { + "client_id": "${ADMIN_CLIENT:admin_client}", + "client_secret": "${ADMIN_SECRET:admin}", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["${ADMIN_REDIRECT:http://localhost:9001/admin/}", "https://oauth.pstmn.io/v1/callback"] + }, + { + "client_id": "${USER_CLIENT:user_client}", + "client_secret": "${USER_SECRET:user}", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["${USER_REDIRECT:http://localhost:9001/}"] + } + ] + } } diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/consent.html b/ui/consent.html new file mode 100644 index 000000000..502b95a2e --- /dev/null +++ b/ui/consent.html @@ -0,0 +1,24 @@ + + + + + + + Consent Etherpad + + +
      + +
      + + + diff --git a/ui/login.html b/ui/login.html new file mode 100644 index 000000000..0ff588363 --- /dev/null +++ b/ui/login.html @@ -0,0 +1,38 @@ + + + + + + + SSO Etherpad + + +
      + +
      + + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..fb5cab322 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,15 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^5.2.2", + "vite": "^5.2.0" + } +} diff --git a/ui/src/consent.ts b/ui/src/consent.ts new file mode 100644 index 000000000..79f6214fe --- /dev/null +++ b/ui/src/consent.ts @@ -0,0 +1,35 @@ +import "./style.css" +//import {MapArrayType} from "ep_etherpad-lite/node/types/MapType"; + +const form = document.querySelector('form')!; +const sessionId = new URLSearchParams(window.location.search).get('state'); + +form.action = '/interaction/' + sessionId; + +/*form.addEventListener('submit', function (event) { + event.preventDefault(); + const formData = new FormData(form); + const data: MapArrayType = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + const sessionId = new URLSearchParams(window.location.search).get('state'); + + fetch('/interaction/' + sessionId, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }).then(response => { + if (response.ok) { + if (response.redirected) { + window.location.href = response.url; + } + } else { + document.getElementById('error')!.innerText = "Error signing in"; + } + }).catch(error => { + document.getElementById('error')!.innerText = "Error signing in" + error; + }) +});*/ diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 000000000..1ff174cdb --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,58 @@ +import './style.css' +import {MapArrayType} from "ep_etherpad-lite/node/types/MapType.ts"; + +const searchParams = new URLSearchParams(window.location.search); + + +document.getElementById('client')!.innerText = searchParams.get('client_id')!; + +const form = document.querySelector('form')!; +form.addEventListener('submit', function (event) { + event.preventDefault(); + const formData = new FormData(form); + const data: MapArrayType = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + const sessionId = new URLSearchParams(window.location.search).get('state'); + + fetch('/interaction/' + sessionId, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + body: JSON.stringify(data), + }).then(response => { + if (response.ok) { + if (response.redirected) { + window.location.href = response.url; + } + } else { + document.getElementById('error')!.innerText = "Error signing in"; + } + }).catch(error => { + document.getElementById('error')!.innerText = "Error signing in" + error; + }) +}); + +const hidePassword = document.querySelector('.toggle-password-visibility')! as HTMLElement +const showPassword = document.getElementById('eye-hide')! as HTMLElement +const togglePasswordVisibility = () => { + const passwordInput = document.getElementsByName('password')[0] as HTMLInputElement; + if (passwordInput.type === 'password') { + showPassword.style.display = 'block'; + hidePassword.style.display = 'none'; + passwordInput.type = 'text'; + } else { + showPassword.style.display = 'none'; + hidePassword.style.display = 'block'; + passwordInput.type = 'password'; + } +} + + +hidePassword.addEventListener('click', togglePasswordVisibility); +showPassword.addEventListener('click', togglePasswordVisibility); + + diff --git a/ui/src/style.css b/ui/src/style.css new file mode 100644 index 000000000..2e4621d15 --- /dev/null +++ b/ui/src/style.css @@ -0,0 +1,125 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + --color-etherpad: #0f775b; +} + +body { + font-size: 16px; + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +#app { + max-width: 1280px; + margin: auto; + padding: 2rem; +} + + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + border-color: #646cff; +} + +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + + a:hover { + color: #747bff; + } + + button { + background-color: #f9f9f9; + } +} + +.login-box { + background-color: #f2f6f7; + padding: 40px; + border-radius: 20px; + color: #607278; +} + +body { + background: radial-gradient(100% 100% at 50% 0%, var(--color-etherpad) 0%, #003A47 100%) fixed +} + +input { + border-radius: 8px; + border: 1px solid #d1d1d1; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #f9f9f9; + transition: border-color 0.25s; +} + +.login-inner-box { + display: flex; + flex-direction: column; + gap: 10px; +} + +.login-inner-box input[type=submit] { + background-color: var(--color-etherpad); + color: white; + border: none; + cursor: pointer; + margin-top: 20px; +} + +.password-label { + position: relative; +} + +.password-label svg { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + width: 16px; +} + +#eye-hide { + display: none; +} + +label { + display: flex; +} + +label input { + flex-grow: 1; +} diff --git a/ui/src/typescript.svg b/ui/src/typescript.svg new file mode 100644 index 000000000..d91c910cc --- /dev/null +++ b/ui/src/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 000000000..75abdef26 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 000000000..2ce13f8f7 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,17 @@ +// vite.config.js +import { resolve } from 'path' +import { defineConfig } from 'vite' + +export default defineConfig({ + base: '/views/', + build: { + outDir: resolve(__dirname, '../src/static/oidc'), + rollupOptions: { + input: { + main: resolve(__dirname, 'consent.html'), + nested: resolve(__dirname, 'login.html'), + }, + }, + emptyOutDir: true, + }, +})