diff --git a/packages/connect-react/src/components/login/CorbadoConnectLoginContainer.tsx b/packages/connect-react/src/components/login/CorbadoConnectLoginContainer.tsx
index a90f04e15..d3e344f57 100644
--- a/packages/connect-react/src/components/login/CorbadoConnectLoginContainer.tsx
+++ b/packages/connect-react/src/components/login/CorbadoConnectLoginContainer.tsx
@@ -19,9 +19,9 @@ const CorbadoConnectLoginContainer = () => {
case LoginScreenType.Init:
return ;
case LoginScreenType.ErrorSoft:
- return ;
+ return ;
case LoginScreenType.ErrorHard:
- return ;
+ return ;
case LoginScreenType.PasskeyReLogin:
return ;
case LoginScreenType.LoginHybridScreen:
diff --git a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx
index ef838b7d2..622969adf 100644
--- a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx
+++ b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx
@@ -9,11 +9,17 @@ import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations
import LoginErrorHard from './base/LoginErrorHard';
import { connectLoginFinishToComplete } from './LoginInitScreen';
-const LoginErrorScreenHard = () => {
+type Props = {
+ previousAssertionOptions: string;
+};
+
+const LoginErrorScreenHard = ({ previousAssertionOptions }: Props) => {
const { config, navigateToScreen, currentIdentifier, loadedMs, fallback } = useLoginProcess();
const { getConnectService } = useShared();
const [loading, setLoading] = useState(false);
const [hardErrorCount, setHardErrorCount] = useState(1);
+ // only for logging purposes
+ const [assertionOptions, setAssertionOptions] = useState(previousAssertionOptions);
const handleSubmit = async () => {
if (loading) {
@@ -26,6 +32,8 @@ const LoginErrorScreenHard = () => {
return handleSituation(LoginSituationCode.CboApiNotAvailablePreAuthenticator);
}
+ setAssertionOptions(resStart.val.assertionOptions);
+
const resFinish = await getConnectService().loginContinue(resStart.val);
if (resFinish.err) {
if (resFinish.val instanceof PasskeyChallengeCancelledError) {
@@ -82,7 +90,7 @@ const LoginErrorScreenHard = () => {
navigateToScreen(LoginScreenType.Invisible);
fallback(identifier, null);
- void getConnectService().recordEventLoginExplicitAbort();
+ void getConnectService().recordEventLoginExplicitAbort(assertionOptions);
break;
}
};
diff --git a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx
index d3d0ac715..42f89a395 100644
--- a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx
+++ b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx
@@ -9,10 +9,15 @@ import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations
import LoginErrorSoft from './base/LoginErrorSoft';
import { connectLoginFinishToComplete } from './LoginInitScreen';
-const LoginErrorScreenSoft = () => {
+type Props = {
+ previousAssertionOptions: string;
+};
+
+const LoginErrorScreenSoft = ({ previousAssertionOptions }: Props) => {
const { config, navigateToScreen, currentIdentifier, loadedMs, fallback } = useLoginProcess();
const { getConnectService } = useShared();
const [loading, setLoading] = useState(false);
+ // only for logging purposes
const handleSubmit = async () => {
if (loading) {
@@ -28,7 +33,7 @@ const LoginErrorScreenSoft = () => {
const resFinish = await getConnectService().loginContinue(resStart.val);
if (resFinish.err) {
if (resFinish.val instanceof PasskeyChallengeCancelledError) {
- return handleSituation(LoginSituationCode.ClientPasskeyOperationCancelled);
+ return handleSituation(LoginSituationCode.ClientPasskeyOperationCancelled, resStart.val.assertionOptions);
}
return handleSituation(LoginSituationCode.CboApiNotAvailablePostAuthenticator);
@@ -42,7 +47,7 @@ const LoginErrorScreenSoft = () => {
}
};
- const handleSituation = (situationCode: LoginSituationCode) => {
+ const handleSituation = (situationCode: LoginSituationCode, data?: unknown) => {
const messageCode = `situation: ${situationCode}`;
log.debug(messageCode);
@@ -58,19 +63,22 @@ const LoginErrorScreenSoft = () => {
setLoading(false);
break;
- case LoginSituationCode.ClientPasskeyOperationCancelled:
- navigateToScreen(LoginScreenType.ErrorHard);
+ case LoginSituationCode.ClientPasskeyOperationCancelled: {
+ const assertionOptions = data as string;
+ navigateToScreen(LoginScreenType.ErrorHard, { previousAssertionOptions: assertionOptions });
config.onError?.(situationCode.toString());
void getConnectService().recordEventLoginError(messageCode);
setLoading(false);
break;
- case LoginSituationCode.ExplicitFallbackByUser:
+ }
+ case LoginSituationCode.ExplicitFallbackByUser: {
navigateToScreen(LoginScreenType.Invisible);
fallback(identifier, null);
- void getConnectService().recordEventLoginExplicitAbort();
+ void getConnectService().recordEventLoginExplicitAbort(previousAssertionOptions);
break;
+ }
}
};
diff --git a/packages/connect-react/src/components/login/LoginHybridScreen.tsx b/packages/connect-react/src/components/login/LoginHybridScreen.tsx
index 7e8582fc6..2eab9c595 100644
--- a/packages/connect-react/src/components/login/LoginHybridScreen.tsx
+++ b/packages/connect-react/src/components/login/LoginHybridScreen.tsx
@@ -64,7 +64,7 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => {
navigateToScreen(LoginScreenType.Invisible);
fallback(identifier, null);
- void getConnectService().recordEventLoginExplicitAbort();
+ void getConnectService().recordEventLoginExplicitAbort(resStart.assertionOptions);
break;
}
};
diff --git a/packages/connect-react/src/components/login/LoginInitScreen.tsx b/packages/connect-react/src/components/login/LoginInitScreen.tsx
index a0782fb2c..d423cca8c 100644
--- a/packages/connect-react/src/components/login/LoginInitScreen.tsx
+++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx
@@ -211,7 +211,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => {
if (res.err) {
setIdentifierBasedLoading(false);
if (res.val instanceof PasskeyChallengeCancelledError) {
- return handleSituation(LoginSituationCode.ClientPasskeyOperationCancelled);
+ return handleSituation(LoginSituationCode.ClientPasskeyOperationCancelled, resStart.val.assertionOptions);
}
return handleSituation(LoginSituationCode.CboApiNotAvailablePostAuthenticator);
@@ -265,13 +265,15 @@ const LoginInitScreen: FC = ({ showFallback = false }) => {
setIdentifierBasedLoading(false);
break;
- case LoginSituationCode.ClientPasskeyOperationCancelled:
- navigateToScreen(LoginScreenType.ErrorSoft);
+ case LoginSituationCode.ClientPasskeyOperationCancelled: {
+ const assertionOptions = data as string;
+ navigateToScreen(LoginScreenType.ErrorSoft, { previousAssertionOptions: assertionOptions });
void getConnectService().recordEventLoginError(messageCode);
config.onError?.(situationCode.toString());
setIdentifierBasedLoading(false);
break;
+ }
case LoginSituationCode.PreAuthenticatorUserNotFound:
setError(message ?? '');
void getConnectService().recordEventLoginErrorUnexpected(messageCode);
diff --git a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx
index 7e9cbcc69..fc4d74342 100644
--- a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx
+++ b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx
@@ -97,7 +97,7 @@ const PasskeyListScreen = () => {
return handleSituation(PasskeyListSituationCode.CboApiNotAvailableDuringDelete);
}
- await getPasskeyList(config);
+ await getPasskeyList(config, true);
hide();
};
@@ -146,7 +146,7 @@ const PasskeyListScreen = () => {
const fetchListToken = async (config: CorbadoConnectPasskeyListConfig) =>
await config.connectTokenProvider(ConnectTokenType.PasskeyList);
- const getPasskeyList = async (config: CorbadoConnectPasskeyListConfig) => {
+ const getPasskeyList = async (config: CorbadoConnectPasskeyListConfig, triggerSignalAllAccepted = false) => {
let listTokenRes = passkeyListToken;
if (!listTokenRes) {
try {
@@ -156,7 +156,7 @@ const PasskeyListScreen = () => {
}
}
- const passkeyList = await getConnectService().manageList(listTokenRes);
+ const passkeyList = await getConnectService().manageList(listTokenRes, triggerSignalAllAccepted);
if (passkeyList.err) {
return handleSituation(PasskeyListSituationCode.CboApiNotAvailableDuringInitialLoad);
}
diff --git a/packages/web-core/openapi/spec_v2.yaml b/packages/web-core/openapi/spec_v2.yaml
index a906f13c2..3cc20023a 100644
--- a/packages/web-core/openapi/spec_v2.yaml
+++ b/packages/web-core/openapi/spec_v2.yaml
@@ -1403,11 +1403,17 @@ components:
type: object
required:
- passkeys
+ - rpID
+ - userID
properties:
passkeys:
type: array
items:
$ref: '#/components/schemas/passkey'
+ rpID:
+ type: string
+ userID:
+ type: string
connectManageDeleteRsp:
type: object
diff --git a/packages/web-core/src/api/v2/api.ts b/packages/web-core/src/api/v2/api.ts
index fd93bad02..47b830413 100644
--- a/packages/web-core/src/api/v2/api.ts
+++ b/packages/web-core/src/api/v2/api.ts
@@ -730,6 +730,18 @@ export interface ConnectManageListRsp {
* @memberof ConnectManageListRsp
*/
'passkeys': Array;
+ /**
+ *
+ * @type {string}
+ * @memberof ConnectManageListRsp
+ */
+ 'rpID': string;
+ /**
+ *
+ * @type {string}
+ * @memberof ConnectManageListRsp
+ */
+ 'userID': string;
}
/**
*
diff --git a/packages/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts
index c674f4eb8..9a9840f2b 100644
--- a/packages/web-core/src/services/ConnectService.ts
+++ b/packages/web-core/src/services/ConnectService.ts
@@ -502,7 +502,10 @@ export class ConnectService {
return Ok(manageData);
}
- async manageList(passkeyListToken: string): Promise> {
+ async manageList(
+ passkeyListToken: string,
+ triggerSignalAllAccepted: boolean,
+ ): Promise> {
const existingProcess = await this.#getExistingProcess(() => this.manageInit(new AbortController()));
if (!existingProcess) {
return Err(CorbadoError.missingInit());
@@ -513,11 +516,20 @@ export class ConnectService {
};
const out = await this.wrapWithErr(() => this.#connectApi.connectManageList(req));
+ if (out.err) {
+ return out;
+ }
+
// self-healing mechanism: if a user has no passkeys, we clear the last login
- if (out.ok && out.val.passkeys.length === 0) {
+ if (out.val.passkeys.length === 0) {
this.clearLastLogin();
}
+ if (triggerSignalAllAccepted) {
+ const credentialIDs = out.val.passkeys.map(pk => pk.credentialID);
+ await WebAuthnService.signalAllAcceptedCredentials(out.val.rpID, out.val.userID, credentialIDs);
+ }
+
return out;
}
@@ -547,8 +559,13 @@ export class ConnectService {
return this.#recordEvent(PasskeyEventType.LoginError, messageCode);
}
- recordEventLoginExplicitAbort() {
- return this.#recordEvent(PasskeyEventType.LoginExplicitAbort);
+ recordEventLoginExplicitAbort(assertionOptions?: string) {
+ let challenge;
+ if (assertionOptions) {
+ challenge = WebAuthnService.challengeFromAssertionOptions(assertionOptions);
+ }
+
+ return this.#recordEvent(PasskeyEventType.LoginExplicitAbort, undefined, challenge);
}
recordEventLoginOneTapSwitch() {
@@ -587,12 +604,17 @@ export class ConnectService {
return this.#recordEvent(PasskeyEventType.ManageErrorUnexpected, messageCode);
}
- recordEventAppendExplicitAbort() {
- return this.#recordEvent(PasskeyEventType.AppendExplicitAbort);
+ recordEventAppendExplicitAbort(attestationOptions?: string) {
+ let challenge;
+ if (attestationOptions) {
+ challenge = WebAuthnService.challengeFromAttestationOptions(attestationOptions);
+ }
+
+ return this.#recordEvent(PasskeyEventType.AppendExplicitAbort, undefined, challenge);
}
// 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, message?: string) {
+ #recordEvent(eventType: PasskeyEventType, message?: string, challenge?: string) {
const existingProcess = ConnectProcess.loadFromStorage(this.#projectId);
if (!existingProcess) {
log.warn('No process found to record event.');
@@ -603,6 +625,7 @@ export class ConnectService {
const req: ConnectEventCreateReq = {
eventType,
message,
+ challenge,
};
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 84edac1ae..2c9db7b45 100644
--- a/packages/web-core/src/services/WebAuthnService.ts
+++ b/packages/web-core/src/services/WebAuthnService.ts
@@ -3,6 +3,7 @@
import type { ClientCapabilities } from '@corbado/types';
import type { CredentialRequestOptionsJSON } from '@corbado/webauthn-json';
import { create, get } from '@corbado/webauthn-json';
+import type { CredentialCreationOptionsJSON } from '@corbado/webauthn-json/src/webauthn-json/basic/json';
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { detectIncognito } from 'detectincognitojs';
import log from 'loglevel';
@@ -215,4 +216,33 @@ export class WebAuthnService {
return;
}
}
+
+ static challengeFromAttestationOptions(attestationOptions: string): string {
+ const typed: CredentialCreationOptionsJSON = JSON.parse(attestationOptions);
+ return typed.publicKey.challenge;
+ }
+
+ static challengeFromAssertionOptions(assertionOptions: string): string | undefined {
+ const typed: CredentialRequestOptionsJSON = JSON.parse(assertionOptions);
+ return typed.publicKey?.challenge;
+ }
+
+ static async signalAllAcceptedCredentials(rpId: string, userId: string, credentialIds: string[]): Promise {
+ // @ts-ignore
+ if (!window.PublicKeyCredential || !window.PublicKeyCredential.signalAllAcceptedCredentials) {
+ return undefined;
+ }
+
+ try {
+ // @ts-ignore
+ await PublicKeyCredential.signalAllAcceptedCredentials({
+ rpId: rpId,
+ userId: userId,
+ allAcceptedCredentialIds: credentialIds,
+ });
+ } catch (e) {
+ log.debug('Error calling signalAllAcceptedCredentials', e);
+ return;
+ }
+ }
}