diff --git a/passkey-sample/.env.example b/passkey-sample/.env.example new file mode 100644 index 0000000..070b683 --- /dev/null +++ b/passkey-sample/.env.example @@ -0,0 +1,11 @@ +# Local dev hostname — must match the auth..ciamlogin.com subdomain you added to your hosts file +# Example: auth.passkeytest.ciamlogin.com +VITE_HOST= +VITE_PORT=3000 + +# SSL certificate filenames (generated locally — see README "Generate SSL Certificate") +VITE_SSL_CERT=auth-cert.pem +VITE_SSL_KEY=auth-key.pem + +# Client secret from your Entra app registration (do NOT commit your real .env) +VITE_APP_SECRET= diff --git a/passkey-sample/.gitignore b/passkey-sample/.gitignore new file mode 100644 index 0000000..a20854d --- /dev/null +++ b/passkey-sample/.gitignore @@ -0,0 +1,9 @@ +# Build files +build/ + +node_modules/ + +*.pem +*.pfx + +.env \ No newline at end of file diff --git a/passkey-sample/README.md b/passkey-sample/README.md new file mode 100644 index 0000000..310ef31 --- /dev/null +++ b/passkey-sample/README.md @@ -0,0 +1,373 @@ +# Microsoft Identity Platform - React SPA with Passkeys + +This is a React Single Page Application (SPA) that demonstrates authentication with Microsoft Identity Platform and passkey management using Microsoft Graph API. + +**⚠️ This sample app is for testing purpose. Please do not deploy to production environment.** + +## 🚀 Quick Start + +### Prerequisites + +#### App Setup + +- **[Node.js](https://nodejs.org/en/download)** (version 20 or higher) +- **Windows Administrator access** (needed to edit the hosts file) +- **[Git for Windows](https://git-scm.com/download/win)** (includes OpenSSL, used to generate a local SSL certificate) + +#### Tenant Setup + +- Microsoft Entra ID (Azure AD) tenant with CIAM configuration (allowlist) +- User account with MFA enforcement +- Client application registered under CIAM tenant with UserAuthMethod-Passkey.ReadWrite.All application permissions granted by admin + +#### Device + +- Yubikey supported FIDO2 + +## Set up locally + +The following instructions set up this sample app **locally**. Throughout this guide, `passkeytest.ciamlogin.com` is used as the example tenant — replace it with your own. + +### 1. Windows Domain Setup (Required for Passkey rp.id Compliance) + +**⚠️ Critical for Passkeys**: WebAuthn requires the `rp.id` (Relying Party ID) to match the domain or subdomain where passkey creation occurs. This setup ensures proper domain matching. + +#### Step 1: Update Windows Hosts File + +1. **Open Command Prompt as Administrator**: + - Press `Win + R`, type `cmd` + - Press `Ctrl + Shift + Enter` (opens as admin) + +2. **Edit the hosts file**: + + ```cmd + notepad C:\Windows\System32\drivers\etc\hosts + ``` + +3. **Add domain mapping** (replace with your actual CIAM domain, locally we need to use **subdomain** of your ciam tenant domain): + +For example, for the example authority `passkeytest.ciamlogin.com`, locally use the subdomain `auth.passkeytest.ciamlogin.com` in order to not impact the login flow. + + ``` + 127.0.0.1 auth.passkeytest.ciamlogin.com + ``` + +4. **Save and close** the file + +#### Step 2: Generate SSL Certificate for Proper Domain + +1. **Install OpenSSL** (if not already installed): + - Download from: https://slproweb.com/products/Win32OpenSSL.html + - Or use Git Bash if you have Git installed + +2. **Open PowerShell as Administrator**: + - Press `Win + X`, select "Windows PowerShell (Admin)" + - Or right-click Start button → "Windows PowerShell (Admin)" + +3. **Generate certificate for your domain**: + + ```powershell + # Navigate to the sample folder (the folder that contains package.json) + cd "" + + # Step 1: Create the certificate (replace with your actual CIAM domain) + New-SelfSignedCertificate -DnsName "auth.passkeytest.ciamlogin.com" -CertStoreLocation "cert:\LocalMachine\My" -NotAfter (Get-Date).AddYears(1) -FriendlyName "authCiamCert" + + # Step 2: Set password for certificate export + $pwd = ConvertTo-SecureString -String '' -Force -AsPlainText + + # Step 3: Get the certificate from the store + $cert = Get-ChildItem -Path "cert:\LocalMachine\My" | Where-Object { $_.Subject -eq "CN=auth.passkeytest.ciamlogin.com" } + + # Step 4: Export certificate to PFX format in the project root + Export-PfxCertificate -Cert $cert -FilePath ".\auth-cert.pfx" -Password $pwd + ``` + +3. **Convert PFX to PEM format using OpenSSL**: + + ```bash + # Extract certificate (PEM format) + openssl pkcs12 -in auth-cert.pfx -out auth-cert.pem -clcerts -nokeys + + # Extract private key (PEM format) + openssl pkcs12 -in auth-cert.pfx -out auth-key.pem -nocerts -nodes + ``` + +4. **Install certificate in Trusted Root Certification Authorities**: + + ```powershell + # Import PFX certificate to Trusted Root store to avoid browser security warnings + Import-PfxCertificate -FilePath ".\auth-cert.pfx" -CertStoreLocation "Cert:\LocalMachine\Root" -Password $pwd + ``` + +5. **Confirm file names**: the generated files must be named `auth-cert.pem` and `auth-key.pem` at the project root — `vite.config.js` will detect them automatically and serve the app over HTTPS. (If you rename them, update `VITE_SSL_CERT` / `VITE_SSL_KEY` in your `.env`.) + +### 2. Tenant Configuration + +#### Step 1: Register Redirect URI in Entra Portal + +**⚠️ Critical Step**: You must register your redirect URI in the Entra portal for authentication to work. + +**Navigate to App Registration:** +1. Go to [Azure Portal](https://portal.azure.com) +2. Navigate to **Microsoft Entra ID** → **App registrations** +3. Select your application registration + +**Configure Single Page Application Platform:** +1. In the left sidebar, click **Authentication** +2. Under **Platform configurations**, click **+ Add a platform** +3. Select **Single-page application (SPA)** +4. In the **Redirect URIs** section, add your application URL: + ``` + https://auth.passkeytest.ciamlogin.com:3000 + ``` + Replace the host with your own subdomain (the one you set as `VITE_HOST` in `.env`). +5. Click **Configure** to save + +#### Step 2: Verify Required Permissions + +Ensure your app registration has the following Microsoft Graph API permissions: + +**Application Permissions (Admin consent required):** +- `UserAuthMethod-Passkey.ReadWrite.All` - Required for passkey management + +**Grant Admin Consent:** +1. In your app registration, go to **API permissions** +2. Click **Grant admin consent for [Your Tenant]** +3. Confirm the consent + +### 3. Application Configuration + +Before running the application, you need to configure your Microsoft Entra ID application registration and update the MSAL configuration. + +#### Step 1: Configure MSAL Authentication Settings + +Update the `msalConfig.auth` section in `src/authConfig.js` with your application details: + +```javascript +export const msalConfig = { + auth: { + clientId: "", // Application (client) ID from app registration + authority: "https://passkeytest.ciamlogin.com/", // Replace passkeytest with your tenant subdomain + redirectUri: "/", // Resolved at runtime to the registered redirect URI + }, + // ... rest of configuration +}; +``` + +**How to get these values:** + +1. **Client ID**: Found in your app registration overview page +2. **Authority**: Your CIAM tenant authority URL in the format `https://passkeytest.ciamlogin.com/` (replace `passkeytest` with your tenant subdomain) +3. **Redirect URI**: The URL where users will be redirected after authentication **(must be registered in Entra portal)** + +#### Step 2: Environment Configuration (.env file) + +The repository does **not** include a `.env` file — you need to create one yourself. Copy `.env.example` to a new file named `.env` in the same `sample/` folder and fill in your values. `.env` is gitignored, so your local copy stays on your machine. + +```env +# Local dev hostname — must match the auth..ciamlogin.com subdomain in your hosts file +VITE_HOST=auth.passkeytest.ciamlogin.com +VITE_PORT=3000 + +# SSL certificate filenames at the project root +VITE_SSL_CERT=auth-cert.pem +VITE_SSL_KEY=auth-key.pem + +# Client secret from your Entra app registration (do NOT commit your real .env) +VITE_APP_SECRET=your-client-secret +``` + +#### Step 3: Application Configuration (authConfig.js) + +The React app authentication configuration is centralized in `src/authConfig.js`. Update the `appConfig` object with your values: + +```javascript +export const appConfig = { + proxyDomain: 'http://localhost:3001/api', + appId: 'your-client-id', + tenantId: 'your-tenant-id', + customDomain: '' // your valid custom domain, if not specify, use tenant subdomain by default +}; +``` + +The client secret is **not** stored here. It is read from `VITE_APP_SECRET` in your local `.env` file (see **Step 2** above) and consumed directly by the SPA at runtime. `.env` is gitignored, so your secret stays on your machine. + +**SECURITY WARNING**: This configuration is for local development only. Never expose the **appSecret** in production environments. Store secrets securely using: + +- Environment variables +- Azure Key Vault +- Other secure secret management systems + +### 4. Start the Application + +#### Step 1: Install Dependencies + +```bash +npm install +``` + +#### Step 2: Start CORS Proxy Server + +Open a terminal and run the following command: + +```bash +npm run cors +``` + +This starts the CORS proxy on `http://localhost:3001`. The proxy forwards token requests to `login.microsoftonline.com` so the browser can complete the client-credentials flow without CORS errors. Microsoft Graph calls go directly from the browser and do **not** route through this proxy. + +#### Step 3: Start sample app + +```bash +npm start +``` + +This starts the Vite development server on `https://auth.passkeytest.ciamlogin.com:3000` (host and port from `.env`). + +### 5. Access the Application + +Open your browser and navigate to: + +``` +https://auth.passkeytest.ciamlogin.com:3000 +``` + +**Note**: The application runs on HTTPS with a self-signed certificate. You may need to accept the security warning in your browser. + +## 🔧 Configuration Details + +### SSL Certificates + +The application includes SSL certificates for HTTPS development: + +- `auth-cert.pem` - SSL certificate +- `auth-key.pem` - SSL private key + +### CORS Proxy + +The `cors.js` file provides a proxy server that: + +- Handles CORS issues when calling the Microsoft Entra token endpoint +- Runs on port 3001 +- Proxies requests to `https://login.microsoftonline.com/{tenantId}` + +For production deployment, consider using [Set up a reverse proxy for a single-page app using Azure Front Door](https://learn.microsoft.com/en-us/entra/identity-platform/how-to-native-authentication-cors-solution-production-environment) instead of the local CORS proxy. + +### Authentication Configuration + +The app uses Microsoft Authentication Library (MSAL) for: + +- User authentication with Microsoft Identity Platform +- Token acquisition for Graph API calls +- Multi-factor authentication (MFA) enforcement for passkey operations + +## 🔐 Features + +### Authentication + +- Sign in/out with Microsoft Identity Platform +- Session management with NGCMFA (Next Generation Credentials Multi-Factor Authentication) + +### Passkey Management + +- View existing passkeys/FIDO2 credentials +- Add new passkeys +- Delete existing passkeys + +### Security Features + +- MFA enforcement for passkey operations +- Automatic re-authentication when tokens expire +- Enhanced error handling and user feedback +- Toast notifications for user actions + +## 🛠️ Development + +### Project Structure + +```text +sample/ +├── public/ # Static assets served at site root +│ ├── favicon.svg # Application icon +│ └── manifest.json # PWA manifest +├── src/ +│ ├── components/ # React components +│ │ ├── common/ # Shared UI components +│ │ │ ├── index.js # Component exports +│ │ │ ├── ToastNotifications.jsx +│ │ │ └── UIComponents.jsx +│ │ ├── passkeys/ # Passkey management components +│ │ │ ├── index.js +│ │ │ ├── PasskeysSection.jsx +│ │ │ └── components/ # Passkey sub-components +│ │ │ ├── DeleteModal.jsx +│ │ │ ├── PasskeyDetails.jsx +│ │ │ ├── PasskeyItem.jsx +│ │ │ ├── PasskeysHeader.jsx +│ │ │ ├── PasskeysList.jsx +│ │ │ └── utils.js +│ │ ├── NavigationBar.jsx +│ │ ├── PageLayout.jsx +│ │ └── SecurityPage.jsx +│ ├── hooks/passkeys/ # Custom React hooks +│ │ ├── index.js +│ │ ├── useAuthentication.js +│ │ ├── usePasskeyAddOperation.js +│ │ ├── usePasskeyDeleteOperation.js +│ │ └── usePasskeyFetcher.js +│ ├── services/ # API service layer +│ │ ├── GraphApiClient.js +│ │ └── PasskeyService.js +│ ├── utils/ # Utility functions +│ │ ├── graphServiceUtils.js +│ │ ├── passkeyUtils.js +│ │ └── tokenUtils.js +│ ├── styles/ +│ │ ├── App.css +│ │ └── index.css +│ ├── App.jsx # Root application component +│ ├── authConfig.js # MSAL and app configuration +│ └── index.jsx # Application entry point +├── index.html # HTML entry (Vite serves from project root) +├── vite.config.js # Vite build/dev-server configuration +├── .env # Local environment variables (gitignored) +├── .env.example # Template for .env +├── auth-cert.pem # SSL certificate for HTTPS development +├── auth-key.pem # SSL private key +├── cors.js # CORS proxy server for development +├── package.json # Node.js dependencies and scripts +├── package-lock.json # Locked dependency versions +└── README.md # This documentation file +``` + +### Architecture Overview + +#### **Component Architecture** +- **Modular Design**: Components are organized by feature (passkeys, common UI) +- **Composition Pattern**: Smaller, focused components compose larger features +- **Separation of Concerns**: UI components separated from business logic + +#### **Hook-Based State Management** +- **Custom Hooks**: Business logic extracted into reusable hooks +- **Separation of Concerns**: Authentication, data fetching, and operations in dedicated hooks +- **Clean API**: Hooks provide simple interfaces for complex operations + +#### **Service Layer** +- **API Abstraction**: Service layer abstracts Microsoft Graph API calls +- **Error Handling**: Centralized error handling and response processing +- **Token Management**: Secure token handling and caching + +#### **Utility Functions** +- **Pure Functions**: Stateless utility functions for data processing +- **Reusability**: Common operations shared across components +- **Type Safety**: Robust data validation and transformation + + +## 📚 Additional Resources + +- [Microsoft Identity Platform Documentation](https://docs.microsoft.com/en-us/azure/active-directory/develop/) +- [MSAL.js Documentation](https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-overview) +- [Microsoft Graph API fido2AuthenticationMethod](https://learn.microsoft.com/en-gb/graph/api/resources/fido2authenticationmethod?view=graph-rest-beta) +- [WebAuthn/FIDO2 Documentation](https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-passwordless) +- [Set up a reverse proxy for a single-page app using Azure Function App](https://learn.microsoft.com/en-us/entra/identity-platform/how-to-native-authentication-cors-solution-test-environment) diff --git a/passkey-sample/cors.js b/passkey-sample/cors.js new file mode 100644 index 0000000..cc83e3f --- /dev/null +++ b/passkey-sample/cors.js @@ -0,0 +1,84 @@ +import http from "http"; +import https from "https"; +import { appConfig } from "./src/authConfig.js"; +const proxyConfig = { + localApiPath: "/api", + port: 3001, + proxy: `https://login.microsoftonline.com/${appConfig.tenantId}`, +}; + +const extraHeaders = [ + "x-client-SKU", + "x-client-VER", + "x-client-OS", + "x-client-CPU", + "x-client-current-telemetry", + "x-client-last-telemetry", + "client-request-id", +]; +http.createServer((req, res) => { + const reqUrl = new URL(req.url, `http://localhost:${proxyConfig.port}`); + const domain = new URL(proxyConfig.proxy).hostname; + + // Set CORS headers for all responses including OPTIONS + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, " + extraHeaders.join(", "), + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "86400", // 24 hours + }; + + // Handle preflight OPTIONS request + if (req.method === "OPTIONS") { + res.writeHead(204, corsHeaders); + res.end(); + return; + } + + if (reqUrl.pathname.startsWith(proxyConfig.localApiPath)) { + const targetUrl = proxyConfig.proxy + (reqUrl.pathname ? reqUrl.pathname.replace(proxyConfig.localApiPath, "") : "") + (reqUrl.search || ""); + + console.log("Incoming request -> " + req.url + " ===> " + reqUrl.pathname); + + const newHeaders = {}; + for (let [key, value] of Object.entries(req.headers)) { + if (key !== 'origin') { + newHeaders[key] = value; + } + } + + const proxyReq = https.request( + targetUrl, // CodeQL [SM04580] The newly generated target URL utilizes the configured proxy URL to resolve the CORS issue and will be used exclusively for demo purposes and run locally. + { + method: req.method, + headers: { + ...newHeaders, + host: domain, + }, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode, { + ...proxyRes.headers, + ...corsHeaders, + }); + + proxyRes.pipe(res); + } + ); + + proxyReq.on("error", (err) => { + console.error("Error with the proxy request:", err); + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Proxy error."); + }); + + req.pipe(proxyReq); + } else { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + } +}).listen(proxyConfig.port, () => { + console.log("CORS proxy running on http://localhost:3001"); + console.log("Proxying from " + proxyConfig.localApiPath + " ===> " + proxyConfig.proxy); +}); diff --git a/passkey-sample/index.html b/passkey-sample/index.html new file mode 100644 index 0000000..469f579 --- /dev/null +++ b/passkey-sample/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + Passkey Management | Microsoft Entra External ID + + + +
+ + + diff --git a/passkey-sample/package-lock.json b/passkey-sample/package-lock.json new file mode 100644 index 0000000..5f2e40f --- /dev/null +++ b/passkey-sample/package-lock.json @@ -0,0 +1,2143 @@ +{ + "name": "ms-eeid-passkey-sample-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ms-eeid-passkey-sample-app", + "version": "1.0.0", + "dependencies": { + "@azure/msal-browser": "^4.30.0", + "@azure/msal-react": "^3.0.29", + "bootstrap": "^5.3.3", + "react": "^19.2.1", + "react-bootstrap": "^2.10.2", + "react-dom": "^19.2.1", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.3.1" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.30.0.tgz", + "integrity": "sha512-HBBKfbZkMVzzF5bofvS1cXuNHFVc+gt4/HOnCmG/0hsHuZRJvJvDg/+7nTwIpoqvJc8BQp5o23rBUfisOLxR+w==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.17.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.17.0.tgz", + "integrity": "sha512-VQ5/gTLFADkwue+FohVuCqlzFPUq4xSrX8jeZe+iwZuY6moliNC8xt86qPVNYdtbQfELDf2Nu6LI+demFPHGgw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-react": { + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-3.0.29.tgz", + "integrity": "sha512-RpFfq3aIpmKajcshbaJH7Q/1CesxQRAeKorMv+uMpDw98jvi+/L0RJkNnTRmeXrV3aM34kj2LFWBQrQ9DOXs1Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@azure/msal-browser": "^4.30.0", + "react": "^16.8.0 || ^17 || ^18 || ^19.2.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/warning": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.4.tgz", + "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/passkey-sample/package.json b/passkey-sample/package.json new file mode 100644 index 0000000..4e75002 --- /dev/null +++ b/passkey-sample/package.json @@ -0,0 +1,26 @@ +{ + "name": "ms-eeid-passkey-sample-app", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@azure/msal-browser": "^4.30.0", + "@azure/msal-react": "^3.0.29", + "bootstrap": "^5.3.3", + "react": "^19.2.1", + "react-bootstrap": "^2.10.2", + "react-dom": "^19.2.1", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.3.1" + }, + "scripts": { + "dev": "vite", + "start": "vite", + "build": "vite build", + "preview": "vite preview", + "cors": "node cors.js" + } +} diff --git a/passkey-sample/public/favicon.svg b/passkey-sample/public/favicon.svg new file mode 100644 index 0000000..1284553 --- /dev/null +++ b/passkey-sample/public/favicon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + Icon-identity-221 + + + + + + + + diff --git a/passkey-sample/public/manifest.json b/passkey-sample/public/manifest.json new file mode 100644 index 0000000..1f9e210 --- /dev/null +++ b/passkey-sample/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Passkey Sample", + "name": "Passkey Management Sample (Microsoft Entra External ID)", + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff", + "icons": [ + { + "src": "/favicon.svg", + "type": "image/svg+xml", + "sizes": "any" + } + ] +} diff --git a/passkey-sample/src/App.jsx b/passkey-sample/src/App.jsx new file mode 100644 index 0000000..624d9e0 --- /dev/null +++ b/passkey-sample/src/App.jsx @@ -0,0 +1,53 @@ +import { MsalProvider, AuthenticatedTemplate, useMsal } from '@azure/msal-react'; +import { Container } from 'react-bootstrap'; +import { PageLayout } from './components/PageLayout'; +import { SecurityPage } from './components/SecurityPage'; + +import './styles/App.css'; + +/** + * Most applications will need to conditionally render certain components based on whether a user is signed in or not. + * msal-react provides 2 easy ways to do this. AuthenticatedTemplate and UnauthenticatedTemplate components will + * only render their children if a user is authenticated or unauthenticated, respectively. For more, visit: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-react/docs/getting-started.md + */ +const MainContent = () => { + /** + * useMsal is hook that returns the PublicClientApplication instance, + * that tells you what msal is currently doing. For more, visit: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-react/docs/hooks.md + */ + const { instance } = useMsal(); + const activeAccount = instance.getActiveAccount(); + + return ( +
+ + {activeAccount ? ( + + + + ) : null} + +
+ ); +}; + +/** + * msal-react is built on the React context API and all parts of your app that require authentication must be + * wrapped in the MsalProvider component. You will first need to initialize an instance of PublicClientApplication + * then pass this to MsalProvider as a prop. All components underneath MsalProvider will have access to the + * PublicClientApplication instance via context as well as all hooks and components provided by msal-react. For more, visit: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-react/docs/getting-started.md + */ +const App = ({ instance }) => { + return ( + + + + + + ); +}; + +export default App; \ No newline at end of file diff --git a/passkey-sample/src/authConfig.js b/passkey-sample/src/authConfig.js new file mode 100644 index 0000000..43fb567 --- /dev/null +++ b/passkey-sample/src/authConfig.js @@ -0,0 +1,93 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { LogLevel } from '@azure/msal-browser'; + +/** + * Configuration object to be passed to MSAL instance on creation. + * For a full list of MSAL.js configuration parameters, visit: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md + */ + +export const msalConfig = { + auth: { + clientId: '', // This is the ONLY mandatory field that you need to supply. + authority: 'https://.ciamlogin.com/', // Replace the placeholder with your tenant subdomain + redirectUri: '/', + postLogoutRedirectUri: '/', + navigateToLoginRequestUrl: false, + }, + cache: { + cacheLocation: 'sessionStorage', // Configures cache location. "sessionStorage" is more secure, but "localStorage" gives you SSO between tabs. + storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge + }, + system: { + loggerOptions: { + loggerCallback: (level, message, containsPii) => { + if (containsPii) { + return; + } + switch (level) { + case LogLevel.Error: + console.error(message); + return; + case LogLevel.Info: + console.info(message); + return; + case LogLevel.Verbose: + console.debug(message); + return; + case LogLevel.Warning: + console.warn(message); + return; + default: + return; + } + }, + }, + }, +}; + +/** + * Scopes you add here will be prompted for user consent during sign-in. + * By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request. + * For more information about OIDC scopes, visit: + * https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes + */ +const claimsRequestValue = { + "id_token": { + "amr": { + "essential": true, + "values": ["ngcmfa"] + } + }, + access_token: { + amr: { + essential: true, + values: ['ngcmfa'] + } + } +}; +const claims = JSON.stringify(claimsRequestValue); +export const loginRequest = { + scopes: [], + // Add following claims to enforce ngcmfa, which means every 15 minutes, user needs to re-authenticate with MFA in order to perform passkey creation/deletion operations. + // This also trigger passkey during sign-in if user already has passkey registered, as passkey could be a default authentication method with highest priority. + // Currently, for CIAM tenant, autofill with passkey as first authentication method is supported. Using passkey as a secondary auth method is not supported. + extraQueryParameters: { + claims: claims, + }, +}; + + +/** + * Application configuration consumed by the SPA at runtime. + */ +export const appConfig = { + proxyDomain: 'http://localhost:3001/api', + appId: 'your-client-id', + tenantId: 'your-tenant-id', + customDomain: '', // Optional: your valid custom domain. If empty, the tenant subdomain from creationOptions.rp.id is used. +}; \ No newline at end of file diff --git a/passkey-sample/src/components/NavigationBar.jsx b/passkey-sample/src/components/NavigationBar.jsx new file mode 100644 index 0000000..ce3b443 --- /dev/null +++ b/passkey-sample/src/components/NavigationBar.jsx @@ -0,0 +1,65 @@ +import { AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from '@azure/msal-react'; +import { Navbar, Button } from 'react-bootstrap'; +import { loginRequest } from '../authConfig'; +import { clearAppTokenCache } from '../utils/tokenUtils'; + +export const NavigationBar = () => { + const { instance } = useMsal(); + + const handleLoginRedirect = async () => { + try { + await instance.loginRedirect({ + ...loginRequest, + prompt: 'login', + }); + } catch (error) { + console.error('Login redirect failed:', error); + } + }; + + const handleLogoutRedirect = async () => { + try { + const accounts = instance.getAllAccounts(); + clearAppTokenCache(instance); + + if (accounts.length === 0) { + await instance.clearCache(); + window.location.href = '/'; + return; + } + + await instance.logoutRedirect({ + account: accounts[0], + }); + } catch (error) { + console.error('Logout redirect failed:', error); + } + }; + + /** + * Most applications will need to conditionally render certain components based on whether a user is signed in or not. + * msal-react provides 2 easy ways to do this. AuthenticatedTemplate and UnauthenticatedTemplate components will + * only render their children if a user is authenticated or unauthenticated, respectively. + */ + return ( + <> + + + Microsoft identity platform + + +
+ +
+
+ +
+ +
+
+
+ + ); +}; diff --git a/passkey-sample/src/components/PageLayout.jsx b/passkey-sample/src/components/PageLayout.jsx new file mode 100644 index 0000000..aa92d74 --- /dev/null +++ b/passkey-sample/src/components/PageLayout.jsx @@ -0,0 +1,24 @@ +import { UnauthenticatedTemplate } from '@azure/msal-react'; +import { NavigationBar } from './NavigationBar.jsx'; + +export const PageLayout = (props) => { + /** + * Most applications will need to conditionally render certain components based on whether a user is signed in or not. + * msal-react provides 2 easy ways to do this. AuthenticatedTemplate and UnauthenticatedTemplate components will + * only render their children if a user is authenticated or unauthenticated, respectively. + */ + return ( + <> + +
+ +
+
Welcome to the Microsoft Authentication Library For React Passkey Tutorial
+
+
+
+ {props.children} +
+ + ); +} diff --git a/passkey-sample/src/components/SecurityPage.jsx b/passkey-sample/src/components/SecurityPage.jsx new file mode 100644 index 0000000..9782ec6 --- /dev/null +++ b/passkey-sample/src/components/SecurityPage.jsx @@ -0,0 +1,219 @@ +import { useState, useEffect } from 'react'; +import { Container, Alert, Spinner } from 'react-bootstrap'; +import { FaBell } from 'react-icons/fa'; +import { useMsal } from '@azure/msal-react'; +import { loginRequest, appConfig } from '../authConfig'; +import { calculateNgcmfaExpiration, getAccessToken, getCachedAppToken } from '../utils/tokenUtils'; + +import { UserProfileHeader, SecurityAlert } from './common/UIComponents'; +import ToastNotifications from './common/ToastNotifications'; +import PasskeysSection from './passkeys/PasskeysSection'; + +const NGCMFA_EXPIRY_MINUTES = 15; +const SECONDS_PER_MINUTE = 60; + +export const SecurityPage = () => { + const { instance, accounts } = useMsal(); + const [accessToken, setAccessToken] = useState(null); + const [appToken, setAppToken] = useState(null); + const [ngcmfaExpiration, setNgcmfaExpiration] = useState(null); + const [loading, setLoading] = useState(true); + const [accessTokenError, setAccessTokenError] = useState(null); + const [appTokenError, setAppTokenError] = useState(null); + const [toasts, setToasts] = useState([]); + + + useEffect(() => { + const fetchAccessToken = async () => { + try { + const result = await getAccessToken(instance, accounts, loginRequest); + + if (result.error) { + setAccessTokenError(result.error); + setLoading(false); + } else { + setAccessTokenError(null); + setAccessToken(result.decodedToken); + setLoading(false); + } + } catch (error) { + setAccessTokenError(`Failed to get access token: ${error.message}`); + setLoading(false); + } + }; + + fetchAccessToken(); + }, [instance, accounts]); + + useEffect(() => { + const fetchAppToken = async () => { + try { + const token = await getCachedAppToken( + instance, + appConfig.proxyDomain, + appConfig.appId, + import.meta.env.VITE_APP_SECRET + ); + + if (token) { + setAppTokenError(null); + setAppToken(token); + } else { + throw new Error('App token request returned empty result'); + } + } catch (error) { + setAppTokenError(`Failed to get app token: ${error.message}. Passkey functionality may be limited.`); + setAppToken(null); + } + }; + + if (instance) { + fetchAppToken(); + } + }, [instance, accessToken]); + + useEffect(() => { + if (accessToken) { + const expiration = calculateNgcmfaExpiration(accessToken, NGCMFA_EXPIRY_MINUTES, SECONDS_PER_MINUTE); + setNgcmfaExpiration(expiration); + } else { + setNgcmfaExpiration(null); + } + }, [accessToken]); + + const getUserId = () => { + if (accessToken && accessToken.oid) { + return accessToken.oid; + } + return null; + }; + + const getUserData = () => { + const defaultUserData = { + name: "User", + email: "user@example.com", + }; + + if (accessToken) { + return { + name: accessToken.name || accessToken.given_name || accessToken.family_name || defaultUserData.name, + email: accessToken.unique_name || accessToken.email || accessToken.preferred_username || accessToken.upn || defaultUserData.email, + }; + } + + return defaultUserData; + }; + + const displayError = accessTokenError || appTokenError; + const userData = !loading && !accessTokenError ? getUserData() : { name: "Loading...", email: "Loading..." }; + const userId = !loading && !accessTokenError ? getUserId() : null; + + const alerts = [ + { + id: 1, + message: "For your security, multi-factor authentication is required when managing your credentials", + type: "info", + icon: FaBell + } + ]; + + const showToast = (toastData) => { + // Check if this is a sessionExpiredWithAction toast and if one already exists + if (toastData.type === 'sessionExpiredWithAction') { + const existingSessionExpiredToast = toasts.find( + toast => toast.type === 'sessionExpiredWithAction' && toast.show + ); + + // If a session expired toast is already showing, don't add another one + if (existingSessionExpiredToast) { + return; + } + } + + const newToast = { + id: `${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, + show: true, + ...toastData + }; + setToasts(prev => [...prev, newToast]); + }; + + const closeToast = (toastId) => { + setToasts(prev => prev.filter(toast => toast.id !== toastId)); + }; + + if (loading) { + return ( + +
+ + Loading... + +
+
+ ); + } + + if (displayError) { + return ( + + + + {accessTokenError ? "Authentication Error" : "Service Error"} + +

{displayError}

+ {accessTokenError && appTokenError && ( + <> +
+

Additional issue: {appTokenError}

+ + )} +
+
+ ); + } + + if (!userId) { + return ( + + + User ID Not Available +

Unable to extract user ID from token claims. Please try logging in again.

+
+
+ ); + } + + return ( + + + + {alerts.map(alert => ( + + ))} + + + + {/* Toast Notifications */} + + + ); +}; + +export default SecurityPage; diff --git a/passkey-sample/src/components/common/ToastNotifications.jsx b/passkey-sample/src/components/common/ToastNotifications.jsx new file mode 100644 index 0000000..a765ce8 --- /dev/null +++ b/passkey-sample/src/components/common/ToastNotifications.jsx @@ -0,0 +1,92 @@ +import { useEffect } from 'react'; +import { Toast, ToastContainer, Button } from 'react-bootstrap'; + +const ToastItem = ({ toast, onClose }) => { + useEffect(() => { + if (toast.show && toast.autoHide !== false) { + const timer = setTimeout(() => { + onClose(); + }, 4000); + return () => clearTimeout(timer); + } + }, [toast.show, toast.autoHide, onClose]); + + return ( + + + {toast.title} + + +
{toast.message}
+ {toast.action && ( +
+ + +
+ )} +
+
+ ); +}; + +// Combined Toast Notifications Container +const ToastNotifications = ({ toasts, onCloseToast }) => { + // Separate toasts by type for different positioning + const sessionExpiredToasts = toasts.filter(toast => toast.type === 'sessionExpiredWithAction'); + const regularToasts = toasts.filter(toast => toast.type !== 'sessionExpiredWithAction'); + + return ( + <> + {/* Regular toasts - top-right corner */} + + {regularToasts.map((toast) => ( + onCloseToast(toast.id)} + /> + ))} + + + {/* Session expired toasts - center of screen */} + {sessionExpiredToasts.length > 0 && ( +
+
+ {sessionExpiredToasts.map((toast) => ( + onCloseToast(toast.id)} + /> + ))} +
+
+ )} + + ); +}; + +export default ToastNotifications; diff --git a/passkey-sample/src/components/common/UIComponents.jsx b/passkey-sample/src/components/common/UIComponents.jsx new file mode 100644 index 0000000..446518e --- /dev/null +++ b/passkey-sample/src/components/common/UIComponents.jsx @@ -0,0 +1,40 @@ +import { FaExclamationTriangle } from 'react-icons/fa'; + +/** + * Security alert component for displaying informational messages with icons + * @param {Object} props - Component props + * @param {string} props.message - Alert message to display + * @param {string} [props.type='info'] - Alert type (info, warning, danger, success) + * @param {React.Component} [props.icon=FaExclamationTriangle] - Icon component to display + * @returns {JSX.Element} Rendered security alert + */ +export const SecurityAlert = ({ message, type = 'info', icon: IconComponent = FaExclamationTriangle }) => { + return ( +
+
+ + {message} +
+
+ ); +}; + +/** + * User profile header component displaying user information and description + * @param {Object} props - Component props + * @param {string} props.name - User's display name + * @param {string} props.email - User's email address + * @returns {JSX.Element} Rendered user profile header + */ +export const UserProfileHeader = ({ name, email }) => { + return ( +
+
+

{email}

+
+

+ Manage sign-in and verification options for your account +

+
+ ); +}; diff --git a/passkey-sample/src/components/common/index.js b/passkey-sample/src/components/common/index.js new file mode 100644 index 0000000..f6f5122 --- /dev/null +++ b/passkey-sample/src/components/common/index.js @@ -0,0 +1,2 @@ +export { default as ToastNotifications } from './ToastNotifications'; +export { SecurityAlert, UserProfileHeader } from './UIComponents'; diff --git a/passkey-sample/src/components/passkeys/PasskeysSection.jsx b/passkey-sample/src/components/passkeys/PasskeysSection.jsx new file mode 100644 index 0000000..51ecfdf --- /dev/null +++ b/passkey-sample/src/components/passkeys/PasskeysSection.jsx @@ -0,0 +1,90 @@ +import { useEffect } from 'react'; +import { Card } from 'react-bootstrap'; +import PasskeysHeader from './components/PasskeysHeader'; +import PasskeysList from './components/PasskeysList'; +import DeleteModal from './components/DeleteModal'; +import { PASSKEY_CONSTANTS } from '../../utils/passkeyUtils'; +import { + usePasskeyFetcher, + usePasskeyAddOperation, + usePasskeyDeleteOperation, + useAuthentication +} from '../../hooks/passkeys'; + +const PasskeysSection = ({ onShowToast, appToken, userId, ngcmfaExpiry }) => { + const maxPasskeys = PASSKEY_CONSTANTS.MAX_PASSKEYS; + + // Custom hooks handle all the complex logic + const { passkeys, isLoading, error, fetchPasskeys } = usePasskeyFetcher({ + appToken, userId, onShowToast + }); + + const { handleAddPasskey, performAddPasskey } = usePasskeyAddOperation({ + appToken, + userId, + ngcmfaExpiry, + onShowToast, + fetchPasskeys, + currentPasskeys: passkeys + }); + + const { initiate: initiateDelete, showConfirmationModal, modalProps } = usePasskeyDeleteOperation({ + appToken, + userId, + ngcmfaExpiry, + onShowToast, + fetchPasskeys, + currentPasskeys: passkeys + }); + + const { getCachedOperation, clearCachedOperation } = useAuthentication({ onShowToast }); + + // Handle initial fetch + useEffect(() => { + if (appToken && userId) { + fetchPasskeys().catch(console.error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appToken, userId]); // Only depend on appToken and userId, not fetchPasskeys + + useEffect(() => { + if (appToken && userId) { + const operation = getCachedOperation(); + if (operation) { + clearCachedOperation(); + + if (operation.action === "add") { + performAddPasskey(); + } else if (operation.action === "delete" && operation.passkey) { + showConfirmationModal(operation.passkey); + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appToken, userId]); + + return ( + <> + + + + + + + + + + ); +}; + +export default PasskeysSection; diff --git a/passkey-sample/src/components/passkeys/components/DeleteModal.jsx b/passkey-sample/src/components/passkeys/components/DeleteModal.jsx new file mode 100644 index 0000000..1933f53 --- /dev/null +++ b/passkey-sample/src/components/passkeys/components/DeleteModal.jsx @@ -0,0 +1,44 @@ +import { Button, Modal } from 'react-bootstrap'; +import { FaExclamationTriangle } from 'react-icons/fa'; + +/** + * Confirmation modal for passkey deletion + * @param {Object} props - Component props + * @param {boolean} props.show - Whether modal is visible + * @param {Object} props.passkey - Passkey object to delete + * @param {Function} props.onConfirm - Callback when user confirms deletion + * @param {Function} props.onCancel - Callback when user cancels deletion + * @returns {JSX.Element} Rendered confirmation modal + */ +const DeleteModal = ({ show, passkey, onConfirm, onCancel }) => { + return ( + + + Delete Passkey + + +
+ +
+

+ Are you sure you want to delete the passkey "{passkey?.name || 'Unknown'}"? +

+

+ This action cannot be undone. You'll need to set up a new passkey if you want to use passkey authentication again. +

+
+
+
+ + + + +
+ ); +}; + +export default DeleteModal; diff --git a/passkey-sample/src/components/passkeys/components/PasskeyDetails.jsx b/passkey-sample/src/components/passkeys/components/PasskeyDetails.jsx new file mode 100644 index 0000000..8809869 --- /dev/null +++ b/passkey-sample/src/components/passkeys/components/PasskeyDetails.jsx @@ -0,0 +1,35 @@ +import { Row, Col } from 'react-bootstrap'; + +/** + * Dropdown details component for expanded passkey information + * @param {Object} props - Component props + * @param {Object} props.passkey - Passkey object containing detailed information + * @returns {JSX.Element} Rendered passkey details + */ +const PasskeyDetails = ({ passkey }) => { + return ( +
+ {/* Two rows, two columns layout for expanded details */} +
+ + +
+ Date Registered + {passkey.created || 'N/A'} +
+ + +
+ AAGUID + + {passkey.aaGuid || 'N/A'} + +
+ +
+
+
+ ); +}; + +export default PasskeyDetails; diff --git a/passkey-sample/src/components/passkeys/components/PasskeyItem.jsx b/passkey-sample/src/components/passkeys/components/PasskeyItem.jsx new file mode 100644 index 0000000..4b33896 --- /dev/null +++ b/passkey-sample/src/components/passkeys/components/PasskeyItem.jsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Button, ListGroup, Collapse, Row, Col } from 'react-bootstrap'; +import { FaChevronDown, FaChevronRight } from 'react-icons/fa'; +import { HiOutlineTrash } from 'react-icons/hi'; +import PasskeyDetails from './PasskeyDetails'; +import { parseDeviceModel } from './utils'; + +/** + * Individual passkey item component for displaying passkey information + * @param {Object} props - Component props + * @param {Object} props.passkey - Passkey object containing id, name, model, created, lastUsed + * @param {Function} props.onDelete - Callback function when delete button is clicked + * @param {boolean} [props.isLoading=false] - Whether component is in loading state + * @returns {JSX.Element} Rendered passkey item + */ +const PasskeyItem = ({ passkey, onDelete, isLoading = false }) => { + const [isExpanded, setIsExpanded] = useState(false); + + const handleToggleExpand = () => { + setIsExpanded(!isExpanded); + }; + + const deviceDetails = parseDeviceModel(passkey.model); + + return ( + + {/* Main Row - always visible */} +
+ {/* Two rows, two columns content area */} +
+ + + Passkey ({passkey.passkeyType}) + + + {deviceDetails.authenticatorDevice} - {deviceDetails.method} + + + + + {deviceDetails.authenticatorDevice} + + + {deviceDetails.method} device + + +
+ + {/* Buttons: Delete first, then Dropdown */} +
+ + +
+
+ + {/* Expandable Details */} + +
+ +
+
+
+ ); +}; + +export default PasskeyItem; diff --git a/passkey-sample/src/components/passkeys/components/PasskeysHeader.jsx b/passkey-sample/src/components/passkeys/components/PasskeysHeader.jsx new file mode 100644 index 0000000..eb0c9f8 --- /dev/null +++ b/passkey-sample/src/components/passkeys/components/PasskeysHeader.jsx @@ -0,0 +1,32 @@ +import { Button } from 'react-bootstrap'; +import { FaPlus } from 'react-icons/fa'; + +/** + * Header component for passkeys section with count display and add button + * @param {Object} props - Component props + * @param {number} props.count - Current number of passkeys + * @param {number} props.maxCount - Maximum allowed number of passkeys + * @param {Function} props.onAddClick - Callback function when add button is clicked + * @param {boolean} [props.isLoading=false] - Whether component is in loading state + * @returns {JSX.Element} Rendered passkeys header + */ +const PasskeysHeader = ({ count, maxCount, onAddClick, isLoading = false }) => { + return ( +
+
+
Passkeys ({count}/{maxCount})
+
+ +
+ ); +}; + +export default PasskeysHeader; diff --git a/passkey-sample/src/components/passkeys/components/PasskeysList.jsx b/passkey-sample/src/components/passkeys/components/PasskeysList.jsx new file mode 100644 index 0000000..ea47052 --- /dev/null +++ b/passkey-sample/src/components/passkeys/components/PasskeysList.jsx @@ -0,0 +1,68 @@ +import { ListGroup, Alert, Spinner } from 'react-bootstrap'; +import { FaKey, FaExclamationTriangle } from 'react-icons/fa'; +import PasskeyItem from './PasskeyItem'; + +/** + * List component for displaying multiple passkeys with loading and error states + * @param {Object} props - Component props + * @param {Array} props.passkeys - Array of passkey objects to display + * @param {Function} props.onDelete - Callback function when a passkey is deleted + * @param {boolean} [props.isLoading=false] - Whether list is in loading state + * @param {string|null} [props.error=null] - Error message to display if any + * @returns {JSX.Element} Rendered passkeys list + */ +const PasskeysList = ({ passkeys, onDelete, isLoading = false, error = null }) => { + if (error) { + return ( + +
+ +
+ Error loading passkeys +

{error}

+
+
+
+ ); + } + + if (isLoading) { + return ( +
+ + Loading... + +

Loading your passkeys...

+
+ ); + } + + if (passkeys.length === 0) { + return ( +
+
+ +
+
No passkeys configured yet
+

+ Create a passkey to sign in faster and more securely +

+
+ ); + } + + return ( + + {passkeys.map(passkey => ( + + ))} + + ); +}; + +export default PasskeysList; diff --git a/passkey-sample/src/components/passkeys/components/utils.js b/passkey-sample/src/components/passkeys/components/utils.js new file mode 100644 index 0000000..3aa1469 --- /dev/null +++ b/passkey-sample/src/components/passkeys/components/utils.js @@ -0,0 +1,25 @@ +/** + * Parse device model string to extract authenticator device and method + * @param {string} modelString - The model string from passkey data + * @returns {Object} Object containing authenticatorDevice and method + */ +export const parseDeviceModel = (modelString) => { + if (!modelString) { + return { authenticatorDevice: 'Unknown Device', method: 'Unknown Method' }; + } + + const withIndex = modelString.toLowerCase().indexOf(' with '); + + if (withIndex === -1) { + // No "with" found, return full string as device + return { authenticatorDevice: modelString.trim(), method: 'Standard' }; + } + + const authenticatorDevice = modelString.substring(0, withIndex).trim(); + const method = modelString.substring(withIndex + 6).trim(); // +6 for " with " + + return { + authenticatorDevice: authenticatorDevice || 'Unknown Device', + method: method || 'Unknown Method' + }; +}; diff --git a/passkey-sample/src/components/passkeys/index.js b/passkey-sample/src/components/passkeys/index.js new file mode 100644 index 0000000..4de6b77 --- /dev/null +++ b/passkey-sample/src/components/passkeys/index.js @@ -0,0 +1,6 @@ +export { default as PasskeysSection } from './PasskeysSection'; +export { default as PasskeysHeader } from './components/PasskeysHeader'; +export { default as PasskeysList } from './components/PasskeysList'; +export { default as PasskeyItem } from './components/PasskeyItem'; +export { default as DeleteModal } from './components/DeleteModal'; +export { default as PasskeyDetails } from './components/PasskeyDetails'; diff --git a/passkey-sample/src/hooks/passkeys/index.js b/passkey-sample/src/hooks/passkeys/index.js new file mode 100644 index 0000000..598b5af --- /dev/null +++ b/passkey-sample/src/hooks/passkeys/index.js @@ -0,0 +1,4 @@ +export { useAuthentication } from './useAuthentication'; +export { usePasskeyFetcher } from './usePasskeyFetcher'; +export { usePasskeyAddOperation } from './usePasskeyAddOperation'; +export { usePasskeyDeleteOperation } from './usePasskeyDeleteOperation'; diff --git a/passkey-sample/src/hooks/passkeys/useAuthentication.js b/passkey-sample/src/hooks/passkeys/useAuthentication.js new file mode 100644 index 0000000..93d7ca0 --- /dev/null +++ b/passkey-sample/src/hooks/passkeys/useAuthentication.js @@ -0,0 +1,83 @@ +import { useMsal } from '@azure/msal-react'; +import { clearAppTokenCache } from '../../utils/tokenUtils'; +import { loginRequest } from '../../authConfig'; +import { checkNgcmfaExpiration, createToastMessages } from '../../utils/passkeyUtils'; + +export const useAuthentication = ({ onShowToast }) => { + const { instance } = useMsal(); + + const handleSignIn = async () => { + try { + if (onShowToast) { + onShowToast({ + title: 'Redirecting...', + message: 'Signing out and redirecting to sign-in page...', + variant: 'info', + autoHide: true + }); + } + + const account = instance.getAllAccounts()[0]; + clearAppTokenCache(instance); + await instance.loginRedirect({ + ...loginRequest, + loginHint: account?.username + }); + } catch (error) { + console.error('Error during sign-in redirect:', error); + if (onShowToast) { + onShowToast({ + title: 'Authentication error', + message: 'Failed to redirect to sign-in page. Please try again.', + variant: 'danger' + }); + } + } + }; + + const handleReAuthentication = async () => { + try { + if (onShowToast) { + onShowToast(createToastMessages.sessionExpiredWithAction(handleSignIn)); + } + } catch (error) { + if (onShowToast) { + onShowToast(createToastMessages.authError()); + } + } + }; + + const isTokenExpired = (ngcmfaExpiry) => { + return checkNgcmfaExpiration(ngcmfaExpiry); + }; + + const cacheOperation = (operation) => { + sessionStorage.setItem("postLoginAction", JSON.stringify(operation)); + }; + + const getCachedOperation = () => { + const actionData = sessionStorage.getItem("postLoginAction"); + if (actionData) { + try { + return JSON.parse(actionData); + } catch (error) { + sessionStorage.removeItem("postLoginAction"); + return null; + } + } + return null; + }; + + const clearCachedOperation = () => { + sessionStorage.removeItem("postLoginAction"); + }; + + return { + handleSignIn, + handleReAuthentication, + isTokenExpired, + cacheOperation, + getCachedOperation, + clearCachedOperation + }; +}; diff --git a/passkey-sample/src/hooks/passkeys/usePasskeyAddOperation.js b/passkey-sample/src/hooks/passkeys/usePasskeyAddOperation.js new file mode 100644 index 0000000..5636887 --- /dev/null +++ b/passkey-sample/src/hooks/passkeys/usePasskeyAddOperation.js @@ -0,0 +1,68 @@ +import { getPasskeyCreationOptions, registerUserPasskey } from '../../services/PasskeyService'; +import { createToastMessages } from '../../utils/passkeyUtils'; +import { useAuthentication } from './useAuthentication'; + +const GRAPH_API_PROPAGATION_DELAY = 2000; + +export const usePasskeyAddOperation = ({ + appToken, + userId, + ngcmfaExpiry, + onShowToast, + fetchPasskeys, + currentPasskeys +}) => { + const { isTokenExpired, handleReAuthentication, cacheOperation } = useAuthentication({ onShowToast }); + + const performAddPasskey = async () => { + const currentCount = currentPasskeys.length; + + try { + if (!appToken || !userId) { + throw new Error('Missing appToken or userId'); + } + + const creationOptions = await getPasskeyCreationOptions(appToken, userId); + await registerUserPasskey(creationOptions, appToken, userId); + + if (onShowToast) { + onShowToast(createToastMessages.passkeyAdded()); + } + + await new Promise(resolve => setTimeout(resolve, GRAPH_API_PROPAGATION_DELAY)); + + await fetchPasskeys({ + type: 'add', + expectedCount: currentCount + 1 + }, { + setLoadingState: true, + showToast: true + }); + + } catch (err) { + if (onShowToast) { + if (err.name === 'NotAllowedError') { + onShowToast(createToastMessages.passkeyAddCancelled()); + } else { + onShowToast(createToastMessages.errorAdding(err.message)); + } + } + } + }; + + const handleAddPasskey = async () => { + if (isTokenExpired(ngcmfaExpiry)) { + const addOperation = { action: 'add' }; + cacheOperation(addOperation); + await handleReAuthentication(); + return; + } + + await performAddPasskey(); + }; + + return { + handleAddPasskey, + performAddPasskey + }; +}; diff --git a/passkey-sample/src/hooks/passkeys/usePasskeyDeleteOperation.js b/passkey-sample/src/hooks/passkeys/usePasskeyDeleteOperation.js new file mode 100644 index 0000000..68d36f8 --- /dev/null +++ b/passkey-sample/src/hooks/passkeys/usePasskeyDeleteOperation.js @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { deleteUserPasskey } from '../../services/PasskeyService'; +import { createToastMessages } from '../../utils/passkeyUtils'; +import { useAuthentication } from './useAuthentication'; + +export const usePasskeyDeleteOperation = ({ + appToken, + userId, + ngcmfaExpiry, + onShowToast, + fetchPasskeys, + currentPasskeys +}) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [passkeyToDelete, setPasskeyToDelete] = useState(null); + + const { isTokenExpired, handleReAuthentication, cacheOperation } = useAuthentication({ onShowToast }); + + const displayModal = (passkey) => { + setPasskeyToDelete(passkey); + setShowDeleteModal(true); + }; + + const performDelete = async (passkeyId, cachedPasskeyName) => { + const targetPasskey = currentPasskeys.find(p => p.id === passkeyId); + const passkeyDisplayName = targetPasskey?.name || cachedPasskeyName; + + try { + await deleteUserPasskey(appToken, userId, passkeyId); + + const updatedPasskeys = await fetchPasskeys({ + type: 'delete', + passkeyId: passkeyId + }, { + setLoadingState: true, + showToast: true + }); + + if (onShowToast && updatedPasskeys !== null) { + onShowToast(createToastMessages.passkeyDeleted(passkeyDisplayName || 'Unknown')); + } + } catch (err) { + if (onShowToast) { + onShowToast(createToastMessages.errorDeleting(err.message)); + } + } + }; + + const initiate = async (passkey) => { + if (isTokenExpired(ngcmfaExpiry)) { + const deleteOperation = { + action: 'delete', + passkey: passkey + }; + cacheOperation(deleteOperation); + await handleReAuthentication(); + return; + } + + displayModal(passkey); + }; + + const confirm = async () => { + if (!passkeyToDelete) return; + + const { id: passkeyId, name: passkeyName } = passkeyToDelete; + + hide(); + + try { + await performDelete(passkeyId, passkeyName); + } catch (error) { + // Error already handled in performDelete + } + }; + + const hide = () => { + setShowDeleteModal(false); + setPasskeyToDelete(null); + }; + + return { + initiate, + performDelete, + showConfirmationModal: displayModal, + modalProps: { + show: showDeleteModal, + passkey: passkeyToDelete, + onConfirm: confirm, + onCancel: hide + } + }; +}; diff --git a/passkey-sample/src/hooks/passkeys/usePasskeyFetcher.js b/passkey-sample/src/hooks/passkeys/usePasskeyFetcher.js new file mode 100644 index 0000000..4497a28 --- /dev/null +++ b/passkey-sample/src/hooks/passkeys/usePasskeyFetcher.js @@ -0,0 +1,123 @@ +import { useState, useCallback } from 'react'; +import { fetchUserPasskey } from '../../services/PasskeyService'; +import { PASSKEY_CONSTANTS, createRetryDelay, createFetchDelay, createToastMessages } from '../../utils/passkeyUtils'; + +/** + * Hook for managing passkey data fetching with retry logic + * @param {Object} params - Hook parameters + * @param {string} params.appToken - Authentication token + * @param {string} params.userId - User ID + * @param {Function} params.onShowToast - Toast notification function + * @returns {Object} Passkey data and fetching utilities + */ +export const usePasskeyFetcher = ({ appToken, userId, onShowToast }) => { + const [passkeys, setPasskeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPasskeys = useCallback(async (expectedChange = null, options = {}) => { + const { + maxRetries = expectedChange ? PASSKEY_CONSTANTS.MAX_RETRIES : 1, + showToast = false, + setLoadingState = true, + } = options; + + if (!appToken || !userId) { + setError('Access token or user ID not available'); + if (setLoadingState) setIsLoading(false); + return; + } + + let lastError; + + if (setLoadingState) { + setIsLoading(true); + setError(null); + } + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + if (maxRetries > 1) { + console.log(`Fetch attempt ${attempt}/${maxRetries}...`); + } + + const transformedPasskeys = await fetchUserPasskey(appToken, userId); + console.log(`Found ${transformedPasskeys.length} passkeys${maxRetries > 1 ? ` on attempt ${attempt}` : ''}`); + + if (expectedChange) { + const { type, passkeyId, expectedCount } = expectedChange; + + if (type === 'add' && expectedCount && transformedPasskeys.length < expectedCount) { + console.log(`Expected ${expectedCount} passkeys after add, but got ${transformedPasskeys.length}. Retrying...`); + if (attempt < maxRetries) { + await createRetryDelay(attempt); + continue; + } + } + + if (type === 'delete' && passkeyId && transformedPasskeys.some(p => p.id === passkeyId)) { + console.log(`Passkey ${passkeyId} still exists after delete. Retrying...`); + if (attempt < maxRetries) { + await createRetryDelay(attempt); + continue; + } + } + } + + setPasskeys(transformedPasskeys); + console.log(`Successfully updated passkey list with ${transformedPasskeys.length} items`); + + if (setLoadingState) { + setIsLoading(false); + } + + if (showToast && transformedPasskeys.length > 0 && onShowToast) { + onShowToast(createToastMessages.passkeysRefreshed(transformedPasskeys.length)); + } + + return transformedPasskeys; + + } catch (error) { + lastError = error; + if (maxRetries > 1) { + console.warn(`Fetch attempt ${attempt} failed:`, error); + } else { + console.error('Error fetching passkeys:', error); + } + + if (attempt < maxRetries) { + await createFetchDelay(attempt); + } + } + } + + const errorMsg = `Failed to load passkeys: ${lastError?.message || 'Unknown error'}`; + setError(errorMsg); + + if (onShowToast) { + onShowToast(createToastMessages.errorLoading()); + } + + if (setLoadingState) { + setIsLoading(false); + } + + if (expectedChange) { + throw lastError || new Error('Max retries exceeded'); + } + + return null; + }, [appToken, userId, onShowToast]); + + const refetch = useCallback(() => { + return fetchPasskeys(); + }, [fetchPasskeys]); + + return { + passkeys, + isLoading, + error, + fetchPasskeys, + refetch + }; +}; diff --git a/passkey-sample/src/index.jsx b/passkey-sample/src/index.jsx new file mode 100644 index 0000000..e2a159d --- /dev/null +++ b/passkey-sample/src/index.jsx @@ -0,0 +1,39 @@ +import { createRoot } from 'react-dom/client'; +import App from './App'; +import { PublicClientApplication, EventType } from '@azure/msal-browser'; +import { msalConfig } from './authConfig'; + +import 'bootstrap/dist/css/bootstrap.min.css'; +import './styles/index.css'; + +/** + * MSAL should be instantiated outside of the component tree to prevent it from being re-instantiated on re-renders. + * For more, visit: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-react/docs/getting-started.md + */ +const msalInstance = new PublicClientApplication(msalConfig); + +// Default to using the first account if no account is active on page load +if (!msalInstance.getActiveAccount() && msalInstance.getAllAccounts().length > 0) { + // Account selection logic is app dependent. Adjust as needed for different use cases. + msalInstance.setActiveAccount(msalInstance.getAllAccounts()[0]); +} + +// Listen for sign-in event and set active account +msalInstance.addEventCallback((event) => { + if (event.eventType === EventType.LOGIN_SUCCESS && event.payload?.account) { + const account = event.payload.account; + msalInstance.setActiveAccount(account); + } +}); + +// Wait for MSAL to process any redirect response before rendering +msalInstance.initialize().then(() => { + msalInstance.handleRedirectPromise().then((response) => { + if (response) { + msalInstance.setActiveAccount(response.account); + } + + const root = createRoot(document.getElementById('root')); + root.render(); + }); +}); \ No newline at end of file diff --git a/passkey-sample/src/services/GraphApiClient.js b/passkey-sample/src/services/GraphApiClient.js new file mode 100644 index 0000000..8618a98 --- /dev/null +++ b/passkey-sample/src/services/GraphApiClient.js @@ -0,0 +1,128 @@ +/** + * Generic HTTP client for Microsoft Graph API + * Handles authentication, error parsing, and common request patterns + */ + +const msGraphDomain = "graph.microsoft.com/beta"; + +/** + * Parse Microsoft Graph API error responses with nested JSON structure + * @param {Response} response - Fetch response object + * @throws {Error} - Formatted error with detailed message + */ +export async function parseGraphApiError(response) { + try { + const errorJson = await response.json(); + + if (errorJson.error) { + let errorMessage = errorJson.error.code || 'Unknown error'; + + if (errorJson.error.message) { + try { + const nestedError = JSON.parse(errorJson.error.message); + if (nestedError["odata.error"]?.message?.value) { + errorMessage = `${errorMessage}: ${nestedError["odata.error"].message.value}`; + } + } catch (parseError) { + errorMessage = `${errorMessage}: ${errorJson.error.message}`; + } + } + + throw new Error(errorMessage); + } + + throw new Error('Unknown API error'); + + } catch (error) { + // If it's already one of our formatted errors, re-throw it + if (error.message && (error.message.includes('badRequest:') || error.message.includes('unauthorized:') || error.message.includes('forbidden:'))) { + throw error; + } + + // Fallback for JSON parsing failures + try { + const textError = await response.text(); + throw new Error(`HTTP ${response.status}: ${textError}`); + } catch (textError) { + throw new Error(`HTTP ${response.status}: Unable to parse error response`); + } + } +} + +/** + * Make a request to Microsoft Graph API with standardized error handling + * @param {string} endpoint - API endpoint path (e.g., '/users/123/authentication/fido2Methods') + * @param {Object} options - Fetch options object + * @param {string} options.method - HTTP method (GET, POST, DELETE, etc.) + * @param {Object} options.headers - Additional headers + * @param {string} options.body - Request body (JSON string) + * @param {string} appToken - Bearer token for authentication + * @returns {Promise} - Fetch response object + * @throws {Error} - Formatted error if request fails + */ +export async function makeGraphRequest(endpoint, options = {}, appToken) { + const defaultHeaders = { + "Content-Type": "application/json", + "Accept-Encoding": "gzip, deflate, br", + }; + + // Add authorization header if token provided + if (appToken) { + defaultHeaders.Authorization = `Bearer ${appToken}`; + } + + const requestOptions = { + method: 'GET', + ...options, + headers: { + ...defaultHeaders, + ...options.headers, + }, + }; + + const response = await fetch(`https://${msGraphDomain}${endpoint}`, requestOptions); + + if (!response.ok) { + await parseGraphApiError(response); + } + + return response; +} + +/** + * Make a GET request to Microsoft Graph API + * @param {string} endpoint - API endpoint path + * @param {string} appToken - Bearer token for authentication + * @param {Object} headers - Additional headers + * @returns {Promise} - Fetch response object + */ +export async function graphGet(endpoint, appToken, headers = {}) { + return makeGraphRequest(endpoint, { method: 'GET', headers }, appToken); +} + +/** + * Make a POST request to Microsoft Graph API + * @param {string} endpoint - API endpoint path + * @param {Object} body - Request body object + * @param {string} appToken - Bearer token for authentication + * @param {Object} headers - Additional headers + * @returns {Promise} - Fetch response object + */ +export async function graphPost(endpoint, body, appToken, headers = {}) { + return makeGraphRequest(endpoint, { + method: 'POST', + body: JSON.stringify(body), + headers + }, appToken); +} + +/** + * Make a DELETE request to Microsoft Graph API + * @param {string} endpoint - API endpoint path + * @param {string} appToken - Bearer token for authentication + * @param {Object} headers - Additional headers + * @returns {Promise} - Fetch response object + */ +export async function graphDelete(endpoint, appToken, headers = {}) { + return makeGraphRequest(endpoint, { method: 'DELETE', headers }, appToken); +} diff --git a/passkey-sample/src/services/PasskeyService.js b/passkey-sample/src/services/PasskeyService.js new file mode 100644 index 0000000..c6ff055 --- /dev/null +++ b/passkey-sample/src/services/PasskeyService.js @@ -0,0 +1,158 @@ +/** + * Microsoft Graph API service for passkey (FIDO2) operations + * Handles passkey registration, retrieval, and deletion + */ + +import { + base64urlToBuffer, + bufferToBase64url, + transformFido2Methods, + generateUniquePasskeyName, + decodeGraphCredentialId +} from '../utils/graphServiceUtils.js'; +import { graphGet, graphPost, graphDelete } from './GraphApiClient.js'; +import { appConfig } from '../authConfig'; + +/** + * Create WebAuthn credential using browser's Credential Management API + * @param {Object} creationOptions - WebAuthn creation options + * @returns {Promise} - Created credential + */ +async function createCredential(creationOptions) { + creationOptions.excludeCredentials = creationOptions.excludeCredentials.map(c => ({ + ...c, + id: decodeGraphCredentialId(c.id) + })); + const publicKey = { + challenge: base64urlToBuffer(creationOptions.challenge), + rp: { + id: appConfig.customDomain || creationOptions.rp.id, + name: creationOptions.rp.name, + }, + user: { + id: base64urlToBuffer(creationOptions.user.id), + name: creationOptions.user.name, + displayName: creationOptions.user.displayName, + }, + pubKeyCredParams: creationOptions.pubKeyCredParams, + excludeCredentials: creationOptions.excludeCredentials, + timeout: creationOptions.timeout, + authenticatorSelection: creationOptions.authenticatorSelection, + attestation: creationOptions.attestation, + }; + + console.log("Passkey creation options configured"); + try { + const credential = await navigator.credentials.create({ publicKey }); + console.log("Passkey credential created successfully"); + return credential; + + } catch (error) { + throw error; + }; +} + +/** + * Register created credential with Microsoft Graph API + * @param {PublicKeyCredential} creationCredential - WebAuthn credential + * @param {string} userId - User ID + * @param {string} appToken - Application access token + */ +async function createPasskey(creationCredential, userId, appToken) { + const body = { + publicKeyCredential: { + id: creationCredential.id, + response: { + attestationObject: bufferToBase64url( + creationCredential.response.attestationObject + ), + clientDataJSON: bufferToBase64url( + creationCredential.response.clientDataJSON + ), + }, + }, + displayName: generateUniquePasskeyName(), + }; + + console.log("Preparing passkey registration request"); + + await graphPost( + `/users/${userId}/authentication/fido2Methods`, + body, + appToken + ); +} + +/** + * Get user's passkeys from Microsoft Graph API + * @param {string} appToken - Application access token + * @param {string} userId - User ID + * @returns {Promise} - Raw Graph API response + */ +async function getUserPasskeys(appToken, userId) { + const response = await graphGet( + `/users/${userId}/authentication/fido2Methods`, + appToken + ); + + const passkeys = await response.json(); + console.log(`Retrieved ${passkeys?.value?.length || 0} user passkeys`); + return passkeys; +} + +/** + * Get passkey creation options from Microsoft Graph API + * @param {string} appToken - Application access token + * @param {string} userId - User ID + * @returns {Promise} - WebAuthn creation options + */ +export async function getPasskeyCreationOptions(appToken, userId) { + const response = await graphGet( + `/users/${userId}/authentication/fido2Methods/creationOptions(challengeTimeoutInMinutes=60)`, + appToken + ); + + const data = await response.json(); + return data.publicKey; +} + +/** + * Register a new passkey for a user using Microsoft Graph API + * @param {string} appToken - Application access token for Graph API authentication + * @param {string} userId - The user ID to register the passkey for + * @returns {Promise} Promise that resolves when passkey registration is complete + * @throws {Error} Throws error if passkey registration fails + */ +export async function registerUserPasskey(creationOptions, appToken, userId) { + const credential = await createCredential(creationOptions); + await createPasskey(credential, userId, appToken); +} + +/** + * Fetch and transform user passkeys from Microsoft Graph API + * @param {string} appToken - Application access token for Graph API authentication + * @param {string} userId - The user ID to fetch passkeys for + * @returns {Promise>} Promise that resolves to array of transformed passkey objects + * @throws {Error} Throws error if fetching passkeys fails + */ +export async function fetchUserPasskey(appToken, userId) { + const passkeys = await getUserPasskeys(appToken, userId); + return transformFido2Methods(passkeys); +} + +/** + * Delete a specific passkey for a user using Microsoft Graph API + * @param {string} appToken - Application access token for Graph API authentication + * @param {string} userId - The user ID that owns the passkey + * @param {string} passkeyId - The ID of the passkey to delete + * @returns {Promise} Promise that resolves when passkey deletion is complete + * @throws {Error} Throws error if passkey deletion fails + */ +export async function deleteUserPasskey(appToken, userId, passkeyId) { + await graphDelete( + `/users/${userId}/authentication/fido2Methods/${passkeyId}`, + appToken + ); + + console.log(`Passkey deleted successfully!`); +} diff --git a/passkey-sample/src/styles/App.css b/passkey-sample/src/styles/App.css new file mode 100644 index 0000000..61c3f2a --- /dev/null +++ b/passkey-sample/src/styles/App.css @@ -0,0 +1,110 @@ +footer { + position: fixed; + bottom: 0%; + width: 100%; +} + +.App { + text-align: center; +} + +.iconText { + margin: 0 0.5rem; +} + +.navbarStyle { + padding: 0.5rem 1rem !important; +} + +.navbarButton { + color: #fff !important; + padding: 0.5rem 1rem !important; +} + +.iconText { + margin: 0 0.5rem; +} + +.navbarStyle { + padding: 0.5rem 1rem; +} + +.navbarButton { + color: #fff !important; +} + +.data-area-div { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-evenly; +} + +.todo-form { + width: 60%; +} + +.todo-list { + width: 60%; +} + +.todo-label { + font-size: large; + margin-right: 22%; + margin-left: 3%; +} + +.todo-view-btn { + float: right; +} + +td { + word-break: break-word; + max-width: 34rem; +} + +.warningMessage { + color: red; +} + +.card-title { + text-align: center; +} + +.signInButton { + margin: 1rem; +} + +.security-alert { + background-color: #e7f3ff; + border: 1px solid #b3d7ff; + border-radius: 6px; + padding: 12px 16px; + color: #1a365d; +} + +.security-alert-icon { + color: #3182ce; + font-size: 16px; +} + +.security-alert-text { + font-size: 14px; + font-weight: 500; +} + +/* User Profile Header Styles */ +.user-profile-header { + padding: 20px 0; +} + +.security-icon { + flex-shrink: 0; +} + +.user-email { + font-size: 20px; + font-weight: 600; + color: #333; + margin-bottom: 4px; +} \ No newline at end of file diff --git a/passkey-sample/src/styles/index.css b/passkey-sample/src/styles/index.css new file mode 100644 index 0000000..ec2585e --- /dev/null +++ b/passkey-sample/src/styles/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/passkey-sample/src/utils/graphServiceUtils.js b/passkey-sample/src/utils/graphServiceUtils.js new file mode 100644 index 0000000..bbc2e09 --- /dev/null +++ b/passkey-sample/src/utils/graphServiceUtils.js @@ -0,0 +1,155 @@ +/** + * Utility functions for Microsoft Graph API services + * Contains encoding, formatting, and data transformation utilities + */ + +/** + * Convert base64url string to ArrayBuffer for WebAuthn operations + * @param {string} base64url - Base64url encoded string + * @returns {ArrayBuffer} - Decoded array buffer + */ +export function base64urlToBuffer(base64url) { + const padding = "=".repeat((4 - (base64url.length % 4)) % 4); + const base64 = (base64url + padding).replace(/-/g, "+").replace(/_/g, "/"); + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)).buffer; +} + +/** + * Convert ArrayBuffer to base64url string for WebAuthn operations + * @param {ArrayBuffer} buffer - Array buffer to encode + * @returns {string} - Base64url encoded string + */ +export function bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** + * Format date string as relative time (e.g., "Today", "2 days ago") + * @param {string} dateString - ISO date string + * @returns {string} - Formatted relative time string + */ +export function formatLastUsed(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffInDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); + + if (diffInDays === 0) { + return "Today"; + } else if (diffInDays === 1) { + return "1 day ago"; + } else if (diffInDays < 7) { + return `${diffInDays} days ago`; + } else if (diffInDays < 30) { + const weeks = Math.floor(diffInDays / 7); + return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`; + } else { + const months = Math.floor(diffInDays / 30); + return months === 1 ? "1 month ago" : `${months} months ago`; + } +} + +/** + * Format date string as detailed localized date and time + * @param {string} dateString - ISO date string + * @returns {string} - Formatted detailed date string + */ +export function formatDetailedDate(dateString) { + try { + const date = new Date(dateString); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + } catch { + return dateString; + } +} + +/** + * Format passkey type for display with proper capitalization + * @param {string} passkeyType - Raw passkey type from Graph API + * @returns {string} - Formatted passkey type for display + */ +export function formatPasskeyType(passkeyType) { + if (!passkeyType) return "Unknown Passkey Type"; + + switch (passkeyType.toLowerCase()) { + case 'synced': + return 'Synced'; + case 'devicebound': + return 'Device Bound'; + default: + return passkeyType; // Return original if unknown type + } +} + +/** + * Generate a unique passkey name with timestamp and random suffix + * @returns {string} - Unique passkey name + */ +export function generateUniquePasskeyName() { + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(2, 8); + return `passkey_${timestamp}_${randomSuffix}`; +} + +/** + * Transform Microsoft Graph FIDO2 methods response to UI-friendly format + * @param {Object} graphResponse - Raw response from Graph API + * @returns {Array} - Array of transformed passkey objects + */ +export function transformFido2Methods(graphResponse) { + if (!graphResponse || !graphResponse.value) { + return []; + } + return graphResponse.value.map((method) => ({ + id: method.id, + name: method.displayName || "Unnamed Passkey", + lastUsed: method.lastUsedDateTime + ? formatLastUsed(method.lastUsedDateTime) + : "Never", + created: method.createdDateTime + ? formatDetailedDate(method.createdDateTime) + : "Unknown", + model: method.model || "Unknown Model", + attestationLevel: method.attestationLevel || "Unknown", + aaGuid: method.aaGuid, + passkeyType: formatPasskeyType(method.passkeyType), + _graphData: method, + })); +} + +export function decodeGraphCredentialId(id) { + // Match base and suffix number + const match = id.match(/^(.*?)(\d)$/); + if (!match) throw new Error("Invalid Microsoft Graph credential ID format"); + + let [, base, padCountStr] = match; + const padCount = parseInt(padCountStr, 10); + + // Add '=' padding + base += "=".repeat(padCount); + + // Convert Base64URL → Base64 + base = base.replace(/-/g, "+").replace(/_/g, "/"); + + // Decode to bytes + const binary = atob(base); + const buffer = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; ++i) { + buffer[i] = binary.charCodeAt(i); + } + return buffer.buffer; +} diff --git a/passkey-sample/src/utils/passkeyUtils.js b/passkey-sample/src/utils/passkeyUtils.js new file mode 100644 index 0000000..45f2358 --- /dev/null +++ b/passkey-sample/src/utils/passkeyUtils.js @@ -0,0 +1,174 @@ +/** + * Utility functions and constants for passkey operations + */ + +/** + * Constants for passkey operations and retry logic + * @type {Object} + * @property {number} MAX_PASSKEYS - Maximum number of passkeys allowed per user + * @property {number} MAX_RETRIES - Maximum number of retry attempts for operations + * @property {number} RETRY_DELAY_BASE - Base delay in milliseconds for retry operations + * @property {number} FETCH_DELAY_BASE - Base delay in milliseconds for fetch operations + */ +export const PASSKEY_CONSTANTS = { + MAX_PASSKEYS: 10, + MAX_RETRIES: 5, + RETRY_DELAY_BASE: 500, + FETCH_DELAY_BASE: 300 +}; + +/** + * Create a delay for retry operations with exponential backoff + * @param {number} attempt - Current attempt number (1-based) + * @param {number} baseDelay - Base delay in milliseconds + * @returns {Promise} - Promise that resolves after the delay + */ +export const createRetryDelay = (attempt, baseDelay = PASSKEY_CONSTANTS.RETRY_DELAY_BASE) => + new Promise(resolve => setTimeout(resolve, baseDelay * attempt)); + +/** + * Create a delay for fetch operations with exponential backoff + * @param {number} attempt - Current attempt number (1-based) + * @param {number} baseDelay - Base delay in milliseconds + * @returns {Promise} - Promise that resolves after the delay + */ +export const createFetchDelay = (attempt, baseDelay = PASSKEY_CONSTANTS.FETCH_DELAY_BASE) => + new Promise(resolve => setTimeout(resolve, baseDelay * attempt)); + +/** + * Validate if the expected change occurred in the passkey list + * @param {Array} passkeys - Current list of passkeys + * @param {Object} expectedChange - Expected change object + * @param {string} expectedChange.type - Type of change ('add' or 'delete') + * @param {string} expectedChange.passkeyId - ID of passkey for delete operations + * @param {number} expectedChange.expectedCount - Expected count for add operations + * @returns {boolean} - True if the expected change is satisfied + */ +export const validateExpectedChange = (passkeys, expectedChange) => { + if (!expectedChange) return true; + + const { type, passkeyId, expectedCount } = expectedChange; + + if (type === 'add' && expectedCount) { + return passkeys.length >= expectedCount; + } + + if (type === 'delete' && passkeyId) { + return !passkeys.some(p => p.id === passkeyId); + } + + return true; +}; + +/** + * Check if NGCMFA token has expired + * @param {number} ngcmfaExpiry - NGCMFA expiration timestamp in seconds + * @returns {boolean} - True if expired, false if still valid + */ +export const checkNgcmfaExpiration = (ngcmfaExpiry) => { + if (!ngcmfaExpiry) { + console.log('NGCMFA check: No expiry time available - considering expired'); + return true; // Consider expired if no expiry time available + } + + const currentTimeInSeconds = Math.floor(Date.now() / 1000); + const isExpired = currentTimeInSeconds > ngcmfaExpiry; + const timeUntilExpiry = ngcmfaExpiry - currentTimeInSeconds; + + console.log('NGCMFA expiration check:', { + currentTime: currentTimeInSeconds, + expiryTime: ngcmfaExpiry, + timeUntilExpiry: timeUntilExpiry, + isExpired: isExpired + }); + + return isExpired; +}; + +/** + * Factory object for creating standardized toast notification messages + * @type {Object} + * @property {Function} passkeyAdded - Creates success message for passkey addition + * @property {Function} passkeyDeleted - Creates success message for passkey deletion + * @property {Function} passkeysRefreshed - Creates success message for passkey list refresh + * @property {Function} errorLoading - Creates error message for loading failures + * @property {Function} errorAdding - Creates error message for addition failures + * @property {Function} errorDeleting - Creates error message for deletion failures + * @property {Function} sessionExpired - Creates warning message for session expiration + * @property {Function} sessionExpiredWithAction - Creates interactive message for session expiration + * @property {Function} authError - Creates error message for authentication failures + */ +export const createToastMessages = { + passkeyAdded: () => ({ + title: 'Passkey added', + message: 'Successfully added passkey.', + variant: 'success' + }), + + passkeyDeleted: (passkeyName = 'Unknown') => ({ + title: 'Passkey deleted', + message: `Passkey ${passkeyName} has been deleted.`, + variant: 'success' + }), + + passkeysRefreshed: (count) => ({ + title: 'Passkeys refreshed', + message: `Found ${count} passkey(s).`, + variant: 'success' + }), + + errorLoading: () => ({ + title: 'Error loading passkeys', + message: 'Failed to load your passkeys. Please try again.', + variant: 'danger' + }), + + errorAdding: (errorMessage) => ({ + title: 'Error adding passkey', + message: `Failed to add your passkey. Please try again: ${errorMessage}`, + variant: 'danger' + }), + + passkeyAddCancelled: (errorMessage) => ({ + title: 'Error adding passkey', + message: `Failed to add your passkey. The operation either timed out or was not allowed or passkey already registered on this device. Please try again.`, + variant: 'danger' + }), + + errorDeleting: (errorMessage) => ({ + title: 'Error deleting passkey', + message: `Failed to delete your passkey. Please try again: ${errorMessage}`, + variant: 'danger' + }), + + sessionExpired: () => ({ + title: 'Session expired', + message: 'Your session has expired. Redirecting to sign in...', + variant: 'warning' + }), + + sessionExpiredWithAction: (onSignIn) => ({ + title: `Let's keep your account secure`, + message: 'You will need to complete multi-factor authentication to perform this action. You will be redirected to verify your identity securely.', + variant: 'warning', + autoHide: false, // Keep visible until user acts + type: 'sessionExpiredWithAction', // Unique identifier for center positioning + action: { + label: 'Next', + variant: 'primary', + onClick: onSignIn + } + }), + + authError: () => ({ + title: 'Authentication error', + message: 'Failed to re-authenticate. Please try again.', + variant: 'danger' + }), + + duplicateRegistrationWarning: (count) => ({ + title: 'Duplicate registration warning', + message: `To avoid registration failure, please use a different security key or phone than previously used.`, + variant: 'warning' + }) +}; diff --git a/passkey-sample/src/utils/tokenUtils.js b/passkey-sample/src/utils/tokenUtils.js new file mode 100644 index 0000000..7db2056 --- /dev/null +++ b/passkey-sample/src/utils/tokenUtils.js @@ -0,0 +1,193 @@ +/** + * Token utility functions for JWT parsing and token management + */ + +/** + * Decode JWT token + * @param {string} token - JWT token to decode + * @returns {Object|null} - Decoded token payload or null if invalid + */ +export const parseJwt = (token) => { + try { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); + return JSON.parse(jsonPayload); + } catch (error) { + console.error('Error parsing JWT:', error); + return null; + } +}; + +/** + * Calculate NGCMFA expiration using token iat + specified minutes + * @param {Object} decodedToken - Decoded JWT token + * @param {number} expiryMinutes - Minutes to add to iat (default: 10) + * @param {number} secondsPerMinute - Seconds per minute (default: 60) + * @returns {number|null} - Expiration timestamp or null if invalid + */ +export const calculateNgcmfaExpiration = (decodedToken, expiryMinutes = 10, secondsPerMinute = 60) => { + if (decodedToken && decodedToken.iat) { + const expiration = decodedToken.iat + (expiryMinutes * secondsPerMinute); + console.log('NGCMFA expiration calculated:', expiration, 'Token iat:', decodedToken.iat, 'Current time:', Math.floor(Date.now() / 1000)); + return expiration; + } + return null; +}; + +/** + * Get access token from MSAL instance + * @param {Object} instance - MSAL instance + * @param {Array} accounts - User accounts + * @param {Object} tokenRequest - Token request configuration + * @returns {Promise} - Object containing { token, decodedToken, error } + */ +export const getAccessToken = async (instance, accounts, loginRequest) => { + if (accounts.length > 0) { + const request = { + claims: loginRequest.extraQueryParameters?.claims, + account: accounts[0], + }; + + try { + console.log('Account found for token request'); + const response = await instance.acquireTokenSilent(request); + const decodedToken = parseJwt(response.accessToken); + console.log('Access token acquired successfully'); + + return { + token: response.accessToken, + decodedToken: decodedToken, + error: null + }; + } catch (error) { + console.error('Error acquiring access token:', error); + if (error.errorCode === 'invalid_grant' && error.message.includes('multi-factor authentication has expired')) { + // Force interactive authentication for MFA expiry + return await instance.acquireTokenRedirect(request); + } + return { + token: null, + decodedToken: null, + error: 'Failed to acquire access token. This might be because the token is not available or has expired. Please re-sign in.' + }; + } + } else { + // No accounts found - redirect to login + try { + await instance.loginRedirect(loginRequest); + return { token: null, decodedToken: null, error: 'Redirecting to login...' }; + } catch (loginError) { + return { token: null, decodedToken: null, error: 'No account found' }; + } + } +}; + +/** + * Get application token using client credentials flow + * @param {string} proxyDomain - Proxy domain URL + * @param {string} appId - Application ID + * @param {string} appSecret - Application secret + * @returns {Promise} - App token or null if failed + */ +export const getAppToken = async (proxyDomain, appId, appSecret) => { + try { + const tokenEndpoint = `${proxyDomain}/oauth2/v2.0/token`; + + const params = new URLSearchParams(); + params.append('client_id', appId); + params.append('client_secret', appSecret); + params.append('grant_type', 'client_credentials'); + params.append('scope', 'https://graph.microsoft.com/.default'); + + const response = await fetch(tokenEndpoint, { + method: 'POST', + body: params, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.access_token; + } catch (error) { + console.error('Error acquiring app token:', error); + return null; + } +}; + +/** + * Get cached application token using MSAL browser storage + * @param {Object} instance - MSAL instance + * @param {string} proxyDomain - Proxy domain URL + * @param {string} appId - Application ID + * @param {string} appSecret - Application secret + * @returns {Promise} - App token or null if failed + */ +export const getCachedAppToken = async (instance, proxyDomain, appId, appSecret) => { + const cacheKey = 'app_token_cache'; + + const storage = instance.getConfiguration().cache.cacheLocation === 'localStorage' + ? window.localStorage + : window.sessionStorage; + + try { + const cached = storage.getItem(cacheKey); + if (cached) { + const { token, expiresAt } = JSON.parse(cached); + if (Date.now() < expiresAt) { + console.log('Using cached app token'); + return token; + } else { + console.log('Cached app token expired, removing from cache'); + storage.removeItem(cacheKey); + } + } + + console.log('Fetching new app token'); + const newToken = await getAppToken(proxyDomain, appId, appSecret); + + if (newToken) { + const decodedToken = parseJwt(newToken); + let expiresAt = Date.now() + (3600 * 1000); + + if (decodedToken && decodedToken.exp) { + expiresAt = (decodedToken.exp * 1000) - (5 * 60 * 1000); + } + + storage.setItem(cacheKey, JSON.stringify({ + token: newToken, + expiresAt: expiresAt, + fetchedAt: Date.now() + })); + + console.log('App token cached until:', new Date(expiresAt)); + } + + return newToken; + } catch (error) { + console.error('Error with cached app token:', error); + storage.removeItem(cacheKey); + return await getAppToken(proxyDomain, appId, appSecret); + } +}; + +/** + * Clear cached application token from MSAL browser storage + * @param {Object} instance - MSAL instance + */ +export const clearAppTokenCache = (instance) => { + const cacheKey = 'app_token_cache'; + const storage = instance.getConfiguration().cache.cacheLocation === 'localStorage' + ? window.localStorage + : window.sessionStorage; + + storage.removeItem(cacheKey); + console.log('App token cache cleared'); +}; diff --git a/passkey-sample/vite.config.js b/passkey-sample/vite.config.js new file mode 100644 index 0000000..41cfc3d --- /dev/null +++ b/passkey-sample/vite.config.js @@ -0,0 +1,34 @@ +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; +import fs from "fs"; +import path from "path"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), "VITE_"); + const certPath = path.resolve( + __dirname, + env.VITE_SSL_CERT || "auth-cert.pem", + ); + const keyPath = path.resolve(__dirname, env.VITE_SSL_KEY || "auth-key.pem"); + const hasSSL = fs.existsSync(certPath) && fs.existsSync(keyPath); + + return { + plugins: [react()], + server: { + host: env.VITE_HOST, + port: Number(env.VITE_PORT) || 3000, + https: hasSSL + ? { + cert: fs.readFileSync(certPath), + key: fs.readFileSync(keyPath), + } + : undefined, + // Workaround for Node 22.21.0 HTTPS bug (shouldUpgradeCallback crash) + // Disable WebSocket-based HMR over the HTTPS server; use polling instead + hmr: hasSSL ? { protocol: "wss", host: env.VITE_HOST } : undefined, + }, + build: { + outDir: "build", + }, + }; +});