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