From 066ab9cd5c8ae8d3357baf95f35043a05d50847d Mon Sep 17 00:00:00 2001 From: x032205 Date: Sat, 15 Nov 2025 18:49:01 -0500 Subject: [PATCH 01/36] allow usage of wallet for oracleDB on self hosted instances --- backend/.gitignore | 2 + backend/package-lock.json | 11 +++ backend/package.json | 1 + .../shared/sql/sql-connection-fns.ts | 87 +++++++++++----- .../integrations/app-connections/oracledb.mdx | 98 +++++++++++++++++-- 5 files changed, 165 insertions(+), 34 deletions(-) diff --git a/backend/.gitignore b/backend/.gitignore index 1521c8b765..00425e090d 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,3 @@ dist + +/wallet diff --git a/backend/package-lock.json b/backend/package-lock.json index a871c38b16..025a0fe221 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -155,6 +155,7 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "^20.19.0", "@types/nodemailer": "^6.4.14", + "@types/oracledb": "^6.10.0", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", @@ -15367,6 +15368,16 @@ "@types/node": "*" } }, + "node_modules/@types/oracledb": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.10.0.tgz", + "integrity": "sha512-dRaEYKRkJhSSM/uKrscE7zXC5D75JSkBgdye5kSxzTRwrMAUI5V675cD3fqCdMuSOiGo2K9Ng3CjjRI4rzeuAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", diff --git a/backend/package.json b/backend/package.json index aa97de2ed4..c65e61a8a5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -101,6 +101,7 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "^20.19.0", "@types/nodemailer": "^6.4.14", + "@types/oracledb": "^6.10.0", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index ca50bae8a3..d97ea45568 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -1,4 +1,6 @@ +import fs from "fs"; import knex, { Knex } from "knex"; +import oracledb from "oracledb"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; @@ -79,12 +81,51 @@ const getConnectionConfig = ({ } }; +const ORACLE_WALLET_REGEX = /^[a-z0-9]+_[a-z]+$/i; + +const isOracleWalletConnection = (app: AppConnection, database: string): boolean => { + return app === AppConnection.OracleDB && ORACLE_WALLET_REGEX.test(database); +}; + +const getOracleWalletKnexClient = ( + credentials: Pick +): Knex => { + if (!process.env.TNS_ADMIN || !fs.existsSync(process.env.TNS_ADMIN)) { + throw new BadRequestError({ + message: + "Oracle wallet is not configured correctly. See documentation for instructions: https://infisical.com/docs/integrations/app-connections/oracledb#mtls-wallet" + }); + } + if (oracledb.thin) { + try { + oracledb.initOracleClient(); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + throw new BadRequestError({ + message: `Failed to initialize Oracle client: ${errorMessage}. See documentation for instructions: https://infisical.com/docs/integrations/app-connections/oracledb#mtls-wallet` + }); + } + } + return knex({ + client: "oracledb", + connection: { + user: credentials.username, + password: credentials.password, + connectString: credentials.database + } + }); +}; + export const getSqlConnectionClient = async (appConnection: Pick) => { const { app, credentials: { host: baseHost, database, port, password, username } } = appConnection; + if (isOracleWalletConnection(app, database)) { + return getOracleWalletKnexClient({ username, password, database }); + } + const [host] = await verifyHostInputValidity(baseHost); const client = knex({ @@ -119,21 +160,30 @@ export const executeWithPotentialGateway = async ( targetPort: credentials.port }); + const createClient = (proxyPort: number): Knex => { + const { database, username, password } = credentials; + if (isOracleWalletConnection(app, database)) { + return getOracleWalletKnexClient({ username, password, database }); + } + + return knex({ + client: SQL_CONNECTION_CLIENT_MAP[app], + connection: { + database: credentials.database, + port: proxyPort, + host: "localhost", + user: credentials.username, + password: credentials.password, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ...getConnectionConfig({ app, credentials }) + } + }); + }; + if (platformConnectionDetails) { return withGatewayV2Proxy( async (proxyPort) => { - const client = knex({ - client: SQL_CONNECTION_CLIENT_MAP[app], - connection: { - database: credentials.database, - port: proxyPort, - host: "localhost", - user: credentials.username, - password: credentials.password, - connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, - ...getConnectionConfig({ app, credentials }) - } - }); + const client = createClient(proxyPort); try { return await operation(client); } finally { @@ -154,18 +204,7 @@ export const executeWithPotentialGateway = async ( return withGatewayProxy( async (proxyPort) => { - const client = knex({ - client: SQL_CONNECTION_CLIENT_MAP[app], - connection: { - database: credentials.database, - port: proxyPort, - host: "localhost", - user: credentials.username, - password: credentials.password, - connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, - ...getConnectionConfig({ app, credentials }) - } - }); + const client = createClient(proxyPort); try { return await operation(client); } finally { diff --git a/docs/integrations/app-connections/oracledb.mdx b/docs/integrations/app-connections/oracledb.mdx index 47aa33356f..d570af91d9 100644 --- a/docs/integrations/app-connections/oracledb.mdx +++ b/docs/integrations/app-connections/oracledb.mdx @@ -44,17 +44,95 @@ Infisical supports connecting to OracleDB using a database user. - You'll need the following information to create your Oracle Database connection: - - `host` - The hostname or IP address of your Oracle Database server - - `port` - The port number your Oracle Database server is listening on (default: 1521) - - `database` - The Oracle Service Name or SID (System Identifier) for the database you are connecting to. For example: `ORCL`, `FREEPDB1`, `XEPDB1` - - `username` - The user name of the login created in the steps above - - `password` - The user password of the login created in the steps above - - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + You'll need the following information to create your Oracle Database connection: + - `host` - The hostname or IP address of your Oracle Database server + - `port` - The port number your Oracle Database server is listening on (default: 1521) + - `database` - The Oracle Service Name or SID (System Identifier) for the database you are connecting to. For example: `ORCL`, `FREEPDB1`, `XEPDB1` + - `username` - The user name of the login created in the steps above + - `password` - The user password of the login created in the steps above + - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) - - If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. - + + If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. + + + + + This configuration can only be done on self-hosted or dedicated instances of Infisical. + + + To connect to an Oracle Database using mTLS with a wallet, you'll need to modify your self-hosted Infisical instance's Docker image. + + 1. Place your Oracle Wallet folder, which must be named `wallet`, inside the `/backend` directory of your Infisical installation source code. + + 2. Add the following instructions to your `Dockerfile`. These instructions install the Oracle Instant Client and configure the environment for the wallet. Choose the tab that matches your server's architecture. + + + + ```Dockerfile +# Install dependencies for Oracle Instant Client +RUN apt-get update && apt-get install -y \ + libaio1 \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +# Download and install Oracle Instant Client for x86_64 +RUN wget https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.x64-23.26.0.0.0.zip && \ + unzip instantclient-basic-linux.x64-23.26.0.0.0.zip -d /opt/oracle && \ + rm instantclient-basic-linux.x64-23.26.0.0.0.zip && \ + echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \ + ldconfig + +# Configure environment variables for Oracle Instant Client and the wallet +ENV LD_LIBRARY_PATH=/opt/oracle/instantclient_23_26:$LD_LIBRARY_PATH +ENV TNS_ADMIN=/app/wallet + +# Update sqlnet.ora to point to the correct wallet directory +RUN sed -i 's|DIRECTORY="?/network/admin"|DIRECTORY="/app/wallet"|g' /app/wallet/sqlnet.ora + ``` + + + ```Dockerfile +# Install dependencies for Oracle Instant Client +RUN apt-get update && apt-get install -y \ + libaio1t64 \ + unzip \ + && ln -s /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1 \ + && rm -rf /var/lib/apt/lists/* + +# Download and install Oracle Instant Client for ARM64 +RUN wget https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.arm64-23.26.0.0.0.zip && \ + unzip instantclient-basic-linux.arm64-23.26.0.0.0.zip -d /opt/oracle && \ + rm instantclient-basic-linux.arm64-23.26.0.0.0.zip && \ + echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \ + ldconfig + +# Configure environment variables for Oracle Instant Client and the wallet +ENV LD_LIBRARY_PATH=/opt/oracle/instantclient_23_26:$LD_LIBRARY_PATH +ENV TNS_ADMIN=/app/wallet + +# Update sqlnet.ora to point to the correct wallet directory +RUN sed -i 's|DIRECTORY="?/network/admin"|DIRECTORY="/app/wallet"|g' /app/wallet/sqlnet.ora + ``` + + + + 3. After rebuilding and deploying your custom Docker image, you'll need the following information to create the connection in Infisical: + - `host` - The hostname or IP address of your Oracle Database server. This is required for the Infisical Gateway to function. + - `port` - The port number your Oracle Database server is listening on. This is required for the Infisical Gateway to function. + - `database` - The connection alias for your Oracle Database from your `tnsnames.ora` file. (e.g. `tyk9ovdixe1dvaj8_high`) + - `username` - The user name of the login created in the steps above. + - `password` - The user password of the login created in the steps above. + + Note that when a wallet is being used, any configured SSL settings are ignored. + + + If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. + + + From 3d8941eda6d098f2ac457330de37acb27727702f Mon Sep 17 00:00:00 2001 From: x032205 Date: Sat, 15 Nov 2025 19:25:20 -0500 Subject: [PATCH 02/36] swap to better detection --- .../shared/sql/sql-connection-fns.ts | 19 ++++++------------- .../integrations/app-connections/oracledb.mdx | 4 ++-- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index d97ea45568..0c852bdcd5 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -81,28 +81,21 @@ const getConnectionConfig = ({ } }; -const ORACLE_WALLET_REGEX = /^[a-z0-9]+_[a-z]+$/i; - -const isOracleWalletConnection = (app: AppConnection, database: string): boolean => { - return app === AppConnection.OracleDB && ORACLE_WALLET_REGEX.test(database); +// if TNS_ADMIN is set and the directory exists, we assume it's a wallet connection for OracleDB +const isOracleWalletConnection = (app: AppConnection): boolean => { + return app === AppConnection.OracleDB && !!process.env.TNS_ADMIN && fs.existsSync(process.env.TNS_ADMIN); }; const getOracleWalletKnexClient = ( credentials: Pick ): Knex => { - if (!process.env.TNS_ADMIN || !fs.existsSync(process.env.TNS_ADMIN)) { - throw new BadRequestError({ - message: - "Oracle wallet is not configured correctly. See documentation for instructions: https://infisical.com/docs/integrations/app-connections/oracledb#mtls-wallet" - }); - } if (oracledb.thin) { try { oracledb.initOracleClient(); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); throw new BadRequestError({ - message: `Failed to initialize Oracle client: ${errorMessage}. See documentation for instructions: https://infisical.com/docs/integrations/app-connections/oracledb#mtls-wallet` + message: `Failed to initialize Oracle client: ${errorMessage}. See documentation for instructions: https://infisical.com/docs/integrations/app-connections/oracledb#mutual-tls-wallet` }); } } @@ -122,7 +115,7 @@ export const getSqlConnectionClient = async (appConnection: Pick( const createClient = (proxyPort: number): Knex => { const { database, username, password } = credentials; - if (isOracleWalletConnection(app, database)) { + if (isOracleWalletConnection(app)) { return getOracleWalletKnexClient({ username, password, database }); } diff --git a/docs/integrations/app-connections/oracledb.mdx b/docs/integrations/app-connections/oracledb.mdx index d570af91d9..6e850d37d2 100644 --- a/docs/integrations/app-connections/oracledb.mdx +++ b/docs/integrations/app-connections/oracledb.mdx @@ -45,7 +45,7 @@ Infisical supports connecting to OracleDB using a database user. - + You'll need the following information to create your Oracle Database connection: - `host` - The hostname or IP address of your Oracle Database server - `port` - The port number your Oracle Database server is listening on (default: 1521) @@ -58,7 +58,7 @@ Infisical supports connecting to OracleDB using a database user. If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. - + This configuration can only be done on self-hosted or dedicated instances of Infisical. From 7fbea4d9931c7a964baafa83d2dcd8b8e790ed6e Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 19 Nov 2025 02:02:18 +0800 Subject: [PATCH 03/36] feat: webauthn support --- backend/package-lock.json | 273 ++++++----- backend/package.json | 1 + backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 10 +- .../20250601000000_add-webauthn-support.ts | 30 ++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + .../src/db/schemas/webauthn-credentials.ts | 25 + backend/src/server/routes/index.ts | 10 + backend/src/server/routes/v1/user-router.ts | 214 +++++++++ backend/src/server/routes/v2/mfa-router.ts | 33 ++ .../services/auth-token/auth-token-service.ts | 7 + .../services/auth-token/auth-token-types.ts | 3 +- .../src/services/auth/auth-login-service.ts | 12 + backend/src/services/auth/auth-type.ts | 3 +- .../webauthn/webauthn-credential-dal.ts | 11 + backend/src/services/webauthn/webauthn-fns.ts | 10 + .../src/services/webauthn/webauthn-service.ts | 402 ++++++++++++++++ .../src/services/webauthn/webauthn-types.ts | 35 ++ frontend/package-lock.json | 131 +++-- frontend/package.json | 1 + frontend/src/components/auth/Mfa.tsx | 390 ++++++++++++--- frontend/src/hooks/api/auth/queries.tsx | 31 ++ frontend/src/hooks/api/auth/types.ts | 3 +- frontend/src/hooks/api/webauthn/index.tsx | 18 + frontend/src/hooks/api/webauthn/mutations.tsx | 96 ++++ frontend/src/hooks/api/webauthn/queries.tsx | 22 + frontend/src/hooks/api/webauthn/types.ts | 37 ++ .../OrgSecurityTab/OrgGenericAuthSection.tsx | 3 + .../components/SecuritySection/MFASection.tsx | 453 ++++++++++++++++-- 30 files changed, 1980 insertions(+), 288 deletions(-) create mode 100644 backend/src/db/migrations/20250601000000_add-webauthn-support.ts create mode 100644 backend/src/db/schemas/webauthn-credentials.ts create mode 100644 backend/src/services/webauthn/webauthn-credential-dal.ts create mode 100644 backend/src/services/webauthn/webauthn-fns.ts create mode 100644 backend/src/services/webauthn/webauthn-service.ts create mode 100644 backend/src/services/webauthn/webauthn-types.ts create mode 100644 frontend/src/hooks/api/webauthn/index.tsx create mode 100644 frontend/src/hooks/api/webauthn/mutations.tsx create mode 100644 frontend/src/hooks/api/webauthn/queries.tsx create mode 100644 frontend/src/hooks/api/webauthn/types.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 9f359b2237..f698149c8f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -55,6 +55,7 @@ "@peculiar/x509": "^1.12.1", "@react-email/components": "0.0.36", "@serdnam/pino-cloudwatch-transport": "^1.0.4", + "@simplewebauthn/server": "^13.2.2", "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.2", "@slack/web-api": "^7.8.0", @@ -8622,6 +8623,12 @@ "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.1.0.tgz", "integrity": "sha512-i1BpaNDVLJdRBEKeJWkVO6tYX6DMFBuwMhSuWqLsY4ufeTKGVuV5rBsUhxPayXqnnWHgXUAmWK16H/ykO5Wj4Q==" }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -9467,6 +9474,12 @@ "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==" }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.13.3", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.3.tgz", @@ -11038,147 +11051,160 @@ "@otplib/plugin-thirty-two": "^12.0.1" } }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz", - "integrity": "sha512-Wtk9R7yQxGaIaawHorWKP2OOOm/RZzamOmSWwaqGphIuU6TcKYih0slL6asZlSSZtVoYTrBfrddSOD/jTu9vuQ==", + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "@peculiar/asn1-x509-attr": "^2.3.8", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz", + "integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509-attr": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.8.tgz", - "integrity": "sha512-ZmAaP2hfzgIGdMLcot8gHTykzoI+X/S53x1xoGbTmratETIaAbSWMiPGvZmXRA0SNEIydpMkzYtq4fQBxN1u1w==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz", + "integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz", - "integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", + "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.8.tgz", - "integrity": "sha512-XhdnCVznMmSmgy68B9pVxiZ1XkKoE1BjO4Hv+eUGiY1pM14msLsFZ3N7K46SoITIVZLq92kKkXpGiTfRjlNLyg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz", + "integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.3.8", - "@peculiar/asn1-pkcs8": "^2.3.8", - "@peculiar/asn1-rsa": "^2.3.8", - "@peculiar/asn1-schema": "^2.3.8", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.8.tgz", - "integrity": "sha512-rL8k2x59v8lZiwLRqdMMmOJ30GHt6yuHISFIuuWivWjAJjnxzZBVzMTQ72sknX5MeTSSvGwPmEFk2/N8+UztFQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz", + "integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.8.tgz", - "integrity": "sha512-+nONq5tcK7vm3qdY7ZKoSQGQjhJYMJbwJGbXLFOhmqsFIxEWyQPHyV99+wshOjpOjg0wUSSkEEzX2hx5P6EKeQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz", + "integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.3.8", - "@peculiar/asn1-pfx": "^2.3.8", - "@peculiar/asn1-pkcs8": "^2.3.8", - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "@peculiar/asn1-x509-attr": "^2.3.8", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-pfx": "^2.6.0", + "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509-attr": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz", - "integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", + "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", - "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", "dependencies": { - "asn1js": "^3.0.5", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2" + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz", - "integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", + "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "asn1js": "^3.0.5", - "ipaddr.js": "^2.1.0", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.8.tgz", - "integrity": "sha512-4Z8mSN95MOuX04Aku9BUyMdsMKtVQUqWnr627IheiWnwFoheUhX3R4Y2zh23M7m80r4/WG8MOAckRKc77IRv6g==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz", + "integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" - } - }, - "node_modules/@peculiar/asn1-x509/node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", - "engines": { - "node": ">= 10" + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/x509": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.12.1.tgz", - "integrity": "sha512-2T9t2viNP9m20mky50igPTpn2ByhHl5NlT6wW4Tp4BejQaQ5XDNZgfsabYwYysLXhChABlgtTCpp2gM3JBZRKA==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.0.tgz", + "integrity": "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==", + "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.3.8", - "@peculiar/asn1-csr": "^2.3.8", - "@peculiar/asn1-ecc": "^2.3.8", - "@peculiar/asn1-pkcs9": "^2.3.8", - "@peculiar/asn1-rsa": "^2.3.8", - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "pvtsutils": "^1.3.5", + "@peculiar/asn1-cms": "^2.5.0", + "@peculiar/asn1-csr": "^2.5.0", + "@peculiar/asn1-ecc": "^2.5.0", + "@peculiar/asn1-pkcs9": "^2.5.0", + "@peculiar/asn1-rsa": "^2.5.0", + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", - "tslib": "^2.6.2", - "tsyringe": "^4.8.0" + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" } }, "node_modules/@phc/format": { @@ -13681,6 +13707,25 @@ "split2": "^4.0.0" } }, + "node_modules/@simplewebauthn/server": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz", + "integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/x509": "^1.13.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sindresorhus/slugify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-1.1.0.tgz", @@ -17154,13 +17199,14 @@ } }, "node_modules/asn1js": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", - "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", + "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "license": "BSD-3-Clause", "dependencies": { - "pvtsutils": "^1.3.2", + "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", - "tslib": "^2.4.0" + "tslib": "^2.8.1" }, "engines": { "node": ">=12.0.0" @@ -29231,11 +29277,12 @@ } }, "node_modules/pvtsutils": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", - "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.1" + "tslib": "^2.8.1" } }, "node_modules/pvutils": { @@ -33014,9 +33061,10 @@ } }, "node_modules/tsyringe": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", - "integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", "dependencies": { "tslib": "^1.9.3" }, @@ -33027,7 +33075,8 @@ "node_modules/tsyringe/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" }, "node_modules/ttl-set": { "version": "1.0.0", diff --git a/backend/package.json b/backend/package.json index 2db395e902..dd49d2dde7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -184,6 +184,7 @@ "@peculiar/x509": "^1.12.1", "@react-email/components": "0.0.36", "@serdnam/pino-cloudwatch-transport": "^1.0.4", + "@simplewebauthn/server": "^13.2.2", "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.2", "@slack/web-api": "^7.8.0", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index d511187d07..c1e346339f 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -130,6 +130,7 @@ import { TTotpServiceFactory } from "@app/services/totp/totp-service"; import { TUpgradePathService } from "@app/services/upgrade-path/upgrade-path-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserServiceFactory } from "@app/services/user/user-service"; +import { TWebAuthnServiceFactory } from "@app/services/webauthn/webauthn-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; @@ -321,6 +322,7 @@ declare module "fastify" { externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory; projectTemplate: TProjectTemplateServiceFactory; totp: TTotpServiceFactory; + webAuthn: TWebAuthnServiceFactory; appConnection: TAppConnectionServiceFactory; secretSync: TSecretSyncServiceFactory; kmip: TKmipServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 603df5f6ca..81c4e2994e 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -571,7 +571,10 @@ import { TWebhooksUpdate, TWorkflowIntegrations, TWorkflowIntegrationsInsert, - TWorkflowIntegrationsUpdate + TWorkflowIntegrationsUpdate, + TWebauthnCredentials, + TWebauthnCredentialsInsert, + TWebauthnCredentialsUpdate } from "@app/db/schemas"; import { TAccessApprovalPoliciesEnvironments, @@ -1465,5 +1468,10 @@ declare module "knex/types/tables" { TVaultExternalMigrationConfigsInsert, TVaultExternalMigrationConfigsUpdate >; + [TableName.WebAuthnCredential]: KnexOriginal.CompositeTableType< + TWebauthnCredentials, + TWebauthnCredentialsInsert, + TWebauthnCredentialsUpdate + >; } } diff --git a/backend/src/db/migrations/20250601000000_add-webauthn-support.ts b/backend/src/db/migrations/20250601000000_add-webauthn-support.ts new file mode 100644 index 0000000000..419a6fc4e6 --- /dev/null +++ b/backend/src/db/migrations/20250601000000_add-webauthn-support.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.WebAuthnCredential))) { + await knex.schema.createTable(TableName.WebAuthnCredential, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("userId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.text("credentialId").notNullable(); // base64url encoded credential ID + t.text("publicKey").notNullable(); // base64url encoded public key + t.bigInteger("counter").defaultTo(0).notNullable(); // signature counter for replay protection + t.specificType("transports", "text[]").nullable(); // transport methods + t.string("name").nullable(); // user-friendly name + t.timestamp("lastUsedAt").nullable(); + t.timestamps(true, true, true); + t.unique("credentialId"); // credential IDs must be unique across all users + t.index("userId"); // index for fast lookups by user + }); + + await createOnUpdateTrigger(knex, TableName.WebAuthnCredential); + } +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.WebAuthnCredential); + await knex.schema.dropTableIfExists(TableName.WebAuthnCredential); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 78dcb1980c..859c03607f 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -194,3 +194,4 @@ export * from "./users"; export * from "./vault-external-migration-configs"; export * from "./webhooks"; export * from "./workflow-integrations"; +export * from "./webauthn-credentials"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 444a6bd97d..f73bfaebcd 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -158,6 +158,7 @@ export enum TableName { InternalKms = "internal_kms", InternalKmsKeyVersion = "internal_kms_key_version", TotpConfig = "totp_configs", + WebAuthnCredential = "webauthn_credentials", // @depreciated KmsKeyVersion = "kms_key_versions", WorkflowIntegrations = "workflow_integrations", diff --git a/backend/src/db/schemas/webauthn-credentials.ts b/backend/src/db/schemas/webauthn-credentials.ts new file mode 100644 index 0000000000..d0b8bf64a8 --- /dev/null +++ b/backend/src/db/schemas/webauthn-credentials.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const WebauthnCredentialsSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + credentialId: z.string(), + publicKey: z.string(), + counter: z.coerce.number().default(0), + transports: z.string().array().nullable().optional(), + name: z.string().nullable().optional(), + lastUsedAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TWebauthnCredentials = z.infer; +export type TWebauthnCredentialsInsert = Omit, TImmutableDBKeys>; +export type TWebauthnCredentialsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7c2e3b326c..3a00664532 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -351,6 +351,8 @@ import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-servi import { totpConfigDALFactory } from "@app/services/totp/totp-config-dal"; import { totpServiceFactory } from "@app/services/totp/totp-service"; import { upgradePathServiceFactory } from "@app/services/upgrade-path/upgrade-path-service"; +import { webAuthnCredentialDALFactory } from "@app/services/webauthn/webauthn-credential-dal"; +import { webAuthnServiceFactory } from "@app/services/webauthn/webauthn-service"; import { userDALFactory } from "@app/services/user/user-dal"; import { userServiceFactory } from "@app/services/user/user-service"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -532,6 +534,7 @@ export const registerRoutes = async ( const projectSlackConfigDAL = projectSlackConfigDALFactory(db); const workflowIntegrationDAL = workflowIntegrationDALFactory(db); const totpConfigDAL = totpConfigDALFactory(db); + const webAuthnCredentialDAL = webAuthnCredentialDALFactory(db); const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db); @@ -872,6 +875,12 @@ export const registerRoutes = async ( kmsService }); + const webAuthnService = webAuthnServiceFactory({ + webAuthnCredentialDAL, + userDAL, + tokenService + }); + const loginService = authLoginServiceFactory({ userDAL, smtpService, @@ -2523,6 +2532,7 @@ export const registerRoutes = async ( externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService, projectTemplate: projectTemplateService, totp: totpService, + webAuthn: webAuthnService, appConnection: appConnectionService, secretSync: secretSyncService, kmip: kmipService, diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index 40fbcf5a2b..9be6cbe4d2 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -1,3 +1,4 @@ +import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server"; import { z } from "zod"; import { UsersSchema } from "@app/db/schemas"; @@ -284,4 +285,217 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }); } }); + + // WebAuthn/Passkey Routes + server.route({ + method: "GET", + url: "/me/webauthn", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + credentials: z.array( + z.object({ + id: z.string(), + credentialId: z.string(), + name: z.string().nullable().optional(), + transports: z.array(z.string()).nullable().optional(), + createdAt: z.date(), + lastUsedAt: z.date().nullable().optional() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const credentials = await server.services.webAuthn.getUserWebAuthnCredentials({ + userId: req.permission.id + }); + return { credentials }; + } + }); + + server.route({ + method: "POST", + url: "/me/webauthn/register", + config: { + rateLimit: writeLimit + }, + schema: { + response: { + 200: z.any() // Returns PublicKeyCredentialCreationOptionsJSON from @simplewebauthn/server + } + }, + onRequest: verifyAuth([AuthMode.JWT], { + requireOrg: false + }), + handler: async (req) => { + return server.services.webAuthn.generateRegistrationOptions({ + userId: req.permission.id + }); + } + }); + + server.route({ + method: "POST", + url: "/me/webauthn/register/verify", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + registrationResponse: z + .object({ + id: z.string(), + rawId: z.string(), + response: z + .object({ + clientDataJSON: z.string(), + attestationObject: z.string() + }) + .passthrough(), + clientExtensionResults: z.record(z.unknown()).optional(), + type: z.literal("public-key") + }) + .passthrough(), + name: z.string().optional() + }), + response: { + 200: z.object({ + credentialId: z.string(), + name: z.string().nullable().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT], { + requireOrg: false + }), + handler: async (req) => { + return server.services.webAuthn.verifyRegistrationResponse({ + userId: req.permission.id, + registrationResponse: req.body.registrationResponse as unknown as RegistrationResponseJSON, + name: req.body.name + }); + } + }); + + server.route({ + method: "POST", + url: "/me/webauthn/authenticate", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.any() // Returns PublicKeyCredentialRequestOptionsJSON from @simplewebauthn/server + } + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + return server.services.webAuthn.generateAuthenticationOptions({ + userId: req.permission.id + }); + } + }); + + server.route({ + method: "POST", + url: "/me/webauthn/authenticate/verify", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + authenticationResponse: z + .object({ + id: z.string(), + rawId: z.string(), + response: z + .object({ + clientDataJSON: z.string(), + authenticatorData: z.string(), + signature: z.string() + }) + .passthrough(), + clientExtensionResults: z.record(z.unknown()).optional(), + type: z.literal("public-key") + }) + .passthrough() + }), + response: { + 200: z.object({ + verified: z.boolean(), + credentialId: z.string(), + sessionToken: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + return server.services.webAuthn.verifyAuthenticationResponse({ + userId: req.permission.id, + authenticationResponse: req.body.authenticationResponse as unknown as AuthenticationResponseJSON + }); + } + }); + + server.route({ + method: "PATCH", + url: "/me/webauthn/:credentialId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + credentialId: z.string() + }), + body: z.object({ + name: z.string().optional() + }), + response: { + 200: z.object({ + id: z.string(), + credentialId: z.string(), + name: z.string().nullable().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + return server.services.webAuthn.updateWebAuthnCredential({ + userId: req.permission.id, + credentialId: req.params.credentialId, + name: req.body.name + }); + } + }); + + server.route({ + method: "DELETE", + url: "/me/webauthn/:credentialId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + credentialId: z.string() + }), + response: { + 200: z.object({ + success: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.webAuthn.deleteWebAuthnCredential({ + userId: req.permission.id, + credentialId: req.params.credentialId + }); + return { success: true }; + } + }); }; diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index e8c2cea693..d897b517ae 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -186,4 +186,37 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { return handleMfaVerification(req, res, server, req.body.recoveryCode, MfaMethod.TOTP, true); } }); + + // WebAuthn MFA routes + server.route({ + method: "GET", + url: "/mfa/check/webauthn", + config: { + rateLimit: mfaRateLimit + }, + schema: { + response: { + 200: z.object({ + hasPasskeys: z.boolean() + }) + } + }, + handler: async (req) => { + try { + const credentials = await server.services.webAuthn.getUserWebAuthnCredentials({ + userId: req.mfa.userId + }); + + return { + hasPasskeys: credentials.length > 0 + }; + } catch (error) { + if (error instanceof NotFoundError) { + return { hasPasskeys: false }; + } + + throw error; + } + } + }); }; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index fb7213109e..e63a7a86c3 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -74,6 +74,13 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 259200000); return { token, expiresAt }; } + case TokenType.TOKEN_WEBAUTHN_SESSION: { + // generate random hex token for WebAuthn session + const token = crypto.randomBytes(32).toString("hex"); + const triesLeft = 1; + const expiresAt = new Date(new Date().getTime() + 60000); // 60 seconds + return { token, triesLeft, expiresAt }; + } default: { const token = crypto.randomBytes(16).toString("hex"); const expiresAt = new Date(); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 3255fbbbc6..0c1d715f84 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -8,7 +8,8 @@ export enum TokenType { TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_PASSWORD_RESET = "passwordReset", TOKEN_EMAIL_PASSWORD_SETUP = "passwordSetup", - TOKEN_USER_UNLOCK = "userUnlock" + TOKEN_USER_UNLOCK = "userUnlock", + TOKEN_WEBAUTHN_SESSION = "webauthnSession" } export type TCreateTokenForUserDTO = { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index b9c7597030..b0e0f41e7d 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -757,6 +757,18 @@ export const authLoginServiceFactory = ({ totp: mfaToken }); } + } else if (mfaMethod === MfaMethod.WEBAUTHN) { + if (!mfaToken) { + throw new BadRequestError({ + message: "WebAuthn session token is required" + }); + } + // Validate the one-time WebAuthn session token (passed as mfaToken) + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_WEBAUTHN_SESSION, + userId, + code: mfaToken + }); } } catch (err) { const updatedUser = await processFailedMfaAttempt(userId); diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index ef54ac0be7..e2c8656908 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -93,5 +93,6 @@ export type AuthModeProviderSignUpTokenPayload = { export enum MfaMethod { EMAIL = "email", - TOTP = "totp" + TOTP = "totp", + WEBAUTHN = "webauthn" } diff --git a/backend/src/services/webauthn/webauthn-credential-dal.ts b/backend/src/services/webauthn/webauthn-credential-dal.ts new file mode 100644 index 0000000000..58bb4f7dd4 --- /dev/null +++ b/backend/src/services/webauthn/webauthn-credential-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TWebAuthnCredentialDALFactory = ReturnType; + +export const webAuthnCredentialDALFactory = (db: TDbClient) => { + const webAuthnCredentialDal = ormify(db, TableName.WebAuthnCredential); + + return webAuthnCredentialDal; +}; diff --git a/backend/src/services/webauthn/webauthn-fns.ts b/backend/src/services/webauthn/webauthn-fns.ts new file mode 100644 index 0000000000..f3c364b146 --- /dev/null +++ b/backend/src/services/webauthn/webauthn-fns.ts @@ -0,0 +1,10 @@ +/** + * Helper functions for WebAuthn operations + */ + +/** + * Verify that a credential ID belongs to a user + */ +export const verifyCredentialOwnership = (userId: string, credentialUserId: string): boolean => { + return userId === credentialUserId; +}; diff --git a/backend/src/services/webauthn/webauthn-service.ts b/backend/src/services/webauthn/webauthn-service.ts new file mode 100644 index 0000000000..5966572416 --- /dev/null +++ b/backend/src/services/webauthn/webauthn-service.ts @@ -0,0 +1,402 @@ +import { + AuthenticatorTransportFuture, + generateAuthenticationOptions, + generateRegistrationOptions, + VerifiedAuthenticationResponse, + VerifiedRegistrationResponse, + verifyAuthenticationResponse, + verifyRegistrationResponse +} from "@simplewebauthn/server"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; +import { TokenType } from "../auth-token/auth-token-types"; +import { TUserDALFactory } from "../user/user-dal"; +import { TWebAuthnCredentialDALFactory } from "./webauthn-credential-dal"; +import { verifyCredentialOwnership } from "./webauthn-fns"; +import { + TDeleteWebAuthnCredentialDTO, + TGenerateAuthenticationOptionsDTO, + TGenerateRegistrationOptionsDTO, + TGetUserWebAuthnCredentialsDTO, + TUpdateWebAuthnCredentialDTO, + TVerifyAuthenticationResponseDTO, + TVerifyRegistrationResponseDTO +} from "./webauthn-types"; + +type TWebAuthnServiceFactoryDep = { + userDAL: TUserDALFactory; + webAuthnCredentialDAL: TWebAuthnCredentialDALFactory; + tokenService: TAuthTokenServiceFactory; +}; + +export type TWebAuthnServiceFactory = ReturnType; + +// In-memory challenge storage (for development) +// TODO: In production, use Redis or database with TTL +const challengeStore = new Map(); +const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes + +const storeChallenge = (userId: string, challenge: string) => { + challengeStore.set(userId, { challenge, timestamp: Date.now() }); + + // Cleanup expired challenges + for (const [key, value] of challengeStore.entries()) { + if (Date.now() - value.timestamp > CHALLENGE_TTL) { + challengeStore.delete(key); + } + } +}; + +const getChallenge = (userId: string): string | null => { + const stored = challengeStore.get(userId); + if (!stored) return null; + + // Check if expired + if (Date.now() - stored.timestamp > CHALLENGE_TTL) { + challengeStore.delete(userId); + return null; + } + + return stored.challenge; +}; + +const clearChallenge = (userId: string) => { + challengeStore.delete(userId); +}; + +export const webAuthnServiceFactory = ({ + userDAL, + webAuthnCredentialDAL, + tokenService +}: TWebAuthnServiceFactoryDep) => { + const appCfg = getConfig(); + + // Relying Party (RP) information - extracted from SITE_URL + const RP_NAME = "Infisical"; + const RP_ID = new URL(appCfg.SITE_URL || "http://localhost:8080").hostname; + const ORIGIN = appCfg.SITE_URL || "http://localhost:8080"; + /** + * Generate registration options for a new passkey + * This is the first step in passkey registration + */ + const generateRegistrationOptionsForUser = async ({ userId }: TGenerateRegistrationOptionsDTO) => { + const user = await userDAL.findById(userId); + + if (!user) { + throw new NotFoundError({ + message: "User not found" + }); + } + + // Get existing credentials to exclude them from registration + const existingCredentials = await webAuthnCredentialDAL.find({ userId }); + + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID: RP_ID, + userID: Buffer.from(userId, "utf-8"), + userName: user.email || "", + userDisplayName: user.email || "", + attestationType: "none", + excludeCredentials: existingCredentials.map((cred) => ({ + id: cred.credentialId, + transports: cred.transports as AuthenticatorTransportFuture[] + })), + authenticatorSelection: { + authenticatorAttachment: "platform", + requireResidentKey: true, + residentKey: "required", + userVerification: "required" + } + }); + + // Store challenge for verification + storeChallenge(userId, options.challenge); + + return options; + }; + + /** + * Verify registration response and store the credential + * This is the second step in passkey registration + */ + const verifyRegistrationResponseFromUser = async ({ + userId, + registrationResponse, + name + }: TVerifyRegistrationResponseDTO) => { + const user = await userDAL.findById(userId); + + if (!user) { + throw new NotFoundError({ + message: "User not found" + }); + } + + // Retrieve the stored challenge + const expectedChallenge = getChallenge(userId); + if (!expectedChallenge) { + throw new BadRequestError({ + message: "Challenge not found or expired. Please try registering again." + }); + } + + let verification: VerifiedRegistrationResponse; + try { + verification = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + requireUserVerification: true + }); + } catch (error: unknown) { + clearChallenge(userId); + throw new BadRequestError({ + message: `Registration verification failed: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + + if (!verification.verified || !verification.registrationInfo) { + clearChallenge(userId); + throw new BadRequestError({ + message: "Registration verification failed" + }); + } + + const { credential: registeredCredential } = verification.registrationInfo; + + // Check if credential already exists + const credentialIdBase64 = registeredCredential.id; + const existingCredential = await webAuthnCredentialDAL.findOne({ + credentialId: credentialIdBase64 + }); + + if (existingCredential) { + clearChallenge(userId); + throw new BadRequestError({ + message: "This credential has already been registered" + }); + } + + // Store the credential + const credential = await webAuthnCredentialDAL.create({ + userId, + credentialId: credentialIdBase64, + publicKey: Buffer.from(registeredCredential.publicKey).toString("base64url"), + counter: registeredCredential.counter, + transports: registrationResponse.response.transports || null, + name: name || "Passkey" + }); + + // Clear the challenge + clearChallenge(userId); + + return { + credentialId: credential.credentialId, + name: credential.name + }; + }; + + /** + * Generate authentication options for passkey verification + * This is used during login/2FA + */ + const generateAuthenticationOptionsForUser = async ({ userId }: TGenerateAuthenticationOptionsDTO) => { + const credentials = await webAuthnCredentialDAL.find({ userId }); + + if (credentials.length === 0) { + throw new NotFoundError({ + message: "No passkeys registered for this user" + }); + } + + const options = await generateAuthenticationOptions({ + rpID: RP_ID, + allowCredentials: credentials.map((cred) => ({ + id: cred.credentialId, + transports: cred.transports as AuthenticatorTransportFuture[] + })), + userVerification: "required" + }); + + // Store challenge for verification + storeChallenge(userId, options.challenge); + + return options; + }; + + /** + * Verify authentication response + * This is used during login/2FA to verify the user's passkey + */ + const verifyAuthenticationResponseFromUser = async ({ + userId, + authenticationResponse + }: TVerifyAuthenticationResponseDTO) => { + const credentialIdBase64 = authenticationResponse.id; + + if (!credentialIdBase64) { + throw new BadRequestError({ + message: "Invalid authentication response" + }); + } + + // Find the credential + const credential = await webAuthnCredentialDAL.findOne({ credentialId: credentialIdBase64 }); + + if (!credential) { + throw new NotFoundError({ + message: "Credential not found" + }); + } + + // Verify the credential belongs to the user + if (!verifyCredentialOwnership(userId, credential.userId)) { + throw new ForbiddenRequestError({ + message: "Credential does not belong to this user" + }); + } + + // Retrieve the stored challenge + const expectedChallenge = getChallenge(userId); + if (!expectedChallenge) { + throw new BadRequestError({ + message: "Challenge not found or expired. Please try authenticating again." + }); + } + + let verification: VerifiedAuthenticationResponse; + try { + verification = await verifyAuthenticationResponse({ + response: authenticationResponse, + expectedChallenge, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + credential: { + id: credential.credentialId, + publicKey: Buffer.from(credential.publicKey, "base64url"), + counter: credential.counter + }, + requireUserVerification: true + }); + } catch (error: unknown) { + clearChallenge(userId); + throw new BadRequestError({ + message: `Authentication verification failed: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + + if (!verification.verified) { + clearChallenge(userId); + throw new BadRequestError({ + message: "Authentication verification failed" + }); + } + + // Update last used timestamp and counter + await webAuthnCredentialDAL.updateById(credential.id, { + lastUsedAt: new Date(), + counter: verification.authenticationInfo.newCounter + }); + + // Clear the challenge + clearChallenge(userId); + + // Generate one-time WebAuthn session token with 60-second expiration + const sessionToken = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_WEBAUTHN_SESSION, + userId + }); + + return { + verified: true, + credentialId: credential.credentialId, + sessionToken + }; + }; + + /** + * Get all WebAuthn credentials for a user + */ + const getUserWebAuthnCredentials = async ({ userId }: TGetUserWebAuthnCredentialsDTO) => { + const credentials = await webAuthnCredentialDAL.find({ userId }); + + // Don't return sensitive data like public keys + return credentials.map((cred) => ({ + id: cred.id, + credentialId: cred.credentialId, + name: cred.name, + transports: cred.transports, + createdAt: cred.createdAt, + lastUsedAt: cred.lastUsedAt + })); + }; + + /** + * Delete a WebAuthn credential + */ + const deleteWebAuthnCredential = async ({ userId, credentialId }: TDeleteWebAuthnCredentialDTO) => { + const credential = await webAuthnCredentialDAL.findOne({ credentialId }); + + if (!credential) { + throw new NotFoundError({ + message: "Credential not found" + }); + } + + if (!verifyCredentialOwnership(userId, credential.userId)) { + throw new ForbiddenRequestError({ + message: "Credential does not belong to this user" + }); + } + + await webAuthnCredentialDAL.deleteById(credential.id); + + return { + success: true + }; + }; + + /** + * Update a WebAuthn credential (e.g., rename it) + */ + const updateWebAuthnCredential = async ({ userId, credentialId, name }: TUpdateWebAuthnCredentialDTO) => { + const credential = await webAuthnCredentialDAL.findOne({ credentialId }); + + if (!credential) { + throw new NotFoundError({ + message: "Credential not found" + }); + } + + if (!verifyCredentialOwnership(userId, credential.userId)) { + throw new ForbiddenRequestError({ + message: "Credential does not belong to this user" + }); + } + + const updatedCredential = await webAuthnCredentialDAL.updateById(credential.id, { + name: name || credential.name + }); + + return { + id: updatedCredential.id, + credentialId: updatedCredential.credentialId, + name: updatedCredential.name + }; + }; + + return { + generateRegistrationOptions: generateRegistrationOptionsForUser, + verifyRegistrationResponse: verifyRegistrationResponseFromUser, + generateAuthenticationOptions: generateAuthenticationOptionsForUser, + verifyAuthenticationResponse: verifyAuthenticationResponseFromUser, + getUserWebAuthnCredentials, + deleteWebAuthnCredential, + updateWebAuthnCredential + }; +}; diff --git a/backend/src/services/webauthn/webauthn-types.ts b/backend/src/services/webauthn/webauthn-types.ts new file mode 100644 index 0000000000..aacd4eaa35 --- /dev/null +++ b/backend/src/services/webauthn/webauthn-types.ts @@ -0,0 +1,35 @@ +import { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server"; + +export type TGenerateRegistrationOptionsDTO = { + userId: string; +}; + +export type TVerifyRegistrationResponseDTO = { + userId: string; + registrationResponse: RegistrationResponseJSON; + name?: string; // User-friendly name for the credential +}; + +export type TGenerateAuthenticationOptionsDTO = { + userId: string; +}; + +export type TVerifyAuthenticationResponseDTO = { + userId: string; + authenticationResponse: AuthenticationResponseJSON; +}; + +export type TGetUserWebAuthnCredentialsDTO = { + userId: string; +}; + +export type TDeleteWebAuthnCredentialDTO = { + userId: string; + credentialId: string; +}; + +export type TUpdateWebAuthnCredentialDTO = { + userId: string; + credentialId: string; + name?: string; +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8965ce45e3..ef1b55238f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -47,6 +47,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.3", "@radix-ui/react-tooltip": "^1.1.5", + "@simplewebauthn/browser": "^13.2.2", "@sindresorhus/slugify": "^2.2.1", "@tanstack/react-query": "^5.62.7", "@tanstack/react-router": "^1.95.1", @@ -150,7 +151,8 @@ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -195,7 +197,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -488,7 +489,6 @@ "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.2.tgz", "integrity": "sha512-KjKXlcjKbUz8dKw7PY56F7qlfOFgxTU6tnlJ8YrbDyWkJMIlHa6VRWzCD8RU20zbJUC1hExhOFggZjm6tf1mUw==", "license": "MIT", - "peer": true, "dependencies": { "@ucast/mongo2js": "^1.3.0" }, @@ -547,7 +547,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1325,7 +1324,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.1.tgz", "integrity": "sha512-8dBIHbfsKlCk2jHQ9PoRBg2Z+4TwyE3vZICSnoDlnsHA6SiMlTwfmW6yX0lHsRmWJugkeb92sA0hZdkXJhuz+g==", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.1" }, @@ -2016,7 +2014,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.0.0", @@ -4060,6 +4057,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@simplewebauthn/browser": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz", + "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==", + "license": "MIT" + }, "node_modules/@sindresorhus/slugify": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", @@ -4412,7 +4415,6 @@ "integrity": "sha512-RnO1SaiCFHn666wNz2QfZEFxvmiNRqhzaMXHXxXXKt+MEP7aajlPxUSMIQpKAaJfverpovEYqjBOXDq6dDcaOQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/utils": "^8.13.0", "eslint-visitor-keys": "^4.2.0", @@ -5038,7 +5040,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.95.1.tgz", "integrity": "sha512-P5x4yNhcdkYsCEoYeGZP8Q9Jlxf0WXJa4G/xvbmM905seZc9FqJqvCSRvX3dWTPOXRABhl4g+8DHqfft0c/AvQ==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/history": "1.95.0", "@tanstack/react-store": "^0.7.0", @@ -5250,6 +5251,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5270,6 +5272,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -5280,6 +5283,7 @@ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", @@ -5299,7 +5303,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/user-event": { "version": "14.6.1", @@ -5307,6 +5312,7 @@ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12", "npm": ">=6" @@ -5327,7 +5333,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5380,6 +5387,7 @@ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -5453,7 +5461,8 @@ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/doctrine": { "version": "0.0.9", @@ -5589,7 +5598,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.16.tgz", "integrity": "sha512-oh8AMIC4Y2ciKufU8hnKgs+ufgbA/dhPTACaZPM86AbwX9QwnFtSoPWEeRUj8fge+v6kFt78BXcDhAU1SrrAsw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5601,7 +5609,6 @@ "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -5649,7 +5656,6 @@ "integrity": "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.0", @@ -5690,7 +5696,6 @@ "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", @@ -5962,6 +5967,7 @@ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", @@ -5979,6 +5985,7 @@ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", @@ -6006,6 +6013,7 @@ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -6016,6 +6024,7 @@ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinyrainbow": "^2.0.0" }, @@ -6029,6 +6038,7 @@ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinyspy": "^4.0.3" }, @@ -6042,6 +6052,7 @@ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", @@ -6115,7 +6126,6 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6232,6 +6242,7 @@ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -6280,6 +6291,7 @@ "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -6360,6 +6372,7 @@ "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -6447,6 +6460,7 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" } @@ -6457,6 +6471,7 @@ "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.1" }, @@ -6469,7 +6484,8 @@ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/asynckit": { "version": "0.4.0", @@ -6519,6 +6535,7 @@ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -6620,6 +6637,7 @@ "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "open": "^8.0.4" }, @@ -6856,7 +6874,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -7038,6 +7055,7 @@ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -7108,6 +7126,7 @@ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 16" } @@ -7493,7 +7512,8 @@ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cssesc": { "version": "3.0.0", @@ -7512,8 +7532,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cva": { "name": "class-variance-authority", @@ -7585,7 +7604,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7639,7 +7657,8 @@ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/data-view-buffer": { "version": "1.0.1", @@ -7749,6 +7768,7 @@ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -7783,6 +7803,7 @@ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -7904,7 +7925,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -8114,6 +8136,7 @@ "integrity": "sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -8197,7 +8220,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8239,6 +8261,7 @@ "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.3.4" }, @@ -8275,7 +8298,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8332,7 +8354,6 @@ "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "eslint-config-airbnb-base": "^15.0.0", "object.assign": "^4.1.2", @@ -8355,7 +8376,6 @@ "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "confusing-browser-globals": "^1.0.10", "object.assign": "^4.1.2", @@ -8386,7 +8406,6 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8486,7 +8505,6 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -8616,6 +8634,7 @@ "integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -8649,7 +8668,6 @@ "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8673,6 +8691,7 @@ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -8686,6 +8705,7 @@ "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -8704,6 +8724,7 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -8865,6 +8886,7 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -9895,7 +9917,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" }, @@ -9989,6 +10010,7 @@ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -10265,6 +10287,7 @@ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "is-docker": "cli.js" }, @@ -10586,6 +10609,7 @@ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -10621,6 +10645,7 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", + "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -10632,6 +10657,7 @@ "integrity": "sha512-x4WH0BWmrMmg4oHHl+duwubhrvczGlyuGAZu3nvrf0UXOfPu8IhZObFEr7DE/iv01YgVZrsOiRcqw2srkKEDIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", @@ -10781,6 +10807,7 @@ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", @@ -10815,7 +10842,8 @@ "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", "dev": true, - "license": "CC0-1.0" + "license": "CC0-1.0", + "peer": true }, "node_modules/language-tags": { "version": "1.0.9", @@ -10823,6 +10851,7 @@ "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "language-subtag-registry": "^0.3.20" }, @@ -10855,6 +10884,7 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.102.tgz", "integrity": "sha512-g70kydI0I1sZU0ChO8mBbhw0oUW/8U0GHzygpvEIx8k+jgOpqnTSb/E+70toYVqHxBhrERD21TwD5QcZJQ40ZQ==", "license": "MIT", + "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -11179,7 +11209,8 @@ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lru-cache": { "version": "5.1.1", @@ -11206,6 +11237,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11897,6 +11929,7 @@ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -12245,6 +12278,7 @@ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", @@ -12514,6 +12548,7 @@ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 14.16" } @@ -12665,7 +12700,6 @@ "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12782,6 +12816,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12797,6 +12832,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -12809,7 +12845,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prismjs": { "version": "1.30.0", @@ -13010,7 +13047,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13038,7 +13074,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -13130,7 +13165,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -13181,7 +13215,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.3.tgz", "integrity": "sha512-IK18V6GVbab4TAo1/cz3kqajxbDPGofdF0w7VHdCo0Nt8PrPlOZcuuDq9YYIV1BtjcX78x0XsldbQRQnQXWXmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13432,6 +13465,7 @@ "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", @@ -13449,6 +13483,7 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13465,6 +13500,7 @@ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -13479,6 +13515,7 @@ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "min-indent": "^1.0.0" }, @@ -13693,7 +13730,6 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14204,6 +14240,7 @@ "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -14219,6 +14256,7 @@ "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -14246,6 +14284,7 @@ "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -14466,8 +14505,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.1", @@ -14571,6 +14609,7 @@ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -14581,6 +14620,7 @@ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -14865,7 +14905,6 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15251,7 +15290,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -15669,6 +15707,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -15713,7 +15752,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -15864,7 +15902,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 8009c2118a..0dbd2353f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,6 +56,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.3", "@radix-ui/react-tooltip": "^1.1.5", + "@simplewebauthn/browser": "^13.2.2", "@sindresorhus/slugify": "^2.2.1", "@tanstack/react-query": "^5.62.7", "@tanstack/react-router": "^1.95.1", diff --git a/frontend/src/components/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index 4577f4ece1..363532486b 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; import ReactCodeInput from "react-code-input"; +import { startAuthentication, startRegistration } from "@simplewebauthn/browser"; import { Link, useNavigate } from "@tanstack/react-router"; import { t } from "i18next"; @@ -7,11 +8,23 @@ import Error from "@app/components/basic/Error"; import TotpRegistration from "@app/components/mfa/TotpRegistration"; import { createNotification } from "@app/components/notifications"; import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Tooltip } from "@app/components/v2"; +import { Button, Input, Tooltip } from "@app/components/v2"; import { isInfisicalCloud } from "@app/helpers/platform"; import { useLogoutUser, useSendMfaToken } from "@app/hooks/api"; -import { checkUserTotpMfa, verifyMfaToken, verifyRecoveryCode } from "@app/hooks/api/auth/queries"; +import { + checkUserTotpMfa, + checkUserWebAuthnMfa, + verifyMfaToken, + verifyRecoveryCode +} from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; +import { getMfaTempToken } from "@app/hooks/api/reactQuery"; +import { + useGenerateAuthenticationOptions, + useGenerateRegistrationOptions, + useVerifyAuthentication, + useVerifyRegistration +} from "@app/hooks/api/webauthn"; // The style for the verification code input const codeInputProps = { @@ -68,9 +81,17 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop const [isLoadingResend, setIsLoadingResend] = useState(false); const [triesLeft, setTriesLeft] = useState(undefined); const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false); + const [shouldShowWebAuthnRegistration, setShouldShowWebAuthnRegistration] = useState(false); + const [credentialName, setCredentialName] = useState(""); + const [isRegisteringPasskey, setIsRegisteringPasskey] = useState(false); const logout = useLogoutUser(true); + const { mutateAsync: generateWebAuthnAuthenticationOptions } = useGenerateAuthenticationOptions(); + const { mutateAsync: verifyWebAuthnAuthentication } = useVerifyAuthentication(); + const sendMfaToken = useSendMfaToken(); + const generateRegistrationOptions = useGenerateRegistrationOptions(); + const verifyRegistration = useVerifyRegistration(); useEffect(() => { if (method === MfaMethod.TOTP) { @@ -80,8 +101,14 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop setShouldShowTotpRegistration(true); } }); + } else if (method === MfaMethod.WEBAUTHN) { + checkUserWebAuthnMfa().then((hasPasskeys) => { + if (!hasPasskeys) { + setShouldShowWebAuthnRegistration(true); + } + }); } - }, []); + }, [method]); const getExpectedCodeLength = () => { if (method === MfaMethod.EMAIL) return 6; @@ -153,6 +180,156 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop } }; + const handleWebAuthnVerification = async () => { + setIsLoading(true); + const mfaToken = getMfaTempToken(); + + try { + SecurityClient.setMfaToken(""); + + // Get authentication options from server + const options = await generateWebAuthnAuthenticationOptions(); + + // Prompt user to authenticate with their passkey + const authenticationResponse = await startAuthentication({ optionsJSON: options }); + + // Verify with server + const result = await verifyWebAuthnAuthentication({ authenticationResponse }); + + // Use the sessionToken to verify MFA + if (result.sessionToken) { + SecurityClient.setMfaToken(mfaToken); + const mfaResult = await verifyMfaToken({ + email, + mfaCode: result.sessionToken, + mfaMethod: MfaMethod.WEBAUTHN + }); + + SecurityClient.setMfaToken(""); + SecurityClient.setToken(mfaResult.token); + + await successCallback(); + if (closeMfa) { + closeMfa(); + } + } + } catch (error: any) { + console.error("WebAuthn verification failed:", error); + + let errorMessage = "Failed to verify passkey"; + if (error.name === "NotAllowedError") { + errorMessage = "Passkey verification was cancelled or timed out"; + } else if (error.message) { + errorMessage = error.message; + } + + SecurityClient.setMfaToken(mfaToken); + + createNotification({ + text: errorMessage, + type: "error" + }); + + if (typeof triesLeft === "number") { + const newTriesLeft = triesLeft - 1; + setTriesLeft(newTriesLeft); + if (newTriesLeft <= 0) { + createNotification({ + text: "User is temporary locked due to multiple failed login attempts. Try again later.", + type: "error" + }); + SecurityClient.setMfaToken(""); + SecurityClient.setToken(""); + SecurityClient.setSignupToken(""); + await logout.mutateAsync(); + navigate({ to: "/login" }); + return; + } + } else { + setTriesLeft(2); + } + } finally { + setIsLoading(false); + } + }; + + const handleRegisterPasskey = async () => { + try { + setIsRegisteringPasskey(true); + + // Check if WebAuthn is supported + if ( + !window.PublicKeyCredential || + !window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable + ) { + createNotification({ + text: "WebAuthn is not supported on this browser", + type: "error" + }); + return; + } + + // Check if platform authenticator is available + const available = + await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + if (!available) { + createNotification({ + text: "No passkey-compatible authenticator found on this device", + type: "error" + }); + return; + } + + // Temporarily clear MFA token so the regular access token is used for registration endpoints + const mfaToken = getMfaTempToken(); + SecurityClient.setMfaToken(""); + + try { + // Generate registration options from server (using regular user endpoint) + const options = await generateRegistrationOptions.mutateAsync(); + const registrationResponse = await startRegistration({ optionsJSON: options }); + + // Verify registration with server (using regular user endpoint) + await verifyRegistration.mutateAsync({ + registrationResponse, + name: credentialName || "Passkey" + }); + + createNotification({ + text: "Successfully registered passkey", + type: "success" + }); + + setShouldShowWebAuthnRegistration(false); + await successCallback(); + if (closeMfa) { + closeMfa(); + } + } finally { + // Restore MFA token + SecurityClient.setMfaToken(mfaToken); + } + } catch (error: any) { + console.error("Failed to register passkey:", error); + + let errorMessage = "Failed to register passkey"; + if (error.name === "NotAllowedError") { + errorMessage = "Passkey registration was cancelled or timed out"; + } else if (error.name === "InvalidStateError") { + errorMessage = "This passkey has already been registered"; + } else if (error.message) { + errorMessage = error.message; + } + + createNotification({ + text: errorMessage, + type: "error" + }); + } finally { + setIsRegisteringPasskey(false); + } + }; + if (shouldShowTotpRegistration) { return ( <> @@ -172,6 +349,35 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop ); } + if (shouldShowWebAuthnRegistration) { + return ( + <> +
+ Your organization requires passkey authentication to be configured. +
+
+
+
+ 1. Click the button below to register your passkey. You'll be prompted to use + your device's biometric authentication (Touch ID, Face ID, Windows Hello, etc.). +
+
2. Optionally, give your passkey a name to identify it later
+
+ setCredentialName(e.target.value)} + value={credentialName} + placeholder="Passkey name (optional)" + /> + +
+
+
+ + ); + } + return (
{!hideLogo && ( @@ -197,83 +403,113 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop

)} -
-
- {method === MfaMethod.EMAIL && ( -
- -
- )} - {method === MfaMethod.TOTP && ( -
- -
- )} + {method === MfaMethod.WEBAUTHN && ( +
+

Passkey Authentication

+

+ Use your registered passkey to complete two-factor authentication +

-
- {method === MfaMethod.EMAIL && ( -
- -
+ )} + {method === MfaMethod.WEBAUTHN ? ( + <> + {typeof triesLeft === "number" && ( + )} - {method === MfaMethod.TOTP && ( -
- -
+
+ +
+ + ) : ( + +
+ {method === MfaMethod.EMAIL && ( +
+ +
+ )} + {method === MfaMethod.TOTP && ( +
+ +
+ )} +
+
+ {method === MfaMethod.EMAIL && ( +
+ +
+ )} + {method === MfaMethod.TOTP && ( +
+ +
+ )} +
+ {typeof triesLeft === "number" && ( + )} -
- {typeof triesLeft === "number" && ( - - )} -
- -
- +
+ +
+ + )} {method === MfaMethod.TOTP && (
+ +
+
+ + + ); + + const renamePasskeyModal = ( + { + if (!isOpen) { + handlePopUpClose("renameWebAuthnCredential"); + setCredentialName(""); + setSelectedCredentialId(""); + } + }} + > + +
+ + setCredentialName(e.target.value)} + placeholder="e.g., My MacBook Pro" + /> + +
+ + +
+
+
+
+ ); + return ( <>
{ > Email Mobile Authenticator + Passkey (WebAuthn) @@ -411,57 +650,162 @@ export const MFASection = () => { )} - - {user?.isMfaEnabled && totpConfiguration?.isVerified && ( -
-

- Mobile Authenticator Management -

- -
-
- - - -
- - {shouldShowRecoveryCodes && ( -
-
- {totpConfiguration.recoveryCodes.map((code, index) => ( -
- {index + 1}. - {code} -
- ))} -
-
- )} -
-
- )} )}
+ {/* Management Sections - Separate from configuration form */} + {user && ( +
+ {/* Mobile Authenticator Management - Show if configured, regardless of active method */} + {totpConfiguration?.isVerified && ( +
+

+ Mobile Authenticator Management +

+

+ Manage your mobile authenticator configuration and recovery codes. +

+ +
+
+ + + +
+ + {shouldShowRecoveryCodes && ( +
+
+ {totpConfiguration.recoveryCodes.map((code, index) => ( +
+ {index + 1}. + {code} +
+ ))} +
+
+ )} +
+
+ )} + + {/* Passkey Management - Always show */} +
+
+
+

Passkey Management

+

+ Manage your passkeys. Passkeys can be used for two-factor authentication. +

+
+ +
+ + {(() => { + if (isWebAuthnCredentialsLoading) { + return ; + } + if (webAuthnCredentials && webAuthnCredentials.length > 0) { + return ( +
+ {webAuthnCredentials.map((credential) => ( +
+
+
+ + {credential.name || "Unnamed Passkey"} + + {credential.transports && credential.transports.length > 0 && ( + + ({credential.transports.join(", ")}) + + )} +
+
+ Added {new Date(credential.createdAt).toLocaleDateString()} + {credential.lastUsedAt && ( + <> + {" "} + · Last used {new Date(credential.lastUsedAt).toLocaleDateString()} + + )} +
+
+
+ + +
+
+ ))} +
+ ); + } + return ( +
+

+ No passkeys registered yet. Add a passkey to use it for two-factor + authentication. +

+
+ ); + })()} +
+
+ )} + handlePopUpToggle("setUpEmail", isOpen)} @@ -481,6 +825,19 @@ export const MFASection = () => { recoveryCodes={totpRegistration?.recoveryCodes || []} onDownloadComplete={() => handlePopUpClose("downloadRecoveryCodes")} /> + + {registerPasskeyModal} + {renamePasskeyModal} + + {/* Delete Passkey Modal */} + handlePopUpToggle("deleteWebAuthnCredential", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleDeleteWebAuthnCredential} + /> ); }; From c0a556240893423e46721f0afee1bce105ad0429 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 19 Nov 2025 03:11:19 +0800 Subject: [PATCH 04/36] misc: minor connections --- backend/src/@types/fastify.d.ts | 2 +- ...bauthn-support.ts => 20251118190904_add-webauthn-support.ts} | 0 backend/src/db/schemas/index.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename backend/src/db/migrations/{20250601000000_add-webauthn-support.ts => 20251118190904_add-webauthn-support.ts} (100%) diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c1e346339f..50b77bc4ee 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -130,8 +130,8 @@ import { TTotpServiceFactory } from "@app/services/totp/totp-service"; import { TUpgradePathService } from "@app/services/upgrade-path/upgrade-path-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserServiceFactory } from "@app/services/user/user-service"; -import { TWebAuthnServiceFactory } from "@app/services/webauthn/webauthn-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; +import { TWebAuthnServiceFactory } from "@app/services/webauthn/webauthn-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; diff --git a/backend/src/db/migrations/20250601000000_add-webauthn-support.ts b/backend/src/db/migrations/20251118190904_add-webauthn-support.ts similarity index 100% rename from backend/src/db/migrations/20250601000000_add-webauthn-support.ts rename to backend/src/db/migrations/20251118190904_add-webauthn-support.ts diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 859c03607f..450624a8f6 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -192,6 +192,6 @@ export * from "./user-encryption-keys"; export * from "./user-group-membership"; export * from "./users"; export * from "./vault-external-migration-configs"; +export * from "./webauthn-credentials"; export * from "./webhooks"; export * from "./workflow-integrations"; -export * from "./webauthn-credentials"; From c69f070c1c347d577deae30aeb6d683e1fc563a8 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 19 Nov 2025 04:01:22 +0800 Subject: [PATCH 05/36] misc: updated qc clear to invalidate --- frontend/src/hooks/api/users/queries.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 7777712aee..88c52ed058 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -338,7 +338,7 @@ export const clearSession = (keepQueryClient?: boolean) => { sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN); if (!keepQueryClient) { - qc.clear(); // Clear React Query cache + qc.invalidateQueries(); } }; From 019a57151fc702d68d7f8d1aa67f14765a603b0d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 19 Nov 2025 19:54:38 +0800 Subject: [PATCH 06/36] feat: add pam session gating --- backend/src/@types/fastify.d.ts | 2 + .../pam-account-routers/pam-account-router.ts | 1 + .../pam-account/pam-account-service.ts | 141 +++++++- .../services/pam-account/pam-account-types.ts | 1 + backend/src/keystore/keystore.ts | 8 +- backend/src/lib/errors/index.ts | 15 +- backend/src/server/plugins/error-handler.ts | 10 +- backend/src/server/routes/index.ts | 22 +- backend/src/server/routes/sanitizedSchemas.ts | 3 +- backend/src/server/routes/v2/index.ts | 2 + .../server/routes/v2/mfa-session-router.ts | 72 ++++ .../mfa-session/mfa-session-service.ts | 142 ++++++++ .../services/mfa-session/mfa-session-types.ts | 32 ++ .../src/services/webauthn/webauthn-service.ts | 75 ++--- frontend/src/helpers/mfaSession.ts | 16 + frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/mfaSession/index.tsx | 1 + frontend/src/hooks/api/mfaSession/queries.tsx | 61 ++++ .../pages/MfaSessionPage/MfaSessionPage.tsx | 311 ++++++++++++++++++ frontend/src/pages/MfaSessionPage/index.tsx | 1 + frontend/src/pages/MfaSessionPage/route.tsx | 7 + frontend/src/routeTree.gen.ts | 27 ++ frontend/src/routes.ts | 1 + 23 files changed, 889 insertions(+), 63 deletions(-) create mode 100644 backend/src/server/routes/v2/mfa-session-router.ts create mode 100644 backend/src/services/mfa-session/mfa-session-service.ts create mode 100644 backend/src/services/mfa-session/mfa-session-types.ts create mode 100644 frontend/src/helpers/mfaSession.ts create mode 100644 frontend/src/hooks/api/mfaSession/index.tsx create mode 100644 frontend/src/hooks/api/mfaSession/queries.tsx create mode 100644 frontend/src/pages/MfaSessionPage/MfaSessionPage.tsx create mode 100644 frontend/src/pages/MfaSessionPage/index.tsx create mode 100644 frontend/src/pages/MfaSessionPage/route.tsx diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 50b77bc4ee..a9900c12fb 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -96,6 +96,7 @@ import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/i import { TMembershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service"; import { TMembershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service"; import { TMembershipUserServiceFactory } from "@app/services/membership-user/membership-user-service"; +import { TMfaSessionServiceFactory } from "@app/services/mfa-session/mfa-session-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; @@ -349,6 +350,7 @@ declare module "fastify" { pamResource: TPamResourceServiceFactory; pamAccount: TPamAccountServiceFactory; pamSession: TPamSessionServiceFactory; + mfaSession: TMfaSessionServiceFactory; upgradePath: TUpgradePathService; membershipUser: TMembershipUserServiceFactory; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index c28770be4e..1d545578e6 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -68,6 +68,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { description: "Access PAM account", body: z.object({ accountId: z.string().uuid(), + mfaSessionId: z.string().optional(), duration: z .string() .min(1) diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index b6f2bbae92..25b8d6c073 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -9,14 +9,21 @@ import { ProjectPermissionPamAccountActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore"; +import { crypto } from "@app/lib/crypto"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; -import { ActorType } from "@app/services/auth/auth-type"; +import { ActorType, MfaMethod } from "@app/services/auth/auth-type"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { MfaSessionStatus, TMfaSession } from "@app/services/mfa-session/mfa-session-types"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; @@ -42,6 +49,7 @@ type TPamAccountServiceFactoryDep = { pamAccountDAL: TPamAccountDALFactory; pamFolderDAL: TPamFolderDALFactory; projectDAL: TProjectDALFactory; + orgDAL: TOrgDALFactory; permissionService: Pick; licenseService: Pick; kmsService: Pick; @@ -51,6 +59,9 @@ type TPamAccountServiceFactoryDep = { >; userDAL: TUserDALFactory; auditLogService: Pick; + keyStore: Pick; + tokenService: Pick; + smtpService: Pick; }; export type TPamAccountServiceFactory = ReturnType; @@ -62,13 +73,53 @@ export const pamAccountServiceFactory = ({ pamAccountDAL, pamFolderDAL, projectDAL, + orgDAL, userDAL, permissionService, licenseService, kmsService, gatewayV2Service, - auditLogService + auditLogService, + keyStore, + tokenService, + smtpService }: TPamAccountServiceFactoryDep) => { + const createMfaSession = async (userId: string, accountId: string, mfaMethod: MfaMethod): Promise => { + const mfaSessionId = crypto.randomBytes(32).toString("hex"); + const mfaSession: TMfaSession = { + sessionId: mfaSessionId, + userId, + resourceId: accountId, + status: MfaSessionStatus.PENDING, + mfaMethod + }; + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.MfaSession(mfaSessionId), + KeyStoreTtls.MfaSessionInSeconds, + JSON.stringify(mfaSession) + ); + + return mfaSessionId; + }; + + // Helper function to send MFA code via email + const sendMfaCode = async (userId: string, email: string) => { + const code = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_MFA, + userId + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailMfa, + subjectLine: "Infisical PAM Access MFA code", + recipients: [email], + substitutions: { + code + } + }); + }; + const create = async ( { credentials, @@ -400,7 +451,7 @@ export const pamAccountServiceFactory = ({ }; const access = async ( - { accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO, + { accountId, actorEmail, actorIp, actorName, actorUserAgent, duration, mfaSessionId }: TAccessAccountDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -416,6 +467,9 @@ export const pamAccountServiceFactory = ({ const resource = await pamResourceDAL.findById(account.resourceId); if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + const project = await projectDAL.findById(account.projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${account.projectId}' not found` }); + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorAuthMethod: actor.authMethod, @@ -440,6 +494,82 @@ export const pamAccountServiceFactory = ({ }) ); + // MFA is required for all PAM account access (as per user requirement) + // Get user to check MFA configuration + const actorUser = await userDAL.findById(actor.id); + if (!actorUser) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` }); + + // If no mfaSessionId is provided, create a new MFA session + if (!mfaSessionId) { + // Get organization to check if MFA is enforced at org level + const org = await orgDAL.findOrgById(project.orgId); + if (!org) throw new NotFoundError({ message: `Organization with ID '${project.orgId}' not found` }); + + // Determine which MFA method to use + // Priority: org-enforced > user-selected > email as fallback + const orgMfaMethod = org.enforceMfa + ? ((org.selectedMfaMethod as MfaMethod | null) ?? MfaMethod.EMAIL) + : undefined; + const userMfaMethod = actorUser.isMfaEnabled + ? ((actorUser.selectedMfaMethod as MfaMethod | null) ?? MfaMethod.EMAIL) + : undefined; + const mfaMethod = (orgMfaMethod ?? userMfaMethod ?? MfaMethod.EMAIL) as MfaMethod; + + // Create MFA session + const newMfaSessionId = await createMfaSession(actorUser.id, accountId, mfaMethod); + + // If MFA method is email, send the code immediately + if (mfaMethod === MfaMethod.EMAIL && actorUser.email) { + await sendMfaCode(actorUser.id, actorUser.email); + } + + // Throw an error with the mfaSessionId to signal that MFA is required + throw new BadRequestError({ + message: "MFA verification required to access PAM account", + name: "SESSION_MFA_REQUIRED", + details: { + mfaSessionId: newMfaSessionId, + mfaMethod + } + }); + } + + // If mfaSessionId is provided, verify it + const mfaSessionKey = KeyStorePrefixes.MfaSession(mfaSessionId); + const mfaSessionData = await keyStore.getItem(mfaSessionKey); + + if (!mfaSessionData) { + throw new BadRequestError({ + message: "MFA session not found or expired" + }); + } + + const mfaSession = JSON.parse(mfaSessionData) as TMfaSession; + + // Verify the session belongs to the current user + if (mfaSession.userId !== actor.id) { + throw new ForbiddenRequestError({ + message: "MFA session does not belong to current user" + }); + } + + // Verify the session is for the same account + if (mfaSession.resourceId !== accountId) { + throw new BadRequestError({ + message: "MFA session is for a different resource" + }); + } + + // Check if MFA session is active + if (mfaSession.status !== MfaSessionStatus.ACTIVE) { + throw new BadRequestError({ + message: "MFA session is not active. Please complete MFA verification first." + }); + } + + // MFA verified successfully, delete the session and proceed with access + await keyStore.deleteItem(mfaSessionKey); + const session = await pamSessionDAL.create({ accountName: account.name, actorEmail, @@ -461,9 +591,6 @@ export const pamAccountServiceFactory = ({ kmsService ); - const user = await userDAL.findById(actor.id); - if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` }); - const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ gatewayId, duration, @@ -474,7 +601,7 @@ export const pamAccountServiceFactory = ({ actorMetadata: { id: actor.id, type: actor.type, - name: user.email ?? "" + name: actorUser.email ?? "" } }); diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts index 4bbccc6faf..78a45d90c6 100644 --- a/backend/src/ee/services/pam-account/pam-account-types.ts +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -17,4 +17,5 @@ export type TAccessAccountDTO = { actorName: string; actorUserAgent: string; duration: number; + mfaSessionId?: string; }; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 204952a92d..f374a1469d 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -79,7 +79,9 @@ export const KeyStorePrefixes = { GroupMemberProjectPermissionPattern: (projectId: string, groupId: string) => `group-member-project-permission:${projectId}:${groupId}:*` as const, - PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const + PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const, + MfaSession: (mfaSessionId: string) => `mfa-session:${mfaSessionId}` as const, + WebAuthnChallenge: (userId: string) => `webauthn-challenge:${userId}` as const }; export const KeyStoreTtls = { @@ -87,7 +89,9 @@ export const KeyStoreTtls = { SetSecretSyncLastRunTimestampInSeconds: 60, AccessTokenStatusUpdateInSeconds: 120, ProjectPermissionCacheInSeconds: 300, // 5 minutes - ProjectPermissionDalVersionTtl: "15m" // Project permission DAL version TTL + ProjectPermissionDalVersionTtl: "15m", // Project permission DAL version TTL + MfaSessionInSeconds: 300, // 5 minutes + WebAuthnChallengeInSeconds: 300 // 5 minutes }; type TDeleteItems = { diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index dab9d32780..426f446e58 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -90,10 +90,23 @@ export class BadRequestError extends Error { error: unknown; - constructor({ name, error, message }: { message?: string; name?: string; error?: unknown }) { + details?: unknown; + + constructor({ + name, + error, + message, + details + }: { + message?: string; + name?: string; + error?: unknown; + details?: unknown; + }) { super(message ?? "The request is invalid"); this.name = name || "BadRequest"; this.error = error; + this.details = details; } } diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index e703df5ef5..d1bae0316a 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -133,9 +133,13 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider } if (error instanceof BadRequestError) { - void res - .status(HttpStatusCodes.BadRequest) - .send({ reqId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name }); + void res.status(HttpStatusCodes.BadRequest).send({ + reqId: req.id, + statusCode: HttpStatusCodes.BadRequest, + message: error.message, + error: error.name, + details: error.details + }); } else if (error instanceof NotFoundError) { void res .status(HttpStatusCodes.NotFound) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3a00664532..f822823618 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -258,6 +258,7 @@ import { membershipIdentityDALFactory } from "@app/services/membership-identity/ import { membershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service"; import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal"; import { membershipUserServiceFactory } from "@app/services/membership-user/membership-user-service"; +import { mfaSessionServiceFactory } from "@app/services/mfa-session/mfa-session-service"; import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; @@ -351,12 +352,12 @@ import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-servi import { totpConfigDALFactory } from "@app/services/totp/totp-config-dal"; import { totpServiceFactory } from "@app/services/totp/totp-service"; import { upgradePathServiceFactory } from "@app/services/upgrade-path/upgrade-path-service"; -import { webAuthnCredentialDALFactory } from "@app/services/webauthn/webauthn-credential-dal"; -import { webAuthnServiceFactory } from "@app/services/webauthn/webauthn-service"; import { userDALFactory } from "@app/services/user/user-dal"; import { userServiceFactory } from "@app/services/user/user-service"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { userEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; +import { webAuthnCredentialDALFactory } from "@app/services/webauthn/webauthn-credential-dal"; +import { webAuthnServiceFactory } from "@app/services/webauthn/webauthn-service"; import { webhookDALFactory } from "@app/services/webhook/webhook-dal"; import { webhookServiceFactory } from "@app/services/webhook/webhook-service"; import { workflowIntegrationDALFactory } from "@app/services/workflow-integration/workflow-integration-dal"; @@ -878,7 +879,8 @@ export const registerRoutes = async ( const webAuthnService = webAuthnServiceFactory({ webAuthnCredentialDAL, userDAL, - tokenService + tokenService, + keyStore }); const loginService = authLoginServiceFactory({ @@ -2347,6 +2349,13 @@ export const registerRoutes = async ( gatewayV2Service }); + const mfaSessionService = mfaSessionServiceFactory({ + keyStore, + tokenService, + smtpService, + totpService + }); + const pamAccountService = pamAccountServiceFactory({ pamAccountDAL, gatewayV2Service, @@ -2357,8 +2366,12 @@ export const registerRoutes = async ( pamSessionDAL, permissionService, projectDAL, + orgDAL, userDAL, - auditLogService + auditLogService, + keyStore, + tokenService, + smtpService }); const pamAccountRotation = pamAccountRotationServiceFactory({ @@ -2554,6 +2567,7 @@ export const registerRoutes = async ( pamResource: pamResourceService, pamAccount: pamAccountService, pamSession: pamSessionService, + mfaSession: mfaSessionService, upgradePath: upgradePathService, membershipUser: membershipUserService, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 17cfcb5ce4..a4dce3ad74 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -37,7 +37,8 @@ export const DefaultResponseErrorsSchema = { reqId: z.string(), statusCode: z.literal(400), message: z.string(), - error: z.string() + error: z.string(), + details: z.any().optional() }), 404: z.object({ reqId: z.string(), diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index d3d91a3baa..80c7672393 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -6,6 +6,7 @@ import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project- import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerMfaRouter } from "./mfa-router"; +import { registerMfaSessionRouter } from "./mfa-session-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerPkiAlertRouter } from "./pki-alert-router"; @@ -17,6 +18,7 @@ import { registerUserRouter } from "./user-router"; export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerMfaRouter, { prefix: "/auth" }); + await server.register(registerMfaSessionRouter, { prefix: "/mfa-sessions" }); await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); await server.register(registerPasswordRouter, { prefix: "/password" }); diff --git a/backend/src/server/routes/v2/mfa-session-router.ts b/backend/src/server/routes/v2/mfa-session-router.ts new file mode 100644 index 0000000000..bf3fcef964 --- /dev/null +++ b/backend/src/server/routes/v2/mfa-session-router.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode, MfaMethod } from "@app/services/auth/auth-type"; +import { MfaSessionStatus } from "@app/services/mfa-session/mfa-session-types"; + +export const registerMfaSessionRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:mfaSessionId/verify", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Verify MFA session", + params: z.object({ + mfaSessionId: z.string().trim() + }), + body: z.object({ + mfaToken: z.string().trim(), + mfaMethod: z.nativeEnum(MfaMethod) + }), + response: { + 200: z.object({ + success: z.boolean(), + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.mfaSession.verifyMfaSession({ + mfaSessionId: req.params.mfaSessionId, + userId: req.permission.id, + mfaToken: req.body.mfaToken, + mfaMethod: req.body.mfaMethod + }); + + return result; + } + }); + + server.route({ + method: "GET", + url: "/:mfaSessionId/status", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get MFA session status", + params: z.object({ + mfaSessionId: z.string().trim() + }), + response: { + 200: z.object({ + status: z.nativeEnum(MfaSessionStatus), + mfaMethod: z.nativeEnum(MfaMethod) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.mfaSession.getMfaSessionStatus({ + mfaSessionId: req.params.mfaSessionId, + userId: req.permission.id + }); + + return result; + } + }); +}; diff --git a/backend/src/services/mfa-session/mfa-session-service.ts b/backend/src/services/mfa-session/mfa-session-service.ts new file mode 100644 index 0000000000..c255b88a84 --- /dev/null +++ b/backend/src/services/mfa-session/mfa-session-service.ts @@ -0,0 +1,142 @@ +import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { MfaMethod } from "@app/services/auth/auth-type"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TTotpServiceFactory } from "@app/services/totp/totp-service"; + +import { MfaSessionStatus, TGetMfaSessionStatusDTO, TMfaSession, TVerifyMfaSessionDTO } from "./mfa-session-types"; + +type TMfaSessionServiceFactoryDep = { + keyStore: Pick; + tokenService: Pick; + smtpService: Pick; + totpService: Pick; +}; + +export type TMfaSessionServiceFactory = ReturnType; + +export const mfaSessionServiceFactory = ({ + keyStore, + tokenService, + smtpService, + totpService +}: TMfaSessionServiceFactoryDep) => { + // Helper function to get MFA session from Redis + const getMfaSession = async (mfaSessionId: string): Promise => { + const mfaSessionKey = KeyStorePrefixes.MfaSession(mfaSessionId); + const mfaSessionData = await keyStore.getItem(mfaSessionKey); + + if (!mfaSessionData) { + return null; + } + + return JSON.parse(mfaSessionData) as TMfaSession; + }; + + // Helper function to update MFA session in Redis + const updateMfaSession = async (mfaSession: TMfaSession, ttlSeconds: number): Promise => { + const mfaSessionKey = KeyStorePrefixes.MfaSession(mfaSession.sessionId); + await keyStore.setItemWithExpiry(mfaSessionKey, ttlSeconds, JSON.stringify(mfaSession)); + }; + + // Helper function to send MFA code via email + const sendMfaCode = async (userId: string, email: string) => { + const code = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_MFA, + userId + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailMfa, + subjectLine: "Infisical MFA code", + recipients: [email], + substitutions: { + code + } + }); + }; + + const verifyMfaSession = async ({ mfaSessionId, userId, mfaToken, mfaMethod }: TVerifyMfaSessionDTO) => { + const mfaSession = await getMfaSession(mfaSessionId); + + if (!mfaSession) { + throw new BadRequestError({ + message: "MFA session not found or expired" + }); + } + + // Verify the session belongs to the current user + if (mfaSession.userId !== userId) { + throw new ForbiddenRequestError({ + message: "MFA session does not belong to current user" + }); + } + + try { + if (mfaMethod === MfaMethod.EMAIL) { + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_MFA, + userId, + code: mfaToken + }); + } else if (mfaMethod === MfaMethod.TOTP) { + if (mfaToken.length !== 6) { + throw new BadRequestError({ + message: "Please use a valid TOTP code." + }); + } + await totpService.verifyUserTotp({ + userId, + totp: mfaToken + }); + } else if (mfaMethod === MfaMethod.WEBAUTHN) { + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_WEBAUTHN_SESSION, + userId, + code: mfaToken + }); + } + } catch (error) { + throw new BadRequestError({ + message: "Invalid MFA code" + }); + } + + mfaSession.status = MfaSessionStatus.ACTIVE; + await updateMfaSession(mfaSession, KeyStoreTtls.MfaSessionInSeconds); + + return { + success: true, + message: "MFA verification successful" + }; + }; + + const getMfaSessionStatus = async ({ mfaSessionId, userId }: TGetMfaSessionStatusDTO) => { + const mfaSession = await getMfaSession(mfaSessionId); + + if (!mfaSession) { + throw new NotFoundError({ + message: "MFA session not found or expired" + }); + } + + if (mfaSession.userId !== userId) { + throw new ForbiddenRequestError({ + message: "MFA session does not belong to current user" + }); + } + + return { + status: mfaSession.status, + mfaMethod: mfaSession.mfaMethod + }; + }; + + return { + verifyMfaSession, + getMfaSessionStatus, + sendMfaCode + }; +}; diff --git a/backend/src/services/mfa-session/mfa-session-types.ts b/backend/src/services/mfa-session/mfa-session-types.ts new file mode 100644 index 0000000000..6dcb45ee90 --- /dev/null +++ b/backend/src/services/mfa-session/mfa-session-types.ts @@ -0,0 +1,32 @@ +import { MfaMethod } from "@app/services/auth/auth-type"; + +export enum MfaSessionStatus { + PENDING = "PENDING", + ACTIVE = "ACTIVE" +} + +export type TMfaSession = { + sessionId: string; + userId: string; + resourceId: string; // Generic - can be accountId, documentId, etc. + status: MfaSessionStatus; + mfaMethod: MfaMethod; +}; + +export type TCreateMfaSessionDTO = { + userId: string; + resourceId: string; + mfaMethod: MfaMethod; +}; + +export type TVerifyMfaSessionDTO = { + mfaSessionId: string; + userId: string; + mfaToken: string; + mfaMethod: MfaMethod; +}; + +export type TGetMfaSessionStatusDTO = { + mfaSessionId: string; + userId: string; +}; diff --git a/backend/src/services/webauthn/webauthn-service.ts b/backend/src/services/webauthn/webauthn-service.ts index 5966572416..6b204149bf 100644 --- a/backend/src/services/webauthn/webauthn-service.ts +++ b/backend/src/services/webauthn/webauthn-service.ts @@ -8,6 +8,7 @@ import { verifyRegistrationResponse } from "@simplewebauthn/server"; +import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -30,48 +31,32 @@ type TWebAuthnServiceFactoryDep = { userDAL: TUserDALFactory; webAuthnCredentialDAL: TWebAuthnCredentialDALFactory; tokenService: TAuthTokenServiceFactory; + keyStore: TKeyStoreFactory; }; export type TWebAuthnServiceFactory = ReturnType; -// In-memory challenge storage (for development) -// TODO: In production, use Redis or database with TTL -const challengeStore = new Map(); -const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes - -const storeChallenge = (userId: string, challenge: string) => { - challengeStore.set(userId, { challenge, timestamp: Date.now() }); - - // Cleanup expired challenges - for (const [key, value] of challengeStore.entries()) { - if (Date.now() - value.timestamp > CHALLENGE_TTL) { - challengeStore.delete(key); - } - } -}; - -const getChallenge = (userId: string): string | null => { - const stored = challengeStore.get(userId); - if (!stored) return null; - - // Check if expired - if (Date.now() - stored.timestamp > CHALLENGE_TTL) { - challengeStore.delete(userId); - return null; - } - - return stored.challenge; -}; - -const clearChallenge = (userId: string) => { - challengeStore.delete(userId); -}; - export const webAuthnServiceFactory = ({ userDAL, webAuthnCredentialDAL, - tokenService + tokenService, + keyStore }: TWebAuthnServiceFactoryDep) => { + const storeChallenge = async (userId: string, challenge: string) => { + const challengeKey = KeyStorePrefixes.WebAuthnChallenge(userId); + await keyStore.setItemWithExpiry(challengeKey, KeyStoreTtls.WebAuthnChallengeInSeconds, challenge); + }; + + const getChallenge = async (userId: string): Promise => { + const challengeKey = KeyStorePrefixes.WebAuthnChallenge(userId); + return keyStore.getItem(challengeKey); + }; + + const clearChallenge = async (userId: string) => { + const challengeKey = KeyStorePrefixes.WebAuthnChallenge(userId); + await keyStore.deleteItem(challengeKey); + }; + const appCfg = getConfig(); // Relying Party (RP) information - extracted from SITE_URL @@ -114,7 +99,7 @@ export const webAuthnServiceFactory = ({ }); // Store challenge for verification - storeChallenge(userId, options.challenge); + await storeChallenge(userId, options.challenge); return options; }; @@ -137,7 +122,7 @@ export const webAuthnServiceFactory = ({ } // Retrieve the stored challenge - const expectedChallenge = getChallenge(userId); + const expectedChallenge = await getChallenge(userId); if (!expectedChallenge) { throw new BadRequestError({ message: "Challenge not found or expired. Please try registering again." @@ -154,14 +139,14 @@ export const webAuthnServiceFactory = ({ requireUserVerification: true }); } catch (error: unknown) { - clearChallenge(userId); + await clearChallenge(userId); throw new BadRequestError({ message: `Registration verification failed: ${error instanceof Error ? error.message : "Unknown error"}` }); } if (!verification.verified || !verification.registrationInfo) { - clearChallenge(userId); + await clearChallenge(userId); throw new BadRequestError({ message: "Registration verification failed" }); @@ -176,7 +161,7 @@ export const webAuthnServiceFactory = ({ }); if (existingCredential) { - clearChallenge(userId); + await clearChallenge(userId); throw new BadRequestError({ message: "This credential has already been registered" }); @@ -193,7 +178,7 @@ export const webAuthnServiceFactory = ({ }); // Clear the challenge - clearChallenge(userId); + await clearChallenge(userId); return { credentialId: credential.credentialId, @@ -224,7 +209,7 @@ export const webAuthnServiceFactory = ({ }); // Store challenge for verification - storeChallenge(userId, options.challenge); + await storeChallenge(userId, options.challenge); return options; }; @@ -262,7 +247,7 @@ export const webAuthnServiceFactory = ({ } // Retrieve the stored challenge - const expectedChallenge = getChallenge(userId); + const expectedChallenge = await getChallenge(userId); if (!expectedChallenge) { throw new BadRequestError({ message: "Challenge not found or expired. Please try authenticating again." @@ -284,14 +269,14 @@ export const webAuthnServiceFactory = ({ requireUserVerification: true }); } catch (error: unknown) { - clearChallenge(userId); + await clearChallenge(userId); throw new BadRequestError({ message: `Authentication verification failed: ${error instanceof Error ? error.message : "Unknown error"}` }); } if (!verification.verified) { - clearChallenge(userId); + await clearChallenge(userId); throw new BadRequestError({ message: "Authentication verification failed" }); @@ -304,7 +289,7 @@ export const webAuthnServiceFactory = ({ }); // Clear the challenge - clearChallenge(userId); + await clearChallenge(userId); // Generate one-time WebAuthn session token with 60-second expiration const sessionToken = await tokenService.createTokenForUser({ diff --git a/frontend/src/helpers/mfaSession.ts b/frontend/src/helpers/mfaSession.ts new file mode 100644 index 0000000000..d9864b4046 --- /dev/null +++ b/frontend/src/helpers/mfaSession.ts @@ -0,0 +1,16 @@ +import { MfaMethod } from "@app/hooks/api/auth/types"; + +export type TMfaSessionError = { + name: "SESSION_MFA_REQUIRED"; + message: string; + error: { + mfaSessionId: string; + mfaMethod: MfaMethod; + }; +}; + +export const isMfaSessionError = ( + error: any +): error is { response: { data: TMfaSessionError } } => { + return error?.response?.data?.name === "SESSION_MFA_REQUIRED"; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 0216ae0300..6c4a0ca27b 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -22,6 +22,7 @@ export * from "./integrationAuth"; export * from "./integrations"; export * from "./kms"; export * from "./ldapConfig"; +export * from "./mfaSession"; export * from "./oidcConfig"; export * from "./orgAdmin"; export * from "./organization"; diff --git a/frontend/src/hooks/api/mfaSession/index.tsx b/frontend/src/hooks/api/mfaSession/index.tsx new file mode 100644 index 0000000000..4980a7e5af --- /dev/null +++ b/frontend/src/hooks/api/mfaSession/index.tsx @@ -0,0 +1 @@ +export { MfaSessionStatus, useMfaSessionStatus, useVerifyMfaSession } from "./queries"; diff --git a/frontend/src/hooks/api/mfaSession/queries.tsx b/frontend/src/hooks/api/mfaSession/queries.tsx new file mode 100644 index 0000000000..b074b545b8 --- /dev/null +++ b/frontend/src/hooks/api/mfaSession/queries.tsx @@ -0,0 +1,61 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { MfaMethod } from "../auth/types"; + +export enum MfaSessionStatus { + PENDING = "PENDING", + ACTIVE = "ACTIVE" +} + +export type TMfaSessionStatusResponse = { + status: MfaSessionStatus; + mfaMethod: MfaMethod; +}; + +export type TVerifyMfaSessionRequest = { + mfaSessionId: string; + mfaToken: string; + mfaMethod: MfaMethod; +}; + +export type TVerifyMfaSessionResponse = { + success: boolean; + message: string; +}; + +export const useMfaSessionStatus = (mfaSessionId: string, enabled = true) => { + return useQuery({ + queryKey: ["mfa-session-status", mfaSessionId], + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v2/mfa-sessions/${mfaSessionId}/status` + ); + return data; + }, + enabled, + refetchInterval: (query) => { + // Poll every 2 seconds if status is still PENDING + if (query.state.data?.status === MfaSessionStatus.PENDING) { + return 2000; + } + return false; + } + }); +}; + +export const useVerifyMfaSession = () => { + return useMutation({ + mutationFn: async ({ mfaSessionId, mfaToken, mfaMethod }: TVerifyMfaSessionRequest) => { + const { data } = await apiRequest.post( + `/api/v2/mfa-sessions/${mfaSessionId}/verify`, + { + mfaToken, + mfaMethod + } + ); + return data; + } + }); +}; diff --git a/frontend/src/pages/MfaSessionPage/MfaSessionPage.tsx b/frontend/src/pages/MfaSessionPage/MfaSessionPage.tsx new file mode 100644 index 0000000000..ab5f73af33 --- /dev/null +++ b/frontend/src/pages/MfaSessionPage/MfaSessionPage.tsx @@ -0,0 +1,311 @@ +import { useEffect, useState } from "react"; +import ReactCodeInput from "react-code-input"; +import { startAuthentication } from "@simplewebauthn/browser"; +import { useParams } from "@tanstack/react-router"; + +import Error from "@app/components/basic/Error"; +import { createNotification } from "@app/components/notifications"; +import { Button } from "@app/components/v2"; +import { MfaMethod } from "@app/hooks/api/auth/types"; +import { + MfaSessionStatus, + useMfaSessionStatus, + useVerifyMfaSession +} from "@app/hooks/api/mfaSession"; +import { useGenerateAuthenticationOptions, useVerifyAuthentication } from "@app/hooks/api/webauthn"; + +const codeInputProps = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "55px", + borderRadius: "5px", + fontSize: "24px", + height: "55px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; + +const codeInputPropsPhone = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "40px", + borderRadius: "5px", + fontSize: "24px", + height: "40px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; + +export const MfaSessionPage = () => { + const { mfaSessionId } = useParams({ strict: false }) as { mfaSessionId: string }; + + const [mfaCode, setMfaCode] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const { data: sessionStatus, isError: isStatusError } = useMfaSessionStatus(mfaSessionId); + const verifyMfaSession = useVerifyMfaSession(); + const { mutateAsync: generateWebAuthnAuthenticationOptions } = useGenerateAuthenticationOptions(); + const { mutateAsync: verifyWebAuthnAuthentication } = useVerifyAuthentication(); + + // Show notification and auto-close when MFA is completed + useEffect(() => { + if (sessionStatus?.status === MfaSessionStatus.ACTIVE) { + createNotification({ + text: "MFA verification successful! Closing window...", + type: "success" + }); + + // Auto-close window after showing success message + setTimeout(() => { + window.close(); + }, 1000); + } + }, [sessionStatus?.status]); + + // Handle status error (session not found or expired) + useEffect(() => { + if (isStatusError) { + setError("MFA session not found or expired. Please try again."); + } + }, [isStatusError]); + + const getExpectedCodeLength = () => { + if (sessionStatus?.mfaMethod === MfaMethod.EMAIL) return 6; + if (sessionStatus?.mfaMethod === MfaMethod.TOTP) return 6; + return 6; + }; + + const isCodeComplete = mfaCode.length === getExpectedCodeLength(); + + const handleVerifyMfa = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!mfaCode.trim() || !isCodeComplete || !sessionStatus?.mfaMethod) return; + + setIsLoading(true); + setError(null); + + try { + await verifyMfaSession.mutateAsync({ + mfaSessionId, + mfaToken: mfaCode.trim(), + mfaMethod: sessionStatus.mfaMethod + }); + + createNotification({ + text: "MFA verification successful! Closing window...", + type: "success" + }); + + // Auto-close window after showing success message + setTimeout(() => { + window.close(); + }, 1000); + } catch (err: any) { + setError(err?.response?.data?.message || "Invalid MFA code. Please try again."); + setMfaCode(""); + } finally { + setIsLoading(false); + } + }; + + const handleWebAuthnVerification = async () => { + if (!sessionStatus?.mfaMethod) return; + + setIsLoading(true); + setError(null); + + try { + // Get authentication options from server + const options = await generateWebAuthnAuthenticationOptions(); + + // Prompt user to authenticate with their passkey + const authenticationResponse = await startAuthentication({ optionsJSON: options }); + + // Verify with server to get session token + const result = await verifyWebAuthnAuthentication({ authenticationResponse }); + + // Use the sessionToken to verify MFA session + if (result.sessionToken) { + await verifyMfaSession.mutateAsync({ + mfaSessionId, + mfaToken: result.sessionToken, + mfaMethod: MfaMethod.WEBAUTHN + }); + + createNotification({ + text: "MFA verification successful! Closing window...", + type: "success" + }); + + // Auto-close window after showing success message + setTimeout(() => { + window.close(); + }, 1000); + } + } catch (err: any) { + console.error("WebAuthn verification failed:", err); + + let errorMessage = "Failed to verify passkey"; + if (err.name === "NotAllowedError") { + errorMessage = "Passkey verification was cancelled or timed out"; + } else if (err?.response?.data?.message) { + errorMessage = err.response.data.message; + } else if (err.message) { + errorMessage = err.message; + } + + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + if (isStatusError) { + return ( +
+
+
+ Infisical logo +
+
+

Session Expired

+

+ This MFA session has expired or is invalid. Please try your action again. +

+
+
+
+ ); + } + + if (!sessionStatus) { + return ( +
+
+
Loading...
+
+
+ ); + } + + if (sessionStatus.status === MfaSessionStatus.ACTIVE) { + return ( +
+
+
+ Infisical logo +
+
+

Verification Complete

+

This window will close automatically...

+
+
+
+ ); + } + + return ( +
+
+
+ Infisical logo +
+ +
+

Two-Factor Authentication

+

+ {sessionStatus.mfaMethod === MfaMethod.EMAIL && + "Enter the verification code sent to your email"} + {sessionStatus.mfaMethod === MfaMethod.TOTP && + "Enter the verification code from your authenticator app"} + {sessionStatus.mfaMethod === MfaMethod.WEBAUTHN && + "Use your registered passkey to complete two-factor authentication"} +

+
+ + {sessionStatus.mfaMethod === MfaMethod.WEBAUTHN ? ( + <> + {error && } +
+ +
+ + ) : ( +
+
+
+ +
+
+
+
+ +
+
+ {error && } +
+ +
+ + )} +
+
+ ); +}; diff --git a/frontend/src/pages/MfaSessionPage/index.tsx b/frontend/src/pages/MfaSessionPage/index.tsx new file mode 100644 index 0000000000..3b769085ad --- /dev/null +++ b/frontend/src/pages/MfaSessionPage/index.tsx @@ -0,0 +1 @@ +export { MfaSessionPage } from "./MfaSessionPage"; diff --git a/frontend/src/pages/MfaSessionPage/route.tsx b/frontend/src/pages/MfaSessionPage/route.tsx new file mode 100644 index 0000000000..a66b02443e --- /dev/null +++ b/frontend/src/pages/MfaSessionPage/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { MfaSessionPage } from "./MfaSessionPage"; + +export const Route = createFileRoute("/_authenticate/mfa-session/$mfaSessionId")({ + component: MfaSessionPage +}); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 4c6558db97..7a93187b43 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -37,6 +37,7 @@ import { Route as authLoginLdapPageRouteImport } from './pages/auth/LoginLdapPag import { Route as authAdminLoginPageRouteImport } from './pages/auth/AdminLoginPage/route' import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route' import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/route' +import { Route as MfaSessionPageRouteImport } from './pages/MfaSessionPage/route' import { Route as authSignUpPageRouteImport } from './pages/auth/SignUpPage/route' import { Route as authLoginPageRouteImport } from './pages/auth/LoginPage/route' import { Route as adminLayoutImport } from './pages/admin/layout' @@ -513,6 +514,12 @@ const organizationNoOrgPageRouteRoute = organizationNoOrgPageRouteImport.update( } as any, ) +const MfaSessionPageRouteRoute = MfaSessionPageRouteImport.update({ + id: '/mfa-session/$mfaSessionId', + path: '/mfa-session/$mfaSessionId', + getParentRoute: () => middlewaresAuthenticateRoute, +} as any) + const authSignUpPageRouteRoute = authSignUpPageRouteImport.update({ id: '/', path: '/', @@ -2302,6 +2309,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof authSignUpPageRouteImport parentRoute: typeof RestrictLoginSignupSignupImport } + '/_authenticate/mfa-session/$mfaSessionId': { + id: '/_authenticate/mfa-session/$mfaSessionId' + path: '/mfa-session/$mfaSessionId' + fullPath: '/mfa-session/$mfaSessionId' + preLoaderRoute: typeof MfaSessionPageRouteImport + parentRoute: typeof middlewaresAuthenticateImport + } '/_authenticate/organization/none': { id: '/_authenticate/organization/none' path: '/organization/none' @@ -4880,6 +4894,7 @@ interface middlewaresAuthenticateRouteChildren { authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren + MfaSessionPageRouteRoute: typeof MfaSessionPageRouteRoute organizationNoOrgPageRouteRoute: typeof organizationNoOrgPageRouteRoute } @@ -4890,6 +4905,7 @@ const middlewaresAuthenticateRouteChildren: middlewaresAuthenticateRouteChildren middlewaresInjectOrgDetailsRouteWithChildren, AuthenticatePersonalSettingsRoute: AuthenticatePersonalSettingsRouteWithChildren, + MfaSessionPageRouteRoute: MfaSessionPageRouteRoute, organizationNoOrgPageRouteRoute: organizationNoOrgPageRouteRoute, } @@ -4985,6 +5001,7 @@ export interface FileRoutesByFullPath { '/signup': typeof RestrictLoginSignupSignupRouteWithChildren '/login/': typeof authLoginPageRouteRoute '/signup/': typeof authSignUpPageRouteRoute + '/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute '/organization/none': typeof organizationNoOrgPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute '/login/admin': typeof authAdminLoginPageRouteRoute @@ -5222,6 +5239,7 @@ export interface FileRoutesByTo { '/personal-settings': typeof userPersonalSettingsPageRouteRoute '/login': typeof authLoginPageRouteRoute '/signup': typeof authSignUpPageRouteRoute + '/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute '/organization/none': typeof organizationNoOrgPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute '/login/admin': typeof authAdminLoginPageRouteRoute @@ -5451,6 +5469,7 @@ export interface FileRoutesById { '/_restrict-login-signup/signup': typeof RestrictLoginSignupSignupRouteWithChildren '/_restrict-login-signup/login/': typeof authLoginPageRouteRoute '/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute + '/_authenticate/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute '/_authenticate/organization/none': typeof organizationNoOrgPageRouteRoute '/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute '/_restrict-login-signup/login/admin': typeof authAdminLoginPageRouteRoute @@ -5701,6 +5720,7 @@ export interface FileRouteTypes { | '/signup' | '/login/' | '/signup/' + | '/mfa-session/$mfaSessionId' | '/organization/none' | '/admin/signup' | '/login/admin' @@ -5937,6 +5957,7 @@ export interface FileRouteTypes { | '/personal-settings' | '/login' | '/signup' + | '/mfa-session/$mfaSessionId' | '/organization/none' | '/admin/signup' | '/login/admin' @@ -6164,6 +6185,7 @@ export interface FileRouteTypes { | '/_restrict-login-signup/signup' | '/_restrict-login-signup/login/' | '/_restrict-login-signup/signup/' + | '/_authenticate/mfa-session/$mfaSessionId' | '/_authenticate/organization/none' | '/_restrict-login-signup/admin/signup' | '/_restrict-login-signup/login/admin' @@ -6459,6 +6481,7 @@ export const routeTree = rootRoute "/_authenticate/password-setup", "/_authenticate/_inject-org-details", "/_authenticate/personal-settings", + "/_authenticate/mfa-session/$mfaSessionId", "/_authenticate/organization/none" ] }, @@ -6543,6 +6566,10 @@ export const routeTree = rootRoute "filePath": "auth/SignUpPage/route.tsx", "parent": "/_restrict-login-signup/signup" }, + "/_authenticate/mfa-session/$mfaSessionId": { + "filePath": "MfaSessionPage/route.tsx", + "parent": "/_authenticate" + }, "/_authenticate/organization/none": { "filePath": "organization/NoOrgPage/route.tsx", "parent": "/_authenticate" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index cc37f30318..7403b3bb37 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -422,6 +422,7 @@ export const routes = rootRoute("root.tsx", [ ]), middleware("authenticate.tsx", [ route("/password-setup", "auth/PasswordSetupPage/route.tsx"), + route("/mfa-session/$mfaSessionId", "MfaSessionPage/route.tsx"), route("/personal-settings", [ layout("user/layout.tsx", [index("user/PersonalSettingsPage/route.tsx")]) ]), From 6061c8f3aeef3bfe6473ae688626c439bf1c7cee Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 19 Nov 2025 22:08:09 +0800 Subject: [PATCH 07/36] feat: add require mfa to pam account --- ...19124200_add-require-mfa-to-pam-account.ts | 19 ++++ backend/src/db/schemas/pam-accounts.ts | 3 +- .../pam-account-endpoints.ts | 8 +- .../ee/services/audit-log/audit-log-types.ts | 2 + .../pam-account/pam-account-service.ts | 90 +++++++++++-------- .../services/pam-account/pam-account-types.ts | 9 +- .../pam-resource/pam-resource-schemas.ts | 6 +- .../src/hooks/api/pam/types/base-account.ts | 1 + frontend/src/hooks/api/pam/types/index.ts | 20 ++++- .../PamAccountForm/MySQLAccountForm.tsx | 11 ++- .../PamAccountForm/PamAccountForm.tsx | 20 ++++- .../PamAccountForm/PostgresAccountForm.tsx | 13 ++- .../PamAccountForm/RequireMfaField.tsx | 31 +++++++ .../PamAccountForm/RotateAccountFields.tsx | 22 +---- .../PamAccountForm/SSHAccountForm.tsx | 17 ++-- 15 files changed, 186 insertions(+), 86 deletions(-) create mode 100644 backend/src/db/migrations/20251119124200_add-require-mfa-to-pam-account.ts create mode 100644 frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/RequireMfaField.tsx diff --git a/backend/src/db/migrations/20251119124200_add-require-mfa-to-pam-account.ts b/backend/src/db/migrations/20251119124200_add-require-mfa-to-pam-account.ts new file mode 100644 index 0000000000..43460adfae --- /dev/null +++ b/backend/src/db/migrations/20251119124200_add-require-mfa-to-pam-account.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.PamAccount, "requireMfa"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.boolean("requireMfa").defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.PamAccount, "requireMfa")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("requireMfa"); + }); + } +} diff --git a/backend/src/db/schemas/pam-accounts.ts b/backend/src/db/schemas/pam-accounts.ts index 4f097a16d8..69d167bfe6 100644 --- a/backend/src/db/schemas/pam-accounts.ts +++ b/backend/src/db/schemas/pam-accounts.ts @@ -23,7 +23,8 @@ export const PamAccountsSchema = z.object({ rotationIntervalSeconds: z.number().nullable().optional(), lastRotatedAt: z.date().nullable().optional(), rotationStatus: z.string().nullable().optional(), - encryptedLastRotationMessage: zodBuffer.nullable().optional() + encryptedLastRotationMessage: zodBuffer.nullable().optional(), + requireMfa: z.boolean().default(false).nullable().optional() }); export type TPamAccounts = z.infer; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts index 44e2a5ea11..6ded3ece1c 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -24,6 +24,7 @@ export const registerPamResourceEndpoints = ({ description?: C["description"]; rotationEnabled: C["rotationEnabled"]; rotationIntervalSeconds?: C["rotationIntervalSeconds"]; + requireMfa?: C["requireMfa"]; }>; updateAccountSchema: z.ZodType<{ credentials?: C["credentials"]; @@ -31,6 +32,7 @@ export const registerPamResourceEndpoints = ({ description?: C["description"]; rotationEnabled?: C["rotationEnabled"]; rotationIntervalSeconds?: C["rotationIntervalSeconds"]; + requireMfa?: C["requireMfa"]; }>; accountResponseSchema: z.ZodTypeAny; }) => { @@ -66,7 +68,8 @@ export const registerPamResourceEndpoints = ({ name: req.body.name, description: req.body.description, rotationEnabled: req.body.rotationEnabled, - rotationIntervalSeconds: req.body.rotationIntervalSeconds + rotationIntervalSeconds: req.body.rotationIntervalSeconds, + requireMfa: req.body.requireMfa } } }); @@ -116,7 +119,8 @@ export const registerPamResourceEndpoints = ({ name: req.body.name, description: req.body.description, rotationEnabled: req.body.rotationEnabled, - rotationIntervalSeconds: req.body.rotationIntervalSeconds + rotationIntervalSeconds: req.body.rotationIntervalSeconds, + requireMfa: req.body.requireMfa } } }); diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index ba409a4fba..bfed20f435 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -3992,6 +3992,7 @@ interface PamAccountCreateEvent { description?: string | null; rotationEnabled: boolean; rotationIntervalSeconds?: number | null; + requireMfa?: boolean | null; }; } @@ -4005,6 +4006,7 @@ interface PamAccountUpdateEvent { description?: string | null; rotationEnabled?: boolean; rotationIntervalSeconds?: number | null; + requireMfa?: boolean | null; }; } diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index 25b8d6c073..a7672b6001 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -128,7 +128,8 @@ export const pamAccountServiceFactory = ({ description, folderId, rotationEnabled, - rotationIntervalSeconds + rotationIntervalSeconds, + requireMfa }: TCreateAccountDTO, actor: OrgServiceActor ) => { @@ -205,7 +206,8 @@ export const pamAccountServiceFactory = ({ description, folderId, rotationEnabled, - rotationIntervalSeconds + rotationIntervalSeconds, + requireMfa }); return { @@ -229,7 +231,15 @@ export const pamAccountServiceFactory = ({ }; const updateById = async ( - { accountId, credentials, description, name, rotationEnabled, rotationIntervalSeconds }: TUpdateAccountDTO, + { + accountId, + credentials, + description, + name, + rotationEnabled, + rotationIntervalSeconds, + requireMfa + }: TUpdateAccountDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -275,6 +285,10 @@ export const pamAccountServiceFactory = ({ updateDoc.name = name; } + if (requireMfa !== undefined) { + updateDoc.requireMfa = requireMfa; + } + if (description !== undefined) { updateDoc.description = description; } @@ -500,7 +514,7 @@ export const pamAccountServiceFactory = ({ if (!actorUser) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` }); // If no mfaSessionId is provided, create a new MFA session - if (!mfaSessionId) { + if (!mfaSessionId && account.requireMfa) { // Get organization to check if MFA is enforced at org level const org = await orgDAL.findOrgById(project.orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${project.orgId}' not found` }); @@ -534,42 +548,44 @@ export const pamAccountServiceFactory = ({ }); } - // If mfaSessionId is provided, verify it - const mfaSessionKey = KeyStorePrefixes.MfaSession(mfaSessionId); - const mfaSessionData = await keyStore.getItem(mfaSessionKey); + if (mfaSessionId && account.requireMfa) { + // If mfaSessionId is provided, verify it + const mfaSessionKey = KeyStorePrefixes.MfaSession(mfaSessionId); + const mfaSessionData = await keyStore.getItem(mfaSessionKey); - if (!mfaSessionData) { - throw new BadRequestError({ - message: "MFA session not found or expired" - }); + if (!mfaSessionData) { + throw new BadRequestError({ + message: "MFA session not found or expired" + }); + } + + const mfaSession = JSON.parse(mfaSessionData) as TMfaSession; + + // Verify the session belongs to the current user + if (mfaSession.userId !== actor.id) { + throw new ForbiddenRequestError({ + message: "MFA session does not belong to current user" + }); + } + + // Verify the session is for the same account + if (mfaSession.resourceId !== accountId) { + throw new BadRequestError({ + message: "MFA session is for a different resource" + }); + } + + // Check if MFA session is active + if (mfaSession.status !== MfaSessionStatus.ACTIVE) { + throw new BadRequestError({ + message: "MFA session is not active. Please complete MFA verification first." + }); + } + + // MFA verified successfully, delete the session and proceed with access + await keyStore.deleteItem(mfaSessionKey); } - const mfaSession = JSON.parse(mfaSessionData) as TMfaSession; - - // Verify the session belongs to the current user - if (mfaSession.userId !== actor.id) { - throw new ForbiddenRequestError({ - message: "MFA session does not belong to current user" - }); - } - - // Verify the session is for the same account - if (mfaSession.resourceId !== accountId) { - throw new BadRequestError({ - message: "MFA session is for a different resource" - }); - } - - // Check if MFA session is active - if (mfaSession.status !== MfaSessionStatus.ACTIVE) { - throw new BadRequestError({ - message: "MFA session is not active. Please complete MFA verification first." - }); - } - - // MFA verified successfully, delete the session and proceed with access - await keyStore.deleteItem(mfaSessionKey); - const session = await pamSessionDAL.create({ accountName: account.name, actorEmail, diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts index 78a45d90c6..c6ba111659 100644 --- a/backend/src/ee/services/pam-account/pam-account-types.ts +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -3,7 +3,14 @@ import { TPamAccount } from "../pam-resource/pam-resource-types"; // DTOs export type TCreateAccountDTO = Pick< TPamAccount, - "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationEnabled" | "rotationIntervalSeconds" + | "name" + | "description" + | "credentials" + | "folderId" + | "resourceId" + | "rotationEnabled" + | "rotationIntervalSeconds" + | "requireMfa" >; export type TUpdateAccountDTO = Partial> & { diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 17ed1ccd19..c5e1afed7f 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -44,12 +44,14 @@ export const BaseCreatePamAccountSchema = z.object({ name: slugSchema({ field: "name" }), description: z.string().max(512).nullable().optional(), rotationEnabled: z.boolean(), - rotationIntervalSeconds: z.number().min(3600).nullable().optional() + rotationIntervalSeconds: z.number().min(3600).nullable().optional(), + requireMfa: z.boolean().optional().default(false) }); export const BaseUpdatePamAccountSchema = z.object({ name: slugSchema({ field: "name" }).optional(), description: z.string().max(512).nullable().optional(), rotationEnabled: z.boolean().optional(), - rotationIntervalSeconds: z.number().min(3600).nullable().optional() + rotationIntervalSeconds: z.number().min(3600).nullable().optional(), + requireMfa: z.boolean().optional() }); diff --git a/frontend/src/hooks/api/pam/types/base-account.ts b/frontend/src/hooks/api/pam/types/base-account.ts index 286c9389a3..2b9b80df0d 100644 --- a/frontend/src/hooks/api/pam/types/base-account.ts +++ b/frontend/src/hooks/api/pam/types/base-account.ts @@ -15,6 +15,7 @@ export interface TBasePamAccount { description?: string | null; rotationEnabled: boolean; rotationIntervalSeconds?: number | null; + requireMfa?: boolean | null; lastRotatedAt?: string | null; lastRotationMessage?: string | null; rotationStatus?: string | null; diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 6f37c97c34..de115f5290 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -79,13 +79,29 @@ export type TDeletePamResourceDTO = { // Account DTOs export type TCreatePamAccountDTO = Pick< TPamAccount, - "name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId" + | "name" + | "description" + | "credentials" + | "projectId" + | "resourceId" + | "folderId" + | "rotationEnabled" + | "rotationIntervalSeconds" + | "requireMfa" > & { resourceType: PamResourceType; }; export type TUpdatePamAccountDTO = Partial< - Pick + Pick< + TPamAccount, + | "name" + | "description" + | "credentials" + | "rotationEnabled" + | "rotationIntervalSeconds" + | "requireMfa" + > > & { accountId: string; resourceType: PamResourceType; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx index e30c79e24b..cc3ad96d69 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx @@ -9,6 +9,7 @@ import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { SqlAccountFields } from "./shared/SqlAccountFields"; import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; +import { RequireMfaField } from "./RequireMfaField"; type Props = { account?: TMySQLAccount; @@ -21,7 +22,8 @@ const formSchema = genericAccountFieldsSchema.extend({ credentials: BaseSqlAccountSchema, // We don't support rotation for now, just feed a false value to // make the schema happy - rotationEnabled: z.boolean().default(false) + rotationEnabled: z.boolean().default(false), + requireMfa: z.boolean().nullable().optional() }); type FormData = z.infer; @@ -49,13 +51,10 @@ export const MySQLAccountForm = ({ account, onSubmit }: Props) => { return ( -
{ - handleSubmit(onSubmit)(e); - }} - > + +