diff --git a/.dockerignore b/.dockerignore index c2658d7..072c450 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,8 @@ +.git/ +.nogit/ +.playwright-mcp/ +.vscode/ +dist/ +dist_*/ node_modules/ +test_watch/ diff --git a/.gitea/workflows/docker_nottags.yaml b/.gitea/workflows/docker_nottags.yaml index 5154a0e..5ef55e2 100644 --- a/.gitea/workflows/docker_nottags.yaml +++ b/.gitea/workflows/docker_nottags.yaml @@ -1,4 +1,4 @@ -name: Docker (tags) +name: Docker (non-tag pushes) on: push: @@ -6,44 +6,12 @@ on: - '**' env: - IMAGE: code.foss.global/hosttoday/ht-docker-node:npmci - NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git - NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}} - NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}} - NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}} - NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }} + IMAGE: code.foss.global/host.today/ht-docker-node:szci + NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }} jobs: - security: - runs-on: ubuntu-latest - container: - image: ${{ env.IMAGE }} - continue-on-error: true - - steps: - - uses: actions/checkout@v3 - - - name: Install pnpm and npmci - run: | - pnpm install -g pnpm - pnpm install -g @ship.zone/npmci - npmci npm prepare - - - name: Audit production dependencies - run: | - npmci command npm config set registry https://registry.npmjs.org - npmci command pnpm audit --audit-level=high --prod - continue-on-error: true - - - name: Audit development dependencies - run: | - npmci command npm config set registry https://registry.npmjs.org - npmci command pnpm audit --audit-level=high --dev - continue-on-error: true - test: - needs: security runs-on: ubuntu-latest container: image: ${{ env.IMAGE }} @@ -54,18 +22,14 @@ jobs: - name: Prepare run: | pnpm install -g pnpm - pnpm install -g @ship.zone/npmci - npmci npm prepare + pnpm install -g @git.zone/tsdocker@latest + pnpm install - - name: Test stable - run: | - npmci node install stable - npmci npm install - npmci npm test + - name: Test + run: pnpm test - - name: Test build - run: | - npmci npm prepare - npmci node install stable - npmci npm install - npmci command npm run build + - name: Build image + run: tsdocker build + + - name: Test image + run: tsdocker test diff --git a/.gitea/workflows/docker_tags.yaml b/.gitea/workflows/docker_tags.yaml index 74684e0..e8e537d 100644 --- a/.gitea/workflows/docker_tags.yaml +++ b/.gitea/workflows/docker_tags.yaml @@ -6,75 +6,15 @@ on: - '*' env: - IMAGE: code.foss.global/hosttoday/ht-docker-node:npmci - NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git - NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}} - NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}} - NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}} - NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }} + IMAGE: code.foss.global/host.today/ht-docker-node:szci + NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }} jobs: - security: - runs-on: ubuntu-latest - container: - image: ${{ env.IMAGE }} - continue-on-error: true - - steps: - - uses: actions/checkout@v3 - - - name: Prepare - run: | - pnpm install -g pnpm - pnpm install -g @ship.zone/npmci - npmci npm prepare - - - name: Audit production dependencies - run: | - npmci command npm config set registry https://registry.npmjs.org - npmci command pnpm audit --audit-level=high --prod - continue-on-error: true - - - name: Audit development dependencies - run: | - npmci command npm config set registry https://registry.npmjs.org - npmci command pnpm audit --audit-level=high --dev - continue-on-error: true - - test: - needs: security - runs-on: ubuntu-latest - container: - image: ${{ env.IMAGE }} - - steps: - - uses: actions/checkout@v3 - - - name: Prepare - run: | - pnpm install -g pnpm - pnpm install -g @ship.zone/npmci - npmci npm prepare - - - name: Test stable - run: | - npmci node install stable - npmci npm install - npmci npm test - - - name: Test build - run: | - npmci node install stable - npmci npm install - npmci command npm run build - release: - needs: test - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest container: - image: code.foss.global/hosttoday/ht-docker-dbase:npmci + image: code.foss.global/host.today/ht-docker-dbase:szci steps: - uses: actions/checkout@v3 @@ -82,25 +22,20 @@ jobs: - name: Prepare run: | pnpm install -g pnpm - pnpm install -g @ship.zone/npmci + pnpm install -g @git.zone/tsdocker@latest + pnpm install - - name: Release - run: | - npmci docker login - npmci docker build - npmci docker test - # npmci docker push - npmci docker push + - name: Login to registries + run: tsdocker login - metadata: - needs: test - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - container: - image: ${{ env.IMAGE }} + - name: List images + run: tsdocker list - steps: - - uses: actions/checkout@v3 + - name: Build images + run: tsdocker build - - name: Trigger - run: npmci trigger + - name: Test images + run: tsdocker test + + - name: Push to code.foss.global + run: tsdocker push --registry=code.foss.global diff --git a/.smartconfig.json b/.smartconfig.json index f2b7239..740f7b3 100644 --- a/.smartconfig.json +++ b/.smartconfig.json @@ -6,7 +6,7 @@ "gitscope": "idp.global", "gitrepo": "app", "description": "An identity provider software managing user authentications, registrations, and sessions.", - "npmPackagename": "@idp.global/idp.global", + "npmPackagename": "@idp.global/app", "license": "MIT", "projectDomain": "idp.global", "keywords": [ @@ -44,6 +44,10 @@ "https://registry.npmjs.org" ], "accessLevel": "public" + }, + "docker": { + "enabled": true, + "engine": "tsdocker" } } }, @@ -103,13 +107,17 @@ "@git.zone/tsdoc": { "legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n" }, - "@ship.zone/szci": { - "npmGlobalTools": [], - "dockerRegistryRepoMap": { - "registry.gitlab.com": "code.foss.global/idp.global/app" + "@git.zone/tsdocker": { + "registries": [ + "code.foss.global" + ], + "registryRepoMap": { + "code.foss.global": "idp.global/app" }, - "dockerBuildargEnvMap": { - "NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2" - } + "platforms": [ + "linux/amd64", + "linux/arm64" + ], + "testDir": "./test" } -} \ No newline at end of file +} diff --git a/Dockerfile b/Dockerfile index b8dec57..d9af65b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,34 @@ # gitzone dockerfile_service ## STAGE 1 // BUILD -FROM code.foss.global/hosttoday/ht-docker-node:npmci as node1 -COPY ./ /app +FROM code.foss.global/host.today/ht-docker-node:lts AS build + WORKDIR /app -ARG NPMCI_TOKEN_NPM2 -ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2 -RUN npmci npm prepare + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm config set registry https://verdaccio.lossless.digital/ RUN pnpm config set store-dir .pnpm-store -RUN rm -rf node_modules && pnpm install +RUN pnpm install --frozen-lockfile + +COPY . ./ RUN pnpm run build - -# gitzone dockerfile_service -## STAGE 2 // install production -FROM code.foss.global/hosttoday/ht-docker-node:npmci as node2 -WORKDIR /app -COPY --from=node1 /app /app +RUN pnpm prune --prod RUN rm -rf .pnpm-store -ARG NPMCI_TOKEN_NPM2 -ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2 -RUN npmci npm prepare -RUN pnpm config set store-dir .pnpm-store -RUN rm -rf node_modules/ && pnpm install --prod +## STAGE 2 // PRODUCTION +FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production -## STAGE 3 // rebuild dependencies for alpine -FROM code.foss.global/hosttoday/ht-docker-node:alpinenpmci as node3 WORKDIR /app -COPY --from=node2 /app /app -ARG NPMCI_TOKEN_NPM2 -ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2 -RUN npmci npm prepare -RUN pnpm config set store-dir .pnpm-store + +ENV NODE_ENV=production + +COPY --from=build /app /app + +# Rebuild native modules such as argon2 against Alpine libc. RUN pnpm rebuild -r -## STAGE 4 // the final production image with all dependencies in place -FROM code.foss.global/hosttoday/ht-docker-node:alpine as node4 -WORKDIR /app -COPY --from=node3 /app /app +LABEL org.opencontainers.image.title="idp.global" \ + org.opencontainers.image.description="Identity provider server, web UI, OIDC provider, MFA, and passkey runtime" \ + org.opencontainers.image.source="https://code.foss.global/idp.global/app" -### Healthchecks -RUN pnpm install -g @servezone/healthy -HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ] - -EXPOSE 80 -CMD ["npm", "start"] +EXPOSE 2999 +CMD ["node", "cli.js"] diff --git a/package.json b/package.json index 4c82132..910cfbf 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@idp.global/idp.global", + "name": "@idp.global/app", "version": "1.21.1", "description": "An identity provider software managing user authentications, registrations, and sessions.", "main": "dist_ts/index.js", @@ -8,6 +8,8 @@ "scripts": { "test": "pnpm run build && tstest test/", "build": "tsbuild tsfolders --web --allowimplicitany && tsbundle", + "build:docker": "tsdocker build --verbose", + "release:docker": "tsdocker push --verbose", "watch": "tswatch", "seed": "tsrun ts_seed/cli.ts", "start": "(node cli.js)", @@ -30,6 +32,7 @@ "@idp.global/interfaces": "^1.0.1", "@idp.global/sdk": "^1.3.0", "@push.rocks/lik": "^6.4.1", + "@push.rocks/projectinfo": "^5.1.0", "@push.rocks/qenv": "^6.1.4", "@push.rocks/smartcli": "^4.3.0", "@push.rocks/smartdata": "^7.1.7", @@ -53,17 +56,20 @@ "@push.rocks/websetup": "^3.0.20", "@push.rocks/webstore": "^2.0.22", "@serve.zone/platformclient": "^1.1.4", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.0", "@tsclass/tsclass": "^9.5.1", "@uptime.link/webwidget": "^1.2.6", - "argon2": "^0.44.0" + "argon2": "^0.44.0", + "otplib": "^13.4.0" }, "devDependencies": { "@git.zone/tsbuild": "^4.4.1", "@git.zone/tsbundle": "^2.10.4", + "@git.zone/tsdocker": "^2.3.0", "@git.zone/tsrun": "^2.0.4", "@git.zone/tstest": "^3.6.6", "@git.zone/tswatch": "^3.3.5", - "@push.rocks/projectinfo": "^5.1.0", "@types/node": "^25.9.0" }, "private": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5207b98..90d62ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@push.rocks/lik': specifier: ^6.4.1 version: 6.4.1 + '@push.rocks/projectinfo': + specifier: ^5.1.0 + version: 5.1.0 '@push.rocks/qenv': specifier: ^6.1.4 version: 6.1.4 @@ -116,6 +119,12 @@ importers: '@serve.zone/platformclient': specifier: ^1.1.4 version: 1.1.4(@push.rocks/smartserve@2.0.4)(@tiptap/pm@2.27.2) + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 + '@simplewebauthn/server': + specifier: ^13.3.0 + version: 13.3.0 '@tsclass/tsclass': specifier: ^9.5.1 version: 9.5.1 @@ -125,6 +134,9 @@ importers: argon2: specifier: ^0.44.0 version: 0.44.0 + otplib: + specifier: ^13.4.0 + version: 13.4.0 devDependencies: '@git.zone/tsbuild': specifier: ^4.4.1 @@ -132,6 +144,9 @@ importers: '@git.zone/tsbundle': specifier: ^2.10.4 version: 2.10.4 + '@git.zone/tsdocker': + specifier: ^2.3.0 + version: 2.3.0 '@git.zone/tsrun': specifier: ^2.0.4 version: 2.0.4 @@ -141,9 +156,6 @@ importers: '@git.zone/tswatch': specifier: ^3.3.5 version: 3.3.5(@tiptap/pm@2.27.2) - '@push.rocks/projectinfo': - specifier: ^5.1.0 - version: 5.1.0 '@types/node': specifier: ^25.9.0 version: 25.9.0 @@ -167,6 +179,9 @@ packages: peerDependencies: '@push.rocks/smartserve': '>=1.1.0' + '@apiglobal/typedrequest-interfaces@1.0.20': + resolution: {integrity: sha512-ybsDtavYbzGJYSLodSbkxDvSLYtfMzBTuNZDJpiANt1rZA2MO/GCq8zk5MVLlrUUQIr/7oxPGWqxi1QDwR+RHQ==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -729,6 +744,10 @@ packages: resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==} hasBin: true + '@git.zone/tsdocker@2.3.0': + resolution: {integrity: sha512-im2hD3Fu7vSb6qM+WMg2tbvLbFfEpX8qVmjy491R5iELky4Pw9cqRMkwzmxW92etn8v+f53ODUQDOoc9DufX2A==} + hasBin: true + '@git.zone/tspublish@1.11.7': resolution: {integrity: sha512-6vXXOQ7mPQu72q/Trr8bHwYLrAX1Y1okX/KoKTXlnx0adtqJKw1V5p9cimGrkq40rX5ivh1rPg+7fTlbXgjyew==} hasBin: true @@ -749,6 +768,9 @@ packages: resolution: {integrity: sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw==} engines: {node: '>=20.0.0'} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@idp.global/catalog@1.1.1': resolution: {integrity: sha512-blVe8FnIfcn6q9zPnauQOzbGDDoVwaHhPx8CGijod8V93nncJSIHYTpbQSuVmc5MDfoydK+7gf1RWp0PG/THng==} @@ -1063,6 +1085,9 @@ packages: resolution: {integrity: sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==} engines: {node: '>=18'} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@lit-labs/ssr-dom-shim@1.5.1': resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} @@ -1151,9 +1176,31 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@otplib/core@13.4.0': + resolution: {integrity: sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==} + + '@otplib/hotp@13.4.0': + resolution: {integrity: sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==} + + '@otplib/plugin-base32-scure@13.4.0': + resolution: {integrity: sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==} + + '@otplib/plugin-crypto-noble@13.4.0': + resolution: {integrity: sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==} + + '@otplib/totp@13.4.0': + resolution: {integrity: sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==} + + '@otplib/uri@13.4.0': + resolution: {integrity: sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==} + '@oxc-project/types@0.129.0': resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} @@ -1163,6 +1210,9 @@ packages: '@pdf-lib/upng@1.0.1': resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@peculiar/asn1-android@2.7.0': + resolution: {integrity: sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==} + '@peculiar/asn1-cms@2.7.0': resolution: {integrity: sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==} @@ -1338,6 +1388,9 @@ packages: '@push.rocks/smartlog-interfaces@3.0.2': resolution: {integrity: sha512-8hGRTJehbsFSJxLhCQkA018mZtXVPxPTblbg9VaE/EqISRzUw+eosJ2EJV7M4Qu0eiTJZjnWnNLn8CkD77ziWw==} + '@push.rocks/smartlog-source-ora@1.0.9': + resolution: {integrity: sha512-s5OmwceGUFbCysYNg3VJZo07lkHxD2GPk8VABJTmhxtrogBw5kChx9d5NMdWQ+CovkNoNhKen1hF3b3l0v6jSQ==} + '@push.rocks/smartlog@3.2.2': resolution: {integrity: sha512-3Nw/Ki/jZ4vrrWnEtpcGPF28jQ+fr9/9Edc7ytaEA6ZWIpojtwacJ5qihMvHbIei+zjpD35w6tZP2mQjvw5VRQ==} @@ -1487,6 +1540,10 @@ packages: resolution: {integrity: sha512-9OJbnRgLTaCRQz+pqu5tB3ZCqRs5Zh0hnBe7t7URE+TgwIZ8aiELUIbWRkgn4mSGVzHyL6pqTyIowP6AjUCG3w==} deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartjson + '@pushrocks/smartlog-interfaces@2.0.23': + resolution: {integrity: sha512-tXqwfrekGxGZJB72BFQppywk7413hXVDgcJNeU+kY6xvmzVjf2HxOMbFYhewh1+p4uai1u9n0xcMb0qbbPy4/Q==} + deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartlog-interfaces + '@pushrocks/smartpromise@3.1.10': resolution: {integrity: sha512-VeTurbZ1+ZMxBDJk1Y1LV8SN9xLI+oDXKVeCFw41FAGEKOUEqordqFpi6t+7Vhe/TXUZzCVpZ5bXxAxrGf8yTQ==} deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartpromise @@ -1654,6 +1711,9 @@ packages: '@swc/helpers': optional: true + '@scure/base@2.2.0': + resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -1663,6 +1723,13 @@ packages: '@serve.zone/platformclient@1.1.4': resolution: {integrity: sha512-TcUQpWOqb28Iyz57ZdC2hXfV6maxeeLfjt85T1cPQc7g1KoYZ+CfFpRr0NBEf4oG2A7vALnsIDvlebtJO1GTow==} + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.0': + resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==} + engines: {node: '>=20.0.0'} + '@smithy/chunked-blob-reader-native@4.2.3': resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} engines: {node: '>=18.0.0'} @@ -2184,6 +2251,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2343,6 +2414,14 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2368,6 +2447,14 @@ packages: resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==} engines: {node: '>= 4.0'} + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -2376,10 +2463,20 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone@1.0.4: + resolution: {integrity: sha1-2jCcwmPfFZlMaIypAheco8fNfH4=} + engines: {node: '>=0.8'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-name@1.1.3: + resolution: {integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -2454,6 +2551,9 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2552,6 +2652,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@1.0.5: + resolution: {integrity: sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2742,6 +2846,14 @@ packages: resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} engines: {node: '>=20.0.0'} + has-flag@3.0.0: + resolution: {integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0=} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -2839,6 +2951,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + is-nan@1.3.2: resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} engines: {node: '>= 0.4'} @@ -2965,6 +3081,10 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=} + log-symbols@3.0.0: + resolution: {integrity: sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==} + engines: {node: '>=8'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3169,6 +3289,10 @@ packages: engines: {node: '>=16'} hasBin: true + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mingo@7.2.1: resolution: {integrity: sha512-MEIQPOSJS2sVCueyQeE2rzgEeW3HpIIhizPbeuwD4v7+miVj7NI3ZVPqqw8t3YPIWCivpIaXA4KsoRI7koyNOA==} @@ -3243,6 +3367,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + mute-stream@1.0.0: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -3303,10 +3430,18 @@ packages: once@1.4.0: resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + ora@4.1.1: + resolution: {integrity: sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==} + engines: {node: '>=8'} + orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} @@ -3314,6 +3449,9 @@ packages: resolution: {integrity: sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=} engines: {node: '>=0.10.0'} + otplib@13.4.0: + resolution: {integrity: sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==} + p-finally@1.0.0: resolution: {integrity: sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=} engines: {node: '>=4'} @@ -3620,6 +3758,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + rimraf@6.1.3: resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} engines: {node: 20 || >=22} @@ -3767,6 +3909,14 @@ packages: resolution: {integrity: sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==} engines: {node: '>=16'} + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + sweet-scroll@4.0.0: resolution: {integrity: sha512-mR6fRsAQANtm3zpzhUE73KAOt2aT4ZsWzNSggiEsSqdO6Zh4gM7ioJG81EngrZEl0XAc3ZvzEfhxggOoEBc8jA==} @@ -3941,6 +4091,9 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + wcwidth@1.0.1: + resolution: {integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=} + webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} @@ -4129,6 +4282,8 @@ snapshots: '@push.rocks/smartstring': 4.1.1 '@push.rocks/smarturl': 3.1.0 + '@apiglobal/typedrequest-interfaces@1.0.20': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -4966,6 +5121,24 @@ snapshots: - supports-color - vue + '@git.zone/tsdocker@2.3.0': + dependencies: + '@push.rocks/lik': 6.4.1 + '@push.rocks/projectinfo': 5.1.0 + '@push.rocks/smartcli': 4.3.0 + '@push.rocks/smartconfig': 6.1.1 + '@push.rocks/smartfs': 1.5.1 + '@push.rocks/smartinteract': 2.0.16 + '@push.rocks/smartlog': 3.2.2 + '@push.rocks/smartlog-destination-local': 9.0.2 + '@push.rocks/smartlog-source-ora': 1.0.9 + '@push.rocks/smartshell': 3.3.8 + transitivePeerDependencies: + - '@nuxt/kit' + - react + - supports-color + - vue + '@git.zone/tspublish@1.11.7': dependencies: '@push.rocks/consolecolor': 2.0.4 @@ -5081,6 +5254,8 @@ snapshots: - bufferutil - utf-8-validate + '@hexagon/base64@1.1.28': {} + '@idp.global/catalog@1.1.1(@tiptap/pm@2.27.2)': dependencies: '@design.estate/dees-catalog': 3.81.0(@tiptap/pm@2.27.2) @@ -5545,6 +5720,8 @@ snapshots: '@jimp/types': 1.6.1 tinycolor2: 1.6.0 + '@levischuck/tiny-cbor@0.2.11': {} + '@lit-labs/ssr-dom-shim@1.5.1': {} '@lit/reactive-element@2.1.2': @@ -5612,8 +5789,37 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/hashes@2.2.0': {} + '@nodable/entities@2.1.0': {} + '@otplib/core@13.4.0': {} + + '@otplib/hotp@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@otplib/uri': 13.4.0 + + '@otplib/plugin-base32-scure@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@scure/base': 2.2.0 + + '@otplib/plugin-crypto-noble@13.4.0': + dependencies: + '@noble/hashes': 2.2.0 + '@otplib/core': 13.4.0 + + '@otplib/totp@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@otplib/hotp': 13.4.0 + '@otplib/uri': 13.4.0 + + '@otplib/uri@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@oxc-project/types@0.129.0': {} '@pdf-lib/standard-fonts@1.0.0': @@ -5624,6 +5830,12 @@ snapshots: dependencies: pako: 1.0.11 + '@peculiar/asn1-android@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + '@peculiar/asn1-cms@2.7.0': dependencies: '@peculiar/asn1-schema': 2.7.0 @@ -6081,6 +6293,11 @@ snapshots: '@api.global/typedrequest-interfaces': 2.0.2 '@tsclass/tsclass': 4.4.4 + '@push.rocks/smartlog-source-ora@1.0.9': + dependencies: + '@pushrocks/smartlog-interfaces': 2.0.23 + ora: 4.1.1 + '@push.rocks/smartlog@3.2.2': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 @@ -6483,6 +6700,10 @@ snapshots: fast-json-stable-stringify: 2.1.0 lodash.clonedeep: 4.5.0 + '@pushrocks/smartlog-interfaces@2.0.23': + dependencies: + '@apiglobal/typedrequest-interfaces': 1.0.20 + '@pushrocks/smartpromise@3.1.10': {} '@pushrocks/smartstring@4.0.7': @@ -6602,6 +6823,8 @@ snapshots: dependencies: '@rspack/binding': 2.0.3 + '@scure/base@2.2.0': {} + '@sec-ant/readable-stream@0.4.1': {} '@serve.zone/interfaces@5.4.6': @@ -6629,6 +6852,19 @@ snapshots: - utf-8-validate - vue + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.0': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.7.0 + '@peculiar/asn1-ecc': 2.7.0 + '@peculiar/asn1-rsa': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + '@peculiar/x509': 1.14.3 + '@smithy/chunked-blob-reader-native@4.2.3': dependencies: '@smithy/util-base64': 4.3.2 @@ -7291,6 +7527,10 @@ snapshots: ansi-regex@5.0.1: {} + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -7436,6 +7676,17 @@ snapshots: ccount@2.0.1: {} + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -7458,6 +7709,12 @@ snapshots: dependencies: source-map: 0.6.1 + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + cli-width@4.1.0: {} cliui@8.0.1: @@ -7466,10 +7723,18 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone@1.0.4: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-name@1.1.3: {} + color-name@1.1.4: {} combined-stream@1.0.8: @@ -7531,6 +7796,10 @@ snapshots: deep-extend@0.6.0: {} + defaults@1.0.4: + dependencies: + clone: 1.0.4 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -7673,6 +7942,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -7897,6 +8168,10 @@ snapshots: - bufferutil - utf-8-validate + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -8011,6 +8286,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-interactive@1.0.0: {} + is-nan@1.3.2: dependencies: call-bind: 1.0.9 @@ -8156,6 +8433,10 @@ snapshots: lodash.once@4.1.1: {} + log-symbols@3.0.0: + dependencies: + chalk: 2.4.2 + longest-streak@3.1.0: {} lower-case@1.1.4: {} @@ -8540,6 +8821,8 @@ snapshots: mime@4.1.0: {} + mimic-fn@2.1.0: {} + mingo@7.2.1: {} minimatch@10.2.5: @@ -8624,6 +8907,8 @@ snapshots: ms@2.1.3: {} + mute-stream@0.0.8: {} + mute-stream@1.0.0: {} nanoid@4.0.2: {} @@ -8666,16 +8951,40 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + open@8.4.2: dependencies: define-lazy-prop: 2.0.0 is-docker: 2.2.1 is-wsl: 2.2.0 + ora@4.1.1: + dependencies: + chalk: 3.0.0 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + log-symbols: 3.0.0 + mute-stream: 0.0.8 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + orderedmap@2.1.1: {} os-tmpdir@1.0.2: {} + otplib@13.4.0: + dependencies: + '@otplib/core': 13.4.0 + '@otplib/hotp': 13.4.0 + '@otplib/plugin-base32-scure': 13.4.0 + '@otplib/plugin-crypto-noble': 13.4.0 + '@otplib/totp': 13.4.0 + '@otplib/uri': 13.4.0 + p-finally@1.0.0: {} p-limit@2.3.0: @@ -9067,6 +9376,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + rimraf@6.1.3: dependencies: glob: 13.0.6 @@ -9269,6 +9583,14 @@ snapshots: '@tokenizer/token': 0.3.0 peek-readable: 5.4.2 + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + sweet-scroll@4.0.0: {} symbol-tree@3.2.4: {} @@ -9463,6 +9785,10 @@ snapshots: w3c-keyname@2.2.8: {} + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + webdriver-bidi-protocol@0.4.1: {} webidl-conversions@7.0.0: {} diff --git a/readme.md b/readme.md index 07f5e85..4e3efa8 100644 --- a/readme.md +++ b/readme.md @@ -140,6 +140,17 @@ The typed request surface includes: - `getPassportDashboard`, `listPassportAlerts`, and `markPassportAlertSeen` for mobile app dashboards and notifications. - `registerPassportPushToken` for push delivery setup. +## MFA And Passkeys + +The reception backend supports real multi-factor authentication for account logins: + +- TOTP enrollment with `startTotpEnrollment` and `finishTotpEnrollment`. +- Hashed one-time backup codes through `regenerateBackupCodes` and `verifyMfaChallenge`. +- WebAuthn passkey registration, revocation, passwordless login, and MFA step-up through the `startPasskey*` and `finishPasskey*` request pairs. +- Password and magic-link logins return `twoFaNeeded`, `mfaChallengeToken`, and `availableMfaMethods` instead of a refresh token when MFA is configured. + +TOTP secrets are AES-GCM encrypted. Set `IDP_TOTP_ENCRYPTION_KEY` in production so encrypted credentials remain stable across deployments. + ## SDK Example Browser integrations should use the dedicated SDK browser entrypoint published by `@idp.global/sdk`. diff --git a/stories/end-user/EU-004-two-factor-auth.md b/stories/end-user/EU-004-two-factor-auth.md index 6f43711..1a624ac 100644 --- a/stories/end-user/EU-004-two-factor-auth.md +++ b/stories/end-user/EU-004-two-factor-auth.md @@ -2,26 +2,26 @@ **ID:** EU-004 **Priority:** High -**Status:** Planned +**Status:** Implemented ## User Story As an end user, I want to enable two-factor authentication on my account so that my account is protected even if my password is compromised. ## Acceptance Criteria -- [ ] User can enable 2FA from account settings -- [ ] Support for TOTP apps (Google Authenticator, Authy, etc.) -- [ ] Backup codes are generated and shown once during setup -- [ ] User must verify 2FA code during setup to confirm it works -- [ ] Login flow prompts for 2FA code when enabled -- [ ] User can disable 2FA (requires current 2FA code) -- [ ] Account recovery option if 2FA device is lost +- [x] User can enable 2FA from account settings +- [x] Support for TOTP apps (Google Authenticator, Authy, etc.) +- [x] Backup codes are generated and shown once during setup +- [x] User must verify 2FA code during setup to confirm it works +- [x] Login flow prompts for 2FA code when enabled +- [x] User can disable 2FA (requires current 2FA code) +- [x] Account recovery option if 2FA device is lost via one-time backup codes ## Technical Notes -- Mobile verification infrastructure exists (SMS OTP in registration) -- Can leverage existing `smarttwilio` integration for SMS-based 2FA -- TOTP implementation needs `otplib` or similar library -- Store encrypted TOTP secret in User model -- Consider supporting multiple 2FA methods (TOTP, SMS, security keys) +- TOTP is implemented with `otplib`. +- TOTP secrets are stored encrypted in dedicated credential records, not on the User model. +- Backup codes are stored as hashes and consumed once. +- WebAuthn passkeys are supported for passwordless login and MFA step-up. +- SMS OTP remains registration-only and is not a default login factor. ## Related TODOs -- New feature - no existing TODO +- Consider adding explicit recovery admin workflows beyond backup codes. diff --git a/test/test.mfa.node.ts b/test/test.mfa.node.ts new file mode 100644 index 0000000..640dee9 --- /dev/null +++ b/test/test.mfa.node.ts @@ -0,0 +1,199 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; + +import * as plugins from '../ts/plugins.js'; +import { LoginSession } from '../ts/reception/classes.loginsession.js'; +import { MfaChallenge } from '../ts/reception/classes.mfachallenge.js'; +import { MfaManager } from '../ts/reception/classes.mfamanager.js'; +import { PasskeyCredential } from '../ts/reception/classes.passkeycredential.js'; +import { TotpCredential } from '../ts/reception/classes.totpcredential.js'; +import { WebAuthnChallenge } from '../ts/reception/classes.webauthnchallenge.js'; + +const getNestedValue = (targetArg: any, pathArg: string) => { + return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg); +}; + +const matchesQuery = (targetArg: any, queryArg: Record) => { + return Object.entries(queryArg).every(([keyArg, valueArg]) => getNestedValue(targetArg, keyArg) === valueArg); +}; + +const createTestMfaManager = () => { + const totpCredentials = new Map(); + const mfaChallenges = new Map(); + const passkeyCredentials = new Map(); + const webAuthnChallenges = new Map(); + const activityLogCalls: Array<{ userId: string; action: string; description: string }> = []; + const user = { + id: 'user-1', + data: { + email: 'user@example.com', + username: 'user', + name: 'Test User', + }, + }; + + const manager = new MfaManager({ + db: { smartdataDb: {} }, + typedrouter: { addTypedRouter: () => undefined }, + options: { name: 'idp.global test', baseUrl: 'https://idp.global' }, + userManager: { + getUserByJwtValidation: async () => user, + CUser: { + getInstance: async (queryArg: Record) => { + if (queryArg.id === user.id) { + return user; + } + return null; + }, + }, + }, + abuseProtectionManager: { + consumeAttempt: async () => undefined, + clearAttempts: async () => undefined, + }, + activityLogManager: { + logActivity: async (userIdArg: string, actionArg: string, descriptionArg: string) => { + activityLogCalls.push({ userId: userIdArg, action: actionArg, description: descriptionArg }); + }, + }, + } as any); + + const originalTotpSave = TotpCredential.prototype.save; + const originalTotpDelete = TotpCredential.prototype.delete; + const originalMfaChallengeSave = MfaChallenge.prototype.save; + const originalMfaChallengeDelete = MfaChallenge.prototype.delete; + const originalPasskeySave = PasskeyCredential.prototype.save; + const originalPasskeyDelete = PasskeyCredential.prototype.delete; + const originalWebAuthnSave = WebAuthnChallenge.prototype.save; + const originalWebAuthnDelete = WebAuthnChallenge.prototype.delete; + const originalLoginSessionSave = LoginSession.prototype.save; + + (TotpCredential.prototype as TotpCredential & { save: () => Promise }).save = async function () { + totpCredentials.set(this.id, this); + }; + (TotpCredential.prototype as TotpCredential & { delete: () => Promise }).delete = async function () { + totpCredentials.delete(this.id); + }; + (MfaChallenge.prototype as MfaChallenge & { save: () => Promise }).save = async function () { + mfaChallenges.set(this.id, this); + }; + (MfaChallenge.prototype as MfaChallenge & { delete: () => Promise }).delete = async function () { + mfaChallenges.delete(this.id); + }; + (PasskeyCredential.prototype as PasskeyCredential & { save: () => Promise }).save = async function () { + passkeyCredentials.set(this.id, this); + }; + (PasskeyCredential.prototype as PasskeyCredential & { delete: () => Promise }).delete = async function () { + passkeyCredentials.delete(this.id); + }; + (WebAuthnChallenge.prototype as WebAuthnChallenge & { save: () => Promise }).save = async function () { + webAuthnChallenges.set(this.id, this); + }; + (WebAuthnChallenge.prototype as WebAuthnChallenge & { delete: () => Promise }).delete = async function () { + webAuthnChallenges.delete(this.id); + }; + (LoginSession.prototype as LoginSession & { save: () => Promise }).save = async function () { + return undefined; + }; + + (manager as any).CTotpCredential = { + getInstance: async (queryArg: Record) => Array.from(totpCredentials.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null, + getInstances: async (queryArg: Record) => Array.from(totpCredentials.values()).filter((docArg) => matchesQuery(docArg, queryArg)), + }; + (manager as any).CMfaChallenge = { + getInstance: async (queryArg: Record) => Array.from(mfaChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null, + getInstances: async (queryArg: Record) => Array.from(mfaChallenges.values()).filter((docArg) => matchesQuery(docArg, queryArg)), + }; + (manager as any).CPasskeyCredential = { + getInstance: async (queryArg: Record) => Array.from(passkeyCredentials.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null, + getInstances: async (queryArg: Record) => Array.from(passkeyCredentials.values()).filter((docArg) => matchesQuery(docArg, queryArg)), + }; + (manager as any).CWebAuthnChallenge = { + getInstance: async (queryArg: Record) => Array.from(webAuthnChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null, + getInstances: async (queryArg: Record) => Array.from(webAuthnChallenges.values()).filter((docArg) => matchesQuery(docArg, queryArg)), + }; + + return { + manager, + user, + totpCredentials, + mfaChallenges, + activityLogCalls, + restore: () => { + TotpCredential.prototype.save = originalTotpSave; + TotpCredential.prototype.delete = originalTotpDelete; + MfaChallenge.prototype.save = originalMfaChallengeSave; + MfaChallenge.prototype.delete = originalMfaChallengeDelete; + PasskeyCredential.prototype.save = originalPasskeySave; + PasskeyCredential.prototype.delete = originalPasskeyDelete; + WebAuthnChallenge.prototype.save = originalWebAuthnSave; + WebAuthnChallenge.prototype.delete = originalWebAuthnDelete; + LoginSession.prototype.save = originalLoginSessionSave; + }, + }; +}; + +tap.test('creates no MFA challenge for users without enrolled factors', async () => { + const testContext = createTestMfaManager(); + try { + const result = await testContext.manager.createMfaChallengeForUser(testContext.user.id, 'password'); + expect(result).toBeNull(); + } finally { + testContext.restore(); + } +}); + +tap.test('enrolls TOTP and returns one-time backup codes', async () => { + const testContext = createTestMfaManager(); + try { + const enrollment = await (testContext.manager as any).startTotpEnrollmentForUser(testContext.user); + const setupCode = await plugins.otplib.generate({ secret: enrollment.secret }); + const result = await (testContext.manager as any).finishTotpEnrollmentForUser( + testContext.user, + enrollment.credentialId, + setupCode, + ); + + expect(result.success).toBeTrue(); + expect(result.backupCodes.length).toEqual(10); + expect(testContext.totpCredentials.get(enrollment.credentialId).data.status).toEqual('active'); + expect(testContext.activityLogCalls.some((callArg) => callArg.action === 'totp_enabled')).toBeTrue(); + } finally { + testContext.restore(); + } +}); + +tap.test('MFA backup codes are consumed once', async () => { + const testContext = createTestMfaManager(); + try { + const enrollment = await (testContext.manager as any).startTotpEnrollmentForUser(testContext.user); + const setupCode = await plugins.otplib.generate({ secret: enrollment.secret }); + const result = await (testContext.manager as any).finishTotpEnrollmentForUser( + testContext.user, + enrollment.credentialId, + setupCode, + ); + + const firstChallenge = await testContext.manager.createMfaChallengeForUser(testContext.user.id, 'password'); + const firstLogin = await (testContext.manager as any).verifyMfaChallengeWithCode( + firstChallenge.token, + 'backupCode', + result.backupCodes[0], + ); + expect(firstLogin.refreshToken.startsWith('refresh_')).toBeTrue(); + + const secondChallenge = await testContext.manager.createMfaChallengeForUser(testContext.user.id, 'password'); + let rejected = false; + await (testContext.manager as any).verifyMfaChallengeWithCode( + secondChallenge.token, + 'backupCode', + result.backupCodes[0], + ).catch(() => { + rejected = true; + }); + expect(rejected).toBeTrue(); + } finally { + testContext.restore(); + } +}); + +export default tap.start(); diff --git a/test/test_latest.sh b/test/test_latest.sh new file mode 100644 index 0000000..2ee7caa --- /dev/null +++ b/test/test_latest.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +node --input-type=module <<'NODE' +import fs from 'node:fs'; + +const readJson = (path) => JSON.parse(fs.readFileSync(path, 'utf8')); + +const checks = { + packageVersion: readJson('/app/package.json').version, + hasCli: fs.existsSync('/app/cli.js'), + hasIndex: fs.existsSync('/app/dist_ts/index.js'), + hasWebBundle: fs.existsSync('/app/dist_serve/bundle.js'), + hasWebIndex: fs.existsSync('/app/dist_serve/index.html'), + hasArgon2: fs.existsSync('/app/node_modules/argon2/package.json'), + hasOtplib: fs.existsSync('/app/node_modules/otplib/package.json'), + hasSimpleWebAuthnServer: fs.existsSync('/app/node_modules/@simplewebauthn/server/package.json'), + hasSimpleWebAuthnBrowser: fs.existsSync('/app/node_modules/@simplewebauthn/browser/package.json'), +}; + +await import('/app/dist_ts/index.js'); +await import('argon2'); +await import('otplib'); +await import('@simplewebauthn/server'); + +if (checks.packageVersion !== '1.21.1') { + throw new Error(`Unexpected idp.global package version ${checks.packageVersion}`); +} +if (!checks.hasCli) { + throw new Error('Missing cli.js'); +} +if (!checks.hasIndex) { + throw new Error('Missing dist_ts/index.js'); +} +if (!checks.hasWebBundle || !checks.hasWebIndex) { + throw new Error('Missing dist_serve web assets'); +} +if (!checks.hasArgon2 || !checks.hasOtplib || !checks.hasSimpleWebAuthnServer || !checks.hasSimpleWebAuthnBrowser) { + throw new Error('Missing MFA/passkey runtime dependencies'); +} + +console.log(JSON.stringify(checks)); +NODE diff --git a/ts/plugins.ts b/ts/plugins.ts index 7b385fb..cdce943 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -34,6 +34,8 @@ import * as smarttime from '@push.rocks/smarttime'; import * as smartunique from '@push.rocks/smartunique'; import * as taskbuffer from '@push.rocks/taskbuffer'; import * as argon2 from 'argon2'; +import * as otplib from 'otplib'; +import * as simpleWebAuthnServer from '@simplewebauthn/server'; export { argon2, @@ -51,6 +53,8 @@ export { smarttime, smartunique, taskbuffer, + otplib, + simpleWebAuthnServer, }; // @tsclass scope diff --git a/ts/reception/classes.housekeeping.ts b/ts/reception/classes.housekeeping.ts index 393f846..29b37a1 100644 --- a/ts/reception/classes.housekeeping.ts +++ b/ts/reception/classes.housekeeping.ts @@ -104,6 +104,16 @@ export class ReceptionHousekeeping { '2 * * * * *' ); + this.taskmanager.addAndScheduleTask( + new plugins.taskbuffer.Task({ + name: 'expiredMfaChallenges', + taskFunction: async () => { + await this.receptionRef.mfaManager.cleanupExpiredChallenges(); + }, + }), + '4 * * * * *' + ); + this.taskmanager.addAndScheduleTask( new plugins.taskbuffer.Task({ name: 'redeliverPassportChallengeHints', diff --git a/ts/reception/classes.loginsessionmanager.ts b/ts/reception/classes.loginsessionmanager.ts index cd27066..c4e8af6 100644 --- a/ts/reception/classes.loginsessionmanager.ts +++ b/ts/reception/classes.loginsessionmanager.ts @@ -84,17 +84,29 @@ export class LoginSessionManager { await user.save(); } + await this.receptionRef.abuseProtectionManager.clearAttempts( + 'passwordLogin', + loginIdentifier + ); + + const mfaChallenge = await this.receptionRef.mfaManager.createMfaChallengeForUser( + user.id, + 'password' + ); + if (mfaChallenge) { + return { + twoFaNeeded: true, + mfaChallengeToken: mfaChallenge.token, + availableMfaMethods: mfaChallenge.availableMethods, + }; + } + const loginSession = await LoginSession.createLoginSessionForUser(user); const refreshToken = await loginSession.getRefreshToken(); if (!refreshToken) { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); } - await this.receptionRef.abuseProtectionManager.clearAttempts( - 'passwordLogin', - loginIdentifier - ); - return { refreshToken, twoFaNeeded: false, @@ -145,7 +157,7 @@ export class LoginSessionManager { ); this.typedRouter.addTypedHandler( - new plugins.typedrequest.TypedHandler( + new plugins.typedrequest.TypedHandler( 'loginWithEmailAfterEmailTokenAquired', async (requestArg) => { await this.receptionRef.abuseProtectionManager.consumeAttempt( @@ -168,6 +180,21 @@ export class LoginSessionManager { if (!user) { throw new plugins.typedrequest.TypedResponseError('User not found'); } + const mfaChallenge = await this.receptionRef.mfaManager.createMfaChallengeForUser( + user.id, + 'email' + ); + if (mfaChallenge) { + await this.receptionRef.abuseProtectionManager.clearAttempts( + 'emailLoginToken', + requestArg.email + ); + return { + twoFaNeeded: true, + mfaChallengeToken: mfaChallenge.token, + availableMfaMethods: mfaChallenge.availableMethods, + }; + } const loginSession = await LoginSession.createLoginSessionForUser(user); const refreshToken = await loginSession.getRefreshToken(); if (!refreshToken) { diff --git a/ts/reception/classes.mfachallenge.ts b/ts/reception/classes.mfachallenge.ts new file mode 100644 index 0000000..93f00ef --- /dev/null +++ b/ts/reception/classes.mfachallenge.ts @@ -0,0 +1,39 @@ +import * as plugins from '../plugins.js'; +import type { MfaManager } from './classes.mfamanager.js'; + +@plugins.smartdata.Manager() +export class MfaChallenge extends plugins.smartdata.SmartDataDbDoc { + public static hashToken(tokenArg: string) { + return plugins.smarthash.sha256FromStringSync(tokenArg); + } + + @plugins.smartdata.unI() + public id: string; + + @plugins.smartdata.svDb() + public data = { + userId: '', + tokenHash: '', + status: 'pending' as 'pending' | 'completed' | 'expired', + availableMethods: [] as Array<'totp' | 'backupCode' | 'passkey'>, + primaryAuthMethod: 'password' as 'password' | 'email', + createdAt: 0, + expiresAt: 0, + completedAt: null as number | null, + }; + + public isExpired(nowArg = Date.now()) { + return this.data.expiresAt < nowArg; + } + + public async markCompleted() { + this.data.status = 'completed'; + this.data.completedAt = Date.now(); + await this.save(); + } + + public async markExpired() { + this.data.status = 'expired'; + await this.save(); + } +} diff --git a/ts/reception/classes.mfamanager.ts b/ts/reception/classes.mfamanager.ts new file mode 100644 index 0000000..9ee3dcd --- /dev/null +++ b/ts/reception/classes.mfamanager.ts @@ -0,0 +1,780 @@ +import * as plugins from '../plugins.js'; + +import { LoginSession } from './classes.loginsession.js'; +import { MfaChallenge } from './classes.mfachallenge.js'; +import { PasskeyCredential } from './classes.passkeycredential.js'; +import { TotpCredential } from './classes.totpcredential.js'; +import { WebAuthnChallenge } from './classes.webauthnchallenge.js'; +import type { Reception } from './classes.reception.js'; +import type { User } from './classes.user.js'; + +type TMfaMethod = 'totp' | 'backupCode' | 'passkey'; + +export class MfaManager { + private readonly mfaChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({ minutes: 5 }); + private readonly webAuthnChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({ minutes: 5 }); + private readonly attemptConfig = { + maxAttempts: 5, + windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), + blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }), + }; + + public typedRouter = new plugins.typedrequest.TypedRouter(); + public CTotpCredential = plugins.smartdata.setDefaultManagerForDoc(this, TotpCredential); + public CMfaChallenge = plugins.smartdata.setDefaultManagerForDoc(this, MfaChallenge); + public CPasskeyCredential = plugins.smartdata.setDefaultManagerForDoc(this, PasskeyCredential); + public CWebAuthnChallenge = plugins.smartdata.setDefaultManagerForDoc(this, WebAuthnChallenge); + + constructor(public receptionRef: Reception) { + this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('getMfaStatus', async (requestArg) => { + const user = await this.getUserByJwt(requestArg.jwt); + const [totpCredential, passkeys] = await Promise.all([ + this.getActiveTotpCredential(user.id), + this.getActivePasskeysForUser(user.id), + ]); + return { + totpEnabled: !!totpCredential, + backupCodesRemaining: totpCredential ? this.getRemainingBackupCodeCount(totpCredential) : 0, + passkeys: passkeys.map((passkeyArg) => this.serializePasskey(passkeyArg)), + availableMethods: await this.getAvailableMfaMethodsForUser(user.id), + }; + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('startTotpEnrollment', async (requestArg) => { + const user = await this.getUserByJwt(requestArg.jwt); + return this.startTotpEnrollmentForUser(user); + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('finishTotpEnrollment', async (requestArg) => { + const user = await this.getUserByJwt(requestArg.jwt); + return this.finishTotpEnrollmentForUser(user, requestArg.credentialId, requestArg.code); + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('disableTotp', async (requestArg) => { + const user = await this.getUserByJwt(requestArg.jwt); + await this.disableTotpForUser(user.id, requestArg.code); + return { success: true }; + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('regenerateBackupCodes', async (requestArg) => { + const user = await this.getUserByJwt(requestArg.jwt); + return { backupCodes: await this.regenerateBackupCodesForUser(user.id, requestArg.code) }; + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('verifyMfaChallenge', async (requestArg) => { + return this.verifyMfaChallengeWithCode( + requestArg.mfaChallengeToken, + requestArg.method, + requestArg.code, + ); + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('startPasskeyRegistration', async (requestArg) => { + const user = await this.getUserByJwt(requestArg.jwt); + return this.startPasskeyRegistrationForUser(user, requestArg.label); + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('finishPasskeyRegistration', async (requestArg) => { + const user = await this.getUserByJwt(requestArg.jwt); + return this.finishPasskeyRegistrationForUser(user, requestArg.challengeId, requestArg.response, requestArg.label); + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('revokePasskey', async (requestArg) => { + const user = await this.getUserByJwt(requestArg.jwt); + await this.revokePasskeyForUser(user.id, requestArg.passkeyId); + return { success: true }; + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('startPasskeyLogin', async (requestArg) => { + return this.startPasskeyLogin(requestArg.username); + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('finishPasskeyLogin', async (requestArg) => { + return this.finishPasskeyLogin(requestArg.challengeId, requestArg.response); + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('startPasskeyMfa', async (requestArg) => { + return this.startPasskeyMfa(requestArg.mfaChallengeToken); + })); + + this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('finishPasskeyMfa', async (requestArg) => { + return this.finishPasskeyMfa( + requestArg.mfaChallengeToken, + requestArg.challengeId, + requestArg.response, + ); + })); + } + + public get db() { + return this.receptionRef.db.smartdataDb; + } + + public async getAvailableMfaMethodsForUser(userIdArg: string): Promise { + const methods: TMfaMethod[] = []; + const [totpCredential, passkeys] = await Promise.all([ + this.getActiveTotpCredential(userIdArg), + this.getActivePasskeysForUser(userIdArg), + ]); + if (totpCredential) { + methods.push('totp'); + if (this.getRemainingBackupCodeCount(totpCredential) > 0) { + methods.push('backupCode'); + } + } + if (passkeys.length > 0) { + methods.push('passkey'); + } + return methods; + } + + public async createMfaChallengeForUser(userIdArg: string, primaryAuthMethodArg: 'password' | 'email') { + const availableMethods = await this.getAvailableMfaMethodsForUser(userIdArg); + if (!availableMethods.length) { + return null; + } + + const token = this.createOpaqueToken('mfa_'); + const mfaChallenge = new MfaChallenge(); + mfaChallenge.id = plugins.smartunique.shortId(); + mfaChallenge.data = { + userId: userIdArg, + tokenHash: MfaChallenge.hashToken(token), + status: 'pending', + availableMethods, + primaryAuthMethod: primaryAuthMethodArg, + createdAt: Date.now(), + expiresAt: Date.now() + this.mfaChallengeMillis, + completedAt: null, + }; + await mfaChallenge.save(); + + return { + token, + availableMethods, + }; + } + + public async cleanupExpiredChallenges() { + const now = Date.now(); + const [mfaChallenges, webAuthnChallenges] = await Promise.all([ + this.CMfaChallenge.getInstances({ 'data.status': 'pending' }), + this.CWebAuthnChallenge.getInstances({ 'data.status': 'pending' }), + ]); + + for (const challenge of mfaChallenges) { + if (challenge.data.expiresAt < now) { + await challenge.markExpired(); + } + } + for (const challenge of webAuthnChallenges) { + if (challenge.data.expiresAt < now) { + await challenge.markExpired(); + } + } + } + + private async getUserByJwt(jwtArg: string): Promise { + const user = await this.receptionRef.userManager.getUserByJwtValidation(jwtArg); + if (!user) { + throw new plugins.typedrequest.TypedResponseError('Invalid JWT'); + } + return user; + } + + private async getUserByIdentifier(identifierArg?: string): Promise { + if (!identifierArg) { + return null; + } + let user = await this.receptionRef.userManager.CUser.getInstance({ + data: { + username: identifierArg, + }, + }); + if (!user && identifierArg.includes('@')) { + user = await this.receptionRef.userManager.CUser.getInstance({ + data: { + email: identifierArg, + }, + }); + } + return user; + } + + private createOpaqueToken(prefixArg: string) { + return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`; + } + + private getOrigin() { + return new URL(this.receptionRef.options.baseUrl).origin; + } + + private getRpId() { + return new URL(this.receptionRef.options.baseUrl).hostname; + } + + private getEncryptionKey() { + const keyMaterial = + process.env.IDP_TOTP_ENCRYPTION_KEY || + process.env.TOTP_ENCRYPTION_KEY || + `${this.receptionRef.options.name}:${this.receptionRef.options.baseUrl}`; + return plugins.crypto.createHash('sha256').update(keyMaterial).digest(); + } + + private encryptSecret(secretArg: string) { + const iv = plugins.crypto.randomBytes(12); + const cipher = plugins.crypto.createCipheriv('aes-256-gcm', this.getEncryptionKey(), iv); + const ciphertext = Buffer.concat([cipher.update(secretArg, 'utf8'), cipher.final()]); + return { + secretCiphertext: ciphertext.toString('base64'), + secretIv: iv.toString('base64'), + secretAuthTag: cipher.getAuthTag().toString('base64'), + }; + } + + private decryptSecret(totpCredentialArg: TotpCredential) { + const decipher = plugins.crypto.createDecipheriv( + 'aes-256-gcm', + this.getEncryptionKey(), + Buffer.from(totpCredentialArg.data.secretIv, 'base64'), + ); + decipher.setAuthTag(Buffer.from(totpCredentialArg.data.secretAuthTag, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(totpCredentialArg.data.secretCiphertext, 'base64')), + decipher.final(), + ]).toString('utf8'); + } + + private normalizeOtpCode(codeArg: string) { + return String(codeArg || '').replace(/\s/g, '').trim(); + } + + private normalizeBackupCode(codeArg: string) { + return String(codeArg || '').replace(/\s/g, '').toLowerCase(); + } + + private hashBackupCode(codeArg: string) { + return plugins.smarthash.sha256FromStringSync(this.normalizeBackupCode(codeArg)); + } + + private createBackupCodes() { + return Array.from({ length: 10 }, () => { + const raw = plugins.crypto.randomBytes(5).toString('hex'); + return `${raw.slice(0, 5)}-${raw.slice(5)}`; + }); + } + + private getRemainingBackupCodeCount(totpCredentialArg: TotpCredential) { + return (totpCredentialArg.data.backupCodes || []).filter((codeArg) => !codeArg.usedAt).length; + } + + private async getActiveTotpCredential(userIdArg: string) { + return this.CTotpCredential.getInstance({ + 'data.userId': userIdArg, + 'data.status': 'active', + }); + } + + private async getActivePasskeysForUser(userIdArg: string) { + return this.CPasskeyCredential.getInstances({ + 'data.userId': userIdArg, + 'data.status': 'active', + }); + } + + private serializePasskey(passkeyArg: PasskeyCredential) { + return { + id: passkeyArg.id, + data: passkeyArg.data, + }; + } + + private async startTotpEnrollmentForUser(userArg: User) { + const activeCredential = await this.getActiveTotpCredential(userArg.id); + if (activeCredential) { + throw new plugins.typedrequest.TypedResponseError('TOTP is already enabled'); + } + + const existingPending = await this.CTotpCredential.getInstances({ + 'data.userId': userArg.id, + 'data.status': 'pending', + }); + for (const pendingCredential of existingPending) { + pendingCredential.data.status = 'disabled'; + pendingCredential.data.disabledAt = Date.now(); + await pendingCredential.save(); + } + + const secret = plugins.otplib.generateSecret(); + const encryptedSecret = this.encryptSecret(secret); + const totpCredential = new TotpCredential(); + totpCredential.id = plugins.smartunique.shortId(); + totpCredential.data = { + userId: userArg.id, + status: 'pending', + ...encryptedSecret, + algorithm: 'sha1', + digits: 6, + period: 30, + backupCodes: [], + createdAt: Date.now(), + verifiedAt: null, + disabledAt: null, + lastUsedAt: null, + }; + await totpCredential.save(); + + const otpauthUrl = plugins.otplib.generateURI({ + issuer: this.receptionRef.options.name, + label: userArg.data.email || userArg.data.username, + secret, + }); + + return { + credentialId: totpCredential.id, + secret, + otpauthUrl, + }; + } + + private async verifyTotpCodeForCredential(totpCredentialArg: TotpCredential, codeArg: string) { + const token = this.normalizeOtpCode(codeArg); + if (!/^\d{6,8}$/.test(token)) { + return false; + } + const secret = this.decryptSecret(totpCredentialArg); + const result = await plugins.otplib.verify({ + secret, + token, + algorithm: totpCredentialArg.data.algorithm, + digits: totpCredentialArg.data.digits, + period: totpCredentialArg.data.period, + epochTolerance: 30, + }); + return !!result.valid; + } + + private async finishTotpEnrollmentForUser(userArg: User, credentialIdArg: string, codeArg: string) { + await this.receptionRef.abuseProtectionManager.consumeAttempt( + 'totpEnrollment', + userArg.id, + this.attemptConfig, + 'Too many TOTP setup attempts. Please wait before trying again.', + ); + + const totpCredential = await this.CTotpCredential.getInstance({ + id: credentialIdArg, + 'data.userId': userArg.id, + 'data.status': 'pending', + }); + if (!totpCredential) { + throw new plugins.typedrequest.TypedResponseError('TOTP enrollment not found'); + } + const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg); + if (!valid) { + throw new plugins.typedrequest.TypedResponseError('Invalid TOTP code'); + } + + const backupCodes = this.createBackupCodes(); + totpCredential.data.status = 'active'; + totpCredential.data.verifiedAt = Date.now(); + totpCredential.data.lastUsedAt = Date.now(); + totpCredential.data.backupCodes = backupCodes.map((codeArg) => ({ + id: plugins.smartunique.shortId(), + codeHash: this.hashBackupCode(codeArg), + usedAt: null, + createdAt: Date.now(), + })); + await totpCredential.save(); + await this.receptionRef.abuseProtectionManager.clearAttempts('totpEnrollment', userArg.id); + await this.receptionRef.activityLogManager.logActivity(userArg.id, 'totp_enabled' as any, 'Enabled TOTP two-factor authentication'); + + return { + success: true, + backupCodes, + }; + } + + private async verifyTotpForUser(userIdArg: string, codeArg: string) { + const totpCredential = await this.getActiveTotpCredential(userIdArg); + if (!totpCredential) { + return false; + } + const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg); + if (valid) { + totpCredential.data.lastUsedAt = Date.now(); + await totpCredential.save(); + } + return valid; + } + + private async consumeBackupCodeForUser(userIdArg: string, codeArg: string) { + const totpCredential = await this.getActiveTotpCredential(userIdArg); + if (!totpCredential) { + return false; + } + const codeHash = this.hashBackupCode(codeArg); + const backupCode = totpCredential.data.backupCodes.find((codeArg) => { + return !codeArg.usedAt && codeArg.codeHash === codeHash; + }); + if (!backupCode) { + return false; + } + backupCode.usedAt = Date.now(); + totpCredential.data.lastUsedAt = Date.now(); + await totpCredential.save(); + return true; + } + + private async disableTotpForUser(userIdArg: string, codeArg: string) { + const totpCredential = await this.getActiveTotpCredential(userIdArg); + if (!totpCredential) { + throw new plugins.typedrequest.TypedResponseError('TOTP is not enabled'); + } + const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg); + if (!valid) { + throw new plugins.typedrequest.TypedResponseError('Invalid TOTP code'); + } + totpCredential.data.status = 'disabled'; + totpCredential.data.disabledAt = Date.now(); + await totpCredential.save(); + await this.receptionRef.activityLogManager.logActivity(userIdArg, 'totp_disabled' as any, 'Disabled TOTP two-factor authentication'); + } + + private async regenerateBackupCodesForUser(userIdArg: string, codeArg: string) { + const totpCredential = await this.getActiveTotpCredential(userIdArg); + if (!totpCredential) { + throw new plugins.typedrequest.TypedResponseError('TOTP is not enabled'); + } + const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg); + if (!valid) { + throw new plugins.typedrequest.TypedResponseError('Invalid TOTP code'); + } + const backupCodes = this.createBackupCodes(); + totpCredential.data.backupCodes = backupCodes.map((backupCodeArg) => ({ + id: plugins.smartunique.shortId(), + codeHash: this.hashBackupCode(backupCodeArg), + usedAt: null, + createdAt: Date.now(), + })); + await totpCredential.save(); + await this.receptionRef.activityLogManager.logActivity(userIdArg, 'backup_codes_regenerated' as any, 'Regenerated TOTP backup codes'); + return backupCodes; + } + + private async getPendingMfaChallengeByToken(tokenArg: string) { + const mfaChallenge = await this.CMfaChallenge.getInstance({ + 'data.tokenHash': MfaChallenge.hashToken(tokenArg), + }); + if (!mfaChallenge || mfaChallenge.data.status !== 'pending') { + throw new plugins.typedrequest.TypedResponseError('MFA challenge not found'); + } + if (mfaChallenge.isExpired()) { + await mfaChallenge.markExpired(); + throw new plugins.typedrequest.TypedResponseError('MFA challenge expired'); + } + return mfaChallenge; + } + + private async completeMfaChallenge(mfaChallengeArg: MfaChallenge) { + const user = await this.receptionRef.userManager.CUser.getInstance({ id: mfaChallengeArg.data.userId }); + if (!user) { + throw new plugins.typedrequest.TypedResponseError('User not found'); + } + await mfaChallengeArg.markCompleted(); + const loginSession = await LoginSession.createLoginSessionForUser(user); + const refreshToken = await loginSession.getRefreshToken(); + if (!refreshToken) { + throw new plugins.typedrequest.TypedResponseError('Could not create login session'); + } + await this.receptionRef.activityLogManager.logActivity(user.id, 'mfa_completed' as any, 'Completed multi-factor authentication'); + return { refreshToken }; + } + + private async verifyMfaChallengeWithCode(tokenArg: string, methodArg: TMfaMethod, codeArg: string) { + const mfaChallenge = await this.getPendingMfaChallengeByToken(tokenArg); + await this.receptionRef.abuseProtectionManager.consumeAttempt( + 'mfaChallenge', + mfaChallenge.id, + this.attemptConfig, + 'Too many MFA attempts. Please wait before trying again.', + ); + + let valid = false; + if (methodArg === 'totp') { + valid = await this.verifyTotpForUser(mfaChallenge.data.userId, codeArg); + } else if (methodArg === 'backupCode') { + valid = await this.consumeBackupCodeForUser(mfaChallenge.data.userId, codeArg); + } + if (!valid) { + throw new plugins.typedrequest.TypedResponseError('Invalid MFA code'); + } + await this.receptionRef.abuseProtectionManager.clearAttempts('mfaChallenge', mfaChallenge.id); + return this.completeMfaChallenge(mfaChallenge); + } + + private async startPasskeyRegistrationForUser(userArg: User, labelArg?: string) { + const passkeys = await this.getActivePasskeysForUser(userArg.id); + const options = await plugins.simpleWebAuthnServer.generateRegistrationOptions({ + rpName: this.receptionRef.options.name, + rpID: this.getRpId(), + userName: userArg.data.email || userArg.data.username, + userDisplayName: userArg.data.name || userArg.data.email || userArg.data.username, + userID: Buffer.from(userArg.id, 'utf8'), + attestationType: 'none', + excludeCredentials: passkeys.map((passkeyArg) => ({ + id: passkeyArg.data.credentialId, + transports: passkeyArg.data.transports as any, + })), + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'required', + }, + supportedAlgorithmIDs: [-7, -257], + }); + + const webAuthnChallenge = new WebAuthnChallenge(); + webAuthnChallenge.id = plugins.smartunique.shortId(); + webAuthnChallenge.data = { + userId: userArg.id, + username: userArg.data.email || userArg.data.username, + mfaChallengeId: null, + type: 'registration', + challenge: options.challenge, + status: 'pending', + createdAt: Date.now(), + expiresAt: Date.now() + this.webAuthnChallengeMillis, + completedAt: null, + }; + await webAuthnChallenge.save(); + + return { + challengeId: webAuthnChallenge.id, + options, + }; + } + + private async getPendingWebAuthnChallenge(challengeIdArg: string, typeArg: 'registration' | 'login' | 'mfa') { + const webAuthnChallenge = await this.CWebAuthnChallenge.getInstance({ + id: challengeIdArg, + 'data.type': typeArg, + 'data.status': 'pending', + }); + if (!webAuthnChallenge) { + throw new plugins.typedrequest.TypedResponseError('WebAuthn challenge not found'); + } + if (webAuthnChallenge.isExpired()) { + await webAuthnChallenge.markExpired(); + throw new plugins.typedrequest.TypedResponseError('WebAuthn challenge expired'); + } + return webAuthnChallenge; + } + + private async finishPasskeyRegistrationForUser(userArg: User, challengeIdArg: string, responseArg: any, labelArg?: string) { + const webAuthnChallenge = await this.getPendingWebAuthnChallenge(challengeIdArg, 'registration'); + if (webAuthnChallenge.data.userId !== userArg.id) { + throw new plugins.typedrequest.TypedResponseError('WebAuthn challenge does not belong to this user'); + } + + const verification = await plugins.simpleWebAuthnServer.verifyRegistrationResponse({ + response: responseArg, + expectedChallenge: webAuthnChallenge.data.challenge, + expectedOrigin: this.getOrigin(), + expectedRPID: this.getRpId(), + requireUserVerification: true, + supportedAlgorithmIDs: [-7, -257], + }); + if (!verification.verified) { + throw new plugins.typedrequest.TypedResponseError('Passkey registration failed'); + } + + const credential = verification.registrationInfo.credential; + const existingCredential = await this.CPasskeyCredential.getInstance({ + 'data.credentialId': credential.id, + 'data.status': 'active', + }); + if (existingCredential) { + throw new plugins.typedrequest.TypedResponseError('Passkey is already registered'); + } + + const passkeyCredential = new PasskeyCredential(); + passkeyCredential.id = plugins.smartunique.shortId(); + passkeyCredential.data = { + userId: userArg.id, + label: labelArg || 'Passkey', + credentialId: credential.id, + publicKeyBase64: Buffer.from(credential.publicKey).toString('base64'), + counter: credential.counter, + deviceType: verification.registrationInfo.credentialDeviceType, + backedUp: verification.registrationInfo.credentialBackedUp, + transports: credential.transports || [], + status: 'active', + createdAt: Date.now(), + lastUsedAt: null, + revokedAt: null, + }; + await passkeyCredential.save(); + await webAuthnChallenge.markCompleted(); + await this.receptionRef.activityLogManager.logActivity(userArg.id, 'passkey_registered' as any, `Registered passkey ${passkeyCredential.data.label}`); + return { + success: true, + passkey: this.serializePasskey(passkeyCredential), + }; + } + + private async revokePasskeyForUser(userIdArg: string, passkeyIdArg: string) { + const passkeyCredential = await this.CPasskeyCredential.getInstance({ + id: passkeyIdArg, + 'data.userId': userIdArg, + 'data.status': 'active', + }); + if (!passkeyCredential) { + throw new plugins.typedrequest.TypedResponseError('Passkey not found'); + } + passkeyCredential.data.status = 'revoked'; + passkeyCredential.data.revokedAt = Date.now(); + await passkeyCredential.save(); + await this.receptionRef.activityLogManager.logActivity(userIdArg, 'passkey_revoked' as any, `Revoked passkey ${passkeyCredential.data.label}`); + } + + private async startPasskeyLogin(usernameArg?: string) { + const user = await this.getUserByIdentifier(usernameArg); + const passkeys = user ? await this.getActivePasskeysForUser(user.id) : []; + if (usernameArg && !passkeys.length) { + throw new plugins.typedrequest.TypedResponseError('No passkeys registered for this account'); + } + + const options = await plugins.simpleWebAuthnServer.generateAuthenticationOptions({ + rpID: this.getRpId(), + allowCredentials: usernameArg ? passkeys.map((passkeyArg) => ({ + id: passkeyArg.data.credentialId, + transports: passkeyArg.data.transports as any, + })) : undefined, + userVerification: 'required', + }); + + const webAuthnChallenge = new WebAuthnChallenge(); + webAuthnChallenge.id = plugins.smartunique.shortId(); + webAuthnChallenge.data = { + userId: user?.id || null, + username: usernameArg || null, + mfaChallengeId: null, + type: 'login', + challenge: options.challenge, + status: 'pending', + createdAt: Date.now(), + expiresAt: Date.now() + this.webAuthnChallengeMillis, + completedAt: null, + }; + await webAuthnChallenge.save(); + + return { + challengeId: webAuthnChallenge.id, + options, + }; + } + + private async verifyPasskeyAuthentication(webAuthnChallengeArg: WebAuthnChallenge, responseArg: any) { + const credentialId = responseArg?.id; + if (!credentialId) { + throw new plugins.typedrequest.TypedResponseError('Passkey credential id missing'); + } + const passkeyCredential = await this.CPasskeyCredential.getInstance({ + 'data.credentialId': credentialId, + 'data.status': 'active', + }); + if (!passkeyCredential) { + throw new plugins.typedrequest.TypedResponseError('Passkey credential not found'); + } + if (webAuthnChallengeArg.data.userId && passkeyCredential.data.userId !== webAuthnChallengeArg.data.userId) { + throw new plugins.typedrequest.TypedResponseError('Passkey does not belong to this challenge'); + } + + const verification = await plugins.simpleWebAuthnServer.verifyAuthenticationResponse({ + response: responseArg, + expectedChallenge: webAuthnChallengeArg.data.challenge, + expectedOrigin: this.getOrigin(), + expectedRPID: this.getRpId(), + credential: { + id: passkeyCredential.data.credentialId, + publicKey: new Uint8Array(Buffer.from(passkeyCredential.data.publicKeyBase64, 'base64')), + counter: passkeyCredential.data.counter, + transports: passkeyCredential.data.transports as any, + }, + requireUserVerification: true, + }); + if (!verification.verified || !verification.authenticationInfo.userVerified) { + throw new plugins.typedrequest.TypedResponseError('Passkey authentication failed'); + } + + passkeyCredential.data.counter = verification.authenticationInfo.newCounter; + passkeyCredential.data.backedUp = verification.authenticationInfo.credentialBackedUp; + passkeyCredential.data.deviceType = verification.authenticationInfo.credentialDeviceType; + passkeyCredential.data.lastUsedAt = Date.now(); + await passkeyCredential.save(); + await webAuthnChallengeArg.markCompleted(); + return passkeyCredential; + } + + private async finishPasskeyLogin(challengeIdArg: string, responseArg: any) { + const webAuthnChallenge = await this.getPendingWebAuthnChallenge(challengeIdArg, 'login'); + const passkeyCredential = await this.verifyPasskeyAuthentication(webAuthnChallenge, responseArg); + const user = await this.receptionRef.userManager.CUser.getInstance({ id: passkeyCredential.data.userId }); + if (!user) { + throw new plugins.typedrequest.TypedResponseError('User not found'); + } + const loginSession = await LoginSession.createLoginSessionForUser(user); + const refreshToken = await loginSession.getRefreshToken(); + if (!refreshToken) { + throw new plugins.typedrequest.TypedResponseError('Could not create login session'); + } + await this.receptionRef.activityLogManager.logActivity(user.id, 'passkey_login' as any, `Signed in with passkey ${passkeyCredential.data.label}`); + return { refreshToken }; + } + + private async startPasskeyMfa(mfaChallengeTokenArg: string) { + const mfaChallenge = await this.getPendingMfaChallengeByToken(mfaChallengeTokenArg); + const passkeys = await this.getActivePasskeysForUser(mfaChallenge.data.userId); + if (!passkeys.length) { + throw new plugins.typedrequest.TypedResponseError('No passkeys registered for this account'); + } + const options = await plugins.simpleWebAuthnServer.generateAuthenticationOptions({ + rpID: this.getRpId(), + allowCredentials: passkeys.map((passkeyArg) => ({ + id: passkeyArg.data.credentialId, + transports: passkeyArg.data.transports as any, + })), + userVerification: 'required', + }); + const webAuthnChallenge = new WebAuthnChallenge(); + webAuthnChallenge.id = plugins.smartunique.shortId(); + webAuthnChallenge.data = { + userId: mfaChallenge.data.userId, + username: null, + mfaChallengeId: mfaChallenge.id, + type: 'mfa', + challenge: options.challenge, + status: 'pending', + createdAt: Date.now(), + expiresAt: Date.now() + this.webAuthnChallengeMillis, + completedAt: null, + }; + await webAuthnChallenge.save(); + return { + challengeId: webAuthnChallenge.id, + options, + }; + } + + private async finishPasskeyMfa(mfaChallengeTokenArg: string, challengeIdArg: string, responseArg: any) { + const mfaChallenge = await this.getPendingMfaChallengeByToken(mfaChallengeTokenArg); + const webAuthnChallenge = await this.getPendingWebAuthnChallenge(challengeIdArg, 'mfa'); + if (webAuthnChallenge.data.mfaChallengeId !== mfaChallenge.id) { + throw new plugins.typedrequest.TypedResponseError('Passkey MFA challenge mismatch'); + } + await this.verifyPasskeyAuthentication(webAuthnChallenge, responseArg); + return this.completeMfaChallenge(mfaChallenge); + } +} diff --git a/ts/reception/classes.passkeycredential.ts b/ts/reception/classes.passkeycredential.ts new file mode 100644 index 0000000..2eace7a --- /dev/null +++ b/ts/reception/classes.passkeycredential.ts @@ -0,0 +1,28 @@ +import * as plugins from '../plugins.js'; +import type { MfaManager } from './classes.mfamanager.js'; + +@plugins.smartdata.Manager() +export class PasskeyCredential extends plugins.smartdata.SmartDataDbDoc { + @plugins.smartdata.unI() + public id: string; + + @plugins.smartdata.svDb() + public data = { + userId: '', + label: '', + credentialId: '', + publicKeyBase64: '', + counter: 0, + deviceType: 'singleDevice' as 'singleDevice' | 'multiDevice', + backedUp: false, + transports: [] as string[], + status: 'active' as 'active' | 'revoked', + createdAt: 0, + lastUsedAt: null as number | null, + revokedAt: null as number | null, + }; + + public isActive() { + return this.data.status === 'active'; + } +} diff --git a/ts/reception/classes.reception.ts b/ts/reception/classes.reception.ts index 9c3507c..4aebc3a 100644 --- a/ts/reception/classes.reception.ts +++ b/ts/reception/classes.reception.ts @@ -21,6 +21,7 @@ import { AbuseProtectionManager } from './classes.abuseprotectionmanager.js'; import { AlertManager } from './classes.alertmanager.js'; import { PassportManager } from './classes.passportmanager.js'; import { PassportPushManager } from './classes.passportpushmanager.js'; +import { MfaManager } from './classes.mfamanager.js'; export interface IReceptionOptions { /** @@ -54,6 +55,7 @@ export class Reception { public alertManager = new AlertManager(this); public userInvitationManager = new UserInvitationManager(this); public abuseProtectionManager = new AbuseProtectionManager(this); + public mfaManager = new MfaManager(this); public passportPushManager = new PassportPushManager(this); public passportManager = new PassportManager(this); public oidcManager = new OidcManager(this); diff --git a/ts/reception/classes.totpcredential.ts b/ts/reception/classes.totpcredential.ts new file mode 100644 index 0000000..fe7d64e --- /dev/null +++ b/ts/reception/classes.totpcredential.ts @@ -0,0 +1,30 @@ +import * as plugins from '../plugins.js'; +import type { MfaManager } from './classes.mfamanager.js'; + +@plugins.smartdata.Manager() +export class TotpCredential extends plugins.smartdata.SmartDataDbDoc { + @plugins.smartdata.unI() + public id: string; + + @plugins.smartdata.svDb() + public data = { + userId: '', + status: 'pending' as 'pending' | 'active' | 'disabled', + secretCiphertext: '', + secretIv: '', + secretAuthTag: '', + algorithm: 'sha1' as 'sha1' | 'sha256' | 'sha512', + digits: 6 as 6 | 7 | 8, + period: 30, + backupCodes: [] as Array<{ + id: string; + codeHash: string; + usedAt?: number | null; + createdAt: number; + }>, + createdAt: 0, + verifiedAt: null as number | null, + disabledAt: null as number | null, + lastUsedAt: null as number | null, + }; +} diff --git a/ts/reception/classes.webauthnchallenge.ts b/ts/reception/classes.webauthnchallenge.ts new file mode 100644 index 0000000..38c31b4 --- /dev/null +++ b/ts/reception/classes.webauthnchallenge.ts @@ -0,0 +1,36 @@ +import * as plugins from '../plugins.js'; +import type { MfaManager } from './classes.mfamanager.js'; + +@plugins.smartdata.Manager() +export class WebAuthnChallenge extends plugins.smartdata.SmartDataDbDoc { + @plugins.smartdata.unI() + public id: string; + + @plugins.smartdata.svDb() + public data = { + userId: null as string | null, + username: null as string | null, + mfaChallengeId: null as string | null, + type: 'login' as 'registration' | 'login' | 'mfa', + challenge: '', + status: 'pending' as 'pending' | 'completed' | 'expired', + createdAt: 0, + expiresAt: 0, + completedAt: null as number | null, + }; + + public isExpired(nowArg = Date.now()) { + return this.data.expiresAt < nowArg; + } + + public async markCompleted() { + this.data.status = 'completed'; + this.data.completedAt = Date.now(); + await this.save(); + } + + public async markExpired() { + this.data.status = 'expired'; + await this.save(); + } +} diff --git a/ts_web/elements/account/content.ts b/ts_web/elements/account/content.ts index 3acc2a3..33a71af 100644 --- a/ts_web/elements/account/content.ts +++ b/ts_web/elements/account/content.ts @@ -77,6 +77,18 @@ export class IdpAccountContent extends DeesElement { @state() private accessor passportEnrollment: plugins.idpCatalog.IIdpAdminPassportEnrollment | null = null; + @state() + private accessor totpEnabled = false; + + @state() + private accessor backupCodesRemaining = 0; + + @state() + private accessor totpEnrollment: any = null; + + @state() + private accessor passkeys: any[] = []; + @state() private accessor credentialMessage = ''; @@ -124,6 +136,10 @@ export class IdpAccountContent extends DeesElement { .adminApps=${this.adminApps} .passportDevices=${this.passportDevices} .passportEnrollment=${this.passportEnrollment} + .totpEnabled=${this.totpEnabled} + .backupCodesRemaining=${this.backupCodesRemaining} + .totpEnrollment=${this.totpEnrollment} + .passkeys=${this.passkeys} .credentialMessage=${this.credentialMessage} @idp-admin-navigate=${this.handleAdminNavigate} @idp-admin-org-select=${this.handleOrgSelect} @@ -136,6 +152,12 @@ export class IdpAccountContent extends DeesElement { @idp-admin-password-change=${this.handlePasswordChange} @idp-admin-passport-enroll=${this.handlePassportEnroll} @idp-admin-passport-revoke=${this.handlePassportRevoke} + @idp-admin-totp-start=${this.handleTotpStart} + @idp-admin-totp-verify=${this.handleTotpVerify} + @idp-admin-totp-disable=${this.handleTotpDisable} + @idp-admin-backup-codes-regenerate=${this.handleBackupCodesRegenerate} + @idp-admin-passkey-register=${this.handlePasskeyRegister} + @idp-admin-passkey-revoke=${this.handlePasskeyRevoke} @idp-admin-member-invite=${this.handleMemberInvite} @idp-admin-member-remove=${this.handleMemberRemove} @idp-admin-member-roles-update=${this.handleMemberRolesUpdate} @@ -495,6 +517,26 @@ export class IdpAccountContent extends DeesElement { })); } + private async loadMfaStatus(idpStateArg: IdpState, jwtArg: string) { + const request = idpStateArg.idpClient.typedsocket.createTypedRequest('getMfaStatus'); + const response = await request.fire({ jwt: jwtArg }); + return { + totpEnabled: Boolean(response.totpEnabled), + backupCodesRemaining: Number(response.backupCodesRemaining || 0), + passkeys: (response.passkeys || []).map((passkeyArg: any) => ({ + id: passkeyArg.id, + label: passkeyArg.data.label, + credentialId: passkeyArg.data.credentialId, + status: passkeyArg.data.status, + backedUp: passkeyArg.data.backedUp, + deviceType: passkeyArg.data.deviceType, + transports: passkeyArg.data.transports || [], + createdAt: passkeyArg.data.createdAt, + lastUsedAt: passkeyArg.data.lastUsedAt, + })), + }; + } + private async loadAdminShellData() { const currentRun = ++this.dataLoadRun; this.dataLoading = true; @@ -506,7 +548,7 @@ export class IdpAccountContent extends DeesElement { const selectedOrg = this.getSelectedOrganization(); const orgId = selectedOrg?.id || ''; - const [sessions, activities, members, invitations, orgApps, adminApps, passportDevices] = await Promise.all([ + const [sessions, activities, members, invitations, orgApps, adminApps, passportDevices, mfaStatus] = await Promise.all([ this.loadSessions(idpState, jwt).catch((error) => { console.error('Error loading sessions:', error); return this.sessions; @@ -535,6 +577,14 @@ export class IdpAccountContent extends DeesElement { console.error('Error loading passport devices:', error); return this.passportDevices; }), + this.loadMfaStatus(idpState, jwt).catch((error) => { + console.error('Error loading MFA status:', error); + return { + totpEnabled: this.totpEnabled, + backupCodesRemaining: this.backupCodesRemaining, + passkeys: this.passkeys, + }; + }), ]); if (currentRun !== this.dataLoadRun) { @@ -548,6 +598,9 @@ export class IdpAccountContent extends DeesElement { this.orgApps = orgApps; this.adminApps = adminApps; this.passportDevices = passportDevices; + this.totpEnabled = mfaStatus.totpEnabled; + this.backupCodesRemaining = mfaStatus.backupCodesRemaining; + this.passkeys = mfaStatus.passkeys; } catch (error) { console.error('Error loading admin shell data:', error); if (currentRun === this.dataLoadRun) { @@ -657,6 +710,83 @@ export class IdpAccountContent extends DeesElement { }); } + private async handleTotpStart() { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('startTotpEnrollment'); + this.totpEnrollment = await request.fire({ jwt: await idpState.idpClient.getJwt() }); + this.credentialMessage = 'Authenticator app enrollment started.'; + }); + } + + private async handleTotpVerify(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('finishTotpEnrollment'); + const response = await request.fire({ + jwt: await idpState.idpClient.getJwt(), + credentialId: eventArg.detail.credentialId, + code: eventArg.detail.code, + }); + this.totpEnrollment = null; + this.credentialMessage = `Authenticator app enabled. Save these backup codes now: ${(response.backupCodes || []).join(', ')}`; + }); + } + + private async handleTotpDisable(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('disableTotp'); + await request.fire({ jwt: await idpState.idpClient.getJwt(), code: eventArg.detail.code }); + this.totpEnrollment = null; + this.credentialMessage = 'Authenticator app disabled.'; + }); + } + + private async handleBackupCodesRegenerate(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('regenerateBackupCodes'); + const response = await request.fire({ jwt: await idpState.idpClient.getJwt(), code: eventArg.detail.code }); + this.credentialMessage = `New backup codes generated. Save them now: ${(response.backupCodes || []).join(', ')}`; + }); + } + + private async handlePasskeyRegister(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const startRequest = idpState.idpClient.typedsocket.createTypedRequest('startPasskeyRegistration'); + const finishRequest = idpState.idpClient.typedsocket.createTypedRequest('finishPasskeyRegistration'); + const startResponse = await startRequest.fire({ + jwt: await idpState.idpClient.getJwt(), + label: eventArg.detail.label, + }); + const registrationResponse = await plugins.simpleWebAuthnBrowser.startRegistration({ + optionsJSON: startResponse.options, + }); + await finishRequest.fire({ + jwt: await idpState.idpClient.getJwt(), + challengeId: startResponse.challengeId, + label: eventArg.detail.label, + response: registrationResponse, + }); + this.credentialMessage = 'Passkey registered.'; + }); + } + + private async handlePasskeyRevoke(eventArg: CustomEvent) { + const passkey = this.passkeys.find((passkeyArg) => passkeyArg.id === eventArg.detail.passkeyId); + if (!passkey || !confirm(`Revoke passkey ${passkey.label}?`)) { + return; + } + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('revokePasskey'); + await request.fire({ jwt: await idpState.idpClient.getJwt(), passkeyId: eventArg.detail.passkeyId }); + this.credentialMessage = 'Passkey revoked.'; + }); + } + private async handleMemberInvite() { const selectedOrg = this.getSelectedOrganization(); if (!selectedOrg) { diff --git a/ts_web/elements/idp-loginprompt.ts b/ts_web/elements/idp-loginprompt.ts index 9d0521f..674dd6a 100644 --- a/ts_web/elements/idp-loginprompt.ts +++ b/ts_web/elements/idp-loginprompt.ts @@ -32,6 +32,18 @@ export class IdpLoginPrompt extends DeesElement { @state() accessor oidcConsentError = ''; + @state() + accessor mfaChallengeToken = ''; + + @state() + accessor availableMfaMethods: string[] = []; + + @state() + accessor mfaMethod: 'totp' | 'backupCode' = 'totp'; + + @state() + accessor passkeyMessage = ''; + @property() accessor productOfInterest: string; @@ -200,6 +212,22 @@ export class IdpLoginPrompt extends DeesElement { return true; } + private async completeLoginWithRefreshToken(refreshTokenArg: string) { + const idpState = await IdpState.getSingletonInstance(); + const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null; + loginForm?.setStatus('pending', 'obtained refreshToken...'); + const jwt = await idpState.idpClient.refreshJwt(refreshTokenArg); + if (!jwt) { + loginForm?.setStatus('error', 'something went wrong'); + return; + } + loginForm?.setStatus('success', 'obtained jwt.'); + const oidcHandled = await this.handleOidcAfterLogin(jwt); + if (!oidcHandled) { + idpState.domtools.router.pushUrl('/dash/overview'); + } + } + public static styles = [ cssManager.defaultStyles, css` @@ -324,6 +352,46 @@ export class IdpLoginPrompt extends DeesElement { ]; public render(): TemplateResult { + if (this.mfaChallengeToken) { + const passkeyAvailable = this.availableMfaMethods.includes('passkey'); + const backupAvailable = this.availableMfaMethods.includes('backupCode'); + return html` + +
+

Verify your sign-in

+

Enter your authenticator code${passkeyAvailable ? ' or approve with a passkey' : ''}.

+
+ ) => { + this.verifyMfaCode(String(eventArg.detail.data.mfaCode || '')); + }} + > + + + + + ${this.passkeyMessage ? html`` : null} +
+ `; + } + if (this.oidcConsentState) { return html` @@ -397,7 +465,7 @@ export class IdpLoginPrompt extends DeesElement { required name="emailAddress" label="Email or Username" - autocomplete="username" + autocomplete="username webauthn" >