Merge branch 'main' into ENG-4013

This commit is contained in:
x032205
2026-01-06 16:16:01 -05:00
97 changed files with 3980 additions and 830 deletions

View File

@@ -41,6 +41,10 @@ jobs:
node-version: "20"
cache: "npm"
cache-dependency-path: backend/package-lock.json
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: npm install
working-directory: backend

View File

@@ -140,6 +140,33 @@ RUN apt-get update && apt-get install -y \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Install Oracle Instant Client for OracleDB mTLS wallet support
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.x64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
apt-get update && apt-get install -y libaio1t64 unzip && \
ln -sf /lib/x86_64-linux-gnu/libaio.so.1t64 /lib/x86_64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
elif [ "$ARCH" = "arm64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.arm64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
apt-get update && apt-get install -y libaio1t64 unzip && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
fi && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig && \
rm -rf /var/lib/apt/lists/*
# Configure ODBC in production
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsS.so\nFileUsage = 1\n" > /etc/odbcinst.ini

View File

@@ -137,10 +137,37 @@ RUN apt-get update && apt-get install -y \
unixodbc-dev \
libc-dev \
freetds-dev \
wget \
wget \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Install Oracle Instant Client for OracleDB mTLS wallet support
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.x64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
apt-get update && apt-get install -y libaio1t64 unzip && \
ln -sf /lib/x86_64-linux-gnu/libaio.so.1t64 /lib/x86_64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
elif [ "$ARCH" = "arm64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.arm64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
apt-get update && apt-get install -y libaio1t64 unzip && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
fi && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig && \
rm -rf /var/lib/apt/lists/*
# Install Infisical CLI
RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \
&& apt-get update && apt-get install -y infisical=0.43.14 \

2
backend/.gitignore vendored
View File

@@ -1 +1,3 @@
dist
/wallet

View File

@@ -48,6 +48,33 @@ RUN apt-get install -y \
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
# Install Oracle Instant Client for OracleDB mTLS wallet support
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.x64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
apt-get update && apt-get install -y libaio1t64 unzip wget && \
ln -sf /lib/x86_64-linux-gnu/libaio.so.1t64 /lib/x86_64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
elif [ "$ARCH" = "arm64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.arm64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
apt-get update && apt-get install -y libaio1t64 unzip wget && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
fi && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig && \
rm -rf /var/lib/apt/lists/*
RUN npm ci --only-production && npm cache clean --force
COPY --from=build /app .

View File

@@ -21,7 +21,18 @@ RUN apt-get update && apt-get install -y \
openssh-client \
openssl \
curl \
pkg-config
pkg-config \
unzip
# Install libaio (required for Oracle Instant Client) - architecture-specific for Debian Trixie
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "arm64" ]; then \
apt-get install -y libaio1t64 && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1; \
else \
apt-get install -y libaio1t64 && \
ln -sf /lib/x86_64-linux-gnu/libaio.so.1t64 /lib/x86_64-linux-gnu/libaio.so.1; \
fi
# Install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apt-get install -y \
@@ -49,6 +60,22 @@ RUN rm -fr ${SOFTHSM2_SOURCES}
# Install pkcs11-tool
RUN apt-get install -y opensc
# Install Oracle Instant Client for OracleDB mTLS (Wallet) support
RUN mkdir -p /opt/oracle && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "arm64" ]; then \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
curl -o /tmp/instantclient.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.arm64-23.26.0.0.0.zip; \
else \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
curl -o /tmp/instantclient.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.x64-23.26.0.0.0.zip; \
fi && \
echo "$EXPECTED_SHA /tmp/instantclient.zip" | sha256sum -c - && \
unzip -oq /tmp/instantclient.zip -d /opt/oracle && \
rm /tmp/instantclient.zip && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig
# ? App setup
# Install Infisical CLI

View File

@@ -22,7 +22,17 @@ RUN apt-get update && apt-get install -y \
curl \
pkg-config \
perl \
wget
wget \
unzip
# Install libaio (required for Oracle Instant Client) - architecture-specific for Debian Trixie
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "arm64" ]; then \
apt-get install -y libaio1t64 && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1; \
else \
apt-get install -y libaio1t64; \
fi
# Install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apt-get install -y \
@@ -50,6 +60,22 @@ RUN rm -fr ${SOFTHSM2_SOURCES}
# Install pkcs11-tool
RUN apt-get install -y opensc
# Install Oracle Instant Client for OracleDB mTLS (Wallet) support
RUN mkdir -p /opt/oracle && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "arm64" ]; then \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
curl -o /tmp/instantclient.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.arm64-23.26.0.0.0.zip; \
else \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
curl -o /tmp/instantclient.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.x64-23.26.0.0.0.zip; \
fi && \
echo "$EXPECTED_SHA /tmp/instantclient.zip" | sha256sum -c - && \
unzip -oq /tmp/instantclient.zip -d /opt/oracle && \
rm /tmp/instantclient.zip && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig
WORKDIR /openssl-build
RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \
&& tar -xf openssl-3.1.2.tar.gz \

View File

@@ -56,6 +56,7 @@
"@peculiar/x509": "^1.12.1",
"@react-email/components": "^1.0.1",
"@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",
@@ -157,6 +158,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",
@@ -662,7 +664,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.637.0.tgz",
"integrity": "sha512-xUi7x4qDubtA8QREtlblPuAcn91GS/09YVEY/RwU7xCY0aqGuFwgszAANlha4OUIqva8oVj2WO4gJuG+iaSnhw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@@ -2109,7 +2110,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.682.0.tgz",
"integrity": "sha512-ZPZ7Y/r/w3nx/xpPzGSqSQsB090Xk5aZZOH+WBhTDn/pBEuim09BYXCLzvvxb7R7NnuoQdrTJiwimdJAhHl7ZQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@@ -2163,7 +2163,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.682.0.tgz",
"integrity": "sha512-xKuo4HksZ+F8m9DOfx/ZuWNhaPuqZFPwwy0xqcBT6sWH7OAuBjv/fnpOTzyQhpVTWddlf+ECtMAMrxjxuOExGQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@@ -2663,7 +2662,6 @@
"version": "3.632.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.632.0.tgz",
"integrity": "sha512-Oh1fIWaoZluihOCb/zDEpRTi+6an82fgJz7fyRBugyLhEtDjmvpCQ3oKjzaOhoN+4EvXAm1ZS/ZgpvXBlIRTgw==",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@@ -2740,7 +2738,6 @@
"version": "3.632.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.632.0.tgz",
"integrity": "sha512-Ss5cBH09icpTvT+jtGGuQlRdwtO7RyE9BF4ZV/CEPATdd9whtJt4Qxdya8BUnkWR7h5HHTrQHqai3YVYjku41A==",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@@ -5178,7 +5175,6 @@
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -7210,7 +7206,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@@ -7232,7 +7227,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -8560,6 +8554,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",
@@ -9213,9 +9213,6 @@
"win32"
]
},
"node_modules/@infisical/quic/node_modules/@infisical/quic-linux-arm": {
"optional": true
},
"node_modules/@ioredis/commands": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
@@ -9427,6 +9424,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/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@@ -9959,6 +9962,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -10665,7 +10669,6 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz",
"integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^4.0.0",
"@octokit/graphql": "^7.1.0",
@@ -11109,7 +11112,6 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -11428,147 +11430,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": {
@@ -11750,7 +11765,6 @@
"resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.0.tgz",
"integrity": "sha512-9GCWmVmKUAoRfloboCd+RKm6X17xn7eGL7HnpAZUnjBXBilWCxsKnLMTC/ixSHDKS/A/057M1Tx6ZUXd89sVBw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
}
@@ -11760,7 +11774,6 @@
"resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.0.tgz",
"integrity": "sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -11773,7 +11786,6 @@
"resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.0.tgz",
"integrity": "sha512-eIrPW9PIFgDopQU0e/OPpwCW2QWQDtNZDSsiN4sJO8KdMnWWnXJicnRfzrit5rHwFo+Y98i+w/Y5ScnBAFr1dQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prismjs": "^1.30.0"
},
@@ -11789,7 +11801,6 @@
"resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.5.tgz",
"integrity": "sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -11865,7 +11876,6 @@
"resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz",
"integrity": "sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -11899,7 +11909,6 @@
"resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.15.tgz",
"integrity": "sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -11912,7 +11921,6 @@
"resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.11.tgz",
"integrity": "sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -11937,7 +11945,6 @@
"resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.11.tgz",
"integrity": "sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -11950,7 +11957,6 @@
"resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.12.tgz",
"integrity": "sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -11978,7 +11984,6 @@
"resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.13.tgz",
"integrity": "sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -12083,7 +12088,6 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.5.tgz",
"integrity": "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -12513,6 +12517,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",
@@ -14072,7 +14095,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -14129,6 +14151,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",
@@ -14551,7 +14583,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.20.0.tgz",
"integrity": "sha512-bYerPDF/H5v6V76MdMYhjwmwgMA+jlPVqjSDq2cRqMi8bP5sR3Z+RLOiOMad3nsnmDVmn2gAFCyNgh/dIrfP/w==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.20.0",
"@typescript-eslint/types": "6.20.0",
@@ -15216,7 +15247,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -15356,22 +15386,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@@ -15721,7 +15735,6 @@
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"peer": true,
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
@@ -15751,13 +15764,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"
@@ -15946,7 +15960,6 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@@ -16548,7 +16561,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -18103,7 +18115,6 @@
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz",
"integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18489,7 +18500,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -18572,7 +18582,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -18671,7 +18680,6 @@
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -18760,7 +18768,6 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz",
"integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==",
"dev": true,
"peer": true,
"dependencies": {
"array-includes": "^3.1.7",
"array.prototype.findlastindex": "^1.2.3",
@@ -19181,6 +19188,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -19242,6 +19250,7 @@
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
"integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
"license": "MIT",
"peer": true,
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
@@ -19259,12 +19268,14 @@
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"peer": true
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -19272,7 +19283,8 @@
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"peer": true
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.1",
@@ -25929,6 +25941,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -26628,7 +26641,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz",
"integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -26971,7 +26983,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -27087,7 +27098,6 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -27459,11 +27469,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": {
@@ -27551,6 +27562,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -27646,7 +27658,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -27656,7 +27667,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -29481,7 +29491,6 @@
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
"integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ip-address": "^9.0.5",
"smart-buffer": "^4.2.0"
@@ -30980,7 +30989,6 @@
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -30996,9 +31004,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"
},
@@ -31009,7 +31018,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",
@@ -31158,7 +31168,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -31199,6 +31208,7 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"peer": true,
"dependencies": {
"random-bytes": "~1.0.0"
},
@@ -31786,7 +31796,6 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -32468,7 +32477,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -102,6 +102,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",
@@ -187,6 +188,7 @@
"@peculiar/x509": "^1.12.1",
"@react-email/components": "^1.0.1",
"@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",

View File

@@ -102,6 +102,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";
@@ -137,6 +138,7 @@ import { TUpgradePathService } from "@app/services/upgrade-path/upgrade-path-ser
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TUserServiceFactory } from "@app/services/user/user-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";
@@ -329,6 +331,7 @@ declare module "fastify" {
externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory;
projectTemplate: TProjectTemplateServiceFactory;
totp: TTotpServiceFactory;
webAuthn: TWebAuthnServiceFactory;
appConnection: TAppConnectionServiceFactory;
secretSync: TSecretSyncServiceFactory;
kmip: TKmipServiceFactory;
@@ -355,6 +358,7 @@ declare module "fastify" {
pamResource: TPamResourceServiceFactory;
pamAccount: TPamAccountServiceFactory;
pamSession: TPamSessionServiceFactory;
mfaSession: TMfaSessionServiceFactory;
upgradePath: TUpgradePathService;
membershipUser: TMembershipUserServiceFactory;

View File

@@ -614,6 +614,9 @@ import {
TVaultExternalMigrationConfigs,
TVaultExternalMigrationConfigsInsert,
TVaultExternalMigrationConfigsUpdate,
TWebauthnCredentials,
TWebauthnCredentialsInsert,
TWebauthnCredentialsUpdate,
TWebhooks,
TWebhooksInsert,
TWebhooksUpdate,
@@ -1528,6 +1531,11 @@ declare module "knex/types/tables" {
TVaultExternalMigrationConfigsInsert,
TVaultExternalMigrationConfigsUpdate
>;
[TableName.WebAuthnCredential]: KnexOriginal.CompositeTableType<
TWebauthnCredentials,
TWebauthnCredentialsInsert,
TWebauthnCredentialsUpdate
>;
[TableName.AiMcpServer]: KnexOriginal.CompositeTableType<TAiMcpServers, TAiMcpServersInsert, TAiMcpServersUpdate>;
[TableName.AiMcpServerTool]: KnexOriginal.CompositeTableType<
TAiMcpServerTools,

View File

@@ -0,0 +1,30 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
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<void> {
await dropOnUpdateTrigger(knex, TableName.WebAuthnCredential);
await knex.schema.dropTableIfExists(TableName.WebAuthnCredential);
}

View File

@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
if (await knex.schema.hasColumn(TableName.PamAccount, "requireMfa")) {
await knex.schema.alterTable(TableName.PamAccount, (t) => {
t.dropColumn("requireMfa");
});
}
}

View File

@@ -209,5 +209,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";

View File

@@ -160,6 +160,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",

View File

@@ -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<typeof PamAccountsSchema>;

View File

@@ -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<typeof WebauthnCredentialsSchema>;
export type TWebauthnCredentialsInsert = Omit<z.input<typeof WebauthnCredentialsSchema>, TImmutableDBKeys>;
export type TWebauthnCredentialsUpdate = Partial<Omit<z.input<typeof WebauthnCredentialsSchema>, TImmutableDBKeys>>;

View File

@@ -24,6 +24,7 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
description?: C["description"];
rotationEnabled?: C["rotationEnabled"];
rotationIntervalSeconds?: C["rotationIntervalSeconds"];
requireMfa?: C["requireMfa"];
}>;
updateAccountSchema: z.ZodType<{
credentials?: C["credentials"];
@@ -31,6 +32,7 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
description?: C["description"];
rotationEnabled?: C["rotationEnabled"];
rotationIntervalSeconds?: C["rotationIntervalSeconds"];
requireMfa?: C["requireMfa"];
}>;
accountResponseSchema: z.ZodTypeAny;
}) => {
@@ -66,7 +68,8 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
name: req.body.name,
description: req.body.description,
rotationEnabled: req.body.rotationEnabled ?? false,
rotationIntervalSeconds: req.body.rotationIntervalSeconds
rotationIntervalSeconds: req.body.rotationIntervalSeconds,
requireMfa: req.body.requireMfa
}
}
});
@@ -116,7 +119,8 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
name: req.body.name,
description: req.body.description,
rotationEnabled: req.body.rotationEnabled,
rotationIntervalSeconds: req.body.rotationIntervalSeconds
rotationIntervalSeconds: req.body.rotationIntervalSeconds,
requireMfa: req.body.requireMfa
}
}
});

View File

@@ -115,6 +115,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
body: z.object({
accountPath: z.string().trim(),
projectId: z.string().uuid(),
mfaSessionId: z.string().optional(),
duration: z
.string()
.min(1)
@@ -163,7 +164,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
actorUserAgent: req.auditLogInfo.userAgent ?? "",
accountPath: req.body.accountPath,
projectId: req.body.projectId,
duration: req.body.duration
duration: req.body.duration,
mfaSessionId: req.body.mfaSessionId
},
req.permission
);

View File

@@ -4199,6 +4199,7 @@ interface PamAccountCreateEvent {
description?: string | null;
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
requireMfa?: boolean | null;
};
}
@@ -4212,6 +4213,7 @@ interface PamAccountUpdateEvent {
description?: string | null;
rotationEnabled?: boolean;
rotationIntervalSeconds?: number | null;
requireMfa?: boolean | null;
};
}

View File

@@ -38,11 +38,16 @@ import { TApprovalPolicyDALFactory } from "@app/services/approval-policy/approva
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
import { APPROVAL_POLICY_FACTORY_MAP } from "@app/services/approval-policy/approval-policy-factory";
import { TApprovalRequestGrantsDALFactory } from "@app/services/approval-policy/approval-request-dal";
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 { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TMfaSessionServiceFactory } from "@app/services/mfa-session/mfa-session-service";
import { MfaSessionStatus } from "@app/services/mfa-session/mfa-session-types";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TPamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types";
@@ -68,7 +73,9 @@ type TPamAccountServiceFactoryDep = {
pamSessionDAL: TPamSessionDALFactory;
pamAccountDAL: TPamAccountDALFactory;
pamFolderDAL: TPamFolderDALFactory;
mfaSessionService: TMfaSessionServiceFactory;
projectDAL: TProjectDALFactory;
orgDAL: TOrgDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
@@ -78,10 +85,13 @@ type TPamAccountServiceFactoryDep = {
>;
userDAL: TUserDALFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
smtpService: Pick<TSmtpService, "sendMail">;
approvalPolicyDAL: TApprovalPolicyDALFactory;
approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory;
pamSessionExpirationService: Pick<TPamSessionExpirationServiceFactory, "scheduleSessionExpiration">;
};
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
const ROTATION_CONCURRENCY_LIMIT = 10;
@@ -90,8 +100,10 @@ export const pamAccountServiceFactory = ({
pamResourceDAL,
pamSessionDAL,
pamAccountDAL,
mfaSessionService,
pamFolderDAL,
projectDAL,
orgDAL,
userDAL,
permissionService,
licenseService,
@@ -110,7 +122,8 @@ export const pamAccountServiceFactory = ({
description,
folderId,
rotationEnabled,
rotationIntervalSeconds
rotationIntervalSeconds,
requireMfa
}: TCreateAccountDTO,
actor: OrgServiceActor
) => {
@@ -198,7 +211,8 @@ export const pamAccountServiceFactory = ({
description,
folderId,
rotationEnabled,
rotationIntervalSeconds
rotationIntervalSeconds,
requireMfa
});
return {
@@ -222,7 +236,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);
@@ -268,6 +290,10 @@ export const pamAccountServiceFactory = ({
updateDoc.name = name;
}
if (requireMfa !== undefined) {
updateDoc.requireMfa = requireMfa;
}
if (description !== undefined) {
updateDoc.description = description;
}
@@ -552,7 +578,16 @@ export const pamAccountServiceFactory = ({
};
const access = async (
{ accountPath, projectId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO,
{
accountPath,
projectId,
actorEmail,
actorIp,
actorName,
actorUserAgent,
duration,
mfaSessionId
}: TAccessAccountDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
@@ -640,6 +675,76 @@ export const pamAccountServiceFactory = ({
);
}
const project = await projectDAL.findById(account.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${account.projectId}' not found` });
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 && 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` });
// Determine which MFA method to use
// Priority: org-enforced > user-selected > email as fallback
const orgMfaMethod = org.enforceMfa ? (org.selectedMfaMethod as MfaMethod | null) : undefined;
const userMfaMethod = actorUser.isMfaEnabled ? (actorUser.selectedMfaMethod as MfaMethod | null) : undefined;
const mfaMethod = (orgMfaMethod ?? userMfaMethod ?? MfaMethod.EMAIL) as MfaMethod;
// Create MFA session
const newMfaSessionId = await mfaSessionService.createMfaSession(actorUser.id, account.id, mfaMethod);
// If MFA method is email, send the code immediately
if (mfaMethod === MfaMethod.EMAIL && actorUser.email) {
await mfaSessionService.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 && account.requireMfa) {
const mfaSession = await mfaSessionService.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 !== actor.id) {
throw new BadRequestError({
message: "MFA session does not belong to current user"
});
}
// Verify the session is for the same account
if (mfaSession.resourceId !== account.id) {
throw new BadRequestError({
message: "MFA session is for a different account"
});
}
// 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 mfaSessionService.deleteMfaSession(mfaSessionId);
}
const { connectionDetails, gatewayId, resourceType } = await decryptResource(
resource,
account.projectId,

View File

@@ -6,7 +6,7 @@ import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
// DTOs
export type TCreateAccountDTO = Pick<
TPamAccount,
"name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationIntervalSeconds"
"name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationIntervalSeconds" | "requireMfa"
> & {
rotationEnabled?: boolean;
};
@@ -23,6 +23,7 @@ export type TAccessAccountDTO = {
actorName: string;
actorUserAgent: string;
duration: number;
mfaSessionId?: string;
};
export type TListAccountsDTO = {

View File

@@ -66,12 +66,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()
});

View File

@@ -81,6 +81,8 @@ export const KeyStorePrefixes = {
`group-member-project-permission:${projectId}:${groupId}:*` 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,
AiMcpServerOAuth: (sessionId: string) => `ai-mcp-server-oauth:${sessionId}` as const,
@@ -94,7 +96,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 = {

View File

@@ -391,6 +391,9 @@ const envSchema = z
})
),
/* OracleDB ----------------------------------------------------------------------------- */
TNS_ADMIN: zpStr(z.string().optional()),
/* INTERNAL ----------------------------------------------------------------------------- */
INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional())
})

