feat(app): add MFA and tsdocker release
This commit is contained in:
@@ -1 +1,8 @@
|
|||||||
|
.git/
|
||||||
|
.nogit/
|
||||||
|
.playwright-mcp/
|
||||||
|
.vscode/
|
||||||
|
dist/
|
||||||
|
dist_*/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
test_watch/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Docker (tags)
|
name: Docker (non-tag pushes)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,44 +6,12 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: code.foss.global/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{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 }}
|
|
||||||
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||||
|
|
||||||
jobs:
|
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:
|
test:
|
||||||
needs: security
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: ${{ env.IMAGE }}
|
image: ${{ env.IMAGE }}
|
||||||
@@ -54,18 +22,14 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @ship.zone/npmci
|
pnpm install -g @git.zone/tsdocker@latest
|
||||||
npmci npm prepare
|
pnpm install
|
||||||
|
|
||||||
- name: Test stable
|
- name: Test
|
||||||
run: |
|
run: pnpm test
|
||||||
npmci node install stable
|
|
||||||
npmci npm install
|
|
||||||
npmci npm test
|
|
||||||
|
|
||||||
- name: Test build
|
- name: Build image
|
||||||
run: |
|
run: tsdocker build
|
||||||
npmci npm prepare
|
|
||||||
npmci node install stable
|
- name: Test image
|
||||||
npmci npm install
|
run: tsdocker test
|
||||||
npmci command npm run build
|
|
||||||
|
|||||||
@@ -6,75 +6,15 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: code.foss.global/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{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 }}
|
|
||||||
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||||
|
|
||||||
jobs:
|
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:
|
release:
|
||||||
needs: test
|
|
||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: code.foss.global/hosttoday/ht-docker-dbase:npmci
|
image: code.foss.global/host.today/ht-docker-dbase:szci
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -82,25 +22,20 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @ship.zone/npmci
|
pnpm install -g @git.zone/tsdocker@latest
|
||||||
|
pnpm install
|
||||||
|
|
||||||
- name: Release
|
- name: Login to registries
|
||||||
run: |
|
run: tsdocker login
|
||||||
npmci docker login
|
|
||||||
npmci docker build
|
|
||||||
npmci docker test
|
|
||||||
# npmci docker push
|
|
||||||
npmci docker push
|
|
||||||
|
|
||||||
metadata:
|
- name: List images
|
||||||
needs: test
|
run: tsdocker list
|
||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: ${{ env.IMAGE }}
|
|
||||||
|
|
||||||
steps:
|
- name: Build images
|
||||||
- uses: actions/checkout@v3
|
run: tsdocker build
|
||||||
|
|
||||||
- name: Trigger
|
- name: Test images
|
||||||
run: npmci trigger
|
run: tsdocker test
|
||||||
|
|
||||||
|
- name: Push to code.foss.global
|
||||||
|
run: tsdocker push --registry=code.foss.global
|
||||||
|
|||||||
+16
-8
@@ -6,7 +6,7 @@
|
|||||||
"gitscope": "idp.global",
|
"gitscope": "idp.global",
|
||||||
"gitrepo": "app",
|
"gitrepo": "app",
|
||||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||||
"npmPackagename": "@idp.global/idp.global",
|
"npmPackagename": "@idp.global/app",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "idp.global",
|
"projectDomain": "idp.global",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -44,6 +44,10 @@
|
|||||||
"https://registry.npmjs.org"
|
"https://registry.npmjs.org"
|
||||||
],
|
],
|
||||||
"accessLevel": "public"
|
"accessLevel": "public"
|
||||||
|
},
|
||||||
|
"docker": {
|
||||||
|
"enabled": true,
|
||||||
|
"engine": "tsdocker"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -103,13 +107,17 @@
|
|||||||
"@git.zone/tsdoc": {
|
"@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"
|
"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": {
|
"@git.zone/tsdocker": {
|
||||||
"npmGlobalTools": [],
|
"registries": [
|
||||||
"dockerRegistryRepoMap": {
|
"code.foss.global"
|
||||||
"registry.gitlab.com": "code.foss.global/idp.global/app"
|
],
|
||||||
|
"registryRepoMap": {
|
||||||
|
"code.foss.global": "idp.global/app"
|
||||||
},
|
},
|
||||||
"dockerBuildargEnvMap": {
|
"platforms": [
|
||||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
"linux/amd64",
|
||||||
}
|
"linux/arm64"
|
||||||
|
],
|
||||||
|
"testDir": "./test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+22
-34
@@ -1,46 +1,34 @@
|
|||||||
# gitzone dockerfile_service
|
# gitzone dockerfile_service
|
||||||
## STAGE 1 // BUILD
|
## STAGE 1 // BUILD
|
||||||
FROM code.foss.global/hosttoday/ht-docker-node:npmci as node1
|
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||||
COPY ./ /app
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG NPMCI_TOKEN_NPM2
|
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN npmci npm prepare
|
RUN pnpm config set registry https://verdaccio.lossless.digital/
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
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
|
RUN pnpm run build
|
||||||
|
RUN pnpm prune --prod
|
||||||
# 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 rm -rf .pnpm-store
|
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
|
WORKDIR /app
|
||||||
COPY --from=node2 /app /app
|
|
||||||
ARG NPMCI_TOKEN_NPM2
|
ENV NODE_ENV=production
|
||||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
|
||||||
RUN npmci npm prepare
|
COPY --from=build /app /app
|
||||||
RUN pnpm config set store-dir .pnpm-store
|
|
||||||
|
# Rebuild native modules such as argon2 against Alpine libc.
|
||||||
RUN pnpm rebuild -r
|
RUN pnpm rebuild -r
|
||||||
|
|
||||||
## STAGE 4 // the final production image with all dependencies in place
|
LABEL org.opencontainers.image.title="idp.global" \
|
||||||
FROM code.foss.global/hosttoday/ht-docker-node:alpine as node4
|
org.opencontainers.image.description="Identity provider server, web UI, OIDC provider, MFA, and passkey runtime" \
|
||||||
WORKDIR /app
|
org.opencontainers.image.source="https://code.foss.global/idp.global/app"
|
||||||
COPY --from=node3 /app /app
|
|
||||||
|
|
||||||
### Healthchecks
|
EXPOSE 2999
|
||||||
RUN pnpm install -g @servezone/healthy
|
CMD ["node", "cli.js"]
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["npm", "start"]
|
|
||||||
|
|||||||
+9
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@idp.global/idp.global",
|
"name": "@idp.global/app",
|
||||||
"version": "1.21.1",
|
"version": "1.21.1",
|
||||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "pnpm run build && tstest test/",
|
"test": "pnpm run build && tstest test/",
|
||||||
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle",
|
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle",
|
||||||
|
"build:docker": "tsdocker build --verbose",
|
||||||
|
"release:docker": "tsdocker push --verbose",
|
||||||
"watch": "tswatch",
|
"watch": "tswatch",
|
||||||
"seed": "tsrun ts_seed/cli.ts",
|
"seed": "tsrun ts_seed/cli.ts",
|
||||||
"start": "(node cli.js)",
|
"start": "(node cli.js)",
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"@idp.global/interfaces": "^1.0.1",
|
"@idp.global/interfaces": "^1.0.1",
|
||||||
"@idp.global/sdk": "^1.3.0",
|
"@idp.global/sdk": "^1.3.0",
|
||||||
"@push.rocks/lik": "^6.4.1",
|
"@push.rocks/lik": "^6.4.1",
|
||||||
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
"@push.rocks/qenv": "^6.1.4",
|
"@push.rocks/qenv": "^6.1.4",
|
||||||
"@push.rocks/smartcli": "^4.3.0",
|
"@push.rocks/smartcli": "^4.3.0",
|
||||||
"@push.rocks/smartdata": "^7.1.7",
|
"@push.rocks/smartdata": "^7.1.7",
|
||||||
@@ -53,17 +56,20 @@
|
|||||||
"@push.rocks/websetup": "^3.0.20",
|
"@push.rocks/websetup": "^3.0.20",
|
||||||
"@push.rocks/webstore": "^2.0.22",
|
"@push.rocks/webstore": "^2.0.22",
|
||||||
"@serve.zone/platformclient": "^1.1.4",
|
"@serve.zone/platformclient": "^1.1.4",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"@tsclass/tsclass": "^9.5.1",
|
"@tsclass/tsclass": "^9.5.1",
|
||||||
"@uptime.link/webwidget": "^1.2.6",
|
"@uptime.link/webwidget": "^1.2.6",
|
||||||
"argon2": "^0.44.0"
|
"argon2": "^0.44.0",
|
||||||
|
"otplib": "^13.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.4.1",
|
"@git.zone/tsbuild": "^4.4.1",
|
||||||
"@git.zone/tsbundle": "^2.10.4",
|
"@git.zone/tsbundle": "^2.10.4",
|
||||||
|
"@git.zone/tsdocker": "^2.3.0",
|
||||||
"@git.zone/tsrun": "^2.0.4",
|
"@git.zone/tsrun": "^2.0.4",
|
||||||
"@git.zone/tstest": "^3.6.6",
|
"@git.zone/tstest": "^3.6.6",
|
||||||
"@git.zone/tswatch": "^3.3.5",
|
"@git.zone/tswatch": "^3.3.5",
|
||||||
"@push.rocks/projectinfo": "^5.1.0",
|
|
||||||
"@types/node": "^25.9.0"
|
"@types/node": "^25.9.0"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
Generated
+329
-3
@@ -47,6 +47,9 @@ importers:
|
|||||||
'@push.rocks/lik':
|
'@push.rocks/lik':
|
||||||
specifier: ^6.4.1
|
specifier: ^6.4.1
|
||||||
version: 6.4.1
|
version: 6.4.1
|
||||||
|
'@push.rocks/projectinfo':
|
||||||
|
specifier: ^5.1.0
|
||||||
|
version: 5.1.0
|
||||||
'@push.rocks/qenv':
|
'@push.rocks/qenv':
|
||||||
specifier: ^6.1.4
|
specifier: ^6.1.4
|
||||||
version: 6.1.4
|
version: 6.1.4
|
||||||
@@ -116,6 +119,12 @@ importers:
|
|||||||
'@serve.zone/platformclient':
|
'@serve.zone/platformclient':
|
||||||
specifier: ^1.1.4
|
specifier: ^1.1.4
|
||||||
version: 1.1.4(@push.rocks/smartserve@2.0.4)(@tiptap/pm@2.27.2)
|
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':
|
'@tsclass/tsclass':
|
||||||
specifier: ^9.5.1
|
specifier: ^9.5.1
|
||||||
version: 9.5.1
|
version: 9.5.1
|
||||||
@@ -125,6 +134,9 @@ importers:
|
|||||||
argon2:
|
argon2:
|
||||||
specifier: ^0.44.0
|
specifier: ^0.44.0
|
||||||
version: 0.44.0
|
version: 0.44.0
|
||||||
|
otplib:
|
||||||
|
specifier: ^13.4.0
|
||||||
|
version: 13.4.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@git.zone/tsbuild':
|
'@git.zone/tsbuild':
|
||||||
specifier: ^4.4.1
|
specifier: ^4.4.1
|
||||||
@@ -132,6 +144,9 @@ importers:
|
|||||||
'@git.zone/tsbundle':
|
'@git.zone/tsbundle':
|
||||||
specifier: ^2.10.4
|
specifier: ^2.10.4
|
||||||
version: 2.10.4
|
version: 2.10.4
|
||||||
|
'@git.zone/tsdocker':
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.3.0
|
||||||
'@git.zone/tsrun':
|
'@git.zone/tsrun':
|
||||||
specifier: ^2.0.4
|
specifier: ^2.0.4
|
||||||
version: 2.0.4
|
version: 2.0.4
|
||||||
@@ -141,9 +156,6 @@ importers:
|
|||||||
'@git.zone/tswatch':
|
'@git.zone/tswatch':
|
||||||
specifier: ^3.3.5
|
specifier: ^3.3.5
|
||||||
version: 3.3.5(@tiptap/pm@2.27.2)
|
version: 3.3.5(@tiptap/pm@2.27.2)
|
||||||
'@push.rocks/projectinfo':
|
|
||||||
specifier: ^5.1.0
|
|
||||||
version: 5.1.0
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.9.0
|
specifier: ^25.9.0
|
||||||
version: 25.9.0
|
version: 25.9.0
|
||||||
@@ -167,6 +179,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@push.rocks/smartserve': '>=1.1.0'
|
'@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':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -729,6 +744,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==}
|
resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@git.zone/tsdocker@2.3.0':
|
||||||
|
resolution: {integrity: sha512-im2hD3Fu7vSb6qM+WMg2tbvLbFfEpX8qVmjy491R5iELky4Pw9cqRMkwzmxW92etn8v+f53ODUQDOoc9DufX2A==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@git.zone/tspublish@1.11.7':
|
'@git.zone/tspublish@1.11.7':
|
||||||
resolution: {integrity: sha512-6vXXOQ7mPQu72q/Trr8bHwYLrAX1Y1okX/KoKTXlnx0adtqJKw1V5p9cimGrkq40rX5ivh1rPg+7fTlbXgjyew==}
|
resolution: {integrity: sha512-6vXXOQ7mPQu72q/Trr8bHwYLrAX1Y1okX/KoKTXlnx0adtqJKw1V5p9cimGrkq40rX5ivh1rPg+7fTlbXgjyew==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -749,6 +768,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw==}
|
resolution: {integrity: sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw==}
|
||||||
engines: {node: '>=20.0.0'}
|
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':
|
'@idp.global/catalog@1.1.1':
|
||||||
resolution: {integrity: sha512-blVe8FnIfcn6q9zPnauQOzbGDDoVwaHhPx8CGijod8V93nncJSIHYTpbQSuVmc5MDfoydK+7gf1RWp0PG/THng==}
|
resolution: {integrity: sha512-blVe8FnIfcn6q9zPnauQOzbGDDoVwaHhPx8CGijod8V93nncJSIHYTpbQSuVmc5MDfoydK+7gf1RWp0PG/THng==}
|
||||||
|
|
||||||
@@ -1063,6 +1085,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==}
|
resolution: {integrity: sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@levischuck/tiny-cbor@0.2.11':
|
||||||
|
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||||
|
|
||||||
'@lit-labs/ssr-dom-shim@1.5.1':
|
'@lit-labs/ssr-dom-shim@1.5.1':
|
||||||
resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==}
|
resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==}
|
||||||
|
|
||||||
@@ -1151,9 +1176,31 @@ packages:
|
|||||||
'@emnapi/core': ^1.7.1
|
'@emnapi/core': ^1.7.1
|
||||||
'@emnapi/runtime': ^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':
|
'@nodable/entities@2.1.0':
|
||||||
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
|
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':
|
'@oxc-project/types@0.129.0':
|
||||||
resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==}
|
resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==}
|
||||||
|
|
||||||
@@ -1163,6 +1210,9 @@ packages:
|
|||||||
'@pdf-lib/upng@1.0.1':
|
'@pdf-lib/upng@1.0.1':
|
||||||
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
|
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':
|
'@peculiar/asn1-cms@2.7.0':
|
||||||
resolution: {integrity: sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==}
|
resolution: {integrity: sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==}
|
||||||
|
|
||||||
@@ -1338,6 +1388,9 @@ packages:
|
|||||||
'@push.rocks/smartlog-interfaces@3.0.2':
|
'@push.rocks/smartlog-interfaces@3.0.2':
|
||||||
resolution: {integrity: sha512-8hGRTJehbsFSJxLhCQkA018mZtXVPxPTblbg9VaE/EqISRzUw+eosJ2EJV7M4Qu0eiTJZjnWnNLn8CkD77ziWw==}
|
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':
|
'@push.rocks/smartlog@3.2.2':
|
||||||
resolution: {integrity: sha512-3Nw/Ki/jZ4vrrWnEtpcGPF28jQ+fr9/9Edc7ytaEA6ZWIpojtwacJ5qihMvHbIei+zjpD35w6tZP2mQjvw5VRQ==}
|
resolution: {integrity: sha512-3Nw/Ki/jZ4vrrWnEtpcGPF28jQ+fr9/9Edc7ytaEA6ZWIpojtwacJ5qihMvHbIei+zjpD35w6tZP2mQjvw5VRQ==}
|
||||||
|
|
||||||
@@ -1487,6 +1540,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9OJbnRgLTaCRQz+pqu5tB3ZCqRs5Zh0hnBe7t7URE+TgwIZ8aiELUIbWRkgn4mSGVzHyL6pqTyIowP6AjUCG3w==}
|
resolution: {integrity: sha512-9OJbnRgLTaCRQz+pqu5tB3ZCqRs5Zh0hnBe7t7URE+TgwIZ8aiELUIbWRkgn4mSGVzHyL6pqTyIowP6AjUCG3w==}
|
||||||
deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartjson
|
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':
|
'@pushrocks/smartpromise@3.1.10':
|
||||||
resolution: {integrity: sha512-VeTurbZ1+ZMxBDJk1Y1LV8SN9xLI+oDXKVeCFw41FAGEKOUEqordqFpi6t+7Vhe/TXUZzCVpZ5bXxAxrGf8yTQ==}
|
resolution: {integrity: sha512-VeTurbZ1+ZMxBDJk1Y1LV8SN9xLI+oDXKVeCFw41FAGEKOUEqordqFpi6t+7Vhe/TXUZzCVpZ5bXxAxrGf8yTQ==}
|
||||||
deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartpromise
|
deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartpromise
|
||||||
@@ -1654,6 +1711,9 @@ packages:
|
|||||||
'@swc/helpers':
|
'@swc/helpers':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@scure/base@2.2.0':
|
||||||
|
resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==}
|
||||||
|
|
||||||
'@sec-ant/readable-stream@0.4.1':
|
'@sec-ant/readable-stream@0.4.1':
|
||||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||||
|
|
||||||
@@ -1663,6 +1723,13 @@ packages:
|
|||||||
'@serve.zone/platformclient@1.1.4':
|
'@serve.zone/platformclient@1.1.4':
|
||||||
resolution: {integrity: sha512-TcUQpWOqb28Iyz57ZdC2hXfV6maxeeLfjt85T1cPQc7g1KoYZ+CfFpRr0NBEf4oG2A7vALnsIDvlebtJO1GTow==}
|
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':
|
'@smithy/chunked-blob-reader-native@4.2.3':
|
||||||
resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==}
|
resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -2184,6 +2251,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
engines: {node: '>=8'}
|
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:
|
ansi-styles@4.3.0:
|
||||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2343,6 +2414,14 @@ packages:
|
|||||||
ccount@2.0.1:
|
ccount@2.0.1:
|
||||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
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:
|
character-entities-html4@2.1.0:
|
||||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||||
|
|
||||||
@@ -2368,6 +2447,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==}
|
resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==}
|
||||||
engines: {node: '>= 4.0'}
|
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:
|
cli-width@4.1.0:
|
||||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
@@ -2376,10 +2463,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
|
|
||||||
|
color-name@1.1.3:
|
||||||
|
resolution: {integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=}
|
||||||
|
|
||||||
color-name@1.1.4:
|
color-name@1.1.4:
|
||||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||||
|
|
||||||
@@ -2454,6 +2551,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
||||||
engines: {node: '>=4.0.0'}
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
defaults@1.0.4:
|
||||||
|
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
|
||||||
|
|
||||||
define-data-property@1.1.4:
|
define-data-property@1.1.4:
|
||||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2552,6 +2652,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
escape-string-regexp@4.0.0:
|
||||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2742,6 +2846,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==}
|
resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
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:
|
has-property-descriptors@1.0.2:
|
||||||
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
|
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
|
||||||
|
|
||||||
@@ -2839,6 +2951,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||||
engines: {node: '>=8'}
|
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:
|
is-nan@1.3.2:
|
||||||
resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==}
|
resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2965,6 +3081,10 @@ packages:
|
|||||||
lodash.once@4.1.1:
|
lodash.once@4.1.1:
|
||||||
resolution: {integrity: sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=}
|
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:
|
longest-streak@3.1.0:
|
||||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||||
|
|
||||||
@@ -3169,6 +3289,10 @@ packages:
|
|||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
mimic-fn@2.1.0:
|
||||||
|
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
mingo@7.2.1:
|
mingo@7.2.1:
|
||||||
resolution: {integrity: sha512-MEIQPOSJS2sVCueyQeE2rzgEeW3HpIIhizPbeuwD4v7+miVj7NI3ZVPqqw8t3YPIWCivpIaXA4KsoRI7koyNOA==}
|
resolution: {integrity: sha512-MEIQPOSJS2sVCueyQeE2rzgEeW3HpIIhizPbeuwD4v7+miVj7NI3ZVPqqw8t3YPIWCivpIaXA4KsoRI7koyNOA==}
|
||||||
|
|
||||||
@@ -3243,6 +3367,9 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
mute-stream@0.0.8:
|
||||||
|
resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
|
||||||
|
|
||||||
mute-stream@1.0.0:
|
mute-stream@1.0.0:
|
||||||
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
|
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||||
@@ -3303,10 +3430,18 @@ packages:
|
|||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
|
resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
|
||||||
|
|
||||||
|
onetime@5.1.2:
|
||||||
|
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
open@8.4.2:
|
open@8.4.2:
|
||||||
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
ora@4.1.1:
|
||||||
|
resolution: {integrity: sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
orderedmap@2.1.1:
|
orderedmap@2.1.1:
|
||||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||||
|
|
||||||
@@ -3314,6 +3449,9 @@ packages:
|
|||||||
resolution: {integrity: sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=}
|
resolution: {integrity: sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
otplib@13.4.0:
|
||||||
|
resolution: {integrity: sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==}
|
||||||
|
|
||||||
p-finally@1.0.0:
|
p-finally@1.0.0:
|
||||||
resolution: {integrity: sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=}
|
resolution: {integrity: sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3620,6 +3758,10 @@ packages:
|
|||||||
resolve-pkg-maps@1.0.0:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
|
restore-cursor@3.1.0:
|
||||||
|
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
rimraf@6.1.3:
|
rimraf@6.1.3:
|
||||||
resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==}
|
resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -3767,6 +3909,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==}
|
resolution: {integrity: sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==}
|
||||||
engines: {node: '>=16'}
|
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:
|
sweet-scroll@4.0.0:
|
||||||
resolution: {integrity: sha512-mR6fRsAQANtm3zpzhUE73KAOt2aT4ZsWzNSggiEsSqdO6Zh4gM7ioJG81EngrZEl0XAc3ZvzEfhxggOoEBc8jA==}
|
resolution: {integrity: sha512-mR6fRsAQANtm3zpzhUE73KAOt2aT4ZsWzNSggiEsSqdO6Zh4gM7ioJG81EngrZEl0XAc3ZvzEfhxggOoEBc8jA==}
|
||||||
|
|
||||||
@@ -3941,6 +4091,9 @@ packages:
|
|||||||
w3c-keyname@2.2.8:
|
w3c-keyname@2.2.8:
|
||||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||||
|
|
||||||
|
wcwidth@1.0.1:
|
||||||
|
resolution: {integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=}
|
||||||
|
|
||||||
webdriver-bidi-protocol@0.4.1:
|
webdriver-bidi-protocol@0.4.1:
|
||||||
resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
|
resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
|
||||||
|
|
||||||
@@ -4129,6 +4282,8 @@ snapshots:
|
|||||||
'@push.rocks/smartstring': 4.1.1
|
'@push.rocks/smartstring': 4.1.1
|
||||||
'@push.rocks/smarturl': 3.1.0
|
'@push.rocks/smarturl': 3.1.0
|
||||||
|
|
||||||
|
'@apiglobal/typedrequest-interfaces@1.0.20': {}
|
||||||
|
|
||||||
'@aws-crypto/crc32@5.2.0':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-crypto/util': 5.2.0
|
'@aws-crypto/util': 5.2.0
|
||||||
@@ -4966,6 +5121,24 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- 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':
|
'@git.zone/tspublish@1.11.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/consolecolor': 2.0.4
|
'@push.rocks/consolecolor': 2.0.4
|
||||||
@@ -5081,6 +5254,8 @@ snapshots:
|
|||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
|
'@hexagon/base64@1.1.28': {}
|
||||||
|
|
||||||
'@idp.global/catalog@1.1.1(@tiptap/pm@2.27.2)':
|
'@idp.global/catalog@1.1.1(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-catalog': 3.81.0(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.81.0(@tiptap/pm@2.27.2)
|
||||||
@@ -5545,6 +5720,8 @@ snapshots:
|
|||||||
'@jimp/types': 1.6.1
|
'@jimp/types': 1.6.1
|
||||||
tinycolor2: 1.6.0
|
tinycolor2: 1.6.0
|
||||||
|
|
||||||
|
'@levischuck/tiny-cbor@0.2.11': {}
|
||||||
|
|
||||||
'@lit-labs/ssr-dom-shim@1.5.1': {}
|
'@lit-labs/ssr-dom-shim@1.5.1': {}
|
||||||
|
|
||||||
'@lit/reactive-element@2.1.2':
|
'@lit/reactive-element@2.1.2':
|
||||||
@@ -5612,8 +5789,37 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@noble/hashes@2.2.0': {}
|
||||||
|
|
||||||
'@nodable/entities@2.1.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': {}
|
'@oxc-project/types@0.129.0': {}
|
||||||
|
|
||||||
'@pdf-lib/standard-fonts@1.0.0':
|
'@pdf-lib/standard-fonts@1.0.0':
|
||||||
@@ -5624,6 +5830,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
pako: 1.0.11
|
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':
|
'@peculiar/asn1-cms@2.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@peculiar/asn1-schema': 2.7.0
|
'@peculiar/asn1-schema': 2.7.0
|
||||||
@@ -6081,6 +6293,11 @@ snapshots:
|
|||||||
'@api.global/typedrequest-interfaces': 2.0.2
|
'@api.global/typedrequest-interfaces': 2.0.2
|
||||||
'@tsclass/tsclass': 4.4.4
|
'@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':
|
'@push.rocks/smartlog@3.2.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
@@ -6483,6 +6700,10 @@ snapshots:
|
|||||||
fast-json-stable-stringify: 2.1.0
|
fast-json-stable-stringify: 2.1.0
|
||||||
lodash.clonedeep: 4.5.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/smartpromise@3.1.10': {}
|
||||||
|
|
||||||
'@pushrocks/smartstring@4.0.7':
|
'@pushrocks/smartstring@4.0.7':
|
||||||
@@ -6602,6 +6823,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@rspack/binding': 2.0.3
|
'@rspack/binding': 2.0.3
|
||||||
|
|
||||||
|
'@scure/base@2.2.0': {}
|
||||||
|
|
||||||
'@sec-ant/readable-stream@0.4.1': {}
|
'@sec-ant/readable-stream@0.4.1': {}
|
||||||
|
|
||||||
'@serve.zone/interfaces@5.4.6':
|
'@serve.zone/interfaces@5.4.6':
|
||||||
@@ -6629,6 +6852,19 @@ snapshots:
|
|||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- vue
|
- 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':
|
'@smithy/chunked-blob-reader-native@4.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/util-base64': 4.3.2
|
'@smithy/util-base64': 4.3.2
|
||||||
@@ -7291,6 +7527,10 @@ snapshots:
|
|||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
|
ansi-styles@3.2.1:
|
||||||
|
dependencies:
|
||||||
|
color-convert: 1.9.3
|
||||||
|
|
||||||
ansi-styles@4.3.0:
|
ansi-styles@4.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-convert: 2.0.1
|
color-convert: 2.0.1
|
||||||
@@ -7436,6 +7676,17 @@ snapshots:
|
|||||||
|
|
||||||
ccount@2.0.1: {}
|
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-html4@2.1.0: {}
|
||||||
|
|
||||||
character-entities-legacy@3.0.0: {}
|
character-entities-legacy@3.0.0: {}
|
||||||
@@ -7458,6 +7709,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
source-map: 0.6.1
|
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: {}
|
cli-width@4.1.0: {}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
@@ -7466,10 +7723,18 @@ snapshots:
|
|||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
wrap-ansi: 7.0.0
|
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:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|
||||||
|
color-name@1.1.3: {}
|
||||||
|
|
||||||
color-name@1.1.4: {}
|
color-name@1.1.4: {}
|
||||||
|
|
||||||
combined-stream@1.0.8:
|
combined-stream@1.0.8:
|
||||||
@@ -7531,6 +7796,10 @@ snapshots:
|
|||||||
|
|
||||||
deep-extend@0.6.0: {}
|
deep-extend@0.6.0: {}
|
||||||
|
|
||||||
|
defaults@1.0.4:
|
||||||
|
dependencies:
|
||||||
|
clone: 1.0.4
|
||||||
|
|
||||||
define-data-property@1.1.4:
|
define-data-property@1.1.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-define-property: 1.0.1
|
es-define-property: 1.0.1
|
||||||
@@ -7673,6 +7942,8 @@ snapshots:
|
|||||||
|
|
||||||
escalade@3.2.0: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
|
escape-string-regexp@1.0.5: {}
|
||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
escape-string-regexp@5.0.0: {}
|
escape-string-regexp@5.0.0: {}
|
||||||
@@ -7897,6 +8168,10 @@ snapshots:
|
|||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
|
has-flag@3.0.0: {}
|
||||||
|
|
||||||
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
has-property-descriptors@1.0.2:
|
has-property-descriptors@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-define-property: 1.0.1
|
es-define-property: 1.0.1
|
||||||
@@ -8011,6 +8286,8 @@ snapshots:
|
|||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
|
|
||||||
|
is-interactive@1.0.0: {}
|
||||||
|
|
||||||
is-nan@1.3.2:
|
is-nan@1.3.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@@ -8156,6 +8433,10 @@ snapshots:
|
|||||||
|
|
||||||
lodash.once@4.1.1: {}
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
|
log-symbols@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
chalk: 2.4.2
|
||||||
|
|
||||||
longest-streak@3.1.0: {}
|
longest-streak@3.1.0: {}
|
||||||
|
|
||||||
lower-case@1.1.4: {}
|
lower-case@1.1.4: {}
|
||||||
@@ -8540,6 +8821,8 @@ snapshots:
|
|||||||
|
|
||||||
mime@4.1.0: {}
|
mime@4.1.0: {}
|
||||||
|
|
||||||
|
mimic-fn@2.1.0: {}
|
||||||
|
|
||||||
mingo@7.2.1: {}
|
mingo@7.2.1: {}
|
||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
@@ -8624,6 +8907,8 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
mute-stream@0.0.8: {}
|
||||||
|
|
||||||
mute-stream@1.0.0: {}
|
mute-stream@1.0.0: {}
|
||||||
|
|
||||||
nanoid@4.0.2: {}
|
nanoid@4.0.2: {}
|
||||||
@@ -8666,16 +8951,40 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
|
|
||||||
|
onetime@5.1.2:
|
||||||
|
dependencies:
|
||||||
|
mimic-fn: 2.1.0
|
||||||
|
|
||||||
open@8.4.2:
|
open@8.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-lazy-prop: 2.0.0
|
define-lazy-prop: 2.0.0
|
||||||
is-docker: 2.2.1
|
is-docker: 2.2.1
|
||||||
is-wsl: 2.2.0
|
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: {}
|
orderedmap@2.1.1: {}
|
||||||
|
|
||||||
os-tmpdir@1.0.2: {}
|
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-finally@1.0.0: {}
|
||||||
|
|
||||||
p-limit@2.3.0:
|
p-limit@2.3.0:
|
||||||
@@ -9067,6 +9376,11 @@ snapshots:
|
|||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
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:
|
rimraf@6.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
glob: 13.0.6
|
glob: 13.0.6
|
||||||
@@ -9269,6 +9583,14 @@ snapshots:
|
|||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
peek-readable: 5.4.2
|
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: {}
|
sweet-scroll@4.0.0: {}
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
@@ -9463,6 +9785,10 @@ snapshots:
|
|||||||
|
|
||||||
w3c-keyname@2.2.8: {}
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
|
wcwidth@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
defaults: 1.0.4
|
||||||
|
|
||||||
webdriver-bidi-protocol@0.4.1: {}
|
webdriver-bidi-protocol@0.4.1: {}
|
||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|||||||
@@ -140,6 +140,17 @@ The typed request surface includes:
|
|||||||
- `getPassportDashboard`, `listPassportAlerts`, and `markPassportAlertSeen` for mobile app dashboards and notifications.
|
- `getPassportDashboard`, `listPassportAlerts`, and `markPassportAlertSeen` for mobile app dashboards and notifications.
|
||||||
- `registerPassportPushToken` for push delivery setup.
|
- `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
|
## SDK Example
|
||||||
|
|
||||||
Browser integrations should use the dedicated SDK browser entrypoint published by `@idp.global/sdk`.
|
Browser integrations should use the dedicated SDK browser entrypoint published by `@idp.global/sdk`.
|
||||||
|
|||||||
@@ -2,26 +2,26 @@
|
|||||||
|
|
||||||
**ID:** EU-004
|
**ID:** EU-004
|
||||||
**Priority:** High
|
**Priority:** High
|
||||||
**Status:** Planned
|
**Status:** Implemented
|
||||||
|
|
||||||
## User Story
|
## 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.
|
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
|
## Acceptance Criteria
|
||||||
- [ ] User can enable 2FA from account settings
|
- [x] User can enable 2FA from account settings
|
||||||
- [ ] Support for TOTP apps (Google Authenticator, Authy, etc.)
|
- [x] Support for TOTP apps (Google Authenticator, Authy, etc.)
|
||||||
- [ ] Backup codes are generated and shown once during setup
|
- [x] Backup codes are generated and shown once during setup
|
||||||
- [ ] User must verify 2FA code during setup to confirm it works
|
- [x] User must verify 2FA code during setup to confirm it works
|
||||||
- [ ] Login flow prompts for 2FA code when enabled
|
- [x] Login flow prompts for 2FA code when enabled
|
||||||
- [ ] User can disable 2FA (requires current 2FA code)
|
- [x] User can disable 2FA (requires current 2FA code)
|
||||||
- [ ] Account recovery option if 2FA device is lost
|
- [x] Account recovery option if 2FA device is lost via one-time backup codes
|
||||||
|
|
||||||
## Technical Notes
|
## Technical Notes
|
||||||
- Mobile verification infrastructure exists (SMS OTP in registration)
|
- TOTP is implemented with `otplib`.
|
||||||
- Can leverage existing `smarttwilio` integration for SMS-based 2FA
|
- TOTP secrets are stored encrypted in dedicated credential records, not on the User model.
|
||||||
- TOTP implementation needs `otplib` or similar library
|
- Backup codes are stored as hashes and consumed once.
|
||||||
- Store encrypted TOTP secret in User model
|
- WebAuthn passkeys are supported for passwordless login and MFA step-up.
|
||||||
- Consider supporting multiple 2FA methods (TOTP, SMS, security keys)
|
- SMS OTP remains registration-only and is not a default login factor.
|
||||||
|
|
||||||
## Related TODOs
|
## Related TODOs
|
||||||
- New feature - no existing TODO
|
- Consider adding explicit recovery admin workflows beyond backup codes.
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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
|
||||||
@@ -34,6 +34,8 @@ import * as smarttime from '@push.rocks/smarttime';
|
|||||||
import * as smartunique from '@push.rocks/smartunique';
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
|
import * as otplib from 'otplib';
|
||||||
|
import * as simpleWebAuthnServer from '@simplewebauthn/server';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
argon2,
|
argon2,
|
||||||
@@ -51,6 +53,8 @@ export {
|
|||||||
smarttime,
|
smarttime,
|
||||||
smartunique,
|
smartunique,
|
||||||
taskbuffer,
|
taskbuffer,
|
||||||
|
otplib,
|
||||||
|
simpleWebAuthnServer,
|
||||||
};
|
};
|
||||||
|
|
||||||
// @tsclass scope
|
// @tsclass scope
|
||||||
|
|||||||
@@ -104,6 +104,16 @@ export class ReceptionHousekeeping {
|
|||||||
'2 * * * * *'
|
'2 * * * * *'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredMfaChallenges',
|
||||||
|
taskFunction: async () => {
|
||||||
|
await this.receptionRef.mfaManager.cleanupExpiredChallenges();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'4 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
this.taskmanager.addAndScheduleTask(
|
this.taskmanager.addAndScheduleTask(
|
||||||
new plugins.taskbuffer.Task({
|
new plugins.taskbuffer.Task({
|
||||||
name: 'redeliverPassportChallengeHints',
|
name: 'redeliverPassportChallengeHints',
|
||||||
|
|||||||
@@ -84,17 +84,29 @@ export class LoginSessionManager {
|
|||||||
await user.save();
|
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 loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||||
const refreshToken = await loginSession.getRefreshToken();
|
const refreshToken = await loginSession.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
|
||||||
'passwordLogin',
|
|
||||||
loginIdentifier
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refreshToken,
|
refreshToken,
|
||||||
twoFaNeeded: false,
|
twoFaNeeded: false,
|
||||||
@@ -145,7 +157,7 @@ export class LoginSessionManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.typedRouter.addTypedHandler(
|
this.typedRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
new plugins.typedrequest.TypedHandler<any>(
|
||||||
'loginWithEmailAfterEmailTokenAquired',
|
'loginWithEmailAfterEmailTokenAquired',
|
||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
@@ -168,6 +180,21 @@ export class LoginSessionManager {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
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 loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||||
const refreshToken = await loginSession.getRefreshToken();
|
const refreshToken = await loginSession.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
|
|||||||
import { AlertManager } from './classes.alertmanager.js';
|
import { AlertManager } from './classes.alertmanager.js';
|
||||||
import { PassportManager } from './classes.passportmanager.js';
|
import { PassportManager } from './classes.passportmanager.js';
|
||||||
import { PassportPushManager } from './classes.passportpushmanager.js';
|
import { PassportPushManager } from './classes.passportpushmanager.js';
|
||||||
|
import { MfaManager } from './classes.mfamanager.js';
|
||||||
|
|
||||||
export interface IReceptionOptions {
|
export interface IReceptionOptions {
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +55,7 @@ export class Reception {
|
|||||||
public alertManager = new AlertManager(this);
|
public alertManager = new AlertManager(this);
|
||||||
public userInvitationManager = new UserInvitationManager(this);
|
public userInvitationManager = new UserInvitationManager(this);
|
||||||
public abuseProtectionManager = new AbuseProtectionManager(this);
|
public abuseProtectionManager = new AbuseProtectionManager(this);
|
||||||
|
public mfaManager = new MfaManager(this);
|
||||||
public passportPushManager = new PassportPushManager(this);
|
public passportPushManager = new PassportPushManager(this);
|
||||||
public passportManager = new PassportManager(this);
|
public passportManager = new PassportManager(this);
|
||||||
public oidcManager = new OidcManager(this);
|
public oidcManager = new OidcManager(this);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,18 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
private accessor passportEnrollment: plugins.idpCatalog.IIdpAdminPassportEnrollment | null = null;
|
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()
|
@state()
|
||||||
private accessor credentialMessage = '';
|
private accessor credentialMessage = '';
|
||||||
|
|
||||||
@@ -124,6 +136,10 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
.adminApps=${this.adminApps}
|
.adminApps=${this.adminApps}
|
||||||
.passportDevices=${this.passportDevices}
|
.passportDevices=${this.passportDevices}
|
||||||
.passportEnrollment=${this.passportEnrollment}
|
.passportEnrollment=${this.passportEnrollment}
|
||||||
|
.totpEnabled=${this.totpEnabled}
|
||||||
|
.backupCodesRemaining=${this.backupCodesRemaining}
|
||||||
|
.totpEnrollment=${this.totpEnrollment}
|
||||||
|
.passkeys=${this.passkeys}
|
||||||
.credentialMessage=${this.credentialMessage}
|
.credentialMessage=${this.credentialMessage}
|
||||||
@idp-admin-navigate=${this.handleAdminNavigate}
|
@idp-admin-navigate=${this.handleAdminNavigate}
|
||||||
@idp-admin-org-select=${this.handleOrgSelect}
|
@idp-admin-org-select=${this.handleOrgSelect}
|
||||||
@@ -136,6 +152,12 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
@idp-admin-password-change=${this.handlePasswordChange}
|
@idp-admin-password-change=${this.handlePasswordChange}
|
||||||
@idp-admin-passport-enroll=${this.handlePassportEnroll}
|
@idp-admin-passport-enroll=${this.handlePassportEnroll}
|
||||||
@idp-admin-passport-revoke=${this.handlePassportRevoke}
|
@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-invite=${this.handleMemberInvite}
|
||||||
@idp-admin-member-remove=${this.handleMemberRemove}
|
@idp-admin-member-remove=${this.handleMemberRemove}
|
||||||
@idp-admin-member-roles-update=${this.handleMemberRolesUpdate}
|
@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() {
|
private async loadAdminShellData() {
|
||||||
const currentRun = ++this.dataLoadRun;
|
const currentRun = ++this.dataLoadRun;
|
||||||
this.dataLoading = true;
|
this.dataLoading = true;
|
||||||
@@ -506,7 +548,7 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
const selectedOrg = this.getSelectedOrganization();
|
const selectedOrg = this.getSelectedOrganization();
|
||||||
const orgId = selectedOrg?.id || '';
|
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) => {
|
this.loadSessions(idpState, jwt).catch((error) => {
|
||||||
console.error('Error loading sessions:', error);
|
console.error('Error loading sessions:', error);
|
||||||
return this.sessions;
|
return this.sessions;
|
||||||
@@ -535,6 +577,14 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
console.error('Error loading passport devices:', error);
|
console.error('Error loading passport devices:', error);
|
||||||
return this.passportDevices;
|
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) {
|
if (currentRun !== this.dataLoadRun) {
|
||||||
@@ -548,6 +598,9 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
this.orgApps = orgApps;
|
this.orgApps = orgApps;
|
||||||
this.adminApps = adminApps;
|
this.adminApps = adminApps;
|
||||||
this.passportDevices = passportDevices;
|
this.passportDevices = passportDevices;
|
||||||
|
this.totpEnabled = mfaStatus.totpEnabled;
|
||||||
|
this.backupCodesRemaining = mfaStatus.backupCodesRemaining;
|
||||||
|
this.passkeys = mfaStatus.passkeys;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading admin shell data:', error);
|
console.error('Error loading admin shell data:', error);
|
||||||
if (currentRun === this.dataLoadRun) {
|
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() {
|
private async handleMemberInvite() {
|
||||||
const selectedOrg = this.getSelectedOrganization();
|
const selectedOrg = this.getSelectedOrganization();
|
||||||
if (!selectedOrg) {
|
if (!selectedOrg) {
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
accessor oidcConsentError = '';
|
accessor oidcConsentError = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor mfaChallengeToken = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor availableMfaMethods: string[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor mfaMethod: 'totp' | 'backupCode' = 'totp';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor passkeyMessage = '';
|
||||||
|
|
||||||
@property()
|
@property()
|
||||||
accessor productOfInterest: string;
|
accessor productOfInterest: string;
|
||||||
|
|
||||||
@@ -200,6 +212,22 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
return true;
|
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 = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
css`
|
css`
|
||||||
@@ -324,6 +352,46 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
public render(): TemplateResult {
|
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) {
|
if (this.oidcConsentState) {
|
||||||
return html`
|
return html`
|
||||||
<idp-centercontainer>
|
<idp-centercontainer>
|
||||||
@@ -397,7 +465,7 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
required
|
required
|
||||||
name="emailAddress"
|
name="emailAddress"
|
||||||
label="Email or Username"
|
label="Email or Username"
|
||||||
autocomplete="username"
|
autocomplete="username webauthn"
|
||||||
></idp-input>
|
></idp-input>
|
||||||
<idp-input
|
<idp-input
|
||||||
id="loginPasswordInput"
|
id="loginPasswordInput"
|
||||||
@@ -409,6 +477,8 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
<idp-form-submit id="loginSubmitButton"></idp-form-submit>
|
<idp-form-submit id="loginSubmitButton"></idp-form-submit>
|
||||||
</idp-form>
|
</idp-form>
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
|
<a @click=${() => this.loginWithPasskey()}>Sign in with passkey</a>
|
||||||
|
<br />
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<a @click=${async () => {
|
<a @click=${async () => {
|
||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
@@ -489,17 +559,11 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.refreshToken) {
|
if (response.refreshToken) {
|
||||||
loginForm.setStatus('pending', 'obtained refreshToken...');
|
await this.completeLoginWithRefreshToken(response.refreshToken);
|
||||||
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
|
} else if (response.twoFaNeeded && response.mfaChallengeToken) {
|
||||||
if (jwt) {
|
this.mfaChallengeToken = response.mfaChallengeToken;
|
||||||
loginForm.setStatus('success', 'obtained jwt.');
|
this.availableMfaMethods = response.availableMfaMethods || ['totp'];
|
||||||
const oidcHandled = await this.handleOidcAfterLogin(jwt);
|
this.mfaMethod = this.availableMfaMethods.includes('totp') ? 'totp' : 'backupCode';
|
||||||
if (!oidcHandled) {
|
|
||||||
idpState.domtools.router.pushUrl('/dash/overview');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
loginForm.setStatus('error', 'something went wrong');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
|
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
|
||||||
loginForm.setStatus('pending', 'sending magic link...');
|
loginForm.setStatus('pending', 'sending magic link...');
|
||||||
@@ -518,6 +582,92 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
loginSubmitButton.disabled = false;
|
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) {
|
public async dispatchJwt(jwtArg?: string) {
|
||||||
if (jwtArg !== undefined) {
|
if (jwtArg !== undefined) {
|
||||||
this.jwt = jwtArg;
|
this.jwt = jwtArg;
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import * as typedrequest from '@api.global/typedrequest';
|
|||||||
|
|
||||||
export { typedrequest };
|
export { typedrequest };
|
||||||
|
|
||||||
|
// SimpleWebAuthn scope
|
||||||
|
import * as simpleWebAuthnBrowser from '@simplewebauthn/browser';
|
||||||
|
|
||||||
|
export { simpleWebAuthnBrowser };
|
||||||
|
|
||||||
// @design.estate scope
|
// @design.estate scope
|
||||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||||
import * as deesDomtools from '@design.estate/dees-domtools';
|
import * as deesDomtools from '@design.estate/dees-domtools';
|
||||||
|
|||||||
Reference in New Issue
Block a user