diff --git a/README.md b/README.md index 0330fc8aa..3ba2259ce 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ npm run web-dev ## Where the web editor is deployed -GitHub Actions builds it and uploads it to Cloudflare Workers as static assets; nothing is built on Cloudflare. Hosting is no longer on Firebase -- Firebase is only used for authentication (see below). +GitHub Actions builds it and uploads it to Cloudflare Workers as static assets; nothing is built on Cloudflare. | Trigger | Deploys to | | ---------------- | -------------------------------------------------- | @@ -106,7 +106,9 @@ Targets are defined in `wrangler.jsonc`, the workflows in `.github/workflows/clo ## Grid Editor and Profile Cloud projects during development - handy firebase commands: `firebase logout`, `firebase login`, `firebase projects:list`, `firebase use THE-PROJECT-NAME` -- is-auth-dev is the central firebase authentication project used by the Grid Editor +- the Grid Editor signs in through the identity provider over plain OIDC (`AUTH_ISSUER_*` and `AUTH_CLIENT_ID_*` in `configuration.json`), the loaded Profile Cloud build picks the environment. The provider is moving from Logto to Keycloak (decision 36 of the authentication rework): desktop sign-in will return through a loopback redirect on `127.0.0.1` instead of `grid-editor://auth/callback`, so the browser tab ends on a "signed in" page, and the web build will use `oauth4webapi` instead of `@logto/browser` - profile-cloud-dev is the profile cloud firebase project used by the Profile Cloud - when firestore security settings are changed, they shouls be either emulated locally or pushed to the appropiate project environment - passing auth is through `oidc` +- sign-in follows the `PUBLIC_APP_ENV` of the loaded Profile Cloud build, and only local and PR preview builds are `development`. The web build keeps no settings across the sign-in redirect, so point it at such a build with `VITE_PROFILE_CLOUD_URL` +- until the Keycloak switch: the seed generates the `grid-editor` client id per Logto database, so point a dev build at your own with `VITE_AUTH_CLIENT_ID_DEV`, and at a hosted Logto with `VITE_AUTH_ISSUER_DEV` in the form `https://host/oidc`, with no trailing slash. Both are read at build time, the `configuration.json` values stay the defaults. With Keycloak the client id is `grid-editor` everywhere, so `VITE_AUTH_CLIENT_ID_DEV` goes away, and the issuer takes the form `https://host/realms/intech` diff --git a/configuration.json b/configuration.json index 737ac1926..8ee4f3ed3 100644 --- a/configuration.json +++ b/configuration.json @@ -60,7 +60,10 @@ "PROFILE_CLOUD_URL_LOCAL": "package://profile-cloud/public", "PROFILE_CLOUD_URL_DEV": "https://profile-cloud-dev.web.app", "PROFILE_CLOUD_URL_PROD": "https://profile-cloud.web.app", - "PROFILE_CLOUD_EMAIL_REGISTRATION": "https://intech.studio/auth", + "AUTH_ISSUER_DEV": "http://localhost:3001/oidc", + "AUTH_ISSUER_PROD": "https://auth.intech.studio/oidc", + "AUTH_CLIENT_ID_DEV": "38r8mztj8hd734bwcf6g5", + "AUTH_CLIENT_ID_PROD": "", "RECOMMENDED_PACKAGES": { "package-photoshop": { "gitHubRepositoryOwner": "intechstudio", diff --git a/electron-builder-config.js b/electron-builder-config.js index 5b79f7be8..e7ac6018d 100644 --- a/electron-builder-config.js +++ b/electron-builder-config.js @@ -150,6 +150,8 @@ const config = { // Desktop integration "--talk-name=org.freedesktop.Notifications", "--talk-name=org.kde.StatusNotifierWatcher", + // OS keychain for the stored sign-in (safeStorage) + "--talk-name=org.freedesktop.secrets", ], }, mac: { diff --git a/package-lock.json b/package-lock.json index 12b255c21..aec9303fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@intechstudio/grid-protocol": "1.20261030.1031", "@intechstudio/grid-uikit": "1.20261014.1352", "@intechstudio/profile-cloud-webcomponent": "1.20261014.1401", + "@logto/browser": "^3.1.2", "@tailwindcss/postcss": "^4.3.3", "adm-zip": "^0.6.1", "axios": "^1.20.0", @@ -23,7 +24,6 @@ "electron-store": "^8.1.0", "electron-updater": "^6.8.9", "eslint": "^9.39.2", - "firebase": "^11.4.0", "fs-extra": "^10.0.0", "googleapis": "^171.4.0", "jszip": "^3.10.1", @@ -34,6 +34,7 @@ "node-fetch": "^2.6.7", "node-fetch-progress": "^1.0.2", "number-to-words": "^1.2.4", + "openid-client": "^6.8.9", "panzoom": "^9.3.0", "redefine-custom-elements": "^0.1.3", "semver": "^7.5.4", @@ -2071,614 +2072,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@firebase/ai": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", - "integrity": "sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@firebase/app-types": "0.x" - } - }, - "node_modules/@firebase/analytics": { - "version": "0.10.17", - "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.17.tgz", - "integrity": "sha512-n5vfBbvzduMou/2cqsnKrIes4auaBjdhg8QNA2ZQZ59QgtO2QiwBaXQZQE4O4sgB0Ds1tvLgUUkY+pwzu6/xEg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/analytics-compat": { - "version": "0.2.23", - "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.23.tgz", - "integrity": "sha512-3AdO10RN18G5AzREPoFgYhW6vWXr3u+OYQv6pl3CX6Fky8QRk0AHurZlY3Q1xkXO0TDxIsdhO3y65HF7PBOJDw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/analytics": "0.10.17", - "@firebase/analytics-types": "0.8.3", - "@firebase/component": "0.6.18", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/analytics-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", - "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.2.tgz", - "integrity": "sha512-jwtMmJa1BXXDCiDx1vC6SFN/+HfYG53UkfJa6qeN5ogvOunzbFDO3wISZy5n9xgYFUrEP6M7e8EG++riHNTv9w==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app-check": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.1.tgz", - "integrity": "sha512-MgNdlms9Qb0oSny87pwpjKush9qUwCJhfmTJHDfrcKo4neLGiSeVE4qJkzP7EQTIUFKp84pbTxobSAXkiuQVYQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/app-check-compat": { - "version": "0.3.26", - "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.26.tgz", - "integrity": "sha512-PkX+XJMLDea6nmnopzFKlr+s2LMQGqdyT2DHdbx1v1dPSqOol2YzgpgymmhC67vitXVpNvS3m/AiWQWWhhRRPQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check": "0.10.1", - "@firebase/app-check-types": "0.5.3", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/app-check-interop-types": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", - "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app-check-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", - "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app-compat": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.2.tgz", - "integrity": "sha512-LssbyKHlwLeiV8GBATyOyjmHcMpX/tFjzRUCS1jnwGAew1VsBB4fJowyS5Ud5LdFbYpJeS+IQoC+RQxpK7eH3Q==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app": "0.13.2", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app-types": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/auth": { - "version": "1.10.8", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.8.tgz", - "integrity": "sha512-GpuTz5ap8zumr/ocnPY57ZanX02COsXloY6Y/2LYPAuXYiaJRf6BAGDEdRq1BMjP93kqQnKNuKZUTMZbQ8MNYA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@react-native-async-storage/async-storage": "^1.18.1" - }, - "peerDependenciesMeta": { - "@react-native-async-storage/async-storage": { - "optional": true - } - } - }, - "node_modules/@firebase/auth-compat": { - "version": "0.5.28", - "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.28.tgz", - "integrity": "sha512-HpMSo/cc6Y8IX7bkRIaPPqT//Jt83iWy5rmDWeThXQCAImstkdNo3giFLORJwrZw2ptiGkOij64EH1ztNJzc7Q==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/auth": "1.10.8", - "@firebase/auth-types": "0.13.0", - "@firebase/component": "0.6.18", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/auth-interop-types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", - "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/auth-types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", - "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/component": { - "version": "0.6.18", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.18.tgz", - "integrity": "sha512-n28kPCkE2dL2U28fSxZJjzPPVpKsQminJ6NrzcKXAI0E/lYC8YhfwpyllScqVEvAI3J2QgJZWYgrX+1qGI+SQQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/data-connect": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.10.tgz", - "integrity": "sha512-VMVk7zxIkgwlVQIWHOKFahmleIjiVFwFOjmakXPd/LDgaB/5vzwsB5DWIYo+3KhGxWpidQlR8geCIn39YflJIQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/database": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.20.tgz", - "integrity": "sha512-H9Rpj1pQ1yc9+4HQOotFGLxqAXwOzCHsRSRjcQFNOr8lhUt6LeYjf0NSRL04sc4X0dWe8DsCvYKxMYvFG/iOJw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/database-compat": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.11.tgz", - "integrity": "sha512-itEsHARSsYS95+udF/TtIzNeQ0Uhx4uIna0sk4E0wQJBUnLc/G1X6D7oRljoOuwwCezRLGvWBRyNrugv/esOEw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/database": "1.0.20", - "@firebase/database-types": "1.0.15", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/database-types": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.15.tgz", - "integrity": "sha512-XWHJ0VUJ0k2E9HDMlKxlgy/ZuTa9EvHCGLjaKSUvrQnwhgZuRU5N3yX6SZ+ftf2hTzZmfRkv+b3QRvGg40bKNw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.12.1" - } - }, - "node_modules/@firebase/firestore": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.8.0.tgz", - "integrity": "sha512-QSRk+Q1/CaabKyqn3C32KSFiOdZpSqI9rpLK5BHPcooElumOBooPFa6YkDdiT+/KhJtel36LdAacha9BptMj2A==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "@firebase/webchannel-wrapper": "1.0.3", - "@grpc/grpc-js": "~1.9.0", - "@grpc/proto-loader": "^0.7.8", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/firestore-compat": { - "version": "0.3.53", - "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.53.tgz", - "integrity": "sha512-qI3yZL8ljwAYWrTousWYbemay2YZa+udLWugjdjju2KODWtLG94DfO4NALJgPLv8CVGcDHNFXoyQexdRA0Cz8Q==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/firestore": "4.8.0", - "@firebase/firestore-types": "3.0.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/firestore-types": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", - "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/functions": { - "version": "0.12.9", - "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.9.tgz", - "integrity": "sha512-FG95w6vjbUXN84Ehezc2SDjGmGq225UYbHrb/ptkRT7OTuCiQRErOQuyt1jI1tvcDekdNog+anIObihNFz79Lg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.18", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/functions-compat": { - "version": "0.3.26", - "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.26.tgz", - "integrity": "sha512-A798/6ff5LcG2LTWqaGazbFYnjBW8zc65YfID/en83ALmkhu2b0G8ykvQnLtakbV9ajrMYPn7Yc/XcYsZIUsjA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/functions": "0.12.9", - "@firebase/functions-types": "0.6.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/functions-types": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", - "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/installations": { - "version": "0.6.18", - "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.18.tgz", - "integrity": "sha512-NQ86uGAcvO8nBRwVltRL9QQ4Reidc/3whdAasgeWCPIcrhOKDuNpAALa6eCVryLnK14ua2DqekCOX5uC9XbU/A==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/util": "1.12.1", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/installations-compat": { - "version": "0.2.18", - "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.18.tgz", - "integrity": "sha512-aLFohRpJO5kKBL/XYL4tN+GdwEB/Q6Vo9eZOM/6Kic7asSUgmSfGPpGUZO1OAaSRGwF4Lqnvi1f/f9VZnKzChw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/installations-types": "0.5.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/installations-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", - "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x" - } - }, - "node_modules/@firebase/logger": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", - "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/messaging": { - "version": "0.12.22", - "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.22.tgz", - "integrity": "sha512-GJcrPLc+Hu7nk+XQ70Okt3M1u1eRr2ZvpMbzbc54oTPJZySHcX9ccZGVFcsZbSZ6o1uqumm8Oc7OFkD3Rn1/og==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.1", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/messaging-compat": { - "version": "0.2.22", - "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.22.tgz", - "integrity": "sha512-5ZHtRnj6YO6f/QPa/KU6gryjmX4Kg33Kn4gRpNU6M1K47Gm8kcQwPkX7erRUYEH1mIWptfvjvXMHWoZaWjkU7A==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/messaging": "0.12.22", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/messaging-interop-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", - "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/performance": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.7.tgz", - "integrity": "sha512-JTlTQNZKAd4+Q5sodpw6CN+6NmwbY72av3Lb6wUKTsL7rb3cuBIhQSrslWbVz0SwK3x0ZNcqX24qtRbwKiv+6w==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0", - "web-vitals": "^4.2.4" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/performance-compat": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.20.tgz", - "integrity": "sha512-XkFK5NmOKCBuqOKWeRgBUFZZGz9SzdTZp4OqeUg+5nyjapTiZ4XoiiUL8z7mB2q+63rPmBl7msv682J3rcDXIQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/performance": "0.7.7", - "@firebase/performance-types": "0.2.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/performance-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", - "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/remote-config": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.5.tgz", - "integrity": "sha512-fU0c8HY0vrVHwC+zQ/fpXSqHyDMuuuglV94VF6Yonhz8Fg2J+KOowPGANM0SZkLvVOYpTeWp3ZmM+F6NjwWLnw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/remote-config-compat": { - "version": "0.2.18", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.18.tgz", - "integrity": "sha512-YiETpldhDy7zUrnS8e+3l7cNs0sL7+tVAxvVYU0lu7O+qLHbmdtAxmgY+wJqWdW2c9nDvBFec7QiF58pEUu0qQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/remote-config": "0.6.5", - "@firebase/remote-config-types": "0.4.0", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/remote-config-types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.4.0.tgz", - "integrity": "sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/storage": { - "version": "0.13.14", - "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.14.tgz", - "integrity": "sha512-xTq5ixxORzx+bfqCpsh+o3fxOsGoDjC1nO0Mq2+KsOcny3l7beyBhP/y1u5T6mgsFQwI1j6oAkbT5cWdDBx87g==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/storage-compat": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.24.tgz", - "integrity": "sha512-XHn2tLniiP7BFKJaPZ0P8YQXKiVJX+bMyE2j2YWjYfaddqiJnROJYqSomwW6L3Y+gZAga35ONXUJQju6MB6SOQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/storage": "0.13.14", - "@firebase/storage-types": "0.8.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/storage-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", - "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/util": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.1.tgz", - "integrity": "sha512-zGlBn/9Dnya5ta9bX/fgEoNC3Cp8s6h+uYPYaDieZsFOAdHP/ExzQ/eaDgxD3GOROdPkLKpvKY0iIzr9adle0w==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/webchannel-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", - "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==", - "license": "Apache-2.0" - }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -2704,37 +2097,6 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@grpc/grpc-js": { - "version": "1.9.16", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", - "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/proto-loader": "^0.7.8", - "@types/node": ">=12.12.47" - }, - "engines": { - "node": "^8.13.0 || >=10.10.0" - } - }, - "node_modules/@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", - "license": "Apache-2.0", - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3506,6 +2868,39 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@logto/browser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@logto/browser/-/browser-3.1.2.tgz", + "integrity": "sha512-EtzLeF3XOuMyu5InkQQLzBSBRWjgWtedTnbgvk86aieRNvbYBaWMajBRj5LNEQ5QbNl2ys5+gSOcgZmMnxGAQw==", + "license": "MIT", + "dependencies": { + "@logto/client": "^3.2.2", + "@silverhand/essentials": "^2.9.3", + "js-base64": "^3.7.4" + } + }, + "node_modules/@logto/client": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@logto/client/-/client-3.2.2.tgz", + "integrity": "sha512-2AVM2c09AbjikFqPtmOm3p8nOcGblwa5mexVdg1jRtENoVDi5GVi/ytsvkI10Cd1wEh4uUyrJLMv2Fig3w83Aw==", + "license": "MIT", + "dependencies": { + "@logto/js": "^6.1.2", + "@silverhand/essentials": "^2.9.3", + "camelcase-keys": "^9.1.3", + "jose": "^5.2.2" + } + }, + "node_modules/@logto/js": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@logto/js/-/js-6.1.2.tgz", + "integrity": "sha512-YB/TfixPGI0Spbs8LXiKuASOKFUE9VmlTkXiPfgg3UXQsIPTU71KjKxEXZRePu3xdPNhsZ6WtnRfRvvcpP+KGQ==", + "license": "MIT", + "dependencies": { + "@silverhand/essentials": "^2.9.3", + "camelcase-keys": "^9.1.3" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -3987,63 +3382,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", - "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", - "license": "BSD-3-Clause" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", @@ -4392,6 +3730,16 @@ "node": ">=v16" } }, + "node_modules/@silverhand/essentials": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@silverhand/essentials/-/essentials-2.9.3.tgz", + "integrity": "sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==", + "license": "MIT", + "engines": { + "node": ">=18.12.0", + "pnpm": "^10.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", @@ -6606,6 +5954,60 @@ "node": ">=6" } }, + "node_modules/camelcase-keys": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz", + "integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==", + "license": "MIT", + "dependencies": { + "camelcase": "^8.0.0", + "map-obj": "5.0.0", + "quick-lru": "^6.1.1", + "type-fest": "^4.3.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -8572,18 +7974,6 @@ "reusify": "^1.0.4" } }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8697,42 +8087,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/firebase": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.10.0.tgz", - "integrity": "sha512-nKBXoDzF0DrXTBQJlZa+sbC5By99ysYU1D6PkMRYknm0nCW7rJly47q492Ht7Ndz5MeYSBuboKuhS1e6mFC03w==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/ai": "1.4.1", - "@firebase/analytics": "0.10.17", - "@firebase/analytics-compat": "0.2.23", - "@firebase/app": "0.13.2", - "@firebase/app-check": "0.10.1", - "@firebase/app-check-compat": "0.3.26", - "@firebase/app-compat": "0.4.2", - "@firebase/app-types": "0.9.3", - "@firebase/auth": "1.10.8", - "@firebase/auth-compat": "0.5.28", - "@firebase/data-connect": "0.3.10", - "@firebase/database": "1.0.20", - "@firebase/database-compat": "2.0.11", - "@firebase/firestore": "4.8.0", - "@firebase/firestore-compat": "0.3.53", - "@firebase/functions": "0.12.9", - "@firebase/functions-compat": "0.3.26", - "@firebase/installations": "0.6.18", - "@firebase/installations-compat": "0.2.18", - "@firebase/messaging": "0.12.22", - "@firebase/messaging-compat": "0.2.22", - "@firebase/performance": "0.7.7", - "@firebase/performance-compat": "0.2.20", - "@firebase/remote-config": "0.6.5", - "@firebase/remote-config-compat": "0.2.18", - "@firebase/storage": "0.13.14", - "@firebase/storage-compat": "0.3.24", - "@firebase/util": "1.12.1" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -9673,12 +9027,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "license": "MIT" - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -9754,12 +9102,6 @@ "node": ">=0.10.0" } }, - "node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "license": "ISC" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -11222,6 +10564,21 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-base64": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.6.tgz", + "integrity": "sha512-LjkzbLtU6Lx6fjXMYGEACEB7rdtqZtxkBe9aS1ZpDNUyWgUivw3yBEIhQ37OVygJYRt+C+9e9fVT8PpFzH2Hww==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11772,12 +11129,6 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT" - }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", @@ -11803,12 +11154,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -11869,6 +11214,18 @@ "tmpl": "1.0.5" } }, + "node_modules/map-obj": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz", + "integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -12688,6 +12045,15 @@ "integrity": "sha512-/fYevVkXRcyBiZDg6yzZbm0RuaD6i0qRfn8yr+6D0KgBMOndFPxuW10qCHpzs50nN8qKuv78k8MuotZhcVX6Pw==", "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12778,6 +12144,28 @@ "node": ">=6" } }, + "node_modules/openid-client": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.9.tgz", + "integrity": "sha512-6mXMRNJxDOf6BflEWLXul1UwzLFz++9Ofz1PYwPwwsJsyXBsbfkO+Jo255SW1qnFNLYz4EJWHz42X4OJJrya/g==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.15", + "oauth4webapi": "^3.8.8" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/jose": { + "version": "6.2.15", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz", + "integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13595,29 +12983,6 @@ "signal-exit": "^3.0.2" } }, - "node_modules/protobufjs": { - "version": "7.6.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", - "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.1", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -16635,12 +16000,6 @@ "node": ">= 8" } }, - "node_modules/web-vitals": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", - "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", - "license": "Apache-2.0" - }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", @@ -16661,29 +16020,6 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, - "node_modules/websocket-driver": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", - "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", diff --git a/package.json b/package.json index 8282a9db2..a41ef1986 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@intechstudio/grid-protocol": "1.20261030.1031", "@intechstudio/grid-uikit": "1.20261014.1352", "@intechstudio/profile-cloud-webcomponent": "1.20261014.1401", + "@logto/browser": "^3.1.2", "@tailwindcss/postcss": "^4.3.3", "adm-zip": "^0.6.1", "axios": "^1.20.0", @@ -53,7 +54,6 @@ "electron-store": "^8.1.0", "electron-updater": "^6.8.9", "eslint": "^9.39.2", - "firebase": "^11.4.0", "fs-extra": "^10.0.0", "googleapis": "^171.4.0", "jszip": "^3.10.1", @@ -64,6 +64,7 @@ "node-fetch": "^2.6.7", "node-fetch-progress": "^1.0.2", "number-to-words": "^1.2.4", + "openid-client": "^6.8.9", "panzoom": "^9.3.0", "redefine-custom-elements": "^0.1.3", "semver": "^7.5.4", diff --git a/src/electron/auth.ts b/src/electron/auth.ts new file mode 100644 index 000000000..d1148b3c3 --- /dev/null +++ b/src/electron/auth.ts @@ -0,0 +1,335 @@ +import { + app, + ipcMain, + powerMonitor, + safeStorage, + shell, + type BrowserWindow, +} from "electron"; +import path from "path"; +import fs from "fs"; +import log from "electron-log"; +import type * as OpenIdClient from "openid-client"; +import { + authProviderFor, + isSameAuthProvider, + type AuthProvider, +} from "../shared/auth-provider"; + +const REDIRECT_URI = "grid-editor://auth/callback"; +const POST_LOGOUT_REDIRECT_URI = "grid-editor://auth/logout"; +// No profile scope: Firebase rejects the null picture claim it adds. +const SCOPE = "openid offline_access email"; +const REFRESH_MARGIN_MS = 5 * 60 * 1000; +const MIN_REFRESH_DELAY_MS = 30 * 1000; +const REFRESH_RETRY_MS = 60 * 1000; + +interface Session extends AuthProvider { + refreshToken: string; + idToken?: string; +} + +interface PendingLogin extends AuthProvider { + codeVerifier: string; + state: string; +} + +type AuthChange = + { event: "login"; providerId: "oidc"; idToken: string } | { event: "logout" }; + +export const auth: { mainWindow: BrowserWindow | null } = { mainWindow: null }; + +let provider = authProviderFor("production"); +let session: Session | undefined; +let pendingLogin: PendingLogin | undefined; +// Null until the stored session has been restored or ruled out. +let current: AuthChange | null = null; +let refreshTimer: NodeJS.Timeout | undefined; +let refreshing = false; +// Moves on whenever the session is replaced, so a late response cannot revive it. +let sessionEpoch = 0; +let discovered: + (AuthProvider & { config: OpenIdClient.Configuration }) | undefined; +let oidc: typeof OpenIdClient; + +function errorSummary(e: any) { + return [...new Set([e?.error, e?.code, e?.cause?.code, e?.message])] + .filter(Boolean) + .join(" "); +} + +async function discover(from: AuthProvider) { + const { issuer, clientId } = from; + if (!clientId) { + throw new Error(`No client id configured for ${issuer}`); + } + // A broken auth dependency must only cost the user sign-in, not the whole app. + oidc ??= await import("openid-client"); + if (!discovered || !isSameAuthProvider(discovered, from)) { + const url = new URL(issuer); + const config = await oidc.discovery( + url, + clientId, + undefined, + oidc.None(), + url.hostname === "localhost" + ? { execute: [oidc.allowInsecureRequests] } + : undefined, + ); + discovered = { issuer, clientId, config }; + } + return discovered.config; +} + +function publish(change: AuthChange) { + current = change; + if (auth.mainWindow && !auth.mainWindow.isDestroyed()) { + auth.mainWindow.webContents.send("auth:changed", change); + } +} + +function sessionFile() { + return path.join(app.getPath("userData"), "auth.bin"); +} + +function temporarySessionFile() { + return `${sessionFile()}.tmp`; +} + +// Signing out must complete for the user even if the file cannot be removed. +function removeSessionFile() { + try { + fs.rmSync(sessionFile(), { force: true }); + fs.rmSync(temporarySessionFile(), { force: true }); + } catch (e) { + log.warn("Could not remove the stored auth session:", errorSummary(e)); + } +} + +function canEncrypt() { + return ( + safeStorage.isEncryptionAvailable() && + (process.platform !== "linux" || + safeStorage.getSelectedStorageBackend() !== "basic_text") + ); +} + +function readStoredSession(): Session | undefined { + if (!fs.existsSync(sessionFile()) || !canEncrypt()) { + return undefined; + } + try { + return JSON.parse( + safeStorage.decryptString(fs.readFileSync(sessionFile())), + ); + } catch (e) { + log.warn("Discarding unreadable auth session:", e); + removeSessionFile(); + return undefined; + } +} + +function writeStoredSession({ issuer, clientId, refreshToken }: Session) { + try { + if (!canEncrypt()) { + log.warn("No OS keychain available, sign-in lasts until the app quits"); + // An earlier account must not come back on the next launch. + removeSessionFile(); + return true; + } + // A crash mid-write must not cost the user their sign-in. + fs.writeFileSync( + temporarySessionFile(), + safeStorage.encryptString( + JSON.stringify({ issuer, clientId, refreshToken }), + ), + { mode: 0o600 }, + ); + fs.renameSync(temporarySessionFile(), sessionFile()); + return true; + } catch (e) { + log.warn("Could not store the auth session:", errorSummary(e)); + return false; + } +} + +function dropSession() { + sessionEpoch++; + clearTimeout(refreshTimer); + session = undefined; + removeSessionFile(); + publish({ event: "logout" }); +} + +function acceptTokens( + from: AuthProvider, + tokens: OpenIdClient.TokenEndpointResponse & + OpenIdClient.TokenEndpointResponseHelpers, + currentRefreshToken?: string, +) { + const refreshToken = tokens.refresh_token ?? currentRefreshToken; + const idToken = tokens.id_token; + if (!refreshToken || !idToken) { + throw new Error("Token response is missing a refresh or ID token"); + } + sessionEpoch++; + session = { + issuer: from.issuer, + clientId: from.clientId, + refreshToken, + idToken, + }; + // After a new sign-in, the previous account must not come back on the next launch. + if (!writeStoredSession(session) && !currentRefreshToken) { + removeSessionFile(); + } + publish({ event: "login", providerId: "oidc", idToken }); + scheduleRefresh(tokens.claims()!.exp * 1000 - REFRESH_MARGIN_MS - Date.now()); +} + +function scheduleRefresh(delay: number) { + clearTimeout(refreshTimer); + refreshTimer = setTimeout(refresh, Math.max(delay, MIN_REFRESH_DELAY_MS)); +} + +function isSessionRevoked(e: any) { + return e?.error === "invalid_grant" || e?.error === "invalid_client"; +} + +async function refresh() { + // Keeps the user signed in: a second request would spend the rotated token. + if (!session || refreshing) { + return; + } + const refreshed = session; + const epoch = sessionEpoch; + refreshing = true; + try { + const config = await discover(refreshed); + const tokens = await oidc.refreshTokenGrant(config, refreshed.refreshToken); + if (epoch === sessionEpoch) { + acceptTokens(refreshed, tokens, refreshed.refreshToken); + } + } catch (e) { + if (epoch !== sessionEpoch) { + return; + } + if (isSessionRevoked(e)) { + log.warn("Auth session is no longer valid"); + dropSession(); + } else { + log.warn("Auth refresh failed, retrying:", errorSummary(e)); + scheduleRefresh(REFRESH_RETRY_MS); + } + } finally { + refreshing = false; + } +} + +async function login() { + const config = await discover(provider); + const codeVerifier = oidc.randomPKCECodeVerifier(); + const state = oidc.randomState(); + pendingLogin = { ...provider, codeVerifier, state }; + const authorizeUrl = oidc.buildAuthorizationUrl(config, { + redirect_uri: REDIRECT_URI, + scope: SCOPE, + prompt: "consent", + state, + code_challenge: await oidc.calculatePKCECodeChallenge(codeVerifier), + code_challenge_method: "S256", + }); + await shell.openExternal(authorizeUrl.href); +} + +async function logout() { + const ended = session; + pendingLogin = undefined; + dropSession(); + if (!ended) { + return; + } + try { + const config = await discover(ended); + oidc + .tokenRevocation(config, ended.refreshToken) + .catch((e) => + log.warn("Refresh token revocation failed:", errorSummary(e)), + ); + const endSessionUrl = oidc.buildEndSessionUrl(config, { + client_id: ended.clientId, + post_logout_redirect_uri: POST_LOGOUT_REDIRECT_URI, + ...(ended.idToken && { id_token_hint: ended.idToken }), + }); + await shell.openExternal(endSessionUrl.href); + } catch (e) { + log.warn( + "Could not reach the auth provider to end the session:", + errorSummary(e), + ); + } +} + +function setEnvironment(environment: string) { + provider = authProviderFor(environment); + if (pendingLogin && !isSameAuthProvider(pendingLogin, provider)) { + pendingLogin = undefined; + } + if (session && !isSameAuthProvider(session, provider)) { + dropSession(); + } +} + +export async function handleAuthDeeplink(url: URL) { + if (url.pathname !== "/callback") { + return; + } + const pending = pendingLogin; + if (!pending || url.searchParams.get("state") !== pending.state) { + log.warn("Ignoring auth callback that matches no pending sign-in"); + return; + } + pendingLogin = undefined; + const epoch = sessionEpoch; + try { + const config = await discover(pending); + const tokens = await oidc.authorizationCodeGrant(config, url, { + pkceCodeVerifier: pending.codeVerifier, + expectedState: pending.state, + }); + if (epoch !== sessionEpoch || !isSameAuthProvider(pending, provider)) { + log.warn("Discarding a sign-in overtaken by another session change"); + return; + } + acceptTokens(pending, tokens); + } catch (e) { + log.error("Sign-in failed:", errorSummary(e)); + } +} + +function isConfiguredProvider(from: AuthProvider) { + return ["production", "development"].some((environment) => + isSameAuthProvider(authProviderFor(environment), from), + ); +} + +export function startAuth() { + ipcMain.handle("auth:login", login); + ipcMain.handle("auth:logout", logout); + ipcMain.handle("auth:state", () => current); + ipcMain.handle("auth:environment", (_event, environment: string) => + setEnvironment(environment), + ); + + // Waking from sleep must not leave the user with an expired token. + powerMonitor.on("resume", refresh); + + session = readStoredSession(); + if (!session) { + publish({ event: "logout" }); + } else if (isConfiguredProvider(session)) { + refresh(); + } else { + dropSession(); + } +} diff --git a/src/electron/main.ts b/src/electron/main.ts index 3baa04259..595397a0e 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -98,6 +98,7 @@ import { websocket } from "./ipcmain_websocket"; import { developerWebsocket } from "./developer_websocket"; import { startLuaLSServer, stopLuaLSServer } from "./ipcmain_luals"; import { store } from "./main-store"; +import { auth, startAuth, handleAuthDeeplink } from "./auth"; import { iconBuffer, iconSize } from "./icon"; import { firmware, @@ -432,9 +433,10 @@ function handleDeeplinkReturnData(returnData: string) { // that isn't a deeplink at all — ignore it instead of crashing. return; } - if (url.searchParams.get("credential") !== null) { - const credential = url.searchParams.get("credential"); - mainWindow.webContents.send("onExternalAuthResponse", credential); + if (url.protocol === "grid-editor:" && url.hostname === "auth") { + showMainWindow(); + handleAuthDeeplink(url); + return; } if (url.searchParams.get("config-link") !== null) { const configLink = url.searchParams.get("config-link"); @@ -456,12 +458,14 @@ if (!gotTheLock) { if (mainWindow) { showMainWindow(); - // Only treat the last arg as a deeplink when it's actually our - // protocol; a relaunch (resetAppSettings, restartApp) passes plain - // flags/paths that new URL() would reject. - const lastArg = commandLine.pop()?.toString(); - if (lastArg && lastArg.startsWith("grid-editor://")) { - handleDeeplinkReturnData(lastArg); + // Only treat an arg as a deeplink when it's actually our protocol; a + // relaunch (resetAppSettings, restartApp) passes plain flags/paths + // that new URL() would reject. + const deeplink = commandLine.find((arg) => + arg.startsWith("grid-editor://"), + ); + if (deeplink) { + handleDeeplinkReturnData(deeplink); } } }, @@ -495,6 +499,7 @@ if (!gotTheLock) { create_tray(); } createApplicationMenu(); + startAuth(); createWindow(); protocol.handle("package", (req) => { let requestPath = req.url.substring("package://".length); @@ -701,6 +706,7 @@ function createWindow() { websocket.mainWindow = mainWindow; firmware.mainWindow = mainWindow; updater.mainWindow = mainWindow; + auth.mainWindow = mainWindow; updater.init( store.get("nightlyEditor"), diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 4b489c09b..136ad5d61 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -12,8 +12,13 @@ contextBridge.exposeInMainWorld("ctxProcess", { contextBridge.exposeInMainWorld("electron", { appPath: () => ipcRenderer.sendSync("get-app-path"), auth: { - onExternalResponse: (callback) => - ipcRenderer.on("onExternalAuthResponse", callback), + login: () => ipcRenderer.invoke("auth:login"), + logout: () => ipcRenderer.invoke("auth:logout"), + getState: () => ipcRenderer.invoke("auth:state"), + setEnvironment: (environment) => + ipcRenderer.invoke("auth:environment", environment), + onChanged: (callback) => + ipcRenderer.on("auth:changed", (_event, change) => callback(change)), }, library: { download: (targetFolder, packageToDownload) => diff --git a/src/renderer/App.svelte b/src/renderer/App.svelte index c539c5a6c..9ec7a3c31 100644 --- a/src/renderer/App.svelte +++ b/src/renderer/App.svelte @@ -30,7 +30,6 @@ import { windowSize } from "./runtime/window-size.svelte"; import { MonacoEditor } from "./lib/monaco"; - import { authStore } from "$lib/auth.store"; import { configLinkStore } from "$lib/configlink.store"; import { watchResize } from "svelte-watch-resize"; @@ -153,10 +152,6 @@ debug_lowlevel_store.push_outbound(new TextEncoder().encode(value)); }); - window.electron.auth.onExternalResponse((_event, value) => { - authStore.socialLogin("google", value); - }); - window.electron.configs.onExternalResponse((_event, value) => { // listening to this store on ProfileCloud.svelte configLinkStore.set({ id: value }); diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 97f9f695a..542991c51 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -1,6 +1,9 @@ interface ImportMetaEnv { readonly VITE_BUILD_TARGET: string; readonly VITE_BUILD_ENV: string; + readonly VITE_PROFILE_CLOUD_URL?: string; + readonly VITE_AUTH_ISSUER_DEV?: string; + readonly VITE_AUTH_CLIENT_ID_DEV?: string; readonly VITE_BRANCH_NAME: string; } diff --git a/src/renderer/lib/auth.store.ts b/src/renderer/lib/auth.store.ts index bf902c0cb..c92c3a0f0 100644 --- a/src/renderer/lib/auth.store.ts +++ b/src/renderer/lib/auth.store.ts @@ -1,31 +1,9 @@ import { writable } from "svelte/store"; -import { prodCentralAuth, devCentralAuth } from "./firebase"; -import { - EmailAuthProvider, - GoogleAuthProvider, - type Unsubscribe, - type User, - sendPasswordResetEmail, - signInWithCredential, - signInWithPopup, - createUserWithEmailAndPassword, - signOut, -} from "firebase/auth"; -import { FirebaseError } from "firebase/app"; - interface AuthStore { event: string; providerId?: string; idToken?: string; - email?: string; - password?: string; - credential?: any; -} - -export enum LoginErrorType { - INVALID_CREDENTIALS = "InvalidCredentials", - GENERAL_ERROR = "GeneralError", } export enum AuthEnvironment { @@ -33,145 +11,23 @@ export enum AuthEnvironment { DEVELOPMENT = "development", } -// Custom exception class with an enum field -export default class LoginError extends Error { - errorType: LoginErrorType; - - constructor(message, errorType) { - super(message); - this.name = "LoginError"; - this.errorType = errorType; - } -} - const createAuth = () => { const { subscribe, set } = writable(null); + const auth = window.electron.auth; - let currentAuthEnvironment: AuthEnvironment | undefined = undefined; - let authUnsubscribe: Unsubscribe | undefined = undefined; - - function getCurrentCentralAuth() { - switch (currentAuthEnvironment) { - case AuthEnvironment.PRODUCTION: - return prodCentralAuth; - case AuthEnvironment.DEVELOPMENT: - return devCentralAuth; - default: - return prodCentralAuth; - } - } - - async function login(email, password): Promise { - // we don't need specific persistence options, as local is default - // https://firebase.google.com/docs/auth/web/auth-state-persistence#supported_types_of_auth_state_persistence - const credential = EmailAuthProvider.credential(email, password); - - return signInWithCredential(getCurrentCentralAuth(), credential).catch( - (e) => { - if (e instanceof FirebaseError) { - switch (e.code) { - case "auth/account-exists-with-different-credential": - case "auth/invalid-credential": - case "auth/user-not-found": - case "auth/wrong-password": - case "auth/invalid-email": - throw new LoginError( - e.message, - LoginErrorType.INVALID_CREDENTIALS, - ); - default: - throw new LoginError(e.message, LoginErrorType.GENERAL_ERROR); - } - } else { - throw e; - } - }, - ); - } - - async function socialLogin(provider, idToken) { - if (provider == "google") { - const credential = GoogleAuthProvider.credential(idToken); - await signInWithCredential(getCurrentCentralAuth(), credential).catch( - (error) => { - console.log(error); - // ... - }, - ); - } - } - - async function signUpWithEmail(email, password) { - return await createUserWithEmailAndPassword( - getCurrentCentralAuth(), - email, - password, - ); - } - - async function googleLoginPopup() { - await signInWithPopup( - getCurrentCentralAuth(), - new GoogleAuthProvider(), - ).catch((error) => { - console.log(error); - }); - } - - async function logout() { - await signOut(getCurrentCentralAuth()).catch((error) => { - console.log(error); - }); - } - - function getCurrentAuthEnvironment() { - return currentAuthEnvironment; - } - - async function setCurrentAuthEnvironment(environment: AuthEnvironment) { - if (environment != currentAuthEnvironment) { - if (authUnsubscribe) { - authUnsubscribe(); - } - if (currentAuthEnvironment) { - await logout(); - } - currentAuthEnvironment = environment; - authUnsubscribe = getCurrentCentralAuth().onAuthStateChanged( - handleAuthStateChanged, - ); - let currentUser = getCurrentCentralAuth().currentUser; - handleAuthStateChanged(currentUser); - console.log(`Current user: ${JSON.stringify(currentUser?.uid)}`); + auth.onChanged(set); + auth.getState().then((state: AuthStore | null) => { + if (state) { + set(state); } - } - - async function handleAuthStateChanged(user: User | null) { - if (user) { - console.log(`Logging in user: ${user.uid}`); - user.getIdToken().then((idToken) => { - set({ event: "login", providerId: "oidc", idToken: idToken }); - }); - } else { - console.log(`Null user, logout`); - set({ event: "logout" }); - } - } - - async function sendForgottenPasswordLink(email) { - sendPasswordResetEmail(getCurrentCentralAuth(), email).catch(() => {}); - } + }); return { subscribe, - login, - socialLogin, - logout, - getCurrentAuthEnvironment, - setCurrentAuthEnvironment, - sendForgottenPasswordLink, - googleLoginPopup, - signUpWithEmail, + login: (): Promise => auth.login(), + logout: (): Promise => auth.logout(), + setCurrentAuthEnvironment: (environment: AuthEnvironment): Promise => + auth.setEnvironment(environment), }; }; diff --git a/src/renderer/lib/auth.web.ts b/src/renderer/lib/auth.web.ts new file mode 100644 index 000000000..4e2113868 --- /dev/null +++ b/src/renderer/lib/auth.web.ts @@ -0,0 +1,195 @@ +import type LogtoClient from "@logto/browser"; +import { authProviderFor } from "../../shared/auth-provider"; + +const ENVIRONMENT_KEY = "auth-environment"; +const CALLBACK_PATH = "/auth/callback"; +const REFRESH_MARGIN_MS = 5 * 60 * 1000; +const MIN_REFRESH_DELAY_MS = 30 * 1000; +const REFRESH_RETRY_MS = 60 * 1000; +const TOKENS_LOCK = "grid-editor-auth-tokens"; + +type AuthChange = + { event: "login"; providerId: "oidc"; idToken: string } | { event: "logout" }; + +export function createWebAuth() { + const listeners: ((change: AuthChange) => void)[] = []; + let current: AuthChange | null = null; + let environment = localStorage.getItem(ENVIRONMENT_KEY); + let client: LogtoClient | undefined; + let refreshTimer: ReturnType | undefined; + + function publish(change: AuthChange) { + current = change; + listeners.forEach((listener) => listener(change)); + } + + async function createClient(forEnvironment: string) { + const { + default: LogtoClient, + ReservedScope, + UserScope, + } = await import("@logto/browser"); + const { issuer, clientId } = authProviderFor(forEnvironment); + if (!clientId) { + throw new Error(`No client id configured for ${issuer}`); + } + return new LogtoClient({ + endpoint: issuer.replace(/\/oidc$/, ""), + appId: clientId, + // No profile scope: Firebase rejects the null picture claim it adds. + includeReservedScopes: false, + scopes: [ + ReservedScope.OpenId, + ReservedScope.OfflineAccess, + UserScope.Email, + ], + }); + } + + function scheduleRefresh(delay: number) { + clearTimeout(refreshTimer); + refreshTimer = setTimeout(refresh, Math.max(delay, MIN_REFRESH_DELAY_MS)); + } + + async function publishIdToken(from: LogtoClient) { + const { exp } = await from.getIdTokenClaims(); + publish({ + event: "login", + providerId: "oidc", + idToken: (await from.getIdToken())!, + }); + scheduleRefresh(exp * 1000 - REFRESH_MARGIN_MS - Date.now()); + } + + // Tabs share one rotating refresh token, so they must take turns changing it. + function inTurn(task: () => Promise) { + return navigator.locks.request(TOKENS_LOCK, task); + } + + function refresh() { + return inTurn(refreshInTurn); + } + + async function refreshInTurn() { + const refreshed = client; + if (!refreshed) { + return; + } + const sentRefreshToken = await refreshed.getRefreshToken(); + try { + // Asks for a new ID token even while the access token is still valid. + await refreshed.clearAccessToken(); + await refreshed.getAccessToken(); + if (refreshed === client) { + await publishIdToken(refreshed); + } + } catch (e) { + if (refreshed !== client) { + return; + } + const stillStored = + (await refreshed.getRefreshToken()) === sentRefreshToken; + if (isRevoked(e) && stillStored) { + console.warn("Auth session is no longer valid"); + await refreshed.clearAllTokens(); + client = undefined; + publish({ event: "logout" }); + } else { + console.warn("Auth refresh failed, retrying:", e); + scheduleRefresh(REFRESH_RETRY_MS); + } + } + } + + function isRevoked(e: any) { + return e?.code === "oidc.invalid_grant" || e?.code === "not_authenticated"; + } + + async function start() { + const callbackUrl = + window.location.pathname === CALLBACK_PATH ? window.location.href : null; + if (callbackUrl) { + window.history.replaceState(null, "", "/"); + } + if (!environment) { + publish({ event: "logout" }); + return; + } + const startedIn = environment; + try { + const restored = await createClient(startedIn); + let signedIn = false; + if (callbackUrl) { + try { + await restored.handleSignInCallback(callbackUrl); + signedIn = true; + } catch (e) { + console.error("Sign-in failed:", e); + } + } + const authenticated = await restored.isAuthenticated(); + if (environment !== startedIn) { + await inTurn(() => restored.clearAllTokens()); + publish({ event: "logout" }); + return; + } + if (!authenticated) { + publish({ event: "logout" }); + return; + } + client = restored; + if (signedIn) { + await publishIdToken(restored); + } else { + await refresh(); + } + } catch (e) { + console.error("Sign-in failed:", e); + publish({ event: "logout" }); + } + } + + async function login() { + environment ??= "production"; + localStorage.setItem(ENVIRONMENT_KEY, environment); + const signingIn = await createClient(environment); + await signingIn.signIn(`${window.location.origin}${CALLBACK_PATH}`); + } + + async function logout() { + const ended = client; + client = undefined; + clearTimeout(refreshTimer); + publish({ event: "logout" }); + if (ended) { + await inTurn(() => ended.signOut(`${window.location.origin}/`)); + } + } + + async function setEnvironment(next: string) { + if (next === environment) { + return; + } + const previous = client; + client = undefined; + clearTimeout(refreshTimer); + environment = next; + localStorage.setItem(ENVIRONMENT_KEY, next); + if (previous) { + publish({ event: "logout" }); + await inTurn(() => previous.clearAllTokens()); + } + } + + start(); + + return { + login, + logout, + setEnvironment, + getState: async () => current, + onChanged: (listener: (change: AuthChange) => void) => { + listeners.push(listener); + }, + }; +} diff --git a/src/renderer/lib/firebase.js b/src/renderer/lib/firebase.js deleted file mode 100644 index b76929b78..000000000 --- a/src/renderer/lib/firebase.js +++ /dev/null @@ -1,32 +0,0 @@ -// Import the functions you need from the SDKs you need -import { initializeApp } from "firebase/app"; -import { getAuth } from "firebase/auth"; -// TODO: Add SDKs for Firebase products that you want to use -// https://firebase.google.com/docs/web/setup#available-libraries - -const devCentralAuthConfig = { - apiKey: "AIzaSyDla2zilILl5X0sQ4fbhAO61uFCqCoVhZc", - authDomain: "is-auth-dev.firebaseapp.com", - projectId: "is-auth-dev", - storageBucket: "is-auth-dev.appspot.com", - messagingSenderId: "610580654354", - appId: "1:610580654354:web:d7dc9982f52ece959c2bcb", -}; - -const prodCentralAuthConfig = { - apiKey: "AIzaSyDoGeGnoBdSBQZ6oG-t3nEq8Ol_jEejFw8", - authDomain: "is-api-auth.firebaseapp.com", - projectId: "is-api-auth", - storageBucket: "is-api-auth.appspot.com", - messagingSenderId: "611038678816", - appId: "1:611038678816:web:5d8fab312b614702539145", -}; - -export const devCentralApp = initializeApp(devCentralAuthConfig, "dev-central"); -export const devCentralAuth = getAuth(devCentralApp); - -export const prodCentralApp = initializeApp( - prodCentralAuthConfig, - "prod-central", -); -export const prodCentralAuth = getAuth(prodCentralApp); diff --git a/src/renderer/lib/user.store.ts b/src/renderer/lib/user.store.ts index 0959fc8bf..20e706008 100644 --- a/src/renderer/lib/user.store.ts +++ b/src/renderer/lib/user.store.ts @@ -1,40 +1,19 @@ -import { type User, onAuthStateChanged } from "firebase/auth"; -import { readable } from "svelte/store"; -import { prodCentralAuth, devCentralAuth } from "./firebase"; +import { derived } from "svelte/store"; +import { authStore } from "./auth.store"; -function createUserStore() { - // this is code for awaiting the currentUser by CaptainCodeman - - const { subscribe } = readable(undefined, (set) => { - let prodAuthUser: User | null = null; - let devAuthUser: User | null = null; - onAuthStateChanged(prodCentralAuth, (user) => { - prodAuthUser = user; - set(prodAuthUser ?? devAuthUser); - }); - onAuthStateChanged(devCentralAuth, (user) => { - devAuthUser = user; - set(prodAuthUser ?? devAuthUser); - }); - }); - - // onAuthStateChange can be null if getAuth.currentUser is not ready - // NOTE: "known" is only used to detect changes. It returns void, not a User. The user is available via the subscribe function on the store. - const known = new Promise((resolve) => { - let unsub = () => {}; - unsub = subscribe((user) => { - //first user maybe null, further run should have valid value - if (user !== undefined) { - resolve(); - unsub(); - } - }); - }); +interface User { + uid: string; + email?: string; +} - return { - subscribe, - known, - }; +function decodeIdToken(idToken: string): User { + const payload = idToken.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); + const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0)); + const { sub, email } = JSON.parse(new TextDecoder().decode(bytes)); + return { uid: sub, email }; } -export const userStore = createUserStore(); +export const userStore = derived( + authStore, + (auth) => (auth?.idToken ? decodeIdToken(auth.idToken) : null), +); diff --git a/src/renderer/main/modals/user-authentication/UserAuthenticationModal.svelte b/src/renderer/main/modals/user-authentication/UserAuthenticationModal.svelte index a42a85904..81dd3bd50 100644 --- a/src/renderer/main/modals/user-authentication/UserAuthenticationModal.svelte +++ b/src/renderer/main/modals/user-authentication/UserAuthenticationModal.svelte @@ -1,39 +1,43 @@
{#if $userStore} - {:else if currentNavigationTarget === "login"} - (currentNavigationTarget = "forgotten")} - on:to-signup={() => (currentNavigationTarget = "signup")} - /> - {:else if currentNavigationTarget === "forgotten"} - - {:else if currentNavigationTarget === "signup"} - + {:else} +
+
Login to profile cloud
+ {#if loginError != ""} +
+

{loginError}

+
+ {/if} + +
{/if}
diff --git a/src/renderer/main/modals/user-authentication/UserForgottenPassword.svelte b/src/renderer/main/modals/user-authentication/UserForgottenPassword.svelte deleted file mode 100644 index d0f35c5dd..000000000 --- a/src/renderer/main/modals/user-authentication/UserForgottenPassword.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - -
- {#if !showSuccess} -
-
Forgotten password
-
-
- - { - emailError = ""; - }} - on:keyup={(e) => { - const { key } = e.detail; - if (key === "Enter") { - e.preventDefault(); - forgottenPassword(); - } - }} - /> -
- - {#if emailError != ""} -
-

{emailError}

-
- {/if} - -
- Enter the email address associated with the account you are recovering! -
- -
- - - -
- {:else} -
-
Password reset email sent!
-
-
- Password reset link has been successfully sent to the given email address - if it is registered! -
- - - {/if} -
diff --git a/src/renderer/main/modals/user-authentication/UserLoggedIn.svelte b/src/renderer/main/modals/user-authentication/UserLoggedIn.svelte index 9ca39af12..265e54df8 100644 --- a/src/renderer/main/modals/user-authentication/UserLoggedIn.svelte +++ b/src/renderer/main/modals/user-authentication/UserLoggedIn.svelte @@ -9,11 +9,6 @@
Signed in as: {$userStore?.email ?? ""}
-{#if $userStore?.userName} -
- username: {$userStore.userName} -
-{/if}
-
- {#if loginError != ""} -
-

{loginError}

-
- {/if} -
- -
- - - -
-
-
- - window.electron.openInBrowser( - configuration.PROFILE_CLOUD_EMAIL_REGISTRATION, - )} - text="Register on Website" - snap="full" - /> - - socialLogin()} - text="Sign in with Google" - /> - diff --git a/src/renderer/main/modals/user-authentication/UserSignUp.svelte b/src/renderer/main/modals/user-authentication/UserSignUp.svelte deleted file mode 100644 index 312a334e0..000000000 --- a/src/renderer/main/modals/user-authentication/UserSignUp.svelte +++ /dev/null @@ -1,145 +0,0 @@ - - -
-
-
Sign up to profile cloud
-
-
- - { - signUpError = ""; - }} - on:keyup={(e) => { - const { key } = e.detail; - if (key === "Enter") { - e.preventDefault(); - passwordField.focus(); - } - }} - /> -
- -
- - { - signUpError = ""; - }} - on:keyup={(e) => { - const { key } = e.detail; - if (key === "Enter") { - e.preventDefault(); - repasswordField.focus(); - } - }} - /> -
- -
- - { - signUpError = ""; - }} - on:keyup={(e) => { - const { key } = e.detail; - if (key === "Enter") { - e.preventDefault(); - submitSignup(); - } - }} - /> -
- - {#if signUpError != ""} -
-

{signUpError}

-
- {/if} -
- - -
-
diff --git a/src/renderer/main/panels/profileCloud/ProfileCloud.svelte b/src/renderer/main/panels/profileCloud/ProfileCloud.svelte index d8f2222ed..32d0d830e 100644 --- a/src/renderer/main/panels/profileCloud/ProfileCloud.svelte +++ b/src/renderer/main/panels/profileCloud/ProfileCloud.svelte @@ -3,7 +3,10 @@ import { ProfileCloud } from "./../../../runtime/string-table"; import { onDestroy, onMount } from "svelte"; import { v4 as uuidv4 } from "uuid"; - import { appSettings } from "../../../runtime/app-helper.store"; + import { + appSettings, + appSettingsHydrated, + } from "../../../runtime/app-helper.store"; import { ModuleOverlay, moduleOverlay } from "../../../runtime/moduleOverlay"; import { Analytics } from "../../../runtime/analytics.js"; @@ -20,7 +23,6 @@ import { selected_actions } from "./../../../runtime/selected-actions.store"; import { authStore, AuthEnvironment } from "$lib/auth.store"; // this only changes if login, logout happens - import { userStore } from "$lib/user.store"; import { configLinkStore } from "$lib/configlink.store"; import { Modal } from "../../modals/modal.store"; import UserAuthenticationModal from "../../modals/user-authentication/UserAuthenticationModal.svelte"; @@ -347,12 +349,13 @@ let profileCloudIsMounted = false; async function handleProfileCloudMounted(event) { - profileCloudIsMounted = true; let authEnvironment = AuthEnvironment.PRODUCTION; if (event.data.environment !== "production") { authEnvironment = AuthEnvironment.DEVELOPMENT; } - authStore.setCurrentAuthEnvironment(authEnvironment); + // the panel must only ever get a token from its own environment + await authStore.setCurrentAuthEnvironment(authEnvironment); + profileCloudIsMounted = true; if ( selectedModuleType !== undefined || selectedControlElementType !== undefined @@ -496,8 +499,8 @@ } onMount(async () => { - // get to know the user - await userStore.known; + // the panel must load the user's chosen Profile Cloud, never the default first + await appSettingsHydrated; console.log( `Profile Cloud url: ${$appSettings.persistent.profileCloudUrl} (status: ${profileCloudIsMounted})`, ); diff --git a/src/renderer/preload-window-config.ts b/src/renderer/preload-window-config.ts index b715c62ff..1fb27f3d1 100644 --- a/src/renderer/preload-window-config.ts +++ b/src/renderer/preload-window-config.ts @@ -10,6 +10,7 @@ */ import configuration from "../../configuration.json"; +import { createWebAuth } from "./lib/auth.web"; import { version } from "../../package.json"; configuration.EDITOR_VERSION = version; @@ -64,9 +65,7 @@ if ( onReceive: async () => {}, onTransmit: async () => {}, }, - auth: { - onExternalResponse: async () => {}, - }, + auth: createWebAuth(), configs: { onExternalResponse: async () => {}, startConfigsWatch: async () => {}, diff --git a/src/renderer/runtime/app-helper.store.ts b/src/renderer/runtime/app-helper.store.ts index ae7e0f4db..38cd82808 100644 --- a/src/renderer/runtime/app-helper.store.ts +++ b/src/renderer/runtime/app-helper.store.ts @@ -104,7 +104,9 @@ const persistentDefaultValues = { firstLaunch: true, fontSize: 12, codeEditorDefaultLines: 7, - profileCloudUrl: configuration.PROFILE_CLOUD_URL_PROD, + profileCloudUrl: + import.meta.env.VITE_PROFILE_CLOUD_URL ?? + configuration.PROFILE_CLOUD_URL_PROD, showPCB: false, nightlyFirmware: false, nightlyEditor: false, @@ -248,7 +250,7 @@ function persistChangedSettings(store) { }); } -init_appsettings(); +export const appSettingsHydrated = init_appsettings(); async function init_appsettings() { let request = []; diff --git a/src/shared/auth-provider.ts b/src/shared/auth-provider.ts new file mode 100644 index 000000000..9545d6184 --- /dev/null +++ b/src/shared/auth-provider.ts @@ -0,0 +1,25 @@ +import configuration from "../../configuration.json"; + +export interface AuthProvider { + issuer: string; + clientId: string; +} + +export function authProviderFor(environment: string): AuthProvider { + return environment === "production" + ? { + issuer: configuration.AUTH_ISSUER_PROD, + clientId: configuration.AUTH_CLIENT_ID_PROD, + } + : { + issuer: + import.meta.env.VITE_AUTH_ISSUER_DEV || configuration.AUTH_ISSUER_DEV, + clientId: + import.meta.env.VITE_AUTH_CLIENT_ID_DEV || + configuration.AUTH_CLIENT_ID_DEV, + }; +} + +export function isSameAuthProvider(a: AuthProvider, b: AuthProvider) { + return a.issuer === b.issuer && a.clientId === b.clientId; +}