View File

@@ -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;
}
}

View File

@@ -134,9 +134,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)

View File

@@ -293,6 +293,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";
@@ -391,6 +392,8 @@ 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";
@@ -570,6 +573,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);
@@ -914,6 +918,13 @@ export const registerRoutes = async (
kmsService
});
const webAuthnService = webAuthnServiceFactory({
webAuthnCredentialDAL,
userDAL,
tokenService,
keyStore
});
const loginService = authLoginServiceFactory({
userDAL,
smtpService,
@@ -2451,6 +2462,13 @@ export const registerRoutes = async (
gatewayV2Service
});
const mfaSessionService = mfaSessionServiceFactory({
keyStore,
tokenService,
smtpService,
totpService
});
const approvalPolicyDAL = approvalPolicyDALFactory(db);
const pamSessionExpirationService = pamSessionExpirationServiceFactory({
queueService,
@@ -2467,8 +2485,12 @@ export const registerRoutes = async (
pamSessionDAL,
permissionService,
projectDAL,
orgDAL,
userDAL,
auditLogService,
mfaSessionService,
tokenService,
smtpService,
approvalRequestGrantsDAL,
approvalPolicyDAL,
pamSessionExpirationService
@@ -2702,6 +2724,7 @@ export const registerRoutes = async (
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
projectTemplate: projectTemplateService,
totp: totpService,
webAuthn: webAuthnService,
appConnection: appConnectionService,
secretSync: secretSyncService,
kmip: kmipService,
@@ -2723,6 +2746,7 @@ export const registerRoutes = async (
pamResource: pamResourceService,
pamAccount: pamAccountService,
pamSession: pamSessionService,
mfaSession: mfaSessionService,
upgradePath: upgradePathService,
membershipUser: membershipUserService,

View File

@@ -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(),

View File

@@ -2,7 +2,6 @@ import { z } from "zod";
import {
AccessScope,
OrgMembershipRole,
ProjectMembershipRole,
ProjectMembershipsSchema,
ProjectUserMembershipRolesSchema,
@@ -275,7 +274,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
orgId: req.permission.orgId
},
data: {
roles: [{ isTemporary: false, role: OrgMembershipRole.NoAccess }],
roles: [],
usernames: usernamesAndEmails
}
});

View File

@@ -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()).default({}),
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 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 AuthenticationResponseJSON
});
}
});
server.route({
method: "PATCH",
url: "/me/webauthn/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: 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,
id: req.params.id,
name: req.body.name
});
}
});
server.route({
method: "DELETE",
url: "/me/webauthn/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: 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,
id: req.params.id
});
return { success: true };
}
});
};

View File

@@ -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" });

View File

@@ -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;
}
}
});
};

View File

@@ -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;
}
});
};

View File

