diff --git a/package-lock.json b/package-lock.json index bff40f5f8..4ad715a1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12187,6 +12187,12 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/detectincognitojs": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/detectincognitojs/-/detectincognitojs-1.3.7.tgz", + "integrity": "sha512-8Z90m1utiUMr0hz0eYqg1x0P5uM6hSqGe0TdLtZGunDEptoaoYmcWKbk2qmAezilErNbY9hmqcdNIYooUc925w==", + "license": "MIT" + }, "node_modules/devtools-protocol": { "version": "0.0.1107588", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1107588.tgz", @@ -26976,7 +26982,7 @@ "version": "0.5.0", "license": "ISC", "dependencies": { - "@corbado/connect-react": "^0.2.4-alpha.0", + "@corbado/connect-react": "^0.8.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -26991,23 +26997,6 @@ "react-i18next": "13.2.2" } }, - "packages/connect-web-js/node_modules/@corbado/connect-react": { - "version": "0.2.4-alpha.0", - "resolved": "https://registry.npmjs.org/@corbado/connect-react/-/connect-react-0.2.4-alpha.0.tgz", - "integrity": "sha512-uLakbrU2sC2EvUz6yCTvqcHZCt6n8kvlH/irJvISIdYRlmGzA80Y4qDLMAWMQw8MZuLg9E2aw3ekicxHDTd83w==", - "license": "ISC", - "dependencies": { - "@corbado/web-core": "^2.13.0", - "date-fns": "^3.6.0", - "i18next": "23.5.1", - "i18next-browser-languagedetector": "7.1.0", - "react-i18next": "13.2.2" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, "packages/react": { "name": "@corbado/react", "version": "2.18.0", @@ -27076,6 +27065,7 @@ "@corbado/webauthn-json": "^2.1.2", "@fingerprintjs/fingerprintjs": "^3.4.2", "axios": "^1.7.4", + "detectincognitojs": "^1.3.7", "loglevel": "^1.8.1", "rxjs": "^7.8.1", "ts-results": "^3.3.0" @@ -27360,6 +27350,126 @@ "devDependencies": { "dotenv-webpack": "^8.0.1" } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.4.tgz", + "integrity": "sha512-QVadW73sWIO6E2VroyUjuAxhWLZWEpiFqHdZdoQ/AMpN9YWGuHV8t2rChr0ahy+irKX5mlDU7OY68k3n4tAZTg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.4.tgz", + "integrity": "sha512-KT6GUrb3oyCfcfJ+WliXuJnD6pCpZiosx2X3k66HLR+DMoilRb76LpWPGb4tZprawTtcnyrv75ElD6VncVamUQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.4.tgz", + "integrity": "sha512-Alv8/XGSs/ytwQcbCHwze1HmiIkIVhDHYLjczSVrf0Wi2MvKn/blt7+S6FJitj3yTlMwMxII1gIJ9WepI4aZ/A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.4.tgz", + "integrity": "sha512-ze0ShQDBPCqxLImzw4sCdfnB3lRmN3qGMB2GWDRlq5Wqy4G36pxtNOo2usu/Nm9+V2Rh/QQnrRc2l94kYFXO6Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.4.tgz", + "integrity": "sha512-8dwC0UJoc6fC7PX70csdaznVMNr16hQrTDAMPvLPloazlcaWfdPogq+UpZX6Drqb1OBlwowz8iG7WR0Tzk/diQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.4.tgz", + "integrity": "sha512-jxyg67NbEWkDyvM+O8UDbPAyYRZqGLQDTPwvrBBeOSyVWW/jFQkQKQ70JDqDSYg1ZDdl+E3nkbFbq8xM8E9x8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.4.tgz", + "integrity": "sha512-twrmN753hjXRdcrZmZttb/m5xaCBFa48Dt3FbeEItpJArxriYDunWxJn+QFXdJ3hPkm4u7CKxncVvnmgQMY1ag==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.4.tgz", + "integrity": "sha512-tkLrjBzqFTP8DVrAAQmZelEahfR9OxWpFR++vAI9FBhCiIxtwHwBHC23SBHCTURBtwB4kc/x44imVOnkKGNVGg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/package.json b/package.json index 5df8019cb..e0a04f93a 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,6 @@ "webpack": "^5.89.0", "webpack-cli": "^5.1.4", "webpack-merge": "^5.10.0" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx b/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx index 86c6d2ca6..c47987bba 100644 --- a/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx +++ b/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx @@ -54,10 +54,10 @@ const AppendAfterErrorScreen = ({ attestationOptions }: { attestationOptions: st case AppendSituationCode.CtApiNotAvailablePreAuthenticator: case AppendSituationCode.CboApiNotAvailablePreAuthenticator: case AppendSituationCode.CboApiNotAvailablePostAuthenticator: - void handleErrorHard(situationCode); + void handleErrorHard(situationCode, false); break; case AppendSituationCode.ClientPasskeyOperationCancelled: - void handleErrorSoft(situationCode); + void handleErrorSoft(situationCode, true); break; case AppendSituationCode.ClientExcludeCredentialsMatch: void handleCredentialExistsError(); diff --git a/packages/connect-react/src/components/append/AppendAfterHybridLoginScreen.tsx b/packages/connect-react/src/components/append/AppendAfterHybridLoginScreen.tsx index 476aeac37..5f0e0394e 100644 --- a/packages/connect-react/src/components/append/AppendAfterHybridLoginScreen.tsx +++ b/packages/connect-react/src/components/append/AppendAfterHybridLoginScreen.tsx @@ -56,10 +56,10 @@ const AppendAfterHybridLoginScreen = ({ attestationOptions }: { attestationOptio case AppendSituationCode.CtApiNotAvailablePreAuthenticator: case AppendSituationCode.CboApiNotAvailablePreAuthenticator: case AppendSituationCode.CboApiNotAvailablePostAuthenticator: - void handleErrorHard(situationCode); + void handleErrorHard(situationCode, false); break; case AppendSituationCode.ClientPasskeyOperationCancelled: - void handleErrorSoft(situationCode); + void handleErrorSoft(situationCode, true); break; case AppendSituationCode.ClientExcludeCredentialsMatch: void handleCredentialExistsError(); diff --git a/packages/connect-react/src/components/append/AppendInitScreen.tsx b/packages/connect-react/src/components/append/AppendInitScreen.tsx index c39085607..5dfbf363f 100644 --- a/packages/connect-react/src/components/append/AppendInitScreen.tsx +++ b/packages/connect-react/src/components/append/AppendInitScreen.tsx @@ -165,12 +165,12 @@ const AppendInitScreen = () => { case AppendSituationCode.CtApiNotAvailablePreAuthenticator: case AppendSituationCode.CboApiNotAvailablePreAuthenticator: case AppendSituationCode.CboApiNotAvailablePostAuthenticator: - void handleErrorHard(situationCode); + void handleErrorHard(situationCode, false); statefulLoader.current.finishWithError(); break; case AppendSituationCode.ClientPasskeyOperationCancelled: - void handleErrorSoft(situationCode); + void handleErrorSoft(situationCode, true); setAppendLoading(false); break; case AppendSituationCode.ClientExcludeCredentialsMatch: diff --git a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx index 45769aaf7..c4f89d7f0 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx @@ -48,7 +48,8 @@ const LoginErrorScreenHard = () => { }; const handleSituation = (situationCode: LoginSituationCode) => { - log.debug(`situation: ${situationCode}`); + const messageCode = `situation: ${situationCode}`; + log.debug(messageCode); const identifier = currentIdentifier; const message = getLoginErrorMessage(situationCode); @@ -58,21 +59,21 @@ const LoginErrorScreenHard = () => { case LoginSituationCode.CboApiNotAvailablePostAuthenticator: navigateToScreen(LoginScreenType.Invisible); config.onFallback(identifier, message); - void getConnectService().recordEventLoginErrorUntyped(); + void getConnectService().recordEventLoginErrorUnexpected(messageCode); setLoading(false); break; case LoginSituationCode.ClientPasskeyOperationCancelledTooManyTimes: navigateToScreen(LoginScreenType.Invisible); config.onFallback(identifier, message); - void getConnectService().recordEventLoginError(); + void getConnectService().recordEventLoginError(messageCode); setLoading(false); break; case LoginSituationCode.ClientPasskeyOperationCancelled: setHardErrorCount(hardErrorCount + 1); - void getConnectService().recordEventLoginError(); + void getConnectService().recordEventLoginError(messageCode); setLoading(false); break; diff --git a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx index 95cd433bb..9f83ac295 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx @@ -42,7 +42,8 @@ const LoginErrorScreenSoft = () => { }; const handleSituation = (situationCode: LoginSituationCode) => { - log.debug(`situation: ${situationCode}`); + const messageCode = `situation: ${situationCode}`; + log.debug(messageCode); const identifier = currentIdentifier; const message = getLoginErrorMessage(situationCode); @@ -52,14 +53,14 @@ const LoginErrorScreenSoft = () => { case LoginSituationCode.CboApiNotAvailablePostAuthenticator: navigateToScreen(LoginScreenType.Invisible); config.onFallback(identifier, message); - void getConnectService().recordEventLoginErrorUntyped(); + void getConnectService().recordEventLoginErrorUnexpected(messageCode); setLoading(false); break; case LoginSituationCode.ClientPasskeyOperationCancelled: navigateToScreen(LoginScreenType.ErrorHard); config.onError?.(situationCode.toString()); - void getConnectService().recordEventLoginError(); + void getConnectService().recordEventLoginError(messageCode); setLoading(false); break; diff --git a/packages/connect-react/src/components/login/LoginHybridScreen.tsx b/packages/connect-react/src/components/login/LoginHybridScreen.tsx index 885b256c0..8178cb41a 100644 --- a/packages/connect-react/src/components/login/LoginHybridScreen.tsx +++ b/packages/connect-react/src/components/login/LoginHybridScreen.tsx @@ -37,7 +37,8 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { }, [getConnectService, config, navigateToScreen, currentIdentifier, loading]); const handleSituation = (situationCode: LoginSituationCode) => { - log.debug(`situation: ${situationCode}`); + const messageCode = `situation: ${situationCode}`; + log.debug(messageCode); const identifier = currentIdentifier; const message = getLoginErrorMessage(situationCode); @@ -47,14 +48,14 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { case LoginSituationCode.CboApiNotAvailablePostAuthenticator: navigateToScreen(LoginScreenType.Invisible); config.onFallback(identifier, message); - void getConnectService().recordEventLoginErrorUntyped(); + void getConnectService().recordEventLoginErrorUnexpected(messageCode); setLoading(false); break; case LoginSituationCode.ClientPasskeyOperationCancelled: navigateToScreen(LoginScreenType.ErrorSoft); config.onError?.(situationCode.toString()); - void getConnectService().recordEventLoginError(); + void getConnectService().recordEventLoginError(messageCode); setLoading(false); break; diff --git a/packages/connect-react/src/components/login/LoginInitScreen.tsx b/packages/connect-react/src/components/login/LoginInitScreen.tsx index 37ff63be2..cea349ff3 100644 --- a/packages/connect-react/src/components/login/LoginInitScreen.tsx +++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx @@ -145,7 +145,6 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { return handleSituation(LoginSituationCode.ClientPasskeyConditionalOperationCancelled); } - void getConnectService().recordEventLoginErrorUntyped(); // if a passkey has been deleted, CUI will fail => fallback with message if (res.val instanceof ConnectConditionalUIPasskeyDeleted) { return handleSituation(LoginSituationCode.PasskeyNotAvailablePostConditionalAuthenticator); @@ -214,13 +213,15 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { }; const handleSituation = (situationCode: LoginSituationCode) => { - log.debug(`situation: ${situationCode}`); + const messageCode = `situation: ${situationCode}`; + log.debug(messageCode); const message = getLoginErrorMessage(situationCode); switch (situationCode) { case LoginSituationCode.CboApiNotAvailablePreAuthenticator: fallback(identifier, message); + void getConnectService().recordEventLoginErrorUnexpected(messageCode); statefulLoader.current.finish(); break; @@ -235,18 +236,20 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { case LoginSituationCode.CtApiNotAvailablePostAuthenticator: case LoginSituationCode.CboApiNotAvailablePostAuthenticator: fallback(identifier, message); + void getConnectService().recordEventLoginErrorUnexpected(messageCode); setIdentifierBasedLoading(false); break; case LoginSituationCode.ClientPasskeyOperationCancelled: navigateToScreen(LoginScreenType.ErrorSoft); - void getConnectService().recordEventLoginError(); + void getConnectService().recordEventLoginError(messageCode); config.onError?.(situationCode.toString()); setIdentifierBasedLoading(false); break; case LoginSituationCode.UserNotFound: setError(message ?? ''); + void getConnectService().recordEventLoginErrorUnexpected(messageCode); setIdentifierBasedLoading(false); break; diff --git a/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx b/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx index 8631e9220..a295b07b1 100644 --- a/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx +++ b/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx @@ -53,7 +53,8 @@ export const LoginPasskeyReLoginScreen = () => { }; const handleSituation = (situationCode: LoginSituationCode) => { - log.debug(`situation: ${situationCode}`); + const messageCode = `situation: ${situationCode}`; + log.debug(messageCode); const identifier = currentIdentifier; const message = getLoginErrorMessage(situationCode); @@ -64,14 +65,14 @@ export const LoginPasskeyReLoginScreen = () => { case LoginSituationCode.CboApiNotAvailablePreAuthenticator: navigateToScreen(LoginScreenType.Invisible); config.onFallback(identifier, message); - void getConnectService().recordEventLoginErrorUntyped(); + void getConnectService().recordEventLoginErrorUnexpected(messageCode); setLoading(false); break; case LoginSituationCode.ClientPasskeyOperationCancelled: navigateToScreen(LoginScreenType.ErrorSoft); config.onError?.(situationCode.toString()); - void getConnectService().recordEventLoginError(); + void getConnectService().recordEventLoginError(messageCode); setLoading(false); break; diff --git a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx index 26a4c9d56..7e9cbcc69 100644 --- a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx +++ b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx @@ -168,7 +168,8 @@ const PasskeyListScreen = () => { }; const handleSituation = (situationCode: PasskeyListSituationCode) => { - log.debug(`situation: ${situationCode}`); + const messageCode = `situation: ${situationCode}`; + log.debug(messageCode); const message = getPasskeyListErrorMessage(situationCode); switch (situationCode) { @@ -188,6 +189,8 @@ const PasskeyListScreen = () => { if (message) { setErrorMessage(message); } + + void getConnectService().recordEventManageErrorUnexpected(messageCode); break; case PasskeyListSituationCode.CtApiNotAvailablePreDelete: case PasskeyListSituationCode.CboApiNotAvailableDuringDelete: @@ -195,6 +198,8 @@ const PasskeyListScreen = () => { if (message) { setErrorMessage(message); } + + void getConnectService().recordEventManageErrorUnexpected(messageCode); break; case PasskeyListSituationCode.CtApiNotAvailablePreAuthenticator: case PasskeyListSituationCode.CboApiNotAvailablePreAuthenticator: @@ -205,6 +210,8 @@ const PasskeyListScreen = () => { if (message) { setErrorMessage(message); } + + void getConnectService().recordEventManageErrorUnexpected(messageCode); } }; diff --git a/packages/connect-react/src/contexts/AppendProcessContext.ts b/packages/connect-react/src/contexts/AppendProcessContext.ts index 05cfbda50..62f0cc2aa 100644 --- a/packages/connect-react/src/contexts/AppendProcessContext.ts +++ b/packages/connect-react/src/contexts/AppendProcessContext.ts @@ -13,8 +13,8 @@ export interface AppendProcessContextProps { currentScreenOptions: any; config: CorbadoConnectAppendConfig; navigateToScreen: (s: AppendScreenType, options?: any) => void; - handleErrorSoft: (situation: AppendSituationCode) => Promise; - handleErrorHard: (situation: AppendSituationCode, explicit?: boolean) => Promise; + handleErrorSoft: (situation: AppendSituationCode, expected: boolean) => Promise; + handleErrorHard: (situation: AppendSituationCode, expected: boolean) => Promise; handleCredentialExistsError: () => Promise; handleSkip: (situation: AppendSituationCode, explicit?: boolean) => Promise; } diff --git a/packages/connect-react/src/contexts/AppendProcessProvider.tsx b/packages/connect-react/src/contexts/AppendProcessProvider.tsx index a1537f247..fe96b02ec 100644 --- a/packages/connect-react/src/contexts/AppendProcessProvider.tsx +++ b/packages/connect-react/src/contexts/AppendProcessProvider.tsx @@ -25,19 +25,24 @@ export const AppendProcessProvider: FC> = ({ children, }, []); const handleErrorSoft = useCallback( - async (situationCode: AppendSituationCode) => { - await getConnectService().recordEventAppendError(); + async (situationCode: AppendSituationCode, expected: boolean) => { + if (expected) { + await getConnectService().recordEventAppendError(); + } else { + await getConnectService().recordEventAppendErrorUnexpected(`situation: ${situationCode}`); + } + config.onError?.(situationCode.toString()); }, [getConnectService, config], ); const handleErrorHard = useCallback( - async (situationCode: AppendSituationCode, explicit?: boolean) => { - if (explicit) { - await getConnectService().recordEventAppendExplicitAbort(); - } else { + async (situationCode: AppendSituationCode, expected: boolean) => { + if (expected) { await getConnectService().recordEventAppendError(); + } else { + await getConnectService().recordEventAppendErrorUnexpected(`situation: ${situationCode}`); } config.onError?.(situationCode.toString()); diff --git a/packages/web-core/openapi/spec_v2.yaml b/packages/web-core/openapi/spec_v2.yaml index 5e019be8d..2070a34b9 100644 --- a/packages/web-core/openapi/spec_v2.yaml +++ b/packages/web-core/openapi/spec_v2.yaml @@ -1419,6 +1419,8 @@ components: properties: eventType: $ref: '#/components/schemas/passkeyEventType' + message: + type: string challenge: type: string @@ -1637,7 +1639,7 @@ components: passkeyEventType: type: string - enum: [ login-explicit-abort, login-error, login-error-untyped, login-one-tap-switch, user-append-after-cross-platform-blacklisted, user-append-after-login-error-blacklisted, append-credential-exists, append-explicit-abort, append-error ] + enum: [ login-explicit-abort, login-error, login-error-untyped, login-error-unexpected, login-one-tap-switch, user-append-after-cross-platform-blacklisted, user-append-after-login-error-blacklisted, append-credential-exists, append-explicit-abort, append-error, append-error-unexpected, manage-error-unexpected ] blockType: type: string @@ -1845,6 +1847,8 @@ components: type: boolean webdriver: type: boolean + privateMode: + type: boolean clientCapabilities: type: object diff --git a/packages/web-core/package.json b/packages/web-core/package.json index 78040eefa..6a2619ec8 100644 --- a/packages/web-core/package.json +++ b/packages/web-core/package.json @@ -37,6 +37,7 @@ "@corbado/webauthn-json": "^2.1.2", "@fingerprintjs/fingerprintjs": "^3.4.2", "axios": "^1.7.4", + "detectincognitojs": "^1.3.7", "loglevel": "^1.8.1", "rxjs": "^7.8.1", "ts-results": "^3.3.0" diff --git a/packages/web-core/src/api/v2/api.ts b/packages/web-core/src/api/v2/api.ts index 403a1b7b6..d03143340 100644 --- a/packages/web-core/src/api/v2/api.ts +++ b/packages/web-core/src/api/v2/api.ts @@ -240,6 +240,12 @@ export interface ClientInformation { * @memberof ClientInformation */ 'webdriver'?: boolean; + /** + * + * @type {boolean} + * @memberof ClientInformation + */ + 'privateMode'?: boolean; } /** * @@ -400,6 +406,12 @@ export interface ConnectEventCreateReq { * @memberof ConnectEventCreateReq */ 'eventType': PasskeyEventType; + /** + * + * @type {string} + * @memberof ConnectEventCreateReq + */ + 'message'?: string; /** * * @type {string} @@ -1781,12 +1793,15 @@ export const PasskeyEventType = { LoginExplicitAbort: 'login-explicit-abort', LoginError: 'login-error', LoginErrorUntyped: 'login-error-untyped', + LoginErrorUnexpected: 'login-error-unexpected', LoginOneTapSwitch: 'login-one-tap-switch', UserAppendAfterCrossPlatformBlacklisted: 'user-append-after-cross-platform-blacklisted', UserAppendAfterLoginErrorBlacklisted: 'user-append-after-login-error-blacklisted', AppendCredentialExists: 'append-credential-exists', AppendExplicitAbort: 'append-explicit-abort', - AppendError: 'append-error' + AppendError: 'append-error', + AppendErrorUnexpected: 'append-error-unexpected', + ManageErrorUnexpected: 'manage-error-unexpected' } as const; export type PasskeyEventType = typeof PasskeyEventType[keyof typeof PasskeyEventType]; diff --git a/packages/web-core/src/models/connect/connectProcess.ts b/packages/web-core/src/models/connect/connectProcess.ts index f09f2f8cf..27b466a8b 100644 --- a/packages/web-core/src/models/connect/connectProcess.ts +++ b/packages/web-core/src/models/connect/connectProcess.ts @@ -6,7 +6,6 @@ export class ConnectProcess { readonly id: string; readonly projectId: string; readonly frontendApiUrl: string; - readonly expiresAt: number; readonly loginData: ConnectLoginInitData | null; readonly appendData: ConnectAppendInitData | null; readonly manageData: ConnectManageInitData | null; @@ -14,7 +13,6 @@ export class ConnectProcess { constructor( id: string, projectId: string, - expiresAt: number, frontendApiUrl: string, loginData: ConnectLoginInitData | null, appendData: ConnectAppendInitData | null, @@ -22,7 +20,6 @@ export class ConnectProcess { ) { this.id = id; this.projectId = projectId; - this.expiresAt = expiresAt; this.frontendApiUrl = frontendApiUrl; this.loginData = loginData; this.appendData = appendData; @@ -30,26 +27,17 @@ export class ConnectProcess { } isValid(): boolean { - return this.expiresAt > Date.now() / 1000 + 10; + return true; } resetLoginData(): ConnectProcess { - return new ConnectProcess( - this.id, - this.projectId, - this.expiresAt, - this.frontendApiUrl, - null, - this.appendData, - this.manageData, - ); + return new ConnectProcess(this.id, this.projectId, this.frontendApiUrl, null, this.appendData, this.manageData); } - copyWithLoginData(loginData: ConnectLoginInitData, expiresAt: number): ConnectProcess { + copyWithLoginData(loginData: ConnectLoginInitData): ConnectProcess { return new ConnectProcess( this.id, this.projectId, - expiresAt, this.frontendApiUrl, loginData, this.appendData, @@ -57,11 +45,10 @@ export class ConnectProcess { ); } - copyWithAppendData(appendData: ConnectAppendInitData, expiresAt: number): ConnectProcess { + copyWithAppendData(appendData: ConnectAppendInitData): ConnectProcess { return new ConnectProcess( this.id, this.projectId, - expiresAt, this.frontendApiUrl, this.loginData, appendData, @@ -69,11 +56,10 @@ export class ConnectProcess { ); } - copyWithManageData(manageData: ConnectManageInitData, expiresAt: number): ConnectProcess { + copyWithManageData(manageData: ConnectManageInitData): ConnectProcess { return new ConnectProcess( this.id, this.projectId, - expiresAt, this.frontendApiUrl, this.loginData, this.appendData, @@ -81,14 +67,50 @@ export class ConnectProcess { ); } + getValidLoginData(): ConnectLoginInitData | undefined { + if (!this.loginData || !this.loginData.expiresAt) { + return; + } + + if (this.loginData.expiresAt < Date.now() / 1000) { + return; + } + + return this.loginData; + } + + getValidAppendData(): ConnectAppendInitData | undefined { + if (!this.appendData || !this.appendData.expiresAt) { + return; + } + + if (this.appendData.expiresAt < Date.now() / 1000) { + return; + } + + return this.appendData; + } + + getValidManageData(): ConnectManageInitData | undefined { + if (!this.manageData || !this.manageData.expiresAt) { + return; + } + + if (this.manageData.expiresAt < Date.now() / 1000) { + return; + } + + return this.manageData; + } + static loadFromStorage(projectId: string): ConnectProcess | undefined { const serialized = localStorage.getItem(getStorageKey(projectId)); if (!serialized) { return undefined; } - const { id, expiresAt, frontendApiUrl, loginData, appendData, manageData } = JSON.parse(serialized); - const process = new ConnectProcess(id, projectId, expiresAt, frontendApiUrl, loginData, appendData, manageData); + const { id, frontendApiUrl, loginData, appendData, manageData } = JSON.parse(serialized); + const process = new ConnectProcess(id, projectId, frontendApiUrl, loginData, appendData, manageData); if (!process.isValid()) { return undefined; } @@ -101,7 +123,6 @@ export class ConnectProcess { getStorageKey(this.projectId), JSON.stringify({ id: this.id, - expiresAt: this.expiresAt, frontendApiUrl: this.frontendApiUrl, loginData: this.loginData, appendData: this.appendData, diff --git a/packages/web-core/src/models/connect/login.ts b/packages/web-core/src/models/connect/login.ts index 034398511..9bfc1ded9 100644 --- a/packages/web-core/src/models/connect/login.ts +++ b/packages/web-core/src/models/connect/login.ts @@ -4,16 +4,19 @@ export type ConnectLoginInitData = { loginAllowed: boolean; conditionalUIChallenge: string | null; flags: Record; + expiresAt?: number; }; export interface ConnectAppendInitData { appendAllowed: boolean; flags: Record; + expiresAt?: number; } export interface ConnectManageInitData { manageAllowed: boolean; flags: Record; + expiresAt?: number; } export interface ConnectManageListData { diff --git a/packages/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts index 4e8974dd3..fd3d95905 100644 --- a/packages/web-core/src/services/ConnectService.ts +++ b/packages/web-core/src/services/ConnectService.ts @@ -22,7 +22,6 @@ import type { ConnectManageListRsp, } from '../api/v2'; import { CorbadoConnectApi, PasskeyEventType } from '../api/v2'; -import type { AuthProcess } from '../models/authProcess'; import { ConnectFlags } from '../models/connect/connectFlags'; import { ConnectInvitation } from '../models/connect/connectInvitation'; import { ConnectLastLogin } from '../models/connect/connectLastLogin'; @@ -93,7 +92,7 @@ export class ConnectService { return out; } - #setApisV2(process?: AuthProcess): void { + #setApisV2(process?: ConnectProcess): void { let frontendApiUrl = this.#getDefaultFrontendApiUrl(); if (process?.frontendApiUrl && process?.frontendApiUrl.length > 0) { frontendApiUrl = process.frontendApiUrl; @@ -124,9 +123,11 @@ export class ConnectService { async loginInit(abortController: AbortController): Promise> { const existingProcess = ConnectProcess.loadFromStorage(this.#projectId); + const maybeLoginData = existingProcess?.getValidLoginData(); if ( - existingProcess?.loginData && - !existingProcess?.loginData.loginAllowed && + existingProcess && + maybeLoginData && + !maybeLoginData.loginAllowed && ConnectInvitation.loadFromStorage()?.token ) { existingProcess.resetLoginData().persistToStorage(); @@ -135,8 +136,8 @@ export class ConnectService { this.#setApisV2(existingProcess); // process has already been initialized - if (existingProcess?.loginData) { - return Ok(existingProcess.loginData); + if (maybeLoginData) { + return Ok(maybeLoginData); } } @@ -150,11 +151,12 @@ export class ConnectService { } const existingProcessFromOtherLoginInit = ConnectProcess.loadFromStorage(this.#projectId); - if (existingProcessFromOtherLoginInit?.loginData) { + const maybeExistingLoginDataFromOtherLoginInit = existingProcessFromOtherLoginInit?.getValidLoginData(); + if (maybeExistingLoginDataFromOtherLoginInit) { log.debug('process exists (after login init attempt'); this.#setApisV2(existingProcessFromOtherLoginInit); - return Ok(existingProcessFromOtherLoginInit.loginData); + return Ok(maybeExistingLoginDataFromOtherLoginInit); } // if the backend decides that a new client handle is needed, we store it in local storage @@ -168,20 +170,26 @@ export class ConnectService { loginAllowed: res.val.loginAllowed, conditionalUIChallenge: res.val.conditionalUIChallenge ?? null, flags: flags.getItemsObject(), + expiresAt: res.val.expiresAt, }; - // update local state - const newProcess = new ConnectProcess( - res.val.token, - this.#projectId, - res.val.expiresAt, - res.val.frontendApiUrl, - loginData, - null, - null, - ); - this.#setApisV2(newProcess); - newProcess.persistToStorage(); + if (existingProcess && existingProcess.id === res.val.token) { + log.debug('process exists, updating login data', loginData); + const p = existingProcess.copyWithLoginData(loginData); + p.persistToStorage(); + } else { + log.debug('creating new process', loginData); + const newProcess = new ConnectProcess( + res.val.token, + this.#projectId, + res.val.frontendApiUrl, + loginData, + null, + null, + ); + this.#setApisV2(newProcess); + newProcess.persistToStorage(); + } // persist flags flags.persistToStorage(this.#projectId); @@ -192,7 +200,7 @@ export class ConnectService { async #getExistingProcess(generator: () => Promise>): Promise { const existingProcess = ConnectProcess.loadFromStorage(this.#projectId); if (existingProcess) { - log.debug('process found', existingProcess.expiresAt); + log.debug('process found'); return existingProcess; } @@ -287,8 +295,9 @@ export class ConnectService { this.#setApisV2(existingProcess); // process has already been initialized - if (existingProcess?.appendData) { - return Ok(existingProcess.appendData); + const maybeAppendData = existingProcess?.getValidAppendData(); + if (maybeAppendData) { + return Ok(maybeAppendData); } } @@ -311,17 +320,19 @@ export class ConnectService { const appendData: ConnectAppendInitData = { appendAllowed: res.val.appendAllowed, flags: flags.getItemsObject(), + expiresAt: res.val.expiresAt, }; // update local state with process - if (existingProcess) { - const p = existingProcess.copyWithAppendData(appendData, res.val.expiresAt); + if (existingProcess && existingProcess.id === res.val.processID) { + log.debug('process exists, updating append data', appendData); + const p = existingProcess.copyWithAppendData(appendData); p.persistToStorage(); } else { + log.debug('creating new process', appendData); const newProcess = new ConnectProcess( res.val.processID, this.#projectId, - res.val.expiresAt, res.val.frontendApiUrl, null, appendData, @@ -435,8 +446,9 @@ export class ConnectService { this.#setApisV2(existingProcess); // process has already been initialized - if (existingProcess?.manageData) { - return Ok(existingProcess.manageData); + const maybeManageData = existingProcess?.getValidManageData(); + if (maybeManageData) { + return Ok(maybeManageData); } } @@ -459,17 +471,17 @@ export class ConnectService { const manageData: ConnectManageInitData = { manageAllowed: res.val.manageAllowed, flags: flags.getItemsObject(), + expiresAt: res.val.expiresAt, }; // update local state with process - if (existingProcess) { - const p = existingProcess.copyWithManageData(manageData, res.val.expiresAt); + if (existingProcess && existingProcess.id === res.val.processID) { + const p = existingProcess.copyWithManageData(manageData); p.persistToStorage(); } else { const newProcess = new ConnectProcess( res.val.processID, this.#projectId, - res.val.expiresAt, res.val.frontendApiUrl, null, null, @@ -526,8 +538,8 @@ export class ConnectService { invitation.persistToStorage(); } - recordEventLoginError() { - return this.#recordEvent(PasskeyEventType.LoginError); + recordEventLoginError(messageCode: string) { + return this.#recordEvent(PasskeyEventType.LoginError, messageCode); } recordEventLoginExplicitAbort() { @@ -558,12 +570,24 @@ export class ConnectService { return this.#recordEvent(PasskeyEventType.AppendError); } + recordEventLoginErrorUnexpected(messageCode: string) { + return this.#recordEvent(PasskeyEventType.LoginErrorUnexpected, messageCode); + } + + recordEventAppendErrorUnexpected(messageCode: string) { + return this.#recordEvent(PasskeyEventType.AppendErrorUnexpected, messageCode); + } + + recordEventManageErrorUnexpected(messageCode: string) { + return this.#recordEvent(PasskeyEventType.ManageErrorUnexpected, messageCode); + } + recordEventAppendExplicitAbort() { return this.#recordEvent(PasskeyEventType.AppendExplicitAbort); } // This function can be used to catch events that would usually not create backend interaction (e.g. when a passkey ceremony is canceled) - #recordEvent(eventType: PasskeyEventType) { + #recordEvent(eventType: PasskeyEventType, message?: string) { const existingProcess = ConnectProcess.loadFromStorage(this.#projectId); if (!existingProcess) { log.warn('No process found to record event.'); @@ -573,6 +597,7 @@ export class ConnectService { const req: ConnectEventCreateReq = { eventType, + message, }; return this.wrapWithErr(() => this.#connectApi.connectEventCreate(req)); diff --git a/packages/web-core/src/services/WebAuthnService.ts b/packages/web-core/src/services/WebAuthnService.ts index 95cdc41ba..84edac1ae 100644 --- a/packages/web-core/src/services/WebAuthnService.ts +++ b/packages/web-core/src/services/WebAuthnService.ts @@ -4,6 +4,7 @@ import type { ClientCapabilities } from '@corbado/types'; import type { CredentialRequestOptionsJSON } from '@corbado/webauthn-json'; import { create, get } from '@corbado/webauthn-json'; import FingerprintJS from '@fingerprintjs/fingerprintjs'; +import { detectIncognito } from 'detectincognitojs'; import log from 'loglevel'; import type { Result } from 'ts-results'; import { Err, Ok } from 'ts-results'; @@ -101,6 +102,7 @@ export class WebAuthnService { javaScriptHighEntropy: javaScriptHighEntropy, clientCapabilities, webdriver: WebAuthnService.getWebdriver(), + privateMode: await WebAuthnService.isPrivateMode(), }; } @@ -130,6 +132,15 @@ export class WebAuthnService { } } + static async isPrivateMode(): Promise { + try { + const res = await detectIncognito(); + return res.isPrivate; + } catch (e) { + return; + } + } + static async canUseBluetooth(): Promise { try { return await navigator.bluetooth.getAvailability(); diff --git a/playground/connect-next/app/login/actions.ts b/playground/connect-next/app/login/actions.ts index 4c7371696..b8c1f5bfd 100644 --- a/playground/connect-next/app/login/actions.ts +++ b/playground/connect-next/app/login/actions.ts @@ -18,6 +18,10 @@ type DecodedToken = { username: string; }; +type TokenWrapper = { + AccessToken: string; +}; + const getKey = (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => { client.getSigningKey(header.kid, (err, key) => { const signingKey = key?.getPublicKey(); @@ -45,7 +49,8 @@ const verifyToken = async (token: string): Promise => { export async function postPasskeyLogin(session: string) { // validate session try { - const decoded = await verifyToken(session); + const tokenWrapper = JSON.parse(session) as TokenWrapper; + const decoded = await verifyToken(tokenWrapper.AccessToken); const username = decoded.username; // create client that loads profile from ~/.aws/credentials or environment variables