From 4e597196f2c790b042b763c2c071e49f5af83599 Mon Sep 17 00:00:00 2001 From: Incorbador Date: Wed, 15 Jan 2025 14:53:11 +0100 Subject: [PATCH 1/4] Improve handling of explicit aborts in connect --- .../components/login/LoginErrorScreenHard.tsx | 6 +++++- .../components/login/LoginErrorScreenSoft.tsx | 6 +++++- .../components/login/LoginHybridScreen.tsx | 2 +- .../web-core/src/services/ConnectService.ts | 21 ++++++++++++++----- .../web-core/src/services/WebAuthnService.ts | 11 ++++++++++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx index ef838b7d2..25555e13e 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx @@ -14,6 +14,8 @@ const LoginErrorScreenHard = () => { const { getConnectService } = useShared(); const [loading, setLoading] = useState(false); const [hardErrorCount, setHardErrorCount] = useState(1); + // only for logging purposes + const [assertionOptions, setAssertionOptions] = useState(); const handleSubmit = async () => { if (loading) { @@ -26,6 +28,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 +86,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..2f152c021 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx @@ -13,6 +13,8 @@ const LoginErrorScreenSoft = () => { const { config, navigateToScreen, currentIdentifier, loadedMs, fallback } = useLoginProcess(); const { getConnectService } = useShared(); const [loading, setLoading] = useState(false); + // only for logging purposes + const [assertionOptions, setAssertionOptions] = useState(); const handleSubmit = async () => { if (loading) { @@ -25,6 +27,8 @@ const LoginErrorScreenSoft = () => { return handleSituation(LoginSituationCode.CboApiNotAvailablePreAuthenticator); } + setAssertionOptions(resStart.val.assertionOptions); + const resFinish = await getConnectService().loginContinue(resStart.val); if (resFinish.err) { if (resFinish.val instanceof PasskeyChallengeCancelledError) { @@ -69,7 +73,7 @@ const LoginErrorScreenSoft = () => { navigateToScreen(LoginScreenType.Invisible); fallback(identifier, null); - void getConnectService().recordEventLoginExplicitAbort(); + void getConnectService().recordEventLoginExplicitAbort(assertionOptions); 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/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts index c674f4eb8..c99614a6d 100644 --- a/packages/web-core/src/services/ConnectService.ts +++ b/packages/web-core/src/services/ConnectService.ts @@ -547,8 +547,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 +592,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 +613,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..e890bd720 100644 --- a/packages/web-core/src/services/WebAuthnService.ts +++ b/packages/web-core/src/services/WebAuthnService.ts @@ -11,6 +11,7 @@ import { Err, Ok } from 'ts-results'; import type { ClientInformation, JavaScriptHighEntropy } from '../api/v2'; import { CorbadoError } from '../utils'; +import type { CredentialCreationOptionsJSON } from '@corbado/webauthn-json/src/webauthn-json/basic/json'; const clientHandleKey = 'cbo_client_handle'; @@ -215,4 +216,14 @@ 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; + } } From 146d4700117f8bbdfa4109bc631807613f038be7 Mon Sep 17 00:00:00 2001 From: Incorbador Date: Wed, 15 Jan 2025 15:04:22 +0100 Subject: [PATCH 2/4] Linter --- packages/web-core/src/services/WebAuthnService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-core/src/services/WebAuthnService.ts b/packages/web-core/src/services/WebAuthnService.ts index e890bd720..7498a9728 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'; @@ -11,7 +12,6 @@ import { Err, Ok } from 'ts-results'; import type { ClientInformation, JavaScriptHighEntropy } from '../api/v2'; import { CorbadoError } from '../utils'; -import type { CredentialCreationOptionsJSON } from '@corbado/webauthn-json/src/webauthn-json/basic/json'; const clientHandleKey = 'cbo_client_handle'; From 835734deb63069ac951c2760816bd5ba49b72db1 Mon Sep 17 00:00:00 2001 From: Incorbador Date: Wed, 15 Jan 2025 16:47:31 +0100 Subject: [PATCH 3/4] Improve handling of explicit aborts in connect (2) --- .../login/CorbadoConnectLoginContainer.tsx | 4 ++-- .../components/login/LoginErrorScreenHard.tsx | 8 +++++-- .../components/login/LoginErrorScreenSoft.tsx | 24 +++++++++++-------- .../src/components/login/LoginInitScreen.tsx | 8 ++++--- 4 files changed, 27 insertions(+), 17 deletions(-) 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 25555e13e..622969adf 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx @@ -9,13 +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(); + const [assertionOptions, setAssertionOptions] = useState(previousAssertionOptions); const handleSubmit = async () => { if (loading) { diff --git a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx index 2f152c021..42f89a395 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx @@ -9,12 +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 [assertionOptions, setAssertionOptions] = useState(); const handleSubmit = async () => { if (loading) { @@ -27,12 +30,10 @@ const LoginErrorScreenSoft = () => { return handleSituation(LoginSituationCode.CboApiNotAvailablePreAuthenticator); } - setAssertionOptions(resStart.val.assertionOptions); - 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); @@ -46,7 +47,7 @@ const LoginErrorScreenSoft = () => { } }; - const handleSituation = (situationCode: LoginSituationCode) => { + const handleSituation = (situationCode: LoginSituationCode, data?: unknown) => { const messageCode = `situation: ${situationCode}`; log.debug(messageCode); @@ -62,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(assertionOptions); + void getConnectService().recordEventLoginExplicitAbort(previousAssertionOptions); 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); From 17d44ed8360d54047081610380f58f90a315caa1 Mon Sep 17 00:00:00 2001 From: Incorbador Date: Wed, 15 Jan 2025 21:49:57 +0100 Subject: [PATCH 4/4] Prepare FAPI for Signals API (3) --- .../passkeyList/PasskeyListScreen.tsx | 6 +++--- packages/web-core/openapi/spec_v2.yaml | 6 ++++++ packages/web-core/src/api/v2/api.ts | 12 ++++++++++++ .../web-core/src/services/ConnectService.ts | 16 ++++++++++++++-- .../web-core/src/services/WebAuthnService.ts | 19 +++++++++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) 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 c99614a6d..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; } diff --git a/packages/web-core/src/services/WebAuthnService.ts b/packages/web-core/src/services/WebAuthnService.ts index 7498a9728..2c9db7b45 100644 --- a/packages/web-core/src/services/WebAuthnService.ts +++ b/packages/web-core/src/services/WebAuthnService.ts @@ -226,4 +226,23 @@ export class WebAuthnService { 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; + } + } }