feat(app): add MFA and tsdocker release

This commit is contained in:
2026-05-19 06:20:38 +00:00
parent ddf4861e95
commit 1e563115d0
23 changed files with 1939 additions and 211 deletions
+7
View File
@@ -1 +1,8 @@
.git/
.nogit/
.playwright-mcp/
.vscode/
dist/
dist_*/
node_modules/
test_watch/
+12 -48
View File
@@ -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
+16 -81
View File
@@ -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
+17 -9
View File
@@ -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"
}
}
}
+22 -34
View File
@@ -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"]
+9 -3
View File
@@ -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,
+329 -3
View File
@@ -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: {}
+11
View File
@@ -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`.
+14 -14
View File
@@ -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.
+199
View File
@@ -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<string, any>) => {
return Object.entries(queryArg).every(([keyArg, valueArg]) => getNestedValue(targetArg, keyArg) === valueArg);
};
const createTestMfaManager = () => {
const totpCredentials = new Map<string, TotpCredential>();
const mfaChallenges = new Map<string, MfaChallenge>();
const passkeyCredentials = new Map<string, PasskeyCredential>();
const webAuthnChallenges = new Map<string, WebAuthnChallenge>();
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<string, any>) => {
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<void> }).save = async function () {
totpCredentials.set(this.id, this);
};
(TotpCredential.prototype as TotpCredential & { delete: () => Promise<void> }).delete = async function () {
totpCredentials.delete(this.id);
};
(MfaChallenge.prototype as MfaChallenge & { save: () => Promise<void> }).save = async function () {
mfaChallenges.set(this.id, this);
};
(MfaChallenge.prototype as MfaChallenge & { delete: () => Promise<void> }).delete = async function () {
mfaChallenges.delete(this.id);
};
(PasskeyCredential.prototype as PasskeyCredential & { save: () => Promise<void> }).save = async function () {
passkeyCredentials.set(this.id, this);
};
(PasskeyCredential.prototype as PasskeyCredential & { delete: () => Promise<void> }).delete = async function () {
passkeyCredentials.delete(this.id);
};
(WebAuthnChallenge.prototype as WebAuthnChallenge & { save: () => Promise<void> }).save = async function () {
webAuthnChallenges.set(this.id, this);
};
(WebAuthnChallenge.prototype as WebAuthnChallenge & { delete: () => Promise<void> }).delete = async function () {
webAuthnChallenges.delete(this.id);
};
(LoginSession.prototype as LoginSession & { save: () => Promise<void> }).save = async function () {
return undefined;
};
(manager as any).CTotpCredential = {
getInstance: async (queryArg: Record<string, any>) => Array.from(totpCredentials.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null,
getInstances: async (queryArg: Record<string, any>) => Array.from(totpCredentials.values()).filter((docArg) => matchesQuery(docArg, queryArg)),
};
(manager as any).CMfaChallenge = {
getInstance: async (queryArg: Record<string, any>) => Array.from(mfaChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null,
getInstances: async (queryArg: Record<string, any>) => Array.from(mfaChallenges.values()).filter((docArg) => matchesQuery(docArg, queryArg)),
};
(manager as any).CPasskeyCredential = {
getInstance: async (queryArg: Record<string, any>) => Array.from(passkeyCredentials.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null,
getInstances: async (queryArg: Record<string, any>) => Array.from(passkeyCredentials.values()).filter((docArg) => matchesQuery(docArg, queryArg)),
};
(manager as any).CWebAuthnChallenge = {
getInstance: async (queryArg: Record<string, any>) => Array.from(webAuthnChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null,
getInstances: async (queryArg: Record<string, any>) => 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();
+43
View File
@@ -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
+4
View File
@@ -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
+10
View File
@@ -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',
+33 -6
View File
@@ -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<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
new plugins.typedrequest.TypedHandler<any>(
'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) {
+39
View File
@@ -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<MfaChallenge, any, MfaManager> {
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();
}
}
+780
View File
@@ -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<any>('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<any>('startTotpEnrollment', async (requestArg) => {
const user = await this.getUserByJwt(requestArg.jwt);
return this.startTotpEnrollmentForUser(user);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('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<any>('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<any>('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<any>('verifyMfaChallenge', async (requestArg) => {
return this.verifyMfaChallengeWithCode(
requestArg.mfaChallengeToken,
requestArg.method,
requestArg.code,
);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('startPasskeyRegistration', async (requestArg) => {
const user = await this.getUserByJwt(requestArg.jwt);
return this.startPasskeyRegistrationForUser(user, requestArg.label);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('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<any>('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<any>('startPasskeyLogin', async (requestArg) => {
return this.startPasskeyLogin(requestArg.username);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('finishPasskeyLogin', async (requestArg) => {
return this.finishPasskeyLogin(requestArg.challengeId, requestArg.response);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('startPasskeyMfa', async (requestArg) => {
return this.startPasskeyMfa(requestArg.mfaChallengeToken);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('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<TMfaMethod[]> {
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<User> {
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<User | null> {
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);
}
}
+28
View File
@@ -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<PasskeyCredential, any, MfaManager> {
@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';
}
}
+2
View File
@@ -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);
+30
View File
@@ -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<TotpCredential, any, MfaManager> {
@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,
};
}
+36
View File
@@ -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<WebAuthnChallenge, any, MfaManager> {
@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();
}
}
+131 -1
View File
@@ -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<any>('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<any>('startTotpEnrollment');
this.totpEnrollment = await request.fire({ jwt: await idpState.idpClient.getJwt() });
this.credentialMessage = 'Authenticator app enrollment started.';
});
}
private async handleTotpVerify(eventArg: CustomEvent<any>) {
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('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<any>) {
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('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<any>) {
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('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<any>) {
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const startRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('startPasskeyRegistration');
const finishRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('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<any>) {
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<any>('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) {
+162 -12
View File
@@ -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`
<idp-centercontainer>
<div class="form-header">
<h2>Verify your sign-in</h2>
<p>Enter your authenticator code${passkeyAvailable ? ' or approve with a passkey' : ''}.</p>
</div>
<idp-form
id="mfaForm"
@idp-submit=${(eventArg: CustomEvent<plugins.idpCatalog.IIdpFormSubmitEventDetail>) => {
this.verifyMfaCode(String(eventArg.detail.data.mfaCode || ''));
}}
>
<idp-input
id="mfaCodeInput"
required
name="mfaCode"
label=${this.mfaMethod === 'backupCode' ? 'Backup code' : 'Authenticator code'}
autocomplete="one-time-code"
></idp-input>
<idp-form-submit id="mfaSubmitButton"></idp-form-submit>
</idp-form>
<div class="form-footer">
${backupAvailable ? html`
<a @click=${() => {
this.mfaMethod = this.mfaMethod === 'backupCode' ? 'totp' : 'backupCode';
}}>${this.mfaMethod === 'backupCode' ? 'Use authenticator code' : 'Use backup code'}</a>
` : null}
${passkeyAvailable ? html`
${backupAvailable ? html` · ` : null}
<a @click=${() => this.verifyMfaWithPasskey()}>Use passkey</a>
` : null}
</div>
${this.passkeyMessage ? html`<div class="form-footer">${this.passkeyMessage}</div>` : null}
</idp-centercontainer>
`;
}
if (this.oidcConsentState) {
return html`
<idp-centercontainer>
@@ -397,7 +465,7 @@ export class IdpLoginPrompt extends DeesElement {
required
name="emailAddress"
label="Email or Username"
autocomplete="username"
autocomplete="username webauthn"
></idp-input>
<idp-input
id="loginPasswordInput"
@@ -409,6 +477,8 @@ export class IdpLoginPrompt extends DeesElement {
<idp-form-submit id="loginSubmitButton"></idp-form-submit>
</idp-form>
<div class="form-footer">
<a @click=${() => this.loginWithPasskey()}>Sign in with passkey</a>
<br />
Don't have an account?
<a @click=${async () => {
const idpState = await IdpState.getSingletonInstance();
@@ -489,17 +559,11 @@ export class IdpLoginPrompt extends DeesElement {
return;
}
if (response.refreshToken) {
loginForm.setStatus('pending', 'obtained refreshToken...');
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
if (jwt) {
loginForm.setStatus('success', 'obtained jwt.');
const oidcHandled = await this.handleOidcAfterLogin(jwt);
if (!oidcHandled) {
idpState.domtools.router.pushUrl('/dash/overview');
}
} else {
loginForm.setStatus('error', 'something went wrong');
}
await this.completeLoginWithRefreshToken(response.refreshToken);
} else if (response.twoFaNeeded && response.mfaChallengeToken) {
this.mfaChallengeToken = response.mfaChallengeToken;
this.availableMfaMethods = response.availableMfaMethods || ['totp'];
this.mfaMethod = this.availableMfaMethods.includes('totp') ? 'totp' : 'backupCode';
}
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
loginForm.setStatus('pending', 'sending magic link...');
@@ -518,6 +582,92 @@ export class IdpLoginPrompt extends DeesElement {
loginSubmitButton.disabled = false;
};
private verifyMfaCode = async (codeArg: string) => {
const mfaForm = this.shadowRoot.querySelector('#mfaForm') as plugins.idpCatalog.IdpForm;
const submitButton = this.shadowRoot.querySelector('#mfaSubmitButton') as plugins.idpCatalog.IdpFormSubmit;
submitButton.disabled = true;
mfaForm.setStatus('pending', 'verifying...');
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('verifyMfaChallenge');
const response = await request.fire({
mfaChallengeToken: this.mfaChallengeToken,
method: this.mfaMethod,
code: codeArg,
}).catch(() => {
mfaForm.setStatus('error', 'invalid verification code');
return null;
});
if (response?.refreshToken) {
await this.completeLoginWithRefreshToken(response.refreshToken);
}
submitButton.disabled = false;
};
private verifyMfaWithPasskey = async () => {
this.passkeyMessage = 'Waiting for passkey approval...';
const idpState = await IdpState.getSingletonInstance();
const startRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('startPasskeyMfa');
const finishRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('finishPasskeyMfa');
const startResponse = await startRequest.fire({
mfaChallengeToken: this.mfaChallengeToken,
}).catch(() => null);
if (!startResponse?.options) {
this.passkeyMessage = 'Could not start passkey verification.';
return;
}
const assertion = await plugins.simpleWebAuthnBrowser.startAuthentication({
optionsJSON: startResponse.options,
}).catch(() => null);
if (!assertion) {
this.passkeyMessage = 'Passkey verification was cancelled.';
return;
}
const finishResponse = await finishRequest.fire({
mfaChallengeToken: this.mfaChallengeToken,
challengeId: startResponse.challengeId,
response: assertion,
}).catch(() => null);
if (!finishResponse?.refreshToken) {
this.passkeyMessage = 'Passkey verification failed.';
return;
}
await this.completeLoginWithRefreshToken(finishResponse.refreshToken);
};
private loginWithPasskey = async () => {
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm;
const emailInput = this.shadowRoot.querySelector('#loginEmailInput') as plugins.idpCatalog.IdpInput;
loginForm.setStatus('pending', 'starting passkey sign-in...');
const idpState = await IdpState.getSingletonInstance();
const startRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('startPasskeyLogin');
const finishRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('finishPasskeyLogin');
const startResponse = await startRequest.fire({
username: emailInput.value || undefined,
}).catch((errorArg) => {
loginForm.setStatus('error', errorArg?.message || 'could not start passkey sign-in');
return null;
});
if (!startResponse?.options) {
return;
}
const assertion = await plugins.simpleWebAuthnBrowser.startAuthentication({
optionsJSON: startResponse.options,
}).catch(() => null);
if (!assertion) {
loginForm.setStatus('error', 'passkey sign-in was cancelled');
return;
}
const finishResponse = await finishRequest.fire({
challengeId: startResponse.challengeId,
response: assertion,
}).catch(() => null);
if (!finishResponse?.refreshToken) {
loginForm.setStatus('error', 'passkey sign-in failed');
return;
}
await this.completeLoginWithRefreshToken(finishResponse.refreshToken);
};
public async dispatchJwt(jwtArg?: string) {
if (jwtArg !== undefined) {
this.jwt = jwtArg;
+5
View File
@@ -12,6 +12,11 @@ import * as typedrequest from '@api.global/typedrequest';
export { typedrequest };
// SimpleWebAuthn scope
import * as simpleWebAuthnBrowser from '@simplewebauthn/browser';
export { simpleWebAuthnBrowser };
// @design.estate scope
import * as deesCatalog from '@design.estate/dees-catalog';
import * as deesDomtools from '@design.estate/dees-domtools';