@@ -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";
@@ -7,6 +9,7 @@ import {
TSqlCredentialsRotationGeneratedCredentials,
TSqlCredentialsRotationWithConnection
} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
@@ -79,12 +82,46 @@ const getConnectionConfig = ({
}
};
// if TNS_ADMIN is set and the directory exists, we assume it's a wallet connection for OracleDB
const isOracleWalletConnection = (app: AppConnection): boolean => {
const { TNS_ADMIN } = getConfig();
return app === AppConnection.OracleDB && !!TNS_ADMIN && fs.existsSync(TNS_ADMIN);
};
const getOracleWalletKnexClient = (
credentials: Pick<TSqlConnection["credentials"], "username" | "password" | "database">
): Knex => {
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#mutual-tls-wallet`
});
}
}
return knex({
client: SQL_CONNECTION_CLIENT_MAP[AppConnection.OracleDB],
connection: {
user: credentials.username,
password: credentials.password,
connectString: credentials.database
}
});
};
export const getSqlConnectionClient = async (appConnection: Pick<TSqlConnection, "credentials" | "app">) => {
const {
app,
credentials: { host: baseHost, database, port, password, username }
} = appConnection;
if (isOracleWalletConnection(app)) {
return getOracleWalletKnexClient({ username, password, database });
}
const [host] = await verifyHostInputValidity(baseHost);
const client = knex({
@@ -119,21 +156,30 @@ export const executeWithPotentialGateway = async <T>(
targetPort: credentials.port
});
const createClient = (proxyPort: number): Knex => {
const { database, username, password } = credentials;
if (isOracleWalletConnection(app)) {
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 +200,7 @@ export const executeWithPotentialGateway = async <T>(
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 {

View File

@@ -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();

View File

@@ -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 = {

View File

@@ -882,6 +882,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);

View File

@@ -100,5 +100,6 @@ export type AuthModeProviderSignUpTokenPayload = {
export enum MfaMethod {
EMAIL = "email",
TOTP = "totp"
TOTP = "totp",
WEBAUTHN = "webauthn"
}

View File

@@ -1,5 +1,6 @@
import {
AccessScope,
OrgMembershipRole,
OrgMembershipStatus,
ProjectMembershipRole,
TemporaryPermissionMode,
@@ -157,13 +158,22 @@ export const membershipUserServiceFactory = ({
const { scopeData, data } = dto;
const factory = scopeFactory[scopeData.scope];
const hasNoPermanentRole = data.roles.every((el) => el.isTemporary);
const orgDetails = await orgDAL.findById(dto.permission.orgId);
// If roles array is empty and scope is Organization, use org's default role
let rolesToUse = data.roles;
if (data.roles.length === 0 && scopeData.scope === AccessScope.Organization) {
const defaultRole = orgDetails.defaultMembershipRole || OrgMembershipRole.NoAccess;
rolesToUse = [{ isTemporary: false, role: defaultRole }];
}
const hasNoPermanentRole = rolesToUse.every((el) => el.isTemporary);
if (hasNoPermanentRole) {
throw new BadRequestError({
message: "User must have at least one permanent role"
});
}
const isInvalidTemporaryRole = data.roles.some((el) => {
const isInvalidTemporaryRole = rolesToUse.some((el) => {
if (el.isTemporary) {
if (!el.temporaryAccessStartTime || !el.temporaryRange) {
return true;
@@ -188,7 +198,6 @@ export const membershipUserServiceFactory = ({
});
if (existingMemberships.length === users.length) return { memberships: [] };
const orgDetails = await orgDAL.findById(dto.permission.orgId);
const isSubOrganization = Boolean(orgDetails.rootOrgId);
const newMembershipUsers = users.filter((user) => !existingMemberships?.find((el) => el.actorUserId === user.id));
@@ -212,7 +221,7 @@ export const membershipUserServiceFactory = ({
};
});
const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role));
const customInputRoles = rolesToUse.filter((el) => factory.isCustomRole(el.role));
const hasCustomRole = customInputRoles.length > 0;
if (hasCustomRole) {
const plan = await licenseService.getPlan(scopeData.orgId);
@@ -241,7 +250,7 @@ export const membershipUserServiceFactory = ({
const roleDocs: TMembershipRolesInsert[] = [];
docs.forEach((membership) => {
data.roles.forEach((membershipRole) => {
rolesToUse.forEach((membershipRole) => {
const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]);
if (membershipRole.isTemporary) {
const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null;

View File

@@ -0,0 +1,175 @@
import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto";
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<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
smtpService: Pick<TSmtpService, "sendMail">;
totpService: Pick<TTotpServiceFactory, "verifyUserTotp" | "verifyWithUserRecoveryCode">;
};
export type TMfaSessionServiceFactory = ReturnType<typeof mfaSessionServiceFactory>;
export const mfaSessionServiceFactory = ({
keyStore,
tokenService,
smtpService,
totpService
}: TMfaSessionServiceFactoryDep) => {
// Helper function to get MFA session from Redis
const getMfaSession = async (mfaSessionId: string): Promise<TMfaSession | null> => {
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<void> => {
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"
});
}
if (mfaSession.mfaMethod !== mfaMethod) {
throw new BadRequestError({
message: "MFA method does not match the session"
});
}
// 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
};
};
const createMfaSession = async (userId: string, resourceId: string, mfaMethod: MfaMethod): Promise<string> => {
const mfaSessionId = crypto.randomBytes(32).toString("hex");
const mfaSession: TMfaSession = {
sessionId: mfaSessionId,
userId,
resourceId,
status: MfaSessionStatus.PENDING,
mfaMethod
};
await keyStore.setItemWithExpiry(
KeyStorePrefixes.MfaSession(mfaSessionId),
KeyStoreTtls.MfaSessionInSeconds,
JSON.stringify(mfaSession)
);
return mfaSessionId;
};
const deleteMfaSession = async (mfaSessionId: string) => {
await keyStore.deleteItem(KeyStorePrefixes.MfaSession(mfaSessionId));
};
return {
createMfaSession,
verifyMfaSession,
getMfaSessionStatus,
sendMfaCode,
getMfaSession,
deleteMfaSession
};
};

View File

@@ -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;
};

View File

@@ -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<typeof webAuthnCredentialDALFactory>;
export const webAuthnCredentialDALFactory = (db: TDbClient) => {
const webAuthnCredentialDal = ormify(db, TableName.WebAuthnCredential);
return webAuthnCredentialDal;
};

View File

@@ -0,0 +1,385 @@
import {
AuthenticatorTransportFuture,
generateAuthenticationOptions,
generateRegistrationOptions,
VerifiedAuthenticationResponse,
VerifiedRegistrationResponse,
verifyAuthenticationResponse,
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";
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 {
TDeleteWebAuthnCredentialDTO,
TGenerateAuthenticationOptionsDTO,
TGenerateRegistrationOptionsDTO,
TGetUserWebAuthnCredentialsDTO,
TUpdateWebAuthnCredentialDTO,
TVerifyAuthenticationResponseDTO,
TVerifyRegistrationResponseDTO
} from "./webauthn-types";
type TWebAuthnServiceFactoryDep = {
userDAL: TUserDALFactory;
webAuthnCredentialDAL: TWebAuthnCredentialDALFactory;
tokenService: TAuthTokenServiceFactory;
keyStore: TKeyStoreFactory;
};
export type TWebAuthnServiceFactory = ReturnType<typeof webAuthnServiceFactory>;
export const webAuthnServiceFactory = ({
userDAL,
webAuthnCredentialDAL,
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<string | null> => {
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
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: {
requireResidentKey: true,
residentKey: "required",
userVerification: "required"
}
});
// Store challenge for verification
await 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 = await 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) {
await clearChallenge(userId);
throw new BadRequestError({
message: `Registration verification failed: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
if (!verification.verified || !verification.registrationInfo) {
await 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) {
await 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
await 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
await 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 (userId !== credential.userId) {
throw new ForbiddenRequestError({
message: "Credential does not belong to this user"
});
}
// Retrieve the stored challenge
const expectedChallenge = await 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) {
await clearChallenge(userId);
throw new BadRequestError({
message: `Authentication verification failed: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
if (!verification.verified) {
await 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
await 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, id }: TDeleteWebAuthnCredentialDTO) => {
const credential = await webAuthnCredentialDAL.findById(id);
if (!credential) {
throw new NotFoundError({
message: "Credential not found"
});
}
if (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, id, name }: TUpdateWebAuthnCredentialDTO) => {
const credential = await webAuthnCredentialDAL.findById(id);
if (!credential) {
throw new NotFoundError({
message: "Credential not found"
});
}
if (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
};
};

View File

@@ -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;
id: string;
};
export type TUpdateWebAuthnCredentialDTO = {
userId: string;
id: string;
name?: string;
};

View File

@@ -44,17 +44,113 @@ Infisical supports connecting to OracleDB using a database user.
</Tabs>
</Step>
<Step title="Get Connection Details">
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)
<Tabs>
<Tab title="One-way TLS">
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)
<Note>
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`.
</Note>
<Note>
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`.
</Note>
</Tab>
<Tab title="Mutual TLS (Wallet)">
<Info>
This configuration can only be done on self-hosted or dedicated instances of Infisical.
</Info>
Infisical includes Oracle Instant Client by default, enabling mTLS wallet-based connections without modifying the Docker image. You only need to mount your Oracle Wallet and configure the environment.
<Warning>
When `TNS_ADMIN` is set and points to a valid wallet directory, **all Oracle Database connections** in your Infisical instance will use the wallet for authentication.
**Gateway Limitation**: Wallet-based connections do not support [Infisical Gateway](/documentation/platform/gateways/overview). The connection details (host, port, protocol) are read directly from the `tnsnames.ora` file in the wallet, bypassing the gateway routing.
</Warning>
### Prerequisites
Your Oracle Wallet folder should contain the following files:
- `cwallet.sso` - Auto-login wallet (SSO wallet)
- `tnsnames.ora` - Connection aliases for your Oracle Database
- `sqlnet.ora` - Network configuration
### Configuration Steps
<Steps>
<Step title="Prepare your wallet">
Ensure your `sqlnet.ora` file points to the correct wallet directory. Update the `DIRECTORY` path to match where you'll mount the wallet in the container:
```ini
WALLET_LOCATION =
(SOURCE =
(METHOD = FILE)
(METHOD_DATA =
(DIRECTORY = /app/wallet)
)
)
SQLNET.AUTHENTICATION_SERVICES = (TCPS)
SSL_CLIENT_AUTHENTICATION = TRUE
```
</Step>
<Step title="Mount the wallet and set environment variables">
Mount your wallet directory and set the `TNS_ADMIN` environment variable to point to it.
**Environment Variable (`.env` file):**
```ini
TNS_ADMIN=/app/wallet
```
**Volume Mount Examples:**
<Tabs>
<Tab title="Docker">
```bash
docker run -d \
-v /path/to/your/wallet:/app/wallet:ro \
--env-file .env \
# ... other Infisical configuration ...
infisical/infisical:latest
```
</Tab>
<Tab title="Docker Compose">
```yaml
services:
infisical:
image: infisical/infisical:latest
env_file:
- .env
volumes:
- /path/to/your/wallet:/app/wallet:ro
# ... other Infisical configuration ...
```
</Tab>
</Tabs>
</Step>
<Step title="Create the connection">
You'll need the following information to create the connection in Infisical:
- `host` - The hostname or IP address of your Oracle Database server (required field, but not used for wallet connections).
- `port` - The port number your Oracle Database server is listening on (required field, but not used for wallet connections).
- `database` - The TNS alias for your Oracle Database from your `tnsnames.ora` file.
- `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>
When a wallet is detected (via the `TNS_ADMIN` environment variable), the connection uses the TNS alias from the `database` field to look up full connection details (host, port, protocol) from your `tnsnames.ora` file.
The host and port fields in the connection form are required but ignored for wallet connections. Any SSL settings in the connection form are also ignored - the wallet's certificates are used instead.
</Note>
</Step>
</Steps>
<Note>
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`.
</Note>
</Tab>
</Tabs>
</Step>
</Steps>

View File

@@ -970,6 +970,13 @@ Please refer to the [templating functions documentation](/integrations/platforms
</Accordion>
<Accordion title="managedKubeSecretReferences[].template.metadata">
Define custom labels and annotations for the managed Kubernetes secret. This allows you to specify metadata that should be applied to the managed secret separately from the InfisicalSecret itself.
For detailed information on how metadata propagation works and examples, see the [Propagating Labels & Annotations](#propagating-labels-&-annotations) section.
</Accordion>
### Operator Managed ConfigMaps
The managed config map properties specify where to store the secrets retrieved from your Infisical project. Config maps can be used to store **non-sensitive** data, such as application configuration variables.
@@ -1078,6 +1085,13 @@ Please refer to the [templating functions documentation](/integrations/platforms
</Accordion>
<Accordion title="managedKubeConfigMapReferences[].template.metadata">
Define custom labels and annotations for the managed Kubernetes ConfigMap. This allows you to specify metadata that should be applied to the managed ConfigMap separately from the InfisicalSecret itself.
This field works the same way as `template.metadata` for managed secrets. For detailed information on how metadata propagation works and examples, see the [Propagating Labels & Annotations](#propagating-labels-&-annotations) section.
</Accordion>
## Applying CRD
Once you have configured the InfisicalSecret CRD with the required fields, you can apply it to your cluster.
@@ -1564,10 +1578,13 @@ stringData:
## Propagating Labels & Annotations
The operator will transfer all labels & annotations present on the `InfisicalSecret` CRD to the managed Kubernetes secret to be created.
Thus, if a specific label is required on the resulting secret, it can be applied as demonstrated in the following example:
The operator provides flexible options for managing labels and annotations on managed Kubernetes secrets.
<Accordion title="Default Behavior (Without template.metadata)">
By default, the operator will transfer all labels & annotations present on the `InfisicalSecret` to the managed Kubernetes secret to be created.
### Example
<Accordion title="Example propagation">
```yaml
apiVersion: secrets.infisical.com/v1alpha1
kind: InfisicalSecret
@@ -1578,28 +1595,96 @@ Thus, if a specific label is required on the resulting secret, it can be applied
annotations:
example.com/annotation-to-be-passed-to-managed-secret: "sample-value"
spec:
..
authentication:
...
# ... auth config ...
managedKubeSecretReferences:
...
- secretName: managed-token
secretNamespace: default
```
This would result in the following managed secret to be created:
This would result in the following managed secret to be created:
```yaml
apiVersion: v1
data: ...
kind: Secret
metadata:
annotations:
example.com/annotation-to-be-passed-to-managed-secret: sample-value
secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw"
labels:
label-to-be-passed-to-managed-secret: sample-value
name: managed-token
namespace: default
type: Opaque
```
```yaml
apiVersion: v1
data: ...
kind: Secret
metadata:
annotations:
example.com/annotation-to-be-passed-to-managed-secret: sample-value
secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw"
labels:
label-to-be-passed-to-managed-secret: sample-value
name: managed-token
namespace: default
type: Opaque
```
</Accordion>
<Accordion title="Custom Metadata (With template.metadata)">
When you specify `template.metadata` in your template configuration, you have full control over which labels and annotations are applied to the managed secret:
- Labels and annotations from `template.metadata` are used exclusively on the managed secret
- InfisicalSecret labels and annotations are NOT propagated to the managed secret
- This allows you to keep InfisicalSecret-specific metadata separate from the managed secret metadata
<Tip>
To prevent any propagation while using `template.metadata`, pass empty objects for labels and/or annotations.
This will ensure no labels or annotations are propagated to the managed secret, even from the InfisicalSecret CRD's own labels/annotations:
```yaml
template:
metadata:
labels: {}
annotations: {}
```
</Tip>
### Example
```yaml
apiVersion: secrets.infisical.com/v1alpha1
kind: InfisicalSecret
metadata:
name: infisicalsecret-with-template-metadata
labels:
managed-by: infisical-operator
annotations:
example.com/cr-specific: "metadata"
spec:
authentication:
# ... auth config ...
managedKubeSecretReferences:
- secretName: managed-secret-with-custom-metadata
secretNamespace: default
template:
includeAllSecrets: true
metadata:
labels:
app: my-application
environment: production
tier: backend
annotations:
secret.example.com/description: "Production database credentials"
secret.example.com/owner: "platform-team"
```
This would result in the following managed secret to be created:
```yaml
apiVersion: v1
data: ...
kind: Secret
metadata:
annotations:
secret.example.com/description: "Production database credentials"
secret.example.com/owner: "platform-team"
labels:
app: my-application
environment: production
tier: backend
name: managed-secret-with-custom-metadata
namespace: default
type: Opaque
```
</Accordion>

View File

@@ -48,6 +48,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",
@@ -4141,6 +4142,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",

View File

@@ -57,6 +57,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",

View File

@@ -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<number | undefined>(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,153 @@ 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();
} 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 +346,35 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
);
}
if (shouldShowWebAuthnRegistration) {
return (
<>
<div className="mb-6 text-center text-lg font-bold text-white">
Your organization requires passkey authentication to be configured.
</div>
<div className="mx-auto w-max pt-4 pb-4 md:mb-16 md:px-8">
<div className="flex max-w-lg flex-col text-bunker-200">
<div className="mb-8">
1. Click the button below to register your passkey. You&apos;ll be prompted to use
your device&apos;s biometric authentication (Touch ID, Face ID, Windows Hello, etc.).
</div>
<div className="mb-4">2. Optionally, give your passkey a name to identify it later</div>
<div className="mb-4 flex flex-col gap-2">
<Input
onChange={(e) => setCredentialName(e.target.value)}
value={credentialName}
placeholder="Passkey name (optional)"
/>
<Button onClick={handleRegisterPasskey} isLoading={isRegisteringPasskey}>
Register Passkey
</Button>
</div>
</div>
</div>
</>
);
}
return (
<div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8">
{!hideLogo && (
@@ -197,83 +400,113 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
</p>
</div>
)}
<form onSubmit={verifyMfa}>
<div className="mx-auto hidden md:block" style={{ minWidth: "600px" }}>
{method === MfaMethod.EMAIL && (
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-6 mb-2"
{...codeInputProps}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mt-8 mb-6 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery" : "totp"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputProps}
/>
</div>
)}
{method === MfaMethod.WEBAUTHN && (
<div className="mb-8 text-center">
<h2 className="mb-3 text-xl font-medium text-bunker-100">Passkey Authentication</h2>
<p className="mx-auto max-w-md text-sm leading-relaxed text-bunker-300">
Use your registered passkey to complete two-factor authentication
</p>
</div>
<div className="mx-auto mt-4 block md:hidden" style={{ minWidth: "400px" }}>
{method === MfaMethod.EMAIL && (
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-2 mb-2"
{...codeInputPropsPhone}
/>
</div>
)}
{method === MfaMethod.WEBAUTHN ? (
<>
{typeof triesLeft === "number" && (
<Error text={`Failed authentication. You have ${triesLeft} attempt(s) remaining.`} />
)}
{method === MfaMethod.TOTP && (
<div className="mt-4 mb-6 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery-mobile" : "totp-mobile"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputPropsPhone}
/>
</div>
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<Button
size="md"
onClick={handleWebAuthnVerification}
isFullWidth
className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={typeof triesLeft === "number" && triesLeft <= 0}
>
Authenticate with Passkey
</Button>
</div>
</>
) : (
<form onSubmit={verifyMfa}>
<div className="mx-auto hidden md:block" style={{ minWidth: "600px" }}>
{method === MfaMethod.EMAIL && (
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-6 mb-2"
{...codeInputProps}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mt-8 mb-6 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery" : "totp"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputProps}
/>
</div>
)}
</div>
<div className="mx-auto mt-4 block md:hidden" style={{ minWidth: "400px" }}>
{method === MfaMethod.EMAIL && (
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-2 mb-2"
{...codeInputPropsPhone}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mt-4 mb-6 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery-mobile" : "totp-mobile"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputPropsPhone}
/>
</div>
)}
</div>
{typeof triesLeft === "number" && (
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
)}
</div>
{typeof triesLeft === "number" && (
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
)}
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<Button
size="md"
type="submit"
isFullWidth
className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={!isCodeComplete || (typeof triesLeft === "number" && triesLeft <= 0)}
>
{String(t("mfa.verify"))}
</Button>
</div>
</form>
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<Button
size="md"
type="submit"
isFullWidth
className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={!isCodeComplete || (typeof triesLeft === "number" && triesLeft <= 0)}
>
{String(t("mfa.verify"))}
</Button>
</div>
</form>
)}
{method === MfaMethod.TOTP && (
<div className="mt-6 flex flex-col items-center gap-4 text-sm">
<button

View File

@@ -28,12 +28,12 @@ const SCOPE_BADGE: Record<NonNullable<Props["scope"]>, { icon: LucideIcon; class
};
export const PageHeader = ({ title, description, children, className, scope }: Props) => (
<div className={twMerge("mb-10 w-full", className)}>
<div className={twMerge("mb-10 w-full border-b border-border pb-8", className)}>
<div className="flex w-full justify-between">
<div className="mr-4 flex min-w-0 flex-1 items-center">
<h1
className={twMerge(
"truncate text-2xl font-medium text-white underline decoration-2 underline-offset-4",
"truncate text-2xl font-medium text-white underline underline-offset-4",
scope === "org" && "decoration-org/90",
scope === "instance" && "decoration-neutral/90",
scope === "namespace" && "decoration-sub-org/90",

View File

@@ -10,7 +10,7 @@ function UnstableAccordion({ ...props }: React.ComponentProps<typeof AccordionPr
return (
<AccordionPrimitive.Root
data-slot="accordion"
className="overflow-clip rounded-md border border-border bg-container"
className="overflow-clip rounded-md border border-border bg-container text-foreground"
{...props}
/>
);

View File

@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "cva";
import { cn } from "../../utils";
const alertVariants = cva(
"relative w-full border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
"relative w-full rounded-md border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {

View File

@@ -51,6 +51,7 @@ function UnstableTableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row"
className={cn(
"border-b border-border transition-colors hover:bg-foreground/5 data-[state=selected]:bg-foreground/5",
props.onClick && "cursor-pointer",
className
)}
{...props}

View File

@@ -11,11 +11,11 @@ export {
ProjectPermissionGroupActions,
ProjectPermissionIdentityActions,
ProjectPermissionKmipActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSshHostActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionSub
} from "./types";

View File

@@ -22,12 +22,12 @@ export {
ProjectPermissionGroupActions,
ProjectPermissionIdentityActions,
ProjectPermissionKmipActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSshHostActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionSub,
useProjectPermission
} from "./ProjectPermissionContext";

View File

@@ -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";
};

View File

@@ -7,6 +7,7 @@ import { SessionStorageKeys } from "@app/const";
import { organizationKeys } from "../organization/queries";
import { projectKeys } from "../projects";
import { setAuthToken } from "../reactQuery";
import { TGenerateAuthenticationOptionsResponse, TVerifyAuthenticationDTO } from "../webauthn";
import {
CompleteAccountDTO,
CompleteAccountSignupDTO,
@@ -333,6 +334,36 @@ export const checkUserTotpMfa = async () => {
return data.isVerified;
};
export const checkUserWebAuthnMfa = async () => {
const { data } = await apiRequest.get<{ hasPasskeys: boolean }>(
"/api/v2/auth/mfa/check/webauthn"
);
return data.hasPasskeys;
};
export const useMfaGenerateAuthenticationOptions = () =>
useMutation({
mutationFn: async () => {
const { data } = await apiRequest.post<TGenerateAuthenticationOptionsResponse>(
"/api/v2/auth/mfa/webauthn/authenticate"
);
return data;
}
});
export const useMfaVerifyAuthentication = () =>
useMutation({
mutationFn: async (dto: TVerifyAuthenticationDTO) => {
const { data } = await apiRequest.post<{
verified: boolean;
credentialId: string;
sessionToken: string;
}>("/api/v2/auth/mfa/webauthn/verify", dto);
return data;
}
});
export const useSendPasswordSetupEmail = () => {
return useMutation({
mutationFn: async () => {

View File

@@ -149,5 +149,6 @@ export enum UserAgentType {
export enum MfaMethod {
EMAIL = "email",
TOTP = "totp"
TOTP = "totp",
WEBAUTHN = "webauthn"
}

View File

@@ -28,6 +28,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";

View File

@@ -0,0 +1,7 @@
export { useMfaSessionStatus, useVerifyMfaSession } from "./queries";
export type {
TMfaSessionStatusResponse,
TVerifyMfaSessionRequest,
TVerifyMfaSessionResponse
} from "./types";
export { MfaSessionStatus } from "./types";

View File

@@ -0,0 +1,45 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
MfaSessionStatus,
TMfaSessionStatusResponse,
TVerifyMfaSessionRequest,
TVerifyMfaSessionResponse
} from "./types";
export const useMfaSessionStatus = (mfaSessionId: string, enabled = true) => {
return useQuery({
queryKey: ["mfa-session-status", mfaSessionId],
queryFn: async () => {
const { data } = await apiRequest.get<TMfaSessionStatusResponse>(
`/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<TVerifyMfaSessionResponse>(
`/api/v2/mfa-sessions/${mfaSessionId}/verify`,
{
mfaToken,
mfaMethod
}
);
return data;
}
});
};

View File

@@ -0,0 +1,22 @@
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;
};

View File

@@ -14,7 +14,8 @@ export interface TBasePamAccount {
name: string;
description?: string | null;
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
rotationIntervalSeconds?: number;
requireMfa?: boolean | null;
lastRotatedAt?: string | null;
lastRotationMessage?: string | null;
rotationStatus?: string | null;

View File

@@ -143,13 +143,13 @@ export type TListPamAccountsDTO = {
export type TCreatePamAccountDTO = Pick<
TPamAccount,
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId"
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId" | "requireMfa"
> & {
resourceType: PamResourceType;
};
export type TUpdatePamAccountDTO = Partial<
Pick<TPamAccount, "name" | "description" | "credentials">
Pick<TPamAccount, "name" | "description" | "credentials" | "requireMfa">
> & {
accountId: string;
resourceType: PamResourceType;

View File

@@ -0,0 +1,18 @@
export {
useDeleteWebAuthnCredential,
useGenerateAuthenticationOptions,
useGenerateRegistrationOptions,
useUpdateWebAuthnCredential,
useVerifyAuthentication,
useVerifyRegistration
} from "./mutations";
export { useGetWebAuthnCredentials } from "./queries";
export type {
TDeleteWebAuthnCredentialDTO,
TGenerateAuthenticationOptionsResponse,
TGenerateRegistrationOptionsResponse,
TUpdateWebAuthnCredentialDTO,
TVerifyAuthenticationDTO,
TVerifyRegistrationDTO,
TWebAuthnCredential
} from "./types";

View File

@@ -0,0 +1,96 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { webAuthnKeys } from "./queries";
import {
TDeleteWebAuthnCredentialDTO,
TGenerateAuthenticationOptionsResponse,
TGenerateRegistrationOptionsResponse,
TUpdateWebAuthnCredentialDTO,
TVerifyAuthenticationDTO,
TVerifyRegistrationDTO
} from "./types";
export const useGenerateRegistrationOptions = () =>
useMutation({
mutationFn: async () => {
const { data } = await apiRequest.post<TGenerateRegistrationOptionsResponse>(
"/api/v1/user/me/webauthn/register"
);
return data;
}
});
export const useVerifyRegistration = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (dto: TVerifyRegistrationDTO) => {
const { data } = await apiRequest.post<{ credentialId: string; name?: string | null }>(
"/api/v1/user/me/webauthn/register/verify",
dto
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: webAuthnKeys.credentials });
}
});
};
export const useGenerateAuthenticationOptions = () =>
useMutation({
mutationFn: async () => {
const { data } = await apiRequest.post<TGenerateAuthenticationOptionsResponse>(
"/api/v1/user/me/webauthn/authenticate"
);
return data;
}
});
export const useVerifyAuthentication = () =>
useMutation({
mutationFn: async (dto: TVerifyAuthenticationDTO) => {
const { data } = await apiRequest.post<{
verified: boolean;
credentialId: string;
sessionToken: string;
}>("/api/v1/user/me/webauthn/authenticate/verify", dto);
return data;
}
});
export const useDeleteWebAuthnCredential = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id }: TDeleteWebAuthnCredentialDTO) => {
const { data } = await apiRequest.delete<{ success: boolean }>(
`/api/v1/user/me/webauthn/${id}`
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: webAuthnKeys.credentials });
}
});
};
export const useUpdateWebAuthnCredential = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, name }: TUpdateWebAuthnCredentialDTO) => {
const { data } = await apiRequest.patch<{
id: string;
credentialId: string;
name?: string | null;
}>(`/api/v1/user/me/webauthn/${id}`, { name });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: webAuthnKeys.credentials });
}
});
};

View File

@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TWebAuthnCredential } from "./types";
export const webAuthnKeys = {
credentials: ["webauthn-credentials"] as const,
registrationOptions: ["webauthn-registration-options"] as const,
authenticationOptions: ["webauthn-authentication-options"] as const
};
export const useGetWebAuthnCredentials = () =>
useQuery({
queryKey: webAuthnKeys.credentials,
queryFn: async () => {
const { data } = await apiRequest.get<{ credentials: TWebAuthnCredential[] }>(
"/api/v1/user/me/webauthn"
);
return data.credentials;
}
});

View File

@@ -0,0 +1,37 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON
} from "@simplewebauthn/browser";
export type TWebAuthnCredential = {
id: string;
credentialId: string;
name?: string | null;
transports?: string[] | null;
createdAt: Date;
lastUsedAt?: Date | null;
};
export type TGenerateRegistrationOptionsResponse = PublicKeyCredentialCreationOptionsJSON;
export type TVerifyRegistrationDTO = {
registrationResponse: RegistrationResponseJSON;
name?: string;
};
export type TGenerateAuthenticationOptionsResponse = PublicKeyCredentialRequestOptionsJSON;
export type TVerifyAuthenticationDTO = {
authenticationResponse: AuthenticationResponseJSON;
};
export type TUpdateWebAuthnCredentialDTO = {
id: string;
name?: string;
};
export type TDeleteWebAuthnCredentialDTO = {
id: string;
};

View File

@@ -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<string | null>(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<HTMLFormElement>) => {
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 (
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800 bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8">
<div className="mb-4 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
<div className="mb-6 text-center">
<h2 className="mb-3 text-xl font-medium text-red-400">Session Expired</h2>
<p className="text-bunker-300">
This MFA session has expired or is invalid. Please try your action again.
</p>
</div>
</div>
</div>
);
}
if (!sessionStatus) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800 bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<div className="text-center">
<div className="mb-4 text-bunker-300">Loading...</div>
</div>
</div>
);
}
if (sessionStatus.status === MfaSessionStatus.ACTIVE) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800 bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8">
<div className="mb-4 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
<div className="mb-6 text-center">
<h2 className="mb-3 text-xl font-medium text-bunker-50">Verification Complete</h2>
<p className="text-bunker-300">This window will close automatically...</p>
</div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800 bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8">
<div className="mb-4 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
<div className="mb-8 text-center">
<h2 className="mb-3 text-xl font-medium text-bunker-100">Two-Factor Authentication</h2>
<p className="mx-auto max-w-md text-sm leading-relaxed text-bunker-300">
{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"}
</p>
</div>
{sessionStatus.mfaMethod === MfaMethod.WEBAUTHN ? (
<>
{error && <Error text={error} />}
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<Button
size="md"
onClick={handleWebAuthnVerification}
isFullWidth
className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
Authenticate with Passkey
</Button>
</div>
</>
) : (
<form onSubmit={handleVerifyMfa}>
<div className="mx-auto hidden md:block" style={{ minWidth: "600px" }}>
<div className="mt-8 mb-6 flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={getExpectedCodeLength()}
onChange={setMfaCode}
value={mfaCode}
className="mb-2"
{...codeInputProps}
/>
</div>
</div>
<div className="mx-auto mt-4 block md:hidden" style={{ minWidth: "400px" }}>
<div className="mt-4 mb-6 flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={getExpectedCodeLength()}
onChange={setMfaCode}
value={mfaCode}
className="mb-2"
{...codeInputPropsPhone}
/>
</div>
</div>
{error && <Error text={error} />}
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<Button
size="md"
type="submit"
isFullWidth
className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={!isCodeComplete}
>
Verify
</Button>
</div>
</form>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { MfaSessionPage } from "./MfaSessionPage";

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { MfaSessionPage } from "./MfaSessionPage";
export const Route = createFileRoute("/_authenticate/mfa-session/$mfaSessionId")({
component: MfaSessionPage
});

View File

@@ -236,7 +236,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
<Table>
<THead>
<Tr className="h-14">
<Th className="w-1/2">
<Th className="w-2/3">
<div className="flex items-center">
Name
<IconButton
@@ -309,7 +309,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
})
}
>
<Td className="group">
<Td className="group max-w-0 truncate">
{name}
{lastLoginAuthMethod && lastLoginTime && (
<Tooltip

View File

@@ -1,13 +1,28 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { ChevronLeftIcon, EllipsisIcon } from "lucide-react";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, Modal, ModalContent, PageHeader } from "@app/components/v2";
import { DeleteActionModal, Modal, ModalContent, PageHeader } from "@app/components/v2";
import {
OrgIcon,
UnstableAlert,
UnstableAlertDescription,
UnstableAlertTitle,
UnstableButton,
UnstableCard,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger
} from "@app/components/v3";
import { ROUTE_PATHS } from "@app/const/routes";
import {
OrgPermissionActions,
@@ -36,7 +51,7 @@ const Page = () => {
const { currentOrg, isSubOrganization } = useOrganization();
const orgId = currentOrg?.id || "";
const { data } = useGetOrgIdentityMembershipById(identityId);
const { mutateAsync: deleteIdentity, isPending: isDeletingIdentity } = useDeleteOrgIdentity();
const { mutateAsync: deleteIdentity } = useDeleteOrgIdentity();
const isAuthHidden = orgId !== data?.identity?.orgId;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -68,41 +83,55 @@ const Page = () => {
});
};
const isScopeIdentity = data?.identity.orgId === currentOrg.id;
return (
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex max-w-8xl flex-col">
{data && (
<div className="mx-auto w-full max-w-8xl">
<>
<Link
to="/organizations/$orgId/access-management"
params={{ orgId }}
search={{
selectedTab: OrgAccessControlTabSections.Identities
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
className="mb-4 flex w-fit items-center gap-x-1 text-sm text-mineshaft-400 transition duration-100 hover:text-mineshaft-400/80"
>
<FontAwesomeIcon icon={faChevronLeft} />
Organization Machine Identities
<ChevronLeftIcon size={16} />
{isSubOrganization ? "Sub-" : ""}Organization Machine Identities
</Link>
<PageHeader
scope={isSubOrganization ? "namespace" : "org"}
description={`${isSubOrganization ? "Sub-" : ""}Organization Machine Identity`}
description={`Configure and manage${isScopeIdentity ? " machine identity and " : " "}${isSubOrganization ? "sub-" : ""}organization access control`}
title={data.identity.name}
>
<div className="flex items-center gap-2">
{isSubOrganization && data.identity.orgId !== currentOrg.id && (
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline">
Options
<EllipsisIcon />
</UnstableButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(data.identity.id);
createNotification({
text: "Machine identity ID copied to clipboard",
type: "info"
});
}}
>
Copy Machine Identity ID
</UnstableDropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Identity}
renderTooltip
allowedLabel="Remove from sub-organization"
>
{(isAllowed) => (
<Button
colorSchema="danger"
variant="outline_bg"
size="xs"
<UnstableDropdownMenuItem
variant="danger"
isDisabled={!isAllowed}
isLoading={isDeletingIdentity}
onClick={() =>
handlePopUpOpen("deleteIdentity", {
identityId: data.identity.id,
@@ -110,23 +139,64 @@ const Page = () => {
})
}
>
Unlink Machine Identity
</Button>
{isScopeIdentity ? "Delete Machine Identity" : "Remove From Sub-Organization"}
</UnstableDropdownMenuItem>
)}
</OrgPermissionCan>
)}
</div>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</PageHeader>
<div className="flex flex-col gap-4 md:flex-row">
<div className="w-full md:w-96">
<IdentityDetailsSection
isOrgIdentity={data.identity.orgId === currentOrg.id}
identityId={identityId}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<div className="flex flex-1 flex-col gap-y-4">
{!isAuthHidden && (
<div className="flex flex-col gap-5 lg:flex-row">
<IdentityDetailsSection
isCurrentOrgIdentity={data.identity.orgId === currentOrg.id}
identityId={identityId}
handlePopUpOpen={handlePopUpOpen}
/>
<div className="flex flex-1 flex-col gap-y-5">
{isAuthHidden ? (
<UnstableCard>
<UnstableCardHeader>
<UnstableCardTitle>Authentication</UnstableCardTitle>
<UnstableCardDescription>
Configure authentication methods
</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<UnstableAlert variant="org">
<OrgIcon />
<UnstableAlertTitle>
Machine identity managed by organization
</UnstableAlertTitle>
<UnstableAlertDescription>
<p>
This machine identity&apos;s authentication methods are managed by your
organization. <br /> To make changes,{" "}
<OrgPermissionCan
I={OrgPermissionIdentityActions.Read}
an={OrgPermissionSubjects.Identity}
>
{(isAllowed) =>
isAllowed ? (
<Link
to="/organizations/$orgId/identities/$identityId"
className="inline-block cursor-pointer text-foreground underline underline-offset-2"
params={{
identityId,
orgId: data.identity.orgId
}}
>
go to organization access control
</Link>
) : null
}
</OrgPermissionCan>
.
</p>
</UnstableAlertDescription>
</UnstableAlert>
</UnstableCardContent>
</UnstableCard>
) : (
<IdentityAuthenticationSection
identityId={identityId}
handlePopUpOpen={handlePopUpOpen}
@@ -135,7 +205,7 @@ const Page = () => {
<IdentityProjectsSection identityId={identityId} />
</div>
</div>
</div>
</>
)}
<Modal
isOpen={popUp?.identity?.isOpen}

View File

@@ -1,9 +1,21 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PlusIcon } from "lucide-react";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
import {
UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle
} from "@app/components/v3";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { IdentityAuthMethod, useGetOrgIdentityMembershipById } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -20,51 +32,92 @@ type Props = {
export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => {
const { data, refetch } = useGetOrgIdentityMembershipById(identityId);
const { isSubOrganization } = useOrganization();
const hasAuthMethods = Boolean(data?.identity.authMethods.length);
return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Authentication</h3>
{!Object.values(IdentityAuthMethod).every((method) =>
data.identity.authMethods.includes(method)
) && (
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId,
name: data.identity.name,
allAuthMethods: data.identity.authMethods
});
}}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
<UnstableCard>
<UnstableCardHeader>
<UnstableCardTitle>Authentication</UnstableCardTitle>
<UnstableCardDescription>Configure authentication methods</UnstableCardDescription>
{hasAuthMethods &&
!Object.values(IdentityAuthMethod).every((method) =>
data.identity.authMethods.includes(method)
) && (
<UnstableCardAction>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{data.identity.authMethods.length ? "Add" : "Create"} Auth Method
</Button>
)}
</OrgPermissionCan>
{(isAllowed) => (
<UnstableButton
variant="outline"
isFullWidth
size="xs"
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId: data.identity.id,
name: data.identity.name,
allAuthMethods: data.identity.authMethods
});
}}
>
<PlusIcon />
Add Auth Method
</UnstableButton>
)}
</OrgPermissionCan>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
{data.identity.authMethods.length > 0 ? (
<ViewIdentityAuth
activeLockoutAuthMethods={data.identity.activeLockoutAuthMethods}
identityId={identityId}
authMethods={data.identity.authMethods}
onResetAllLockouts={refetch}
/>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
This machine identity has no auth methods configured
</UnstableEmptyTitle>
<UnstableEmptyDescription>
Add an auth method to use this machine identity
</UnstableEmptyDescription>
</UnstableEmptyHeader>
<UnstableEmptyContent>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<UnstableButton
variant={isSubOrganization ? "sub-org" : "org"}
size="xs"
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId,
name: data.identity.name,
allAuthMethods: data.identity.authMethods
});
}}
>
<PlusIcon />
Add Auth Method
</UnstableButton>
)}
</OrgPermissionCan>
</UnstableEmptyContent>
</UnstableEmpty>
)}
</div>
{data.identity.authMethods.length > 0 ? (
<ViewIdentityAuth
activeLockoutAuthMethods={data.identity.activeLockoutAuthMethods}
identityId={identityId}
authMethods={data.identity.authMethods}
onResetAllLockouts={refetch}
/>
) : (
<div className="w-full space-y-2 pt-2">
<p className="text-sm text-mineshaft-300">
No authentication methods configured. Get started by creating a new auth method.
</p>
</div>
)}
</div>
</UnstableCardContent>
</UnstableCard>
) : (
<div />
);

View File

@@ -1,26 +1,25 @@
import {
faCheck,
faChevronDown,
faCopy,
faEdit,
faKey,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { BanIcon, CheckIcon, ClipboardListIcon, PencilIcon } from "lucide-react";
import { OrgPermissionCan } from "@app/components/permissions";
import { Tooltip } from "@app/components/v2";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Tag,
Tooltip
} from "@app/components/v2";
Badge,
Detail,
DetailGroup,
DetailLabel,
DetailValue,
OrgIcon,
SubOrgIcon,
UnstableButtonGroup,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableIconButton
} from "@app/components/v3";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { identityAuthToNameMap, useGetOrgIdentityMembershipById } from "@app/hooks/api";
@@ -28,187 +27,171 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
identityId: string;
isOrgIdentity?: boolean;
isCurrentOrgIdentity?: boolean;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["identity", "identityAuthMethod", "deleteIdentity"]>,
data?: object
) => void;
};
export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdentity }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
export const IdentityDetailsSection = ({
identityId,
handlePopUpOpen,
isCurrentOrgIdentity
}: Props) => {
const [, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { isSubOrganization } = useOrganization();
const { data } = useGetOrgIdentityMembershipById(identityId);
return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Details</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="xs"
rightIcon={
<FontAwesomeIcon
className="ml-1 transition-transform duration-200 group-data-[state=open]:rotate-180"
icon={faChevronDown}
/>
}
colorSchema="secondary"
className="group select-none"
>
Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="mt-3 min-w-[120px]" align="end">
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={async () => {
handlePopUpOpen("identity", {
identityId,
name: data.identity.name,
orgId: data.identity.orgId,
hasDeleteProtection: data.identity.hasDeleteProtection,
role: data.role,
customRole: data.customRole,
metadata: data.metadata
});
}}
disabled={!isAllowed}
>
{isOrgIdentity ? "Edit Machine Identity" : "Edit Machine Identity Role"}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Delete}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:bg-red-500! hover:text-white!"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("deleteIdentity", {
identityId,
name: data.identity.name
});
}}
icon={<FontAwesomeIcon icon={faTrash} />}
disabled={!isAllowed}
>
{!isOrgIdentity ? "Remove From Sub-Organization" : "Delete Machine Identity"}
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Machine Identity ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{data.identity.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Machine identity details</UnstableCardDescription>
<UnstableCardAction>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<UnstableIconButton
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identity", {
identityId,
name: data.identity.name,
orgId: data.identity.orgId,
hasDeleteProtection: data.identity.hasDeleteProtection,
role: data.role,
customRole: data.customRole,
metadata: data.metadata
});
}}
size="xs"
variant="outline"
>
<PencilIcon />
</UnstableIconButton>
)}
</OrgPermissionCan>
</UnstableCardAction>
</UnstableCardHeader>
<UnstableCardContent>
<DetailGroup>
<Detail>
<DetailLabel>Name</DetailLabel>
<DetailValue>{data.identity.name}</DetailValue>
</Detail>
<Detail>
<DetailLabel>ID</DetailLabel>
<DetailValue className="flex items-center gap-x-1">
{data.identity.id}
<Tooltip content="Copy machine identity ID to clipboard">
<UnstableIconButton
onClick={() => {
navigator.clipboard.writeText(data.identity.id);
setCopyTextId("Copied");
}}
variant="ghost"
size="xs"
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
{/* TODO(scott): color this should be a button variant and create re-usable copy button */}
{isCopyingId ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.identity.name}</p>
</div>
{isSubOrganization && (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Managed By</p>
<p className="text-sm text-mineshaft-300">
{isOrgIdentity ? "Organization" : "Root Organization"}
</p>
</div>
)}
{isOrgIdentity && (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Auth Method</p>
<p className="text-sm text-mineshaft-300">
{data.lastLoginAuthMethod ? identityAuthToNameMap[data.lastLoginAuthMethod] : "-"}
</p>
</div>
)}
{isOrgIdentity && (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Time</p>
<p className="text-sm text-mineshaft-300">
{data.lastLoginTime ? format(data.lastLoginTime, "PPpp") : "-"}
</p>
</div>
)}
{isOrgIdentity && (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Delete Protection</p>
<p className="text-sm text-mineshaft-300">
{data.identity.hasDeleteProtection ? "On" : "Off"}
</p>
</div>
)}
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{data.role}</p>
</div>
<div>
<p className="text-sm font-medium text-mineshaft-300">Metadata</p>
{data?.metadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{data.metadata?.map((el) => (
<div key={el.id} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Managed by</DetailLabel>
<DetailValue>
{isSubOrganization && isCurrentOrgIdentity ? (
<Badge variant="sub-org">
<SubOrgIcon />
Sub-Organization
</Badge>
) : (
<Badge variant="org">
<OrgIcon />
Organization
</Badge>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Organization Role</DetailLabel>
<DetailValue>{data.role}</DetailValue>
</Detail>
<Detail>
<DetailLabel>Metadata</DetailLabel>
<DetailValue className="flex flex-wrap gap-2">
{data?.metadata?.length ? (
data.metadata?.map((el) => (
<UnstableButtonGroup className="min-w-0" key={el.id}>
<Badge isTruncatable>
<span>{el.key}</span>
</Badge>
<Badge variant="outline" isTruncatable>
<span>{el.value}</span>
</Badge>
</UnstableButtonGroup>
))
) : (
<span className="text-muted"></span>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>
{isSubOrganization && !isCurrentOrgIdentity ? "Joined sub-organization" : "Created"}
</DetailLabel>
<DetailValue>{format(data.createdAt, "PPpp")}</DetailValue>
</Detail>
{isCurrentOrgIdentity && (
<>
<Detail>
<DetailLabel>Last Login Method</DetailLabel>
<DetailValue>
{data.lastLoginAuthMethod ? (
identityAuthToNameMap[data.lastLoginAuthMethod]
) : (
<span className="text-muted"></span>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Last Logged In</DetailLabel>
<DetailValue>
{data.lastLoginTime ? (
format(data.lastLoginTime, "PPpp")
) : (
<span className="text-muted"></span>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Delete protection</DetailLabel>
<DetailValue>
{data.identity.hasDeleteProtection ? (
<Badge variant="success">
<CheckIcon />
Enabled
</Badge>
) : (
<Badge variant="neutral">
<BanIcon />
Disabled
</Badge>
)}
</DetailValue>
</Detail>
</>
)}
</div>
</div>
</div>
</DetailGroup>
</UnstableCardContent>
</UnstableCard>
) : (
<div />
);

View File

@@ -1,11 +1,18 @@
import { useMemo } from "react";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { MoreHorizontalIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
import {
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableIconButton,
UnstableTableCell,
UnstableTableRow
} from "@app/components/v3";
import { useOrganization } from "@app/context";
import { getProjectBaseURL } from "@app/helpers/project";
import { formatProjectRoleName } from "@app/helpers/roles";
@@ -47,8 +54,7 @@ export const IdentityProjectRow = ({
}, [workspaces, project]);
return (
<Tr
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
<UnstableTableRow
key={`identity-project-membership-${id}`}
onClick={() => {
if (isAccessible) {
@@ -71,36 +77,55 @@ export const IdentityProjectRow = ({
});
}}
>
<Td className="max-w-0 truncate">{project.name}</Td>
<Td>{`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${
<UnstableTableCell className="max-w-0 truncate">{project.name}</UnstableTableCell>
<UnstableTableCell>{`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td>
{isAccessible && (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeIdentityFromProject", {
identityId: identity.id,
identityName: identity.name,
projectId: project.id,
projectName: project.name
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</Td>
</Tr>
}`}</UnstableTableCell>
<UnstableTableCell>{format(new Date(createdAt), "yyyy-MM-dd")}</UnstableTableCell>
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger>
<UnstableIconButton variant="ghost" size="xs">
<MoreHorizontalIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
isDisabled={!isAccessible}
onClick={(e) => {
e.stopPropagation();
navigate({
to: `${getProjectBaseURL(project.type)}/access-management` as const,
params: {
orgId: currentOrg?.id || "",
projectId: project.id
},
search: {
selectedTab: TabSections.Identities
}
});
}}
>
Access Project
</UnstableDropdownMenuItem>
<UnstableDropdownMenuItem
variant="danger"
isDisabled={!isAccessible}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeIdentityFromProject", {
identityId: identity.id,
identityName: identity.name,
projectId: project.id,
projectName: project.name
});
}}
>
Remove From Project
</UnstableDropdownMenuItem>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
</UnstableTableRow>
);
};

View File

@@ -1,9 +1,20 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PlusIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { useDeleteProjectIdentityMembership } from "@app/hooks/api";
import { DeleteActionModal } from "@app/components/v2";
import {
UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle
} from "@app/components/v3";
import {
useDeleteProjectIdentityMembership,
useGetIdentityProjectMemberships
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { IdentityAddToProjectModal } from "./IdentityAddToProjectModal";
@@ -35,24 +46,35 @@ export const IdentityProjectsSection = ({ identityId }: Props) => {
handlePopUpClose("removeIdentityFromProject");
};
const { data: projectMemberships } = useGetIdentityProjectMemberships(identityId);
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Projects</h3>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addIdentityToProject");
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
<div className="py-4">
<IdentityProjectsTable identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
</div>
<>
<UnstableCard>
<UnstableCardHeader>
<UnstableCardTitle>Projects</UnstableCardTitle>
<UnstableCardDescription>
Manage machine identity project memberships
</UnstableCardDescription>
{Boolean(projectMemberships?.length) && (
<UnstableCardAction>
<UnstableButton
onClick={() => {
handlePopUpOpen("addIdentityToProject");
}}
size="xs"
variant="outline"
>
<PlusIcon />
Add to Project
</UnstableButton>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
<IdentityProjectsTable identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
</UnstableCardContent>
</UnstableCard>
<DeleteActionModal
isOpen={popUp.removeIdentityFromProject.isOpen}
title={`Are you sure you want to remove ${
@@ -76,6 +98,6 @@ export const IdentityProjectsSection = ({ identityId }: Props) => {
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
</>
);
};

View File

@@ -1,26 +1,24 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ChevronDownIcon, PlusIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Lottie } from "@app/components/v2";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
UnstableButton,
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableInput,
UnstablePagination,
UnstableTable,
UnstableTableBody,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import { useOrganization } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
@@ -36,7 +34,7 @@ import { IdentityProjectRow } from "./IdentityProjectRow";
type Props = {
identityId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeIdentityFromProject"]>,
popUpName: keyof UsePopUpState<["removeIdentityFromProject", "addIdentityToProject"]>,
data?: object
) => void;
};
@@ -48,6 +46,8 @@ enum IdentityProjectsOrderBy {
export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {
const { data: projectMemberships = [], isPending } = useGetIdentityProjectMemberships(identityId);
const { isSubOrganization } = useOrganization();
const {
search,
setSearch,
@@ -90,41 +90,43 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
setPage
});
if (isPending) {
return (
// scott: todo proper loader
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div>
);
}
return (
<div>
<Input
<>
<UnstableInput
className="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search projects..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-2/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
{filteredProjectMemberships.length ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead onClick={toggleOrderDirection} className="w-1/3">
Name
<ChevronDownIcon
className={twMerge(
orderDirection === OrderByDirection.DESC && "rotate-180",
"transition-transform"
)}
/>
</UnstableTableHead>
<Th>Role</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
<UnstableTableHead>Role</UnstableTableHead>
<UnstableTableHead>Added On</UnstableTableHead>
<UnstableTableHead className="w-5" />
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{!isPending &&
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
return (
@@ -135,28 +137,45 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
/>
);
})}
</TBody>
</Table>
{Boolean(filteredProjectMemberships.length) && (
<Pagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredProjectMemberships?.length && (
<EmptyState
title={
projectMemberships.length
? "No projects match search..."
: "This machine identity has not been assigned to any projects"
}
icon={projectMemberships.length ? faSearch : faFolder}
/>
)}
</TableContainer>
</div>
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
{projectMemberships.length
? "No projects match this search"
: "This machine identity is not a member of any projects"}
</UnstableEmptyTitle>
<UnstableEmptyDescription>
{projectMemberships.length
? "Adjust search filters to view project memberships."
: "Assign this machine identity to a project."}
</UnstableEmptyDescription>
{!projectMemberships.length && (
<UnstableEmptyContent>
<UnstableButton
variant={isSubOrganization ? "sub-org" : "org"}
size="xs"
onClick={() => handlePopUpOpen("addIdentityToProject")}
>
<PlusIcon />
Add to Project
</UnstableButton>
</UnstableEmptyContent>
)}
</UnstableEmptyHeader>
</UnstableEmpty>
)}
{Boolean(filteredProjectMemberships.length) && (
<UnstablePagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</>
);
};

View File

@@ -87,6 +87,9 @@ export const OrgGenericAuthSection = () => {
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
Mobile Authenticator
</SelectItem>
<SelectItem value={MfaMethod.WEBAUTHN} key="mfa-method-webauthn">
Passkey (WebAuthn)
</SelectItem>
</Select>
</FormControl>
)}

View File

@@ -1,22 +1,56 @@
import { LogInIcon, PackageOpenIcon } from "lucide-react";
import { useCallback } from "react";
import { faCheck, faCopy, faEdit, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EllipsisIcon, LogInIcon, PackageOpenIcon } from "lucide-react";
import { Badge, UnstableButton } from "@app/components/v3";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/v2";
import { Badge, UnstableButton, UnstableIconButton } from "@app/components/v3";
import {
ProjectPermissionPamAccountActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useToggle } from "@app/hooks";
import { PAM_RESOURCE_TYPE_MAP, TPamAccount } from "@app/hooks/api/pam";
type Props = {
account: TPamAccount;
onAccess: (resource: TPamAccount) => void;
onUpdate: (resource: TPamAccount) => void;
onDelete: (resource: TPamAccount) => void;
accountPath?: string;
};
export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
const { name, description, resource } = account;
export const PamAccountCard = ({ account, onAccess, accountPath, onUpdate, onDelete }: Props) => {
const { id, name, description, resource } = account;
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[account.resource.resourceType];
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(
(idToCopy: string) => {
setIsIdCopied.on();
navigator.clipboard.writeText(idToCopy);
createNotification({
text: "Account ID copied to clipboard",
type: "info"
});
setTimeout(() => setIsIdCopied.off(), 2000);
},
[setIsIdCopied]
);
return (
<button
type="button"
<div
key={account.id}
className="flex flex-col overflow-clip rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-4 text-start transition-transform duration-100"
>
@@ -29,10 +63,58 @@ export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-lg font-medium text-mineshaft-100">{name}</p>
<UnstableButton onClick={() => onAccess(account)} size="xs" variant="outline">
<LogInIcon />
Connect
</UnstableButton>
<div className="flex items-center gap-2">
<UnstableButton onClick={() => onAccess(account)} size="xs" variant="outline">
<LogInIcon />
Connect
</UnstableButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<UnstableIconButton size="xs" variant="ghost">
<EllipsisIcon />
</UnstableIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
e.stopPropagation();
handleCopyId(id);
}}
>
Copy Account ID
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionPamAccountActions.Edit}
a={ProjectPermissionSub.PamAccounts}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onUpdate(account)}
>
Edit Account
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionPamAccountActions.Delete}
a={ProjectPermissionSub.PamAccounts}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(account)}
>
Delete Account
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<p
@@ -47,6 +129,6 @@ export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
{resource.name}
</Badge>
<p className="mt-2 truncate text-sm text-mineshaft-400">{description || "No description"}</p>
</button>
</div>
);
};

View File

@@ -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<typeof formSchema>;
@@ -49,13 +51,10 @@ export const MySQLAccountForm = ({ account, onSubmit }: Props) => {
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
handleSubmit(onSubmit)(e);
}}
>
<form onSubmit={handleSubmit(onSubmit)}>
<GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} />
<RequireMfaField />
<div className="mt-6 flex items-center">
<Button
className="mr-4"

View File

@@ -39,7 +39,7 @@ const CreateForm = ({
const createPamAccount = useCreatePamAccount();
const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials" | "requireMfa">
) => {
const account = await createPamAccount.mutateAsync({
...formData,
@@ -97,7 +97,7 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
const updatePamAccount = useUpdatePamAccount();
const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials" | "requireMfa">
) => {
const updatedAccount = await updatePamAccount.mutateAsync({
accountId: account.id,

View File

@@ -15,6 +15,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";
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
type Props = {
@@ -25,7 +26,8 @@ type Props = {
};
const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
credentials: BaseSqlAccountSchema
credentials: BaseSqlAccountSchema,
requireMfa: z.boolean().nullable().optional()
});
type FormData = z.infer<typeof formSchema>;
@@ -77,6 +79,7 @@ export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmi
<GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} />
<RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
<RequireMfaField />
<div className="mt-6 flex items-center">
<Button
className="mr-4"

View File

@@ -0,0 +1,31 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Switch } from "@app/components/v2";
export const RequireMfaField = () => {
const { control } = useFormContext<{
requireMfa?: boolean | null;
}>();
return (
<div className="flex h-9 w-fit items-center gap-3">
<Controller
control={control}
name="requireMfa"
defaultValue={false}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-0">
<Switch
className="ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="require-mfa"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value ?? false}
/>
</FormControl>
)}
/>
<span className="text-sm">Require MFA for Access</span>
</div>
);
};

View File

@@ -6,7 +6,7 @@ import { FormControl, Select, SelectItem, Switch, Tooltip } from "@app/component
export const rotateAccountFieldsSchema = z.object({
rotationEnabled: z.boolean(),
rotationIntervalSeconds: z.number().nullable().optional()
rotationIntervalSeconds: z.number()
});
export const RotateAccountFields = ({
@@ -16,15 +16,15 @@ export const RotateAccountFields = ({
}) => {
const { control, watch } = useFormContext<{
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
rotationIntervalSeconds: number;
}>();
const rotationEnabled = watch("rotationEnabled");
return (
<Tooltip
isDisabled={rotationCredentialsConfigured}
content="The resource which owns this account does not have rotation credentials configured."
isDisabled={rotationCredentialsConfigured}
>
<div
className={twMerge(
@@ -62,7 +62,7 @@ export const RotateAccountFields = ({
className="mb-0"
>
<Select
value={(value || 2592000).toString()}
value={value.toString()}
onValueChange={(val) => onChange(parseInt(val, 10))}
className="w-full border border-mineshaft-500 capitalize"
position="popper"

View File

@@ -18,6 +18,7 @@ import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
import { SshCaSetupSection } from "../../../components/SshCaSetupSection";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { RequireMfaField } from "./RequireMfaField";
type Props = {
account?: TSSHAccount;
@@ -53,7 +54,8 @@ const formSchema = genericAccountFieldsSchema.extend({
credentials: BaseSshAccountSchema,
// 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<typeof formSchema>;
@@ -225,6 +227,8 @@ export const SshAccountForm = ({ account, resourceId, onSubmit }: Props) => {
: {
name: "",
description: "",
requireMfa: false,
rotationEnabled: false,
credentials: {
authMethod: SSHAuthMethod.Password,
username: "",
@@ -247,6 +251,7 @@ export const SshAccountForm = ({ account, resourceId, onSubmit }: Props) => {
>
<GenericAccountFields />
<SshAccountFields isUpdate={isUpdate} resourceId={effectiveResourceId} />
<RequireMfaField />
<div className="mt-6 flex items-center">
<Button
className="mr-4"

View File

@@ -66,12 +66,9 @@ export const PamAccountRow = ({
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
setTimeout(() => setIsIdCopied.off(), 2000);
},
[isIdCopied]
[setIsIdCopied]
);
return (

View File

@@ -16,7 +16,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
@@ -251,13 +250,8 @@ export const PamAccountsTable = ({ projectId }: Props) => {
});
if (requiresApproval) {
createNotification({
text: "This account is protected by an approval policy, you must request access",
type: "info"
});
// Open request access modal with pre-populated path
handlePopUpOpen("requestAccount", { accountPath: fullAccountPath });
handlePopUpOpen("requestAccount", { accountPath: fullAccountPath, accountAccessed: true });
return;
}
@@ -439,6 +433,8 @@ export const PamAccountsTable = ({ projectId }: Props) => {
account={account}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
onAccess={(e: TPamAccount) => accessAccount(e)}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
/>
))}
</div>
@@ -560,6 +556,7 @@ export const PamAccountsTable = ({ projectId }: Props) => {
isOpen={popUp.requestAccount.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("requestAccount", isOpen)}
accountPath={popUp.requestAccount.data?.accountPath}
accountAccessed={popUp.requestAccount.data?.accountAccessed}
/>
<PamDeleteAccountModal
isOpen={popUp.deleteAccount.isOpen}

View File

@@ -1,5 +1,6 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { InfoIcon } from "lucide-react";
import ms from "ms";
import { z } from "zod";
@@ -17,9 +18,11 @@ import {
import { useProject } from "@app/context";
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
import { useCreateApprovalRequest } from "@app/hooks/api/approvalRequests/mutations";
import { UnstableAlert, UnstableAlertDescription, UnstableAlertTitle } from "@app/components/v3";
type Props = {
accountPath?: string;
accountAccessed?: boolean;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
@@ -45,7 +48,7 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>;
const Content = ({ onOpenChange, accountPath }: Props) => {
const Content = ({ onOpenChange, accountPath, accountAccessed }: Props) => {
const { projectId } = useProject();
const { mutateAsync: createApprovalRequest, isPending: isSubmitting } =
useCreateApprovalRequest();
@@ -94,6 +97,15 @@ const Content = ({ onOpenChange, accountPath }: Props) => {
return (
<form onSubmit={handleSubmit(onSubmit)}>
{accountAccessed && (
<UnstableAlert variant="info" className="mb-3">
<InfoIcon />
<UnstableAlertTitle>This account is protected by an approval policy</UnstableAlertTitle>
<UnstableAlertDescription>
You must request access by filling out the fields below.
</UnstableAlertDescription>
</UnstableAlert>
)}
<Controller
name="accountPath"
control={control}

View File

@@ -34,7 +34,7 @@ export const GroupDetailsSection = ({ groupMembership }: Props) => {
return (
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader>
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Group details</UnstableCardDescription>
</UnstableCardHeader>

View File

@@ -85,7 +85,7 @@ export const ProjectIdentityAuthenticationSection = ({ identity, refetchIdentity
activeLockoutAuthMethods={identity.activeLockoutAuthMethods}
/>
) : (
<UnstableEmpty className="rounded-sm border bg-mineshaft-800/50">
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
This machine identity has no auth methods configured

View File

@@ -51,7 +51,7 @@ export const ProjectIdentityDetailsSection = ({
return (
<>
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader>
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Machine identity details</UnstableCardDescription>
{!isOrgIdentity && (

View File

@@ -40,7 +40,7 @@ export const ProjectMemberDetailsSection = ({ membership }: Props) => {
return (
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader>
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>User membership details</UnstableCardDescription>
</UnstableCardHeader>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { startRegistration } from "@simplewebauthn/browser";
import { useQueryClient } from "@tanstack/react-query";
import QRCode from "qrcode";
@@ -11,6 +12,8 @@ import {
EmailServiceSetupModal,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
@@ -28,6 +31,13 @@ import {
useGetUserTotpRegistration
} from "@app/hooks/api/users/queries";
import { AuthMethod } from "@app/hooks/api/users/types";
import {
useDeleteWebAuthnCredential,
useGenerateRegistrationOptions,
useGetWebAuthnCredentials,
useUpdateWebAuthnCredential,
useVerifyRegistration
} from "@app/hooks/api/webauthn";
import { usePopUp } from "@app/hooks/usePopUp";
export const MFASection = () => {
@@ -46,7 +56,10 @@ export const MFASection = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"setUpEmail",
"deleteTotpConfig",
"downloadRecoveryCodes"
"downloadRecoveryCodes",
"deleteWebAuthnCredential",
"renameWebAuthnCredential",
"registerPasskey"
] as const);
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
const { data: totpConfiguration } = useGetUserTotpConfiguration();
@@ -60,6 +73,20 @@ export const MFASection = () => {
const queryClient = useQueryClient();
const { data: serverDetails } = useFetchServerStatus();
// WebAuthn/Passkey hooks
const { data: webAuthnCredentials, isPending: isWebAuthnCredentialsLoading } =
useGetWebAuthnCredentials();
const { mutateAsync: generateRegistrationOptions, isPending: isGeneratingOptions } =
useGenerateRegistrationOptions();
const { mutateAsync: verifyRegistration, isPending: isVerifyingRegistration } =
useVerifyRegistration();
const { mutateAsync: deleteWebAuthnCredential } = useDeleteWebAuthnCredential();
const { mutateAsync: updateWebAuthnCredential } = useUpdateWebAuthnCredential();
const [selectedCredentialId, setSelectedCredentialId] = useState<string>("");
const [credentialName, setCredentialName] = useState<string>("");
const [isRegisteringPasskey, setIsRegisteringPasskey] = useState(false);
// Update form data when user data changes
useEffect(() => {
if (user) {
@@ -107,6 +134,114 @@ export const MFASection = () => {
});
};
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;
}
// Generate registration options from server
const options = await generateRegistrationOptions();
const registrationResponse = await startRegistration({ optionsJSON: options });
// Verify registration with server
await verifyRegistration({
registrationResponse,
name: credentialName || "Passkey"
});
createNotification({
text: "Successfully registered passkey",
type: "success"
});
handlePopUpClose("registerPasskey");
setCredentialName("");
} catch (error: any) {
console.error("Failed to register passkey:", error);
// Better error messages
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);
}
};
const handleDeleteWebAuthnCredential = async () => {
try {
await deleteWebAuthnCredential({ id: selectedCredentialId });
createNotification({
text: "Successfully deleted passkey",
type: "success"
});
handlePopUpClose("deleteWebAuthnCredential");
setSelectedCredentialId("");
} catch (error: any) {
createNotification({
text: error.message || "Failed to delete passkey",
type: "error"
});
}
};
const handleRenameWebAuthnCredential = async () => {
try {
await updateWebAuthnCredential({
id: selectedCredentialId,
name: credentialName
});
createNotification({
text: "Successfully renamed passkey",
type: "success"
});
handlePopUpClose("renameWebAuthnCredential");
setSelectedCredentialId("");
setCredentialName("");
} catch (error: any) {
createNotification({
text: error.message || "Failed to rename passkey",
type: "error"
});
}
};
const handleFormDataChange = async (field: string, value: any) => {
setFormData((prev) => ({
...prev,
@@ -149,6 +284,19 @@ export const MFASection = () => {
return;
}
// If selecting passkey as 2FA method but no passkeys registered
if (
formData.isMfaEnabled &&
formData.selectedMfaMethod === MfaMethod.WEBAUTHN &&
(!webAuthnCredentials || webAuthnCredentials.length === 0)
) {
createNotification({
text: "Please register at least one passkey before selecting it as your two-factor authentication method",
type: "error"
});
return;
}
setIsLoading(true);
// If enabling 2FA with mobile authenticator, verify TOTP first
@@ -240,9 +388,97 @@ export const MFASection = () => {
if (totpConfiguration?.isVerified) return true;
return totpCode.trim().length > 0;
}
if (formData.selectedMfaMethod === MfaMethod.WEBAUTHN) return true;
return false;
};
const registerPasskeyModal = (
<Modal
isOpen={popUp.registerPasskey?.isOpen || false}
onOpenChange={(isOpen) => {
if (!isOpen) {
handlePopUpClose("registerPasskey");
setCredentialName("");
}
}}
>
<ModalContent title="Register New Passkey">
<div className="space-y-4">
<p className="text-sm text-mineshaft-300">
Give your passkey a name to help you identify it later. After clicking
&quot;Register&quot;, you&apos;ll be prompted to use your device&apos;s biometric
authentication (Touch ID, Face ID, Windows Hello, etc.).
</p>
<FormControl label="Passkey Name">
<Input
value={credentialName}
onChange={(e) => setCredentialName(e.target.value)}
placeholder="e.g., My MacBook Pro"
/>
</FormControl>
<div className="flex gap-2">
<Button
onClick={handleRegisterPasskey}
isLoading={isRegisteringPasskey || isVerifyingRegistration}
disabled={!credentialName.trim()}
colorSchema="primary"
>
Register
</Button>
<Button
variant="outline_bg"
onClick={() => handlePopUpClose("registerPasskey")}
disabled={isRegisteringPasskey || isVerifyingRegistration}
>
Cancel
</Button>
</div>
</div>
</ModalContent>
</Modal>
);
const renamePasskeyModal = (
<Modal
isOpen={popUp.renameWebAuthnCredential?.isOpen || false}
onOpenChange={(isOpen) => {
if (!isOpen) {
handlePopUpClose("renameWebAuthnCredential");
setCredentialName("");
setSelectedCredentialId("");
}
}}
>
<ModalContent title="Rename Passkey">
<div className="space-y-4">
<FormControl label="Passkey Name">
<Input
value={credentialName}
onChange={(e) => setCredentialName(e.target.value)}
placeholder="e.g., My MacBook Pro"
/>
</FormControl>
<div className="flex gap-2">
<Button
onClick={handleRenameWebAuthnCredential}
disabled={!credentialName.trim()}
colorSchema="primary"
>
Save
</Button>
<Button
variant="outline_bg"
onClick={() => handlePopUpClose("renameWebAuthnCredential")}
>
Cancel
</Button>
</div>
</div>
</ModalContent>
</Modal>
);
return (
<>
<form
@@ -293,6 +529,7 @@ export const MFASection = () => {
>
<SelectItem value={MfaMethod.EMAIL}>Email</SelectItem>
<SelectItem value={MfaMethod.TOTP}>Mobile Authenticator</SelectItem>
<SelectItem value={MfaMethod.WEBAUTHN}>Passkey (WebAuthn)</SelectItem>
</Select>
</FormControl>
</div>
@@ -411,57 +648,162 @@ export const MFASection = () => {
</Button>
</div>
)}
{user?.isMfaEnabled && totpConfiguration?.isVerified && (
<div className="mt-8 border-t border-mineshaft-600 pt-6">
<h3 className="mb-4 text-lg font-medium text-mineshaft-100">
Mobile Authenticator Management
</h3>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={setShouldShowRecoveryCodes.toggle}
>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={handleGenerateMoreRecoveryCodes}
>
Generate more codes
</Button>
<Button
colorSchema="danger"
variant="outline_bg"
onClick={() => handlePopUpOpen("deleteTotpConfig")}
>
Remove Authenticator
</Button>
</div>
{shouldShowRecoveryCodes && (
<div className="w-fit rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4 pr-8">
<div className="grid grid-cols-2 gap-x-6 gap-y-2 font-mono text-sm">
{totpConfiguration.recoveryCodes.map((code, index) => (
<div key={code} className="flex items-center text-mineshaft-200">
<span className="w-8 text-right text-mineshaft-400">{index + 1}.</span>
<span className="pl-2">{code}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
)}
</form>
{/* Management Sections - Separate from configuration form */}
{user && (
<div className="mt-6 mb-6 space-y-6">
{/* Mobile Authenticator Management - Show if configured, regardless of active method */}
{totpConfiguration?.isVerified && (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h3 className="mb-4 text-lg font-medium text-mineshaft-100">
Mobile Authenticator Management
</h3>
<p className="mb-4 text-sm text-mineshaft-400">
Manage your mobile authenticator configuration and recovery codes.
</p>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={setShouldShowRecoveryCodes.toggle}
>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={handleGenerateMoreRecoveryCodes}
>
Generate more codes
</Button>
<Button
colorSchema="danger"
variant="outline_bg"
onClick={() => handlePopUpOpen("deleteTotpConfig")}
>
Remove Authenticator
</Button>
</div>
{shouldShowRecoveryCodes && (
<div className="w-fit rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4 pr-8">
<div className="grid grid-cols-2 gap-x-6 gap-y-2 font-mono text-sm">
{totpConfiguration.recoveryCodes.map((code, index) => (
<div key={code} className="flex items-center text-mineshaft-200">
<span className="w-8 text-right text-mineshaft-400">{index + 1}.</span>
<span className="pl-2">{code}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Passkey Management - Always show */}
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-mineshaft-100">Passkey Management</h3>
<p className="mt-1 text-sm text-mineshaft-400">
Manage your passkeys. Passkeys can be used for two-factor authentication.
</p>
</div>
<Button
colorSchema="primary"
variant="outline_bg"
onClick={() => {
setCredentialName("");
handlePopUpOpen("registerPasskey");
}}
isLoading={isGeneratingOptions}
>
Add Passkey
</Button>
</div>
{(() => {
if (isWebAuthnCredentialsLoading) {
return <ContentLoader />;
}
if (webAuthnCredentials && webAuthnCredentials.length > 0) {
return (
<div className="space-y-3">
{webAuthnCredentials.map((credential) => (
<div
key={credential.id}
className="flex items-center justify-between rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-mineshaft-100">
{credential.name || "Unnamed Passkey"}
</span>
{credential.transports && credential.transports.length > 0 && (
<span className="text-xs text-mineshaft-400">
({credential.transports.join(", ")})
</span>
)}
</div>
<div className="mt-1 text-xs text-mineshaft-400">
Added {new Date(credential.createdAt).toLocaleDateString()}
{credential.lastUsedAt && (
<>
{" "}
· Last used {new Date(credential.lastUsedAt).toLocaleDateString()}
</>
)}
</div>
</div>
<div className="flex gap-2">
<Button
size="xs"
colorSchema="secondary"
variant="outline_bg"
onClick={() => {
setSelectedCredentialId(credential.id);
setCredentialName(credential.name || "");
handlePopUpOpen("renameWebAuthnCredential");
}}
>
Rename
</Button>
<Button
size="xs"
colorSchema="danger"
variant="outline_bg"
onClick={() => {
setSelectedCredentialId(credential.id);
handlePopUpOpen("deleteWebAuthnCredential");
}}
>
Remove
</Button>
</div>
</div>
))}
</div>
);
}
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-6 text-center">
<p className="text-sm text-mineshaft-300">
No passkeys registered yet. Add a passkey to use it for two-factor
authentication.
</p>
</div>
);
})()}
</div>
</div>
)}
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
@@ -481,6 +823,19 @@ export const MFASection = () => {
recoveryCodes={totpRegistration?.recoveryCodes || []}
onDownloadComplete={() => handlePopUpClose("downloadRecoveryCodes")}
/>
{registerPasskeyModal}
{renamePasskeyModal}
{/* Delete Passkey Modal */}
<DeleteActionModal
isOpen={popUp.deleteWebAuthnCredential?.isOpen || false}
title="Remove passkey?"
subTitle="This action is irreversible. You'll need to register this passkey again if you want to use it in the future."
onChange={(isOpen) => handlePopUpToggle("deleteWebAuthnCredential", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleDeleteWebAuthnCredential}
/>
</>
);
};

View File

@@ -38,6 +38,7 @@ import { Route as authAdminLoginPageRouteImport } from './pages/auth/AdminLoginP
import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route'
import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/route'
import { Route as organizationMcpEndpointFinalizePageRouteImport } from './pages/organization/McpEndpointFinalizePage/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 redirectsProjectRedirectImport } from './pages/redirects/project-redirect'
@@ -549,6 +550,12 @@ const organizationMcpEndpointFinalizePageRouteRoute =
getParentRoute: () => middlewaresAuthenticateRoute,
} as any)
const MfaSessionPageRouteRoute = MfaSessionPageRouteImport.update({
id: '/mfa-session/$mfaSessionId',
path: '/mfa-session/$mfaSessionId',
getParentRoute: () => middlewaresAuthenticateRoute,
} as any)
const authSignUpPageRouteRoute = authSignUpPageRouteImport.update({
id: '/',
path: '/',
@@ -2478,6 +2485,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/mcp-endpoint-finalize': {
id: '/_authenticate/organization/mcp-endpoint-finalize'
path: '/organization/mcp-endpoint-finalize'
@@ -5268,6 +5282,7 @@ interface middlewaresAuthenticateRouteChildren {
authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute
middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren
AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren
MfaSessionPageRouteRoute: typeof MfaSessionPageRouteRoute
organizationMcpEndpointFinalizePageRouteRoute: typeof organizationMcpEndpointFinalizePageRouteRoute
organizationNoOrgPageRouteRoute: typeof organizationNoOrgPageRouteRoute
}
@@ -5279,6 +5294,7 @@ const middlewaresAuthenticateRouteChildren: middlewaresAuthenticateRouteChildren
middlewaresInjectOrgDetailsRouteWithChildren,
AuthenticatePersonalSettingsRoute:
AuthenticatePersonalSettingsRouteWithChildren,
MfaSessionPageRouteRoute: MfaSessionPageRouteRoute,
organizationMcpEndpointFinalizePageRouteRoute:
organizationMcpEndpointFinalizePageRouteRoute,
organizationNoOrgPageRouteRoute: organizationNoOrgPageRouteRoute,
@@ -5376,6 +5392,7 @@ export interface FileRoutesByFullPath {
'/signup': typeof RestrictLoginSignupSignupRouteWithChildren
'/login/': typeof authLoginPageRouteRoute
'/signup/': typeof authSignUpPageRouteRoute
'/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
'/organizations/none': typeof organizationNoOrgPageRouteRoute
'/admin/signup': typeof adminSignUpPageRouteRoute
@@ -5631,6 +5648,7 @@ export interface FileRoutesByTo {
'/personal-settings': typeof userPersonalSettingsPageRouteRoute
'/login': typeof authLoginPageRouteRoute
'/signup': typeof authSignUpPageRouteRoute
'/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
'/organizations/none': typeof organizationNoOrgPageRouteRoute
'/admin/signup': typeof adminSignUpPageRouteRoute
@@ -5878,6 +5896,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/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
'/_authenticate/organizations/none': typeof organizationNoOrgPageRouteRoute
'/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute
@@ -6147,6 +6166,7 @@ export interface FileRouteTypes {
| '/signup'
| '/login/'
| '/signup/'
| '/mfa-session/$mfaSessionId'
| '/organization/mcp-endpoint-finalize'
| '/organizations/none'
| '/admin/signup'
@@ -6401,6 +6421,7 @@ export interface FileRouteTypes {
| '/personal-settings'
| '/login'
| '/signup'
| '/mfa-session/$mfaSessionId'
| '/organization/mcp-endpoint-finalize'
| '/organizations/none'
| '/admin/signup'
@@ -6646,6 +6667,7 @@ export interface FileRouteTypes {
| '/_restrict-login-signup/signup'
| '/_restrict-login-signup/login/'
| '/_restrict-login-signup/signup/'
| '/_authenticate/mfa-session/$mfaSessionId'
| '/_authenticate/organization/mcp-endpoint-finalize'
| '/_authenticate/organizations/none'
| '/_restrict-login-signup/admin/signup'
@@ -6960,6 +6982,7 @@ export const routeTree = rootRoute
"/_authenticate/password-setup",
"/_authenticate/_inject-org-details",
"/_authenticate/personal-settings",
"/_authenticate/mfa-session/$mfaSessionId",
"/_authenticate/organization/mcp-endpoint-finalize",
"/_authenticate/organizations/none"
]
@@ -7047,6 +7070,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/mcp-endpoint-finalize": {
"filePath": "organization/McpEndpointFinalizePage/route.tsx",
"parent": "/_authenticate"

View File

@@ -443,6 +443,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")])
]),