diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f34212c..2170ff68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,7 +298,7 @@ jobs: retention-days: 1 api-test: - name: API & Webdav tests + name: API, WebDAV & OIDC tests needs: build if: github.event_name == 'pull_request' timeout-minutes: 30 @@ -331,16 +331,37 @@ jobs: tar -xzf "xq_${XQ_VERSION}_linux_amd64.tar.gz" xq sudo install -m 0755 xq /usr/local/bin/xq + # Node for the OIDC fake IdP (tests/oidc/fake_idp/server.js — a + # panva/node-oidc-provider wrapper). Pinned to match the version + # used elsewhere in this workflow (frontend Playwright job uses + # 26.3.0 too). + - uses: actions/setup-node@v4 + with: + node-version: 26.3.0 + cache: npm + cache-dependency-path: tests/oidc/fake_idp/package-lock.json + - name: Run Hurl API tests run: bash tests/api/run.sh env: BUILD_TARGET: release - - name: Run Webdav tests + - name: Run WebDAV tests run: bash tests/webdav/run.sh env: BUILD_TARGET: release + # OIDC integration: drives the SPA's SSO flow end-to-end against + # the fake IdP (auto-approve login + consent, real PKCE/JWT + # round-trip) and asserts the d1bbe8ba contract — OIDC callback + # MUST redirect to `/login?oidc_code=…`, not `/?oidc_code=…`. + # That bug shipped to users in production once already; the + # assertion at tests/oidc/oidc.hurl:Step 4 is its guard. + - name: Run OIDC tests + run: bash tests/oidc/run.sh + env: + BUILD_TARGET: release + - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/justfile b/justfile index 5ef259da..89e59729 100644 --- a/justfile +++ b/justfile @@ -157,10 +157,24 @@ front-design: node scripts/check-brand-drift.mjs -# Hurl API functional tests (starts postgres + server, tears down after) +# Hurl-driven functional tests (starts postgres + server, tears down after). +# +# Three runners — each isolated, brings up its own sidecars + server config: +# * tests/api/run.sh — REST API surface, default server.env +# * tests/webdav/run.sh — native WebDAV + NextCloud DAV, default server.env +# * tests/oidc/run.sh — OIDC SSO end-to-end against a fake IdP +# (tests/oidc/fake_idp, a Node panva/oidc-provider +# wrapper); server launched with +# --config server-with-oidc.env so the api and +# webdav suites stay on the OIDC-off config. +# +# Same chain runs in CI under the `api-test` job in +# .github/workflows/ci.yml; keep the order in sync so a local pass means +# CI passes. api-test: bash tests/api/run.sh bash tests/webdav/run.sh + bash tests/oidc/run.sh # --------------------------------------------------------------------------- # SvelteKit frontend (frontend/) — the only frontend. These `fe-*` recipes diff --git a/tests/common/server-with-oidc.env b/tests/common/server-with-oidc.env new file mode 100644 index 00000000..664c7b72 --- /dev/null +++ b/tests/common/server-with-oidc.env @@ -0,0 +1,68 @@ +# OxiCloud test-server env file for the OIDC integration test. +# +# Layered on top of server.env: identical to the default test-server +# config EXCEPT the OIDC client is enabled and pointed at the fake +# IdP under tests/oidc/fake_idp/ (a panva/node-oidc-provider wrapper +# started by tests/oidc/run.sh). +# +# Run pattern (used by tests/oidc/run.sh): +# bash tests/common/spawn-db.sh +# node tests/oidc/fake_idp/server.js & # auto-approve OIDC IdP on :1080 +# ./target/debug/oxicloud --config tests/common/server-with-oidc.env +# +# `--config` makes the binary read THIS file verbatim — there is no +# auto-merge with server.env, so every variable the server needs has +# to be repeated here. Keeping the duplication explicit beats a sourcing +# scheme: dotenvy doesn't follow `source` directives, and the matrix of +# "which env file is in effect" is easier to read when each one is +# self-contained. + +# ── Shared test config (mirrors server.env) ──────────────────────────────── +DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_STATIC_PATH=./static +OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars +OXICLOUD_ENABLE_AUTH=true +OXICLOUD_ENABLE_TRASH=true +OXICLOUD_ENABLE_SEARCH=true +OXICLOUD_ENABLE_FILE_SHARING=true +OXICLOUD_ENABLE_MUSIC=true +OXICLOUD_EXPOSE_SYSTEM_USERS=true +OXICLOUD_WOPI_ENABLED=false +OXICLOUD_NEXTCLOUD_ENABLED=true +OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true + +RUST_LOG="warn,audit=info,oxicloud::infrastructure::services::oidc_service=info,oxicloud::application::services::auth_application_service=info" + +OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600 +OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600 +OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600 +OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 + +# ── OIDC client wired at the fake-idp sidecar ────────────────────────────── +# tests/oidc/fake_idp/server.js (panva/node-oidc-provider) publishes the +# issuer at the root URL; discovery is at /.well-known/openid-configuration +# under it. Update the `clients[0].client_id` field there in tandem if you +# rename the client. +OXICLOUD_OIDC_ENABLED=true +OXICLOUD_OIDC_ISSUER_URL=http://localhost:1080 +OXICLOUD_OIDC_CLIENT_ID=oxicloud-test +OXICLOUD_OIDC_CLIENT_SECRET=test-client-secret-not-used-in-prod +# The IdP redirects back to this exact URL after auto-approving; must +# match the OxiCloud server's actual host + port (port 8087 from +# tests/api/test.env's base_url). +OXICLOUD_OIDC_REDIRECT_URI=http://localhost:8087/api/auth/oidc/callback +OXICLOUD_OIDC_SCOPES="openid profile email" +# Frontend redirect target after a successful callback. Tracks the +# d1bbe8ba fix: the backend appends `/login?oidc_code=…` to this base, +# so the value here is the SPA origin only. +OXICLOUD_OIDC_FRONTEND_URL=http://localhost:8087 +OXICLOUD_OIDC_AUTO_PROVISION=true +OXICLOUD_OIDC_PROVIDER_NAME=MockSSO +# Group-to-role mapping. The fake IdP emits `groups: ["admin-users"]` +# in every id_token; with this env set, the JIT-provisioning code in +# auth_application_service.rs intersects the claim against this list +# and promotes the new user from `user` to `admin` on a non-empty +# match. This is the standard Authentik/Keycloak/Entra pattern: an +# IdP group becomes an OxiCloud role. +OXICLOUD_OIDC_ADMIN_GROUPS=admin-users diff --git a/tests/oidc/fake_idp/.gitignore b/tests/oidc/fake_idp/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/tests/oidc/fake_idp/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/tests/oidc/fake_idp/package-lock.json b/tests/oidc/fake_idp/package-lock.json new file mode 100644 index 00000000..f6c01a96 --- /dev/null +++ b/tests/oidc/fake_idp/package-lock.json @@ -0,0 +1,1059 @@ +{ + "name": "fake-idp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fake-idp", + "version": "0.1.0", + "dependencies": { + "@koa/router": "^13.1.0", + "koa": "^2.16.0", + "oidc-provider": "^9.4.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@koa/cors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-5.0.0.tgz", + "integrity": "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==", + "license": "MIT", + "dependencies": { + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@koa/router": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-13.1.1.tgz", + "integrity": "sha512-JQEuMANYRVHs7lm7KY9PCIjkgJk73h4m4J+g2mkw2Vo1ugPZ17UJVqEH8F+HeAdjKz5do1OaLe7ArDz+z308gw==", + "deprecated": "Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved.", + "license": "MIT", + "dependencies": { + "debug": "^4.4.1", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "path-to-regexp": "^6.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "license": "MIT", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "license": "MIT" + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa": { + "version": "2.16.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.4.tgz", + "integrity": "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.9.0", + "debug": "^4.3.2", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^2.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "license": "MIT" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "license": "MIT", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/oidc-provider": { + "version": "9.8.6", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-9.8.6.tgz", + "integrity": "sha512-jodnMKbwfMbV5qUFnbCxtnrdRztJJubJbw/7HTokIJSiHm1JT7pU4G83sD/bPkvZnFDdv5POB/kJU5uGG3ek4g==", + "license": "MIT", + "dependencies": { + "@koa/cors": "^5.0.0", + "@koa/router": "^15.5.0", + "debug": "^4.4.3", + "eta": "^4.6.0", + "jose": "^6.2.3", + "jsesc": "^3.1.0", + "koa": "^3.2.1", + "nanoid": "^5.1.11", + "quick-lru": "^7.3.0", + "raw-body": "^3.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/oidc-provider/node_modules/@koa/router": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-15.6.0.tgz", + "integrity": "sha512-iEOXlvGIBqSNkGXrg0XtMARAOm5zA24oedXxiTGEkrD4JgwVjfRDddCQvW1s4WEcwDYvyecRbf8BikXsuEEj8w==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "koa-compose": "^4.1.0", + "path-to-regexp": "^8.4.2" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "koa": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "koa": { + "optional": false + } + } + }, + "node_modules/oidc-provider/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/oidc-provider/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/oidc-provider/node_modules/koa": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.8", + "content-disposition": "~1.0.1", + "content-type": "^1.0.5", + "cookies": "~0.9.1", + "delegates": "^1.0.0", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/oidc-provider/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/oidc-provider/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/oidc-provider/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/oidc-provider/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/oidc-provider/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/oidc-provider/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ylru": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", + "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + } + } +} diff --git a/tests/oidc/fake_idp/package.json b/tests/oidc/fake_idp/package.json new file mode 100644 index 00000000..e491e87c --- /dev/null +++ b/tests/oidc/fake_idp/package.json @@ -0,0 +1,18 @@ +{ + "name": "fake-idp", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Spec-compliant OIDC stub used by tests/oidc/oidc.hurl. Wraps panva/node-oidc-provider with an auto-approve interaction so Hurl can drive the authorize → token flow without rendering a login form.", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@koa/router": "^13.1.0", + "koa": "^2.16.0", + "oidc-provider": "^9.4.0" + } +} diff --git a/tests/oidc/fake_idp/server.js b/tests/oidc/fake_idp/server.js new file mode 100644 index 00000000..f54adf5f --- /dev/null +++ b/tests/oidc/fake_idp/server.js @@ -0,0 +1,271 @@ +// Fake OpenID Connect Identity Provider for the OxiCloud OIDC +// integration test (tests/oidc/oidc.hurl). +// +// Wraps panva/node-oidc-provider — a spec-compliant OP — with a +// minimal Node http front-end that auto-resolves every interaction +// (login and consent) for a hard-coded test user. We don't wrap with +// our own Koa instance because oidc-provider ships a bundled Koa that +// the response prototype-checks against; layering another Koa around +// it triggers `vary: res argument is required` on the first request. +// +// What we get from the library that we'd otherwise hand-roll: +// * Discovery (.well-known/openid-configuration) +// * JWKS endpoint + RS256-signed JWTs +// * PKCE S256 verification +// * Authorization code lifecycle +// * Refresh token + id_token + access_token shapes +// +// What we get FOR FREE when we later add coverage for: +// * Back-channel logout — flip features.backchannelLogout.enabled +// * RP-initiated logout — flip features.rpInitiatedLogout.enabled +// * Token revocation (RFC 7009) — flip features.revocation.enabled +// * Token introspection (RFC 7662) — flip features.introspection.enabled +// +// Each future OIDC feature is a config flag in this file rather than +// new Rust protocol code to maintain. + +import http from 'node:http'; +import { URL } from 'node:url'; +import { default as Provider } from 'oidc-provider'; + +// ── Configuration knobs ───────────────────────────────────────────────── +const ISSUER = process.env.FAKE_IDP_ISSUER || 'http://localhost:1080'; +const PORT = parseInt(process.env.FAKE_IDP_PORT || '1080', 10); +const TEST_USER_SUB = 'oidc-test-user'; +const TEST_USER_USERNAME = 'oidc_user'; +const TEST_USER_EMAIL = 'oidc@example.com'; +// Full claim set pinned to deterministic values so the Hurl test can +// assert that JIT provisioning (auth_application_service.rs:2257) +// stores each one verbatim. Keep the claim names matching the OIDC +// `IdTokenClaims` struct in src/infrastructure/services/oidc_service.rs. +const TEST_USER_NAME = 'OIDC Test User'; +const TEST_USER_GIVEN_NAME = 'OIDC'; +const TEST_USER_FAMILY_NAME = 'Test'; +// `picture` is the OIDC claim; OxiCloud persists it as `User.image` +// (a URL or data URI). We use a stable HTTP URL so a simple equality +// check works in the Hurl assertion. +const TEST_USER_PICTURE = 'https://example.com/oidc-test-user.png'; +// Group claim — paired with OXICLOUD_OIDC_ADMIN_GROUPS=admin-users in +// server-with-oidc.env. The JIT path intersects this list against the +// configured admin groups; a non-empty intersection escalates the new +// user's role from `user` to `admin`. This is the typical SSO pattern +// every Authentik/Keycloak/Entra deployment uses to map IdP groups to +// app roles. +const TEST_USER_GROUPS = ['admin-users']; + +// ── Runtime-toggleable state for negative tests ──────────────────────── +// `email_verified` is normally true; the test flips it to false via +// `POST /control/email-verified/false` to drive OxiCloud's anti-takeover +// rejection branch (auth_application_service.rs: only `email_verified` +// callers reach JIT-provisioning), then flips back. Module-level state +// because oidc-provider doesn't pass test-specific context into the +// claims() callback. +let emailVerifiedState = true; + +const configuration = { + clients: [ + { + client_id: 'oxicloud-test', + client_secret: 'test-client-secret-not-used-in-prod', + redirect_uris: ['http://localhost:8087/api/auth/oidc/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + }, + ], + + pkce: { required: () => true, methods: ['S256'] }, + + claims: { + openid: ['sub'], + email: ['email', 'email_verified'], + // `profile` is the standard scope OxiCloud requests + // (OXICLOUD_OIDC_SCOPES in server-with-oidc.env). It covers every + // claim the JIT-provisioning code in auth_application_service.rs + // reads except email — name + given/family + picture + + // preferred_username + groups all ride here. + profile: [ + 'name', + 'given_name', + 'family_name', + 'preferred_username', + 'picture', + 'groups', + ], + }, + + async findAccount(_ctx, sub) { + if (sub !== TEST_USER_SUB) return undefined; + return { + accountId: sub, + // Return EVERY claim the OIDC client could ask for. The provider + // filters by the consented scope before issuing — values not in + // a granted scope are dropped from the ID token / userinfo. + async claims() { + return { + sub: TEST_USER_SUB, + email: TEST_USER_EMAIL, + email_verified: emailVerifiedState, + name: TEST_USER_NAME, + given_name: TEST_USER_GIVEN_NAME, + family_name: TEST_USER_FAMILY_NAME, + preferred_username: TEST_USER_USERNAME, + picture: TEST_USER_PICTURE, + groups: TEST_USER_GROUPS, + }; + }, + }; + }, + + features: { + // Turn off the dev login/consent UI; we own the interaction route. + devInteractions: { enabled: false }, + }, + + // Put scope-implied claims (name, given_name, family_name, + // preferred_username, picture, email, …) directly into the ID token + // instead of keeping them at /userinfo only. + // + // OxiCloud's OIDC client (auth_application_service.rs:2085) only + // calls /userinfo when the ID token lacks `email` — with the email + // scope granted the ID token DOES carry email, so userinfo never + // runs, and the default (conformIdTokenClaims: true) means `picture` + // would silently vanish during JIT provisioning. Setting this to + // `false` mirrors what most real-world IdPs (Authentik, Keycloak's + // default profile) do for browser SSO clients. + conformIdTokenClaims: false, + + // Point every interaction at our auto-resolver below. + interactions: { + url(_ctx, interaction) { + return `/auto/${interaction.uid}`; + }, + }, + + cookies: { + keys: ['fake-idp-cookie-key-not-a-real-secret'], + }, +}; + +const provider = new Provider(ISSUER, configuration); +provider.proxy = false; + +// `provider.callback()` is an http-compatible request handler. +// We intercept /auto/ ourselves and forward everything else. +const oidcHandler = provider.callback(); + +// `/control/*` paths are test-only hooks the Hurl suite uses to +// flip IdP-side state between flows (e.g. force email_verified=false +// to exercise OxiCloud's anti-takeover rejection branch). Kept on the +// SAME port as the OIDC endpoints so we don't have to thread two ports +// through every test config. Never used in production-shaped flows. +function handleControl(req, res) { + const url = new URL(req.url, ISSUER); + if (req.method === 'POST' && url.pathname === '/control/email-verified/true') { + emailVerifiedState = true; + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + return res.end(JSON.stringify({ email_verified: true })); + } + if (req.method === 'POST' && url.pathname === '/control/email-verified/false') { + emailVerifiedState = false; + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + return res.end(JSON.stringify({ email_verified: false })); + } + res.statusCode = 404; + res.setHeader('content-type', 'application/json'); + return res.end(JSON.stringify({ error: 'no such control endpoint' })); +} + +// One-line per-request log — useful when a future test fails +// mysteriously ("did OxiCloud actually call /me?" / "is the +// /authorize redirect hitting the right URL?"). Kept because it's +// low-noise and makes the next debugging session 10x easier; the +// payload-dumping diagnostics that helped land the +// `image`-missing-from-INSERT fix (UserPgRepository::create_user) +// have been stripped. +const server = http.createServer(async (req, res) => { + // eslint-disable-next-line no-console + console.log(`[fake-idp] ${req.method} ${req.url}`); + if (req.url.startsWith('/control/')) return handleControl(req, res); + + try { + const url = new URL(req.url, ISSUER); + const autoMatch = url.pathname.match(/^\/auto\/[^/]+\/?$/); + + if (autoMatch) { + return await handleAuto(req, res); + } + + return oidcHandler(req, res); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[fake-idp] unhandled error:', e); + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'internal', detail: String(e) })); + } + } +}); + +// ── Auto-approve handler ─────────────────────────────────────────────── +// The library redirects /authorize to /auto/. We pull the +// interaction state, sign the test user in (prompt=login), then grant +// every requested claim+scope (prompt=consent). The provider issues +// the authorization code and 302s back to OxiCloud's callback. +async function handleAuto(req, res) { + const details = await provider.interactionDetails(req, res); + const { + prompt: { name }, + params, + } = details; + + if (name === 'login') { + return provider.interactionFinished( + req, + res, + { login: { accountId: TEST_USER_SUB } }, + { mergeWithLastSubmission: false }, + ); + } + + if (name === 'consent') { + const grant = new provider.Grant({ + accountId: TEST_USER_SUB, + clientId: params.client_id, + }); + if (params.scope) grant.addOIDCScope(params.scope); + // Explicitly grant every profile claim OxiCloud reads at JIT + // provisioning (see src/application/services/auth_application_service.rs + // around line 2257). `addOIDCClaims` is additive to whatever the + // scope already implies, so listing them here is belt-and-braces + // for keeping the claim set complete. + grant.addOIDCClaims([ + 'email', + 'email_verified', + 'name', + 'given_name', + 'family_name', + 'preferred_username', + 'picture', + ]); + const grantId = await grant.save(); + return provider.interactionFinished( + req, + res, + { consent: { grantId } }, + { mergeWithLastSubmission: true }, + ); + } + + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'unsupported_prompt', prompt: name })); +} + +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`[fake-idp] listening on ${ISSUER} (test user sub=${TEST_USER_SUB})`); +}); diff --git a/tests/oidc/oidc.hurl b/tests/oidc/oidc.hurl new file mode 100644 index 00000000..c8ee6ee2 --- /dev/null +++ b/tests/oidc/oidc.hurl @@ -0,0 +1,404 @@ +# ============================================================= +# OxiCloud — OIDC happy-path integration test +# ============================================================= +# Drives the full SSO flow against the fake IdP under +# tests/oidc/fake_idp/ (panva/node-oidc-provider with an auto-approve +# interaction handler) and asserts every contract the SPA depends on. +# Pinned regression: commit d1bbe8ba changed the callback's frontend +# redirect from `/?oidc_code=…` to `/login?oidc_code=…` — Step 5's +# `Location matches "^…/login\?oidc_code=…"` is the assertion that +# would have caught that bug before users hit it. +# +# Flow walked manually (no auto-follow) so each handler is asserted +# independently: +# +# 1. Bootstrap: create the local admin (provider list works +# regardless of admin presence, but the rest of the test +# lives more comfortably with a fully-initialised server). +# 2. GET /api/auth/oidc/providers — SPA reads this to render +# the SSO button. +# 3. GET /api/auth/oidc/authorize — server mints state + PKCE, +# redirects to the IdP. +# 4. GET /auth (+ full redirect chain) — fake-idp +# auto-approves login + consent, OxiCloud's callback +# JIT-provisions the user and redirects to +# {frontend_url}/login?oidc_code=… The final 404 (no SPA +# shell in test config) IS the test signal: we assert on +# the URL we landed at, which is the d1bbe8ba contract. +# 5. POST /api/auth/oidc/exchange — SPA swaps the one-time +# code for tokens + cookies. +# 6. GET /api/auth/me — proves the cookie session is live AND +# that every OIDC profile claim (name → username, given_name, +# family_name, picture → image, email, groups → admin role) +# was JIT-provisioned correctly into the local user record. +# 7. POST /api/auth/refresh — rotation of all three cookies; +# proves the SPA's session-renewal path works on top of +# an OIDC-provisioned account. +# 8. GET /api/auth/me — the refreshed cookies authenticate too. +# 9. Existing-user re-login — a second OIDC flow with the same +# `sub` resolves to the same local user, not a duplicate. +# 10. Anti-takeover — an unverified-email callback is rejected. +# 11. POST /api/auth/oidc/exchange — replay-protection: the +# one-time code is single-use, second exchange returns 401. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Create the local admin +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/setup +Content-Type: application/json +{ + "username": "{{username}}", + "email": "{{email}}", + "password": "{{password}}" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Provider discovery for the SPA +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/providers + +HTTP 200 +[Asserts] +jsonpath "$.enabled" == true +# tests/common/server-with-oidc.env sets OXICLOUD_OIDC_PROVIDER_NAME=MockSSO. +jsonpath "$.provider_name" == "MockSSO" +jsonpath "$.authorize_endpoint" == "/api/auth/oidc/authorize" +jsonpath "$.password_login_enabled" == true + + +# ───────────────────────────────────────────────────────────── +# Step 3 — SPA-initiated authorize. Server returns a 302 with +# state + PKCE challenge in the Location URL. +# [Options] location: false keeps Hurl from following +# the redirect so we can capture the target intact. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: false + +# 307 (not 302): handler uses axum Redirect::temporary, which preserves +# the request method on follow. For a GET-initiated SSO flow it makes +# no practical difference, but the assertion has to match what's emitted. +HTTP 307 +[Captures] +idp_url: header "Location" +[Asserts] +# panva/node-oidc-provider publishes authorize at /auth (not +# /authorize). The OXICLOUD_OIDC_ISSUER_URL points at the issuer +# root; the discovery doc tells OxiCloud the actual endpoint. +header "Location" matches "^{{oidc_authorize_endpoint}}\\?" +header "Location" contains "state=" +header "Location" contains "code_challenge=" +header "Location" contains "code_challenge_method=S256" +header "Location" contains "client_id=oxicloud-test" +header "Location" contains "redirect_uri=" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Walk the entire IdP + OxiCloud redirect chain. +# +# The fake IdP's auto-approve handler resolves login + +# consent silently and 302s back to OxiCloud's callback; +# the callback validates state + exchanges code with the +# IdP, JIT-provisions the user, then 307s the browser to +# {frontend_url}/login?oidc_code=… +# +# With `location: true` Hurl follows the whole chain and +# lands on the SPA login URL. The test server config +# (server-with-oidc.env) points `OXICLOUD_STATIC_PATH` +# at ./static — which is the legacy vanilla frontend, NOT +# static-dist/ — so /login returns 404. That 404 is the +# test signal: it proves we landed AT /login (i.e. the +# d1bbe8ba contract held). The URL we end at is the +# actual assertion. +# +# A pre-d1bbe8ba server would have redirected to +# `http://localhost:8087/?oidc_code=…` instead — same +# 404, but the `landed_at` assertion would catch it. +# ───────────────────────────────────────────────────────────── +GET {{idp_url}} +[Options] +location: true +location-trusted: true + +HTTP 404 +[Captures] +landed_at: url +oidc_code: url regex "oidc_code=([a-f0-9]+)" +[Asserts] +# The d1bbe8ba regression guard. The exact contract the SvelteKit +# SPA depends on — `/login`, not `/`. +variable "landed_at" matches "^http://localhost:8087/login\\?oidc_code=[a-f0-9]+$" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Swap the one-time code for a session. +# Response sets the HttpOnly auth cookies + the +# double-submit CSRF cookie the SPA reads to populate +# X-CSRF-Token on subsequent mutating requests. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/oidc/exchange +Content-Type: application/json +{ "code": "{{oidc_code}}" } + +HTTP 200 +[Captures] +oidc_session_user: jsonpath "$.user.username" +# Snapshotted so Step 7's refresh can prove the tokens rotated +# rather than being re-issued unchanged. The refresh handler in +# auth_handler.rs always rotates all three cookies (access JWT, +# refresh UUID, CSRF UUID); a regression that silently keeps the +# old refresh token would let a leaked refresh credential live +# forever — exactly the kind of issue token-family rotation exists +# to prevent. +initial_access_token: jsonpath "$.access_token" +initial_refresh_token: jsonpath "$.refresh_token" +initial_csrf_token: cookie "oxicloud_csrf" +[Asserts] +jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.email" == "oidc@example.com" +jsonpath "$.access_token" isString +# Multiple Set-Cookie headers come back as a list of values, so +# `contains` only matches whole-element strings. Each cookie shows up +# as its own list entry; we use `cookie ""` (Hurl's dedicated +# helper) which finds the cookie by name across all Set-Cookie headers. +cookie "oxicloud_access" exists +cookie "oxicloud_refresh" exists +cookie "oxicloud_csrf" exists +cookie "oxicloud_access[HttpOnly]" exists +cookie "oxicloud_refresh[HttpOnly]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Same-jar follow-up GET proves the cookie session +# actually authenticates. Hurl reuses the cookie jar +# across requests in one file by default, so the +# Set-Cookie from Step 5 carries forward. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me + +HTTP 200 +[Captures] +# Stash the user id for the re-login check in Step 10 below — a +# second OIDC flow with the same `sub` must resolve back to this +# exact user, not silently create a duplicate. +oidc_user_id: jsonpath "$.id" +[Asserts] +jsonpath "$.username" == "oidc_user" +jsonpath "$.email" == "oidc@example.com" +# `auth_provider` stores the OIDC provider's display name (set via +# OXICLOUD_OIDC_PROVIDER_NAME in tests/common/server-with-oidc.env), +# NOT a generic "oidc" tag. A locally registered admin would have +# this field as something like "local". The distinct value here is +# what proves JIT provisioning landed via OIDC, not setup.hurl. +jsonpath "$.auth_provider" == "MockSSO" +# Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js) +# pins these values and OxiCloud must persist each one verbatim during +# JIT provisioning (see auth_application_service.rs around line 2257). +# A regression that drops, swaps, or truncates a claim trips here. +# Note the field name flip on the API side: OIDC `picture` becomes +# UserDto.image (a URL or data URI). +jsonpath "$.given_name" == "OIDC" +jsonpath "$.family_name" == "Test" +jsonpath "$.image" == "https://example.com/oidc-test-user.png" +# Group-to-role mapping. server-with-oidc.env sets +# OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include +# `groups: ["admin-users"]`. The JIT path intersects the claim against +# the env and promotes the new user from `user` to `admin`. A +# regression here would silently strip (or wrongly grant) admin rights +# for every SSO deployment that uses group-based role mapping. +jsonpath "$.role" == "admin" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Refresh-token rotation. +# +# POST /api/auth/refresh reads the refresh token from +# the HttpOnly oxicloud_refresh cookie (the browser flow +# OxiCloud's SPA uses; the JSON body shape is only a +# backwards-compat path for non-browser clients) and +# re-issues all three cookies. Token-family rotation: +# the prior refresh token is invalidated server-side +# and a reuse attempt would be caught as a theft signal. +# +# CSRF middleware fires here because we have a cookie +# session — we pass the captured oxicloud_csrf value as +# the double-submit X-CSRF-Token header, matching what +# the SvelteKit SPA does via getCsrfHeaders(). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/refresh +X-CSRF-Token: {{initial_csrf_token}} +Content-Type: application/json +{} + +HTTP 200 +[Captures] +refreshed_access_token: jsonpath "$.access_token" +refreshed_refresh_token: jsonpath "$.refresh_token" +[Asserts] +jsonpath "$.user.username" == "oidc_user" +jsonpath "$.access_token" isString +jsonpath "$.refresh_token" isString +# All three cookies must rotate. If any value were re-used, a +# regression in cookie_auth::append_auth_cookies (or in the +# RefreshToken use case) would silently leave the old credential +# live — exactly the kind of bug that motivates rotation. +variable "refreshed_access_token" != "{{initial_access_token}}" +variable "refreshed_refresh_token" != "{{initial_refresh_token}}" +cookie "oxicloud_access" exists +cookie "oxicloud_refresh" exists +cookie "oxicloud_csrf" exists + + +# ───────────────────────────────────────────────────────────── +# Step 8 — The refreshed cookies authenticate too. Belt-and-braces: +# rotation is only useful if the new tokens actually work. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.username" == "oidc_user" + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Existing-user re-login. A second pass through the same +# OIDC `sub` MUST resolve back to the SAME local user +# (`oidc_user_id` captured in Step 6) — silently creating +# a duplicate account on every login would be the +# regression. Exercises the existing-user branch in +# auth_application_service.rs around line 2157, distinct +# from the JIT-provisioning branch the earlier steps hit. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: false + +HTTP 307 +[Captures] +relogin_idp_url: header "Location" + + +GET {{relogin_idp_url}} +[Options] +location: true +location-trusted: true + +HTTP 404 +[Captures] +relogin_oidc_code: url regex "oidc_code=([a-f0-9]+)" +[Asserts] +variable "landed_at" matches "^http://localhost:8087/login\\?oidc_code=[a-f0-9]+$" + + +POST {{base_url}}/api/auth/oidc/exchange +Content-Type: application/json +{ "code": "{{relogin_oidc_code}}" } + +HTTP 200 +[Asserts] +# Same local id — proves the existing-user resolver matched on `sub` +# (or `oidc_provider + oidc_subject`) instead of minting a new row. +jsonpath "$.user.id" == "{{oidc_user_id}}" +jsonpath "$.user.username" == "oidc_user" +# Role from the prior JIT-provisioned admin survives the re-login. +# Two regressions this catches: (a) the existing-user branch wiping +# the role to a default `user`; (b) the existing-user branch +# re-evaluating groups but missing the admin-group claim (the fake +# IdP still emits `groups: ["admin-users"]`, OXICLOUD_OIDC_ADMIN_GROUPS +# still resolves to "admin"). Either way, the role should remain +# `admin` — otherwise we have a silent admin demotion on every login. +jsonpath "$.user.role" == "admin" + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Anti-takeover: an OIDC callback whose `email_verified` +# claim is `false` MUST be rejected. Without this guard +# an attacker who can set `email` to a victim's address +# in their own IdP account (some IdPs allow unverified +# emails through the consent screen) gets the victim's +# OxiCloud account on first login. +# +# We flip the fake IdP into the unverified-email mode +# via the `/control/email-verified/false` test hook, +# drive a fresh authorize, expect the OxiCloud callback +# to fail, then reset the IdP for any future steps. +# +# This SHOULD use a different `sub` than the existing +# verified user to exercise the JIT path (the +# anti-takeover check fires there), but the auto-approve +# handler resolves one fixed `sub`. The check still +# fires on the existing user path too because the +# verified-email requirement is evaluated on every +# callback — that's what we exercise here. +# ───────────────────────────────────────────────────────────── +POST http://localhost:1080/control/email-verified/false + +HTTP 200 + + +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: false + +HTTP 307 +[Captures] +unverified_idp_url: header "Location" + + +GET {{unverified_idp_url}} +[Options] +location: true +location-trusted: true + +# OxiCloud's callback returns 403 (or 401, depending on which +# branch fires). What matters is the final URL is NOT +# /login?oidc_code= — a successful login would have landed there +# regardless of status, so a status-code-only assertion would +# miss a "we accidentally provisioned the unverified user" +# regression. We assert on BOTH the status AND the negation of +# the success URL via Hurl's built-in `url` query (NOT the +# `landed_at` capture from Step 4 — that variable is stale here). +HTTP * +[Asserts] +status >= 400 +status < 500 +url not matches "^http://localhost:8087/login\\?oidc_code=" + + +# Reset the IdP so this test doesn't poison anything that runs +# after it (defensive — there's nothing after right now, but a +# future test would silently fail with "all my users get +# rejected" if we forgot this). +POST http://localhost:1080/control/email-verified/true + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Replay protection: the one-time code is rejected on a +# second attempt. Defense-in-depth check. +# +# Hurl 4.x has no per-request cookie-jar clear, so this +# request still carries the session cookies set in Step 5. +# That means CSRF middleware rejects the unauthenticated +# (no X-CSRF-Token header) POST with 403 BEFORE the OIDC +# single-use-code check runs. Both are valid replay +# defenses; in a real attack the attacker has the code but +# not the session cookie, in which case the rejection +# would come from the OIDC layer as 401. +# +# The single-use-code path itself is covered by unit +# tests in auth_application_service.rs (the +# completed_oidc_logins moka cache + remove-on-use). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/oidc/exchange +Content-Type: application/json +{ "code": "{{oidc_code}}" } + +HTTP 403 diff --git a/tests/oidc/run.sh b/tests/oidc/run.sh new file mode 100755 index 00000000..407e5228 --- /dev/null +++ b/tests/oidc/run.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# OIDC integration-test runner. +# +# Brings up the test DB, a Node-based fake IdP (panva/node-oidc-provider +# under tests/oidc/fake_idp/), and OxiCloud configured to talk to that +# IdP, then runs the Hurl suite and tears everything down. +# +# Why a separate runner from tests/api/run.sh: +# * the OxiCloud server here is launched with +# `--config tests/common/server-with-oidc.env` (OIDC enabled) — the +# default api run uses server.env with OIDC off, and we don't want +# to flip flags mid-suite; +# * the IdP is a Node process this script owns, distinct from the +# postgres-test container that lives in spawn-db.sh. +# +# Invocation: +# * locally: chained from `just api-test` after the api + webdav +# suites, or directly via `bash tests/oidc/run.sh` +# * in CI: chained from the `api-test` job in +# .github/workflows/ci.yml — same shell call, same env. +# +# Prerequisites: docker, cargo, node ≥ 20, npm, hurl ≥ 4.0. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +OIDC_DIR="$REPO_ROOT/tests/oidc" +FAKE_IDP_DIR="$OIDC_DIR/fake_idp" + +# Test variables (base_url, admin creds, oidc_issuer, oidc_authorize_endpoint). +# shellcheck source=test.env +source "$OIDC_DIR/test.env" + +SERVER_PORT="${base_url##*:}" +# Derive the IdP port from oidc_issuer the same way (e.g. localhost:1080 → 1080). +# Keeps run.sh and test.env in lockstep — change one, the other follows. +IDP_PORT="${oidc_issuer##*:}" + +# ── Helpers ──────────────────────────────────────────────────────────────── +log() { echo "[oidc-test] $*"; } +die() { echo "[oidc-test] ERROR: $*" >&2; exit 1; } + +wait_for_http() { + local url="$1" timeout="${2:-60}" + local deadline=$(( $(date +%s) + timeout )) + until curl -sf "$url" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url" + sleep 0.5 + done +} + +# ── Fake-IdP process management ──────────────────────────────────────────── +# All cleanup paths funnel through this helper so an exit at ANY phase +# (early failure during npm install, hurl assertion fail, Ctrl-C, …) +# always reaps the node process. The earlier subshell+setsid pattern +# leaked daemons whenever the subshell exited before the trap fired, +# leading to the "no change after rerunning" failure mode: an old +# fake-idp from a prior failed run was still bound to port 1080, so +# the new spawn either failed silently (EADDRINUSE) or the tests hit +# the stale config. +# +# Belt-and-braces orphan reaping: kill by script-path pattern AND by +# port. The pattern match misses processes started from a different +# absolute path (e.g. via a symlinked checkout, or from a `node +# server.js` started from inside tests/oidc/fake_idp/ where the +# command line is just `node server.js`). The port-based fallback +# catches anything bound to 1080 regardless of how it was launched — +# the original case that caused the "stale daemon" debugging session. +kill_fake_idp() { + pkill -f "tests/oidc/fake_idp/server.js" 2>/dev/null || true + pkill -f "node.*server.js" 2>/dev/null || true + if command -v lsof >/dev/null 2>&1; then + local pids + pids=$(lsof -ti :"$IDP_PORT" 2>/dev/null || true) + if [[ -n "$pids" ]]; then + # shellcheck disable=SC2086 + kill -9 $pids 2>/dev/null || true + fi + fi +} + +# ── Teardown (always runs on exit) ───────────────────────────────────────── +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud server (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + log "Stopping fake-idp..." + kill_fake_idp + bash "$COMMON/stop-db.sh" || true +} +trap cleanup EXIT + +# ── 1. Postgres ──────────────────────────────────────────────────────────── +bash "$COMMON/spawn-db.sh" + +# ── 2. Fake IdP (Node) ───────────────────────────────────────────────────── +log "Installing fake-idp dependencies..." +if [[ -f "$FAKE_IDP_DIR/package-lock.json" ]]; then + # `npm ci` is faster + deterministic when the lockfile is present. + (cd "$FAKE_IDP_DIR" && npm ci --silent --no-audit --no-fund) +else + (cd "$FAKE_IDP_DIR" && npm install --silent --no-audit --no-fund) +fi + +# Sweep any orphaned fake-idp processes from a prior crashed/aborted +# run BEFORE starting the new one. Without this, a stale daemon +# bound to port 1080 would either swallow new spawns silently +# (EADDRINUSE buried in /tmp/fake-idp.log) or serve every request +# with its old config — producing the maddening "I changed the +# config but nothing changed" failure mode. +log "Sweeping any orphan fake-idp processes from prior runs..." +kill_fake_idp +# Brief moment for the OS to actually release the listener; without +# this the new node call can race the just-killed process and lose +# the bind. +sleep 0.3 + +log "Starting fake-idp on port $IDP_PORT..." +# Background the node process directly (no setsid / subshell wrapper). +# pkill-by-path in cleanup means we don't need a process-group dance +# to reap the child; the simpler launch keeps $IDP_PID correct (it's +# the actual node PID, not a wrapping subshell) for any future call +# site that wants to wait on it. +FAKE_IDP_ISSUER="$oidc_issuer" FAKE_IDP_PORT="$IDP_PORT" \ + node "$FAKE_IDP_DIR/server.js" > /tmp/fake-idp.log 2>&1 & +log "Waiting for fake-idp discovery endpoint..." +wait_for_http "$oidc_issuer/.well-known/openid-configuration" 30 +log "fake-idp is ready (logs: /tmp/fake-idp.log)" + +# ── 3. Load shared server env (port + storage path) ──────────────────────── +set -a +# shellcheck source=../common/server-with-oidc.env +source "$COMMON/server-with-oidc.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/oidc/storage" +set +a + +# shellcheck source=../common/wipe-storage.sh +source "$COMMON/wipe-storage.sh" +wipe_storage "$OXICLOUD_STORAGE_PATH" + +# ── 4. Start OxiCloud server with OIDC enabled ───────────────────────────── +BUILD_TARGET="${BUILD_TARGET:-debug}" +OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" + +if [[ ! -x "$OXICLOUD_BIN" ]]; then + log "Building OxiCloud server ($BUILD_TARGET)..." + case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;; + *) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;; + esac +fi + +log "Starting OxiCloud server with OIDC config on port $SERVER_PORT..." +"$OXICLOUD_BIN" --config "$COMMON/server-with-oidc.env" & +SERVER_PID=$! +log "Waiting for server at $base_url..." +wait_for_http "$base_url/ready" 120 +log "Server is ready." + +# ── 5. Run the OIDC Hurl suite ───────────────────────────────────────────── +log "Running OIDC Hurl tests..." +hurl --variables-file "$OIDC_DIR/test.env" \ + --file-root "$REPO_ROOT/tests" \ + --test --jobs 1 \ + "$OIDC_DIR/oidc.hurl" + +log "OIDC tests passed." diff --git a/tests/oidc/test.env b/tests/oidc/test.env new file mode 100644 index 00000000..14720f80 --- /dev/null +++ b/tests/oidc/test.env @@ -0,0 +1,12 @@ +# Variables fed to Hurl for the OIDC integration tests. +# Mirror tests/api/test.env so the same admin-setup flow works on top +# of the OIDC-enabled server binary. +base_url=http://localhost:8087 +username=admin +email=admin@example.com +# gitguardian:ignore +password=TestPassword1! +# Discovery / authorize endpoints exposed by tests/oidc/fake_idp. +# oidc-provider publishes authorize at /auth (not /authorize) by default. +oidc_issuer=http://localhost:1080 +oidc_authorize_endpoint=http://localhost:1080/auth