Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ const CorbadoConnectLoginContainer = () => {
case LoginScreenType.Init:
return <LoginInitScreen {...currentScreenOptions} />;
case LoginScreenType.ErrorSoft:
return <LoginErrorScreenSoft />;
return <LoginErrorScreenSoft {...currentScreenOptions} />;
case LoginScreenType.ErrorHard:
return <LoginErrorScreenHard />;
return <LoginErrorScreenHard {...currentScreenOptions} />;
case LoginScreenType.PasskeyReLogin:
return <LoginPasskeyReLoginScreen />;
case LoginScreenType.LoginHybridScreen:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>(previousAssertionOptions);

const handleSubmit = async () => {
if (loading) {
Expand All @@ -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) {
Expand Down Expand Up @@ -82,7 +90,7 @@ const LoginErrorScreenHard = () => {
navigateToScreen(LoginScreenType.Invisible);
fallback(identifier, null);

void getConnectService().recordEventLoginExplicitAbort();
void getConnectService().recordEventLoginExplicitAbort(assertionOptions);
break;
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -42,7 +47,7 @@ const LoginErrorScreenSoft = () => {
}
};

const handleSituation = (situationCode: LoginSituationCode) => {
const handleSituation = (situationCode: LoginSituationCode, data?: unknown) => {
const messageCode = `situation: ${situationCode}`;
log.debug(messageCode);

Expand All @@ -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;
}
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => {
navigateToScreen(LoginScreenType.Invisible);
fallback(identifier, null);

void getConnectService().recordEventLoginExplicitAbort();
void getConnectService().recordEventLoginExplicitAbort(resStart.assertionOptions);
break;
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ const LoginInitScreen: FC<Props> = ({ 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);
Expand Down Expand Up @@ -265,13 +265,15 @@ const LoginInitScreen: FC<Props> = ({ 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const PasskeyListScreen = () => {
return handleSituation(PasskeyListSituationCode.CboApiNotAvailableDuringDelete);
}

await getPasskeyList(config);
await getPasskeyList(config, true);
hide();
};

Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/web-core/openapi/spec_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/web-core/src/api/v2/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,18 @@ export interface ConnectManageListRsp {
* @memberof ConnectManageListRsp
*/
'passkeys': Array<Passkey>;
/**
*
* @type {string}
* @memberof ConnectManageListRsp
*/
'rpID': string;
/**
*
* @type {string}
* @memberof ConnectManageListRsp
*/
'userID': string;
}
/**
*
Expand Down
37 changes: 30 additions & 7 deletions packages/web-core/src/services/ConnectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,10 @@ export class ConnectService {
return Ok(manageData);
}

async manageList(passkeyListToken: string): Promise<Result<ConnectManageListRsp, CorbadoError>> {
async manageList(
passkeyListToken: string,
triggerSignalAllAccepted: boolean,
): Promise<Result<ConnectManageListRsp, CorbadoError>> {
const existingProcess = await this.#getExistingProcess(() => this.manageInit(new AbortController()));
if (!existingProcess) {
return Err(CorbadoError.missingInit());
Expand All @@ -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;
}

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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.');
Expand All @@ -603,6 +625,7 @@ export class ConnectService {
const req: ConnectEventCreateReq = {
eventType,
message,
challenge,
};

return this.wrapWithErr(() => this.#connectApi.connectEventCreate(req));
Expand Down
30 changes: 30 additions & 0 deletions packages/web-core/src/services/WebAuthnService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
// @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;
}
}
}
Loading