diff --git a/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx b/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx index 0cc328ca1..8d31d6fee 100644 --- a/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx +++ b/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx @@ -24,7 +24,7 @@ const AppendAfterErrorScreen = ({ attestationOptions }: { attestationOptions: st setLoading(true); setErrorMessage(undefined); - const res = await getConnectService().completeAppend(attestationOptions); + const res = await getConnectService().completeAppend(attestationOptions, 'manual'); if (res.err) { if (res.val.type === ConnectErrorType.ExcludeCredentialsMatch) { return handleSituation(AppendSituationCode.ClientExcludeCredentialsMatch, res.val); diff --git a/packages/connect-react/src/components/append/AppendInitScreen.tsx b/packages/connect-react/src/components/append/AppendInitScreen.tsx index 8a6916eaf..e4e03262b 100644 --- a/packages/connect-react/src/components/append/AppendInitScreen.tsx +++ b/packages/connect-react/src/components/append/AppendInitScreen.tsx @@ -1,5 +1,6 @@ import type { ConnectError } from '@corbado/web-core'; import { ConnectErrorType } from '@corbado/web-core'; +import type { AppendCompletionType } from '@corbado/web-core/dist/models/connect/append'; import log from 'loglevel'; import React, { useCallback, useEffect, useRef, useState } from 'react'; @@ -132,11 +133,23 @@ const AppendInitScreen = () => { } setAttestationOptions(startAppendRes.val.attestationOptions); - statefulLoader.current.finish(); - log.debug('startAppendRes', startAppendRes, flags); + + if (startAppendRes.val.conditionalAppend) { + log.debug('starting conditional create'); + const handledByConditionalCreate = await handleConditionalCreate(startAppendRes.val.attestationOptions); + log.debug('handledByConditionalCreate', handledByConditionalCreate); + + if (handledByConditionalCreate) { + statefulLoader.current.finish(); + return; + } + } + + statefulLoader.current.finish(); if (startAppendRes.val.autoAppend || flags.hasSupportForAutomaticAppend()) { - await handleSubmit(startAppendRes.val.attestationOptions, false); + console.log('starting auto-append'); + await handleSubmit(startAppendRes.val.attestationOptions, 'auto'); } }; @@ -153,7 +166,7 @@ const AppendInitScreen = () => { }, []); const handleSubmit = useCallback( - async (attestationOptions: string, showErrorIfCancelled: boolean) => { + async (attestationOptions: string, completionType: AppendCompletionType) => { if (appendLoading || skipping) { return; } @@ -161,17 +174,17 @@ const AppendInitScreen = () => { setAppendLoading(true); setErrorMessage(undefined); - const res = await getConnectService().completeAppend(attestationOptions); + const res = await getConnectService().completeAppend(attestationOptions, completionType); if (res.err) { if (res.val.type === ConnectErrorType.ExcludeCredentialsMatch) { return handleSituation(AppendSituationCode.ClientExcludeCredentialsMatch, res.val); } if (res.val.type === ConnectErrorType.Cancel) { - if (showErrorIfCancelled) { - return handleSituation(AppendSituationCode.ClientPasskeyOperationCancelled, res.val); - } else { + if (completionType === 'auto') { return handleSituation(AppendSituationCode.ClientPasskeyOperationCancelledSilent, res.val); + } else { + return handleSituation(AppendSituationCode.ClientPasskeyOperationCancelled, res.val); } } @@ -184,7 +197,26 @@ const AppendInitScreen = () => { aaguidIcon: res.val.passkeyOperation.aaguidDetails?.iconLight, }); }, - [config, getConnectService, appendLoading, skipping], + [getConnectService, appendLoading, skipping], + ); + + const handleConditionalCreate = useCallback( + async (attestationOptions: string) => { + const res = await getConnectService().completeAppend(attestationOptions, 'conditional'); + if (res.err) { + await handleSituation(AppendSituationCode.ClientPasskeyOperationErrorSilent, res.val); + + return res.val.type === ConnectErrorType.RaceTimeout; + } + + navigateToScreen(AppendScreenType.Success, { + aaguidName: res.val.passkeyOperation.aaguidDetails?.name, + aaguidIcon: res.val.passkeyOperation.aaguidDetails?.iconLight, + }); + + return true; + }, + [getConnectService], ); const handleSituation = async (situationCode: AppendSituationCode, error?: ConnectError) => { @@ -222,6 +254,10 @@ const AppendInitScreen = () => { case AppendSituationCode.ExplicitSkipByUser: await handleSkip(situationCode, true); break; + case AppendSituationCode.ClientPasskeyOperationErrorSilent: + void handleErrorSoft(situationCode, false, false, error); + setAppendLoading(false); + break; } }; @@ -250,7 +286,14 @@ const AppendInitScreen = () => { void onReadMoreClick(); setAppendInitState(AppendInitState.ShowBenefits); }} - handleSubmit={() => void handleSubmit(attestationOptions, true)} + handleSubmit={() => { + let completionType: AppendCompletionType = 'manual'; + if (errorMessage) { + completionType = 'manual-retry'; + } + + void handleSubmit(attestationOptions, completionType); + }} handleSkip={() => onSkip()} /> ); diff --git a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx index 644198235..0a082d635 100644 --- a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx +++ b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx @@ -130,7 +130,7 @@ const PasskeyListScreen = () => { return handleSituation(PasskeyListSituationCode.CboApiPasskeysNotSupported); } - const res = await getConnectService().completeAppend(startAppendRes.val.attestationOptions); + const res = await getConnectService().completeAppend(startAppendRes.val.attestationOptions, 'manual'); if (res.err) { if (res.val.type === ConnectErrorType.Cancel) { return handleSituation(PasskeyListSituationCode.ClientPasskeyOperationCancelled, res.val); diff --git a/packages/connect-react/src/types/situations.ts b/packages/connect-react/src/types/situations.ts index 075ec5873..8c88bdd46 100644 --- a/packages/connect-react/src/types/situations.ts +++ b/packages/connect-react/src/types/situations.ts @@ -27,6 +27,7 @@ export enum AppendSituationCode { DeniedByPasskeyIntel, ExplicitSkipByUser, ClientPasskeyOperationCancelledSilent, + ClientPasskeyOperationErrorSilent, } export enum PasskeyListSituationCode { diff --git a/packages/web-core/openapi/spec_v2.yaml b/packages/web-core/openapi/spec_v2.yaml index f6892816b..e1b592241 100644 --- a/packages/web-core/openapi/spec_v2.yaml +++ b/packages/web-core/openapi/spec_v2.yaml @@ -1486,6 +1486,7 @@ components: - variant - isRestrictedBrowser - autoAppend + - conditionalAppend properties: attestationOptions: type: string @@ -1499,14 +1500,19 @@ components: type: boolean autoAppend: type: boolean + conditionalAppend: + type: boolean connectAppendFinishReq: type: object required: - attestationResponse + - completionType properties: attestationResponse: type: string + completionType: + $ref: "#/components/schemas/appendCompletionType" connectAppendFinishRsp: type: object @@ -2373,6 +2379,10 @@ components: error: $ref: "#/components/schemas/requestError" + appendCompletionType: + type: string + enum: ["auto", "conditional", "manual", "manual-retry"] + responses: "200": description: Operation succeeded diff --git a/packages/web-core/src/api/v2/api.ts b/packages/web-core/src/api/v2/api.ts index 1fd5b5118..5ec6d54cb 100644 --- a/packages/web-core/src/api/v2/api.ts +++ b/packages/web-core/src/api/v2/api.ts @@ -48,6 +48,22 @@ export interface AaguidDetails { */ 'iconDark': string; } +/** + * + * @export + * @enum {string} + */ + +export const AppendCompletionType = { + Auto: 'auto', + Conditional: 'conditional', + Manual: 'manual', + ManualRetry: 'manual-retry' +} as const; + +export type AppendCompletionType = typeof AppendCompletionType[keyof typeof AppendCompletionType]; + + /** * * @export @@ -299,7 +315,15 @@ export interface ConnectAppendFinishReq { * @memberof ConnectAppendFinishReq */ 'attestationResponse': string; + /** + * + * @type {AppendCompletionType} + * @memberof ConnectAppendFinishReq + */ + 'completionType': AppendCompletionType; } + + /** * * @export @@ -442,6 +466,12 @@ export interface ConnectAppendStartRsp { * @memberof ConnectAppendStartRsp */ 'autoAppend': boolean; + /** + * + * @type {boolean} + * @memberof ConnectAppendStartRsp + */ + 'conditionalAppend': boolean; } export const ConnectAppendStartRspVariantEnum = { diff --git a/packages/web-core/src/models/connect/append.ts b/packages/web-core/src/models/connect/append.ts index e69de29bb..3c4cfdc7f 100644 --- a/packages/web-core/src/models/connect/append.ts +++ b/packages/web-core/src/models/connect/append.ts @@ -0,0 +1 @@ +export type AppendCompletionType = 'manual' | 'manual-retry' | 'auto' | 'conditional'; diff --git a/packages/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts index d50ab26ad..7509d3ff0 100644 --- a/packages/web-core/src/services/ConnectService.ts +++ b/packages/web-core/src/services/ConnectService.ts @@ -29,6 +29,7 @@ import type { ConnectManageListRsp, } from '../api/v2'; import { CorbadoConnectApi, PasskeyEventType } from '../api/v2'; +import type { AppendCompletionType } from '../models/connect/append'; import { ConnectFlags } from '../models/connect/connectFlags'; import { ConnectInvitation } from '../models/connect/connectInvitation'; import { ConnectProcess } from '../models/connect/connectProcess'; @@ -413,27 +414,6 @@ export class ConnectService { return Ok(appendData); } - async append(appendTokenValue: string, loadedMs: number): Promise> { - const existingProcess = await this.#getExistingProcess(() => this.appendInit(new AbortController())); - if (!existingProcess) { - return Err(new ConnectError(ConnectErrorType.MissingInit)); - } - - const resStart = await this.wrapWithErr(() => - this.#connectApi.connectAppendStart({ appendTokenValue: appendTokenValue, loadedMs }), - ); - if (resStart.err) { - return resStart; - } - - const platformRes = await this.#webAuthnCreatePasskey(resStart.val.attestationOptions); - if (platformRes.err) { - return platformRes; - } - - return this.wrapWithErr(() => this.#connectApi.connectAppendFinish({ attestationResponse: platformRes.val })); - } - async startAppend( appendTokenValue: string, loadedMs: number, @@ -453,19 +433,23 @@ export class ConnectService { ); } - async completeAppend(attestationOptions: string): Promise> { + async completeAppend( + attestationOptions: string, + completionType: AppendCompletionType, + ): Promise> { const existingProcess = await this.#getExistingProcess(() => this.appendInit(new AbortController())); if (!existingProcess) { return Err(new ConnectError(ConnectErrorType.MissingInit)); } - const res = await this.#webAuthnCreatePasskey(attestationOptions); + const conditional = completionType === 'conditional'; + const res = await this.#webAuthnCreatePasskey(attestationOptions, conditional); if (res.err) { return res; } const finishRes = await this.wrapWithErr(() => - this.#connectApi.connectAppendFinish({ attestationResponse: res.val }), + this.#connectApi.connectAppendFinish({ attestationResponse: res.val, completionType }), ); if (finishRes.ok) { const latestLogin = finishRes.val.passkeyOperation as LastLogin; @@ -771,18 +755,29 @@ export class ConnectService { const started = Date.now(); try { const res = await this.#webAuthnService.loginRaw(serializedChallenge, isConditional, onConditionalLoginStart); - return Ok(res); + if (res.message) { + void this.recordEventLoginErrorUnexpected(res.message); + } + + return Ok(res.response); } catch (e) { const runtime = Date.now() - started; return Err(ConnectError.fromFrontendError(e, runtime)); } } - async #webAuthnCreatePasskey(serializedChallenge: string): Promise> { + async #webAuthnCreatePasskey( + serializedChallenge: string, + conditional: boolean, + ): Promise> { const started = Date.now(); try { - const res = await this.#webAuthnService.createPasskeyRaw(serializedChallenge); - return Ok(res); + const res = await this.#webAuthnService.createPasskeyRaw(serializedChallenge, conditional); + if (res.message) { + void this.recordEventAppendErrorUnexpected(res.message); + } + + return Ok(res.response); } catch (e) { const runtime = Date.now() - started; return Err(ConnectError.fromFrontendError(e, runtime)); diff --git a/packages/web-core/src/services/WebAuthnService.ts b/packages/web-core/src/services/WebAuthnService.ts index ea4bb4414..a2ff7a6cf 100644 --- a/packages/web-core/src/services/WebAuthnService.ts +++ b/packages/web-core/src/services/WebAuthnService.ts @@ -1,9 +1,7 @@ /// /// <- add this line 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,10 +9,15 @@ import type { Result } from 'ts-results'; import { Err, Ok } from 'ts-results'; import type { ClientInformation, ClientStateMeta, JavaScriptHighEntropy } from '../api/v2'; -import { CorbadoError } from '../utils'; +import { ConnectError, ConnectErrorType, CorbadoError } from '../utils'; import type { ClientStateEntry } from './ClientStateService'; import { ClientStateService } from './ClientStateService'; +export type ResponseWithMessage = { + response: string; + message?: string; +}; + /** * AuthenticatorService handles all interactions with webAuthn platform authenticators. * Currently, this includes the creation of passkeys and the login with existing passkeys. @@ -25,8 +28,8 @@ export class WebAuthnService { async createPasskey(serializedChallenge: string): Promise> { try { - const res = await this.createPasskeyRaw(serializedChallenge); - return Ok(res); + const res = await this.createPasskeyRaw(serializedChallenge, false); + return Ok(res.response); } catch (e) { if (e instanceof DOMException) { return Err(CorbadoError.fromDOMException(e)); @@ -36,14 +39,44 @@ export class WebAuthnService { } } - async createPasskeyRaw(serializedChallenge: string): Promise { + async createPasskeyRaw(attestationOptions: string, conditional: boolean): Promise { const abortController = this.abortOngoingOperation(); - const challenge = JSON.parse(serializedChallenge); - challenge.signal = abortController.signal; + const attestationOptionsJSON = JSON.parse(attestationOptions); this.#abortController = abortController; - const signedChallenge = await create(challenge); - return JSON.stringify(signedChallenge); + if (!PublicKeyCredential.parseCreationOptionsFromJSON) { + attestationOptionsJSON.signal = abortController.signal; + const signedChallenge = await create(attestationOptionsJSON); + return { + response: JSON.stringify(signedChallenge), + message: 'parseCreationOptionsFromJSON not available', + }; + } + + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(attestationOptionsJSON.publicKey); + let credential: PublicKeyCredential; + if (conditional) { + const result = await WebAuthnService.raceWithTimeout( + navigator.credentials.create({ + publicKey, + signal: abortController.signal, + mediation: 'conditional', + } as never), + 5000, + ); + + credential = result as PublicKeyCredential; + } else { + credential = (await navigator.credentials.create({ + publicKey, + signal: abortController.signal, + } as never)) as PublicKeyCredential; + } + + return { + response: JSON.stringify(credential.toJSON()), + message: '', + }; } async login( @@ -53,7 +86,7 @@ export class WebAuthnService { ): Promise> { try { const res = await this.loginRaw(serializedChallenge, conditional, onConditionalLoginStart); - return Ok(res); + return Ok(res.response); } catch (e) { if (e instanceof DOMException) { return Err(CorbadoError.fromDOMException(e)); @@ -64,24 +97,38 @@ export class WebAuthnService { } async loginRaw( - serializedChallenge: string, + assertionOptions: string, conditional: boolean, onConditionalLoginStart?: (ac: AbortController) => void, - ): Promise { + ): Promise { const abortController = this.abortOngoingOperation(); - - const challenge: CredentialRequestOptionsJSON = JSON.parse(serializedChallenge); - - challenge.signal = abortController.signal; + const assertionOptionsJSON = JSON.parse(assertionOptions); this.#abortController = abortController; onConditionalLoginStart?.(abortController); + if (!PublicKeyCredential.parseRequestOptionsFromJSON) { + const signedChallenge = await get(assertionOptionsJSON); + return { + response: JSON.stringify(signedChallenge), + message: 'parseRequestOptionsFromJSON not available', + }; + } + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(assertionOptionsJSON.publicKey); + let mediation: CredentialMediationRequirement | undefined; if (conditional) { - challenge.mediation = 'conditional'; + mediation = 'conditional'; } - const signedChallenge = await get(challenge); - return JSON.stringify(signedChallenge); + const credential = (await navigator.credentials.get({ + publicKey, + mediation, + signal: abortController.signal, + })) as PublicKeyCredential; + + return { + response: JSON.stringify(credential.toJSON()), + message: '', + }; } async getClientInformation(maybeClientHandle: ClientStateEntry | undefined): Promise { @@ -126,12 +173,12 @@ export class WebAuthnService { } static async doesBrowserSupportPasskeys(): Promise { - if (!window.PublicKeyCredential) { + if (!PublicKeyCredential) { return undefined; } try { - return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } catch (e) { log.debug('Error checking passkey availability', e); return; @@ -139,12 +186,12 @@ export class WebAuthnService { } static async doesBrowserSupportConditionalUI(): Promise { - if (!window.PublicKeyCredential) { + if (!PublicKeyCredential) { return undefined; } try { - return await window.PublicKeyCredential.isConditionalMediationAvailable(); + return await PublicKeyCredential.isConditionalMediationAvailable(); } catch (e) { log.debug('Error checking conditional UI availability', e); return; @@ -212,7 +259,7 @@ export class WebAuthnService { } static async getClientCapabilities(): Promise { - if (!window.PublicKeyCredential) { + if (!PublicKeyCredential) { log.debug('PublicKeyCredential is not supported on this browser'); return; } @@ -220,7 +267,7 @@ export class WebAuthnService { try { // We will ignore the type check as getClientCapabilities does not exist in the stable authn version and types // @ts-ignore - return await window.PublicKeyCredential.getClientCapabilities(); + return await PublicKeyCredential.getClientCapabilities(); } catch (e) { log.debug('Error using getClientCapabilities: ', e); return; @@ -228,18 +275,18 @@ export class WebAuthnService { } static challengeFromAttestationOptions(attestationOptions: string): string { - const typed: CredentialCreationOptionsJSON = JSON.parse(attestationOptions); + const typed = JSON.parse(attestationOptions); return typed.publicKey.challenge; } static challengeFromAssertionOptions(assertionOptions: string): string | undefined { - const typed: CredentialRequestOptionsJSON = JSON.parse(assertionOptions); + const typed = 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) { + if (!PublicKeyCredential || !PublicKeyCredential.signalAllAcceptedCredentials) { return undefined; } @@ -251,9 +298,7 @@ export class WebAuthnService { allAcceptedCredentialIds: credentialIds, }); - const p2 = new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after 2000ms`)), 2000)); - - await Promise.race([p1, p2]); + await WebAuthnService.raceWithTimeout(p1, 2000); } catch (e) { log.debug('Error calling signalAllAcceptedCredentials', e); return; @@ -262,7 +307,7 @@ export class WebAuthnService { static async signalUnknownCredential(rpId: string, credentialId: string): Promise { // @ts-ignore - if (!window.PublicKeyCredential || !window.PublicKeyCredential.signalUnknownCredential) { + if (!PublicKeyCredential || !PublicKeyCredential.signalUnknownCredential) { return undefined; } @@ -277,4 +322,12 @@ export class WebAuthnService { return; } } + + static async raceWithTimeout(p: Promise, ms: number): Promise { + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new ConnectError(ConnectErrorType.RaceTimeout, `timeout of ${ms}ms reached`)), ms), + ); + + return Promise.race([p, timeout]); + } } diff --git a/packages/web-core/src/utils/errors/connectErrors.ts b/packages/web-core/src/utils/errors/connectErrors.ts index d8b8d40d0..b3bb7d3ad 100644 --- a/packages/web-core/src/utils/errors/connectErrors.ts +++ b/packages/web-core/src/utils/errors/connectErrors.ts @@ -8,6 +8,7 @@ export enum ConnectErrorType { InvalidState, SecurityError, ExcludeCredentialsMatch, + RaceTimeout, } export class ConnectError { diff --git a/playground/connect-next/app/(no-auth)/login/ConventionalLogin.tsx b/playground/connect-next/app/(no-auth)/login/ConventionalLogin.tsx index 3e3afed92..bfe8bfdbd 100644 --- a/playground/connect-next/app/(no-auth)/login/ConventionalLogin.tsx +++ b/playground/connect-next/app/(no-auth)/login/ConventionalLogin.tsx @@ -46,6 +46,10 @@ export const ConventionalLogin = ({ initialUserProvidedIdentifier, initialError } } catch (e) { if (e instanceof Error) { + if (e.name === 'UserAlreadyAuthenticatedException') { + router.push('/profile'); + } + return e.message; } diff --git a/playground/connect-next/app/(no-auth)/login/PasswordForm.tsx b/playground/connect-next/app/(no-auth)/login/PasswordForm.tsx index 854583130..d10edea53 100644 --- a/playground/connect-next/app/(no-auth)/login/PasswordForm.tsx +++ b/playground/connect-next/app/(no-auth)/login/PasswordForm.tsx @@ -35,7 +35,7 @@ export const PasswordForm = ({ onClick, initialUserProvidedIdentifier, initialEr name='email' type='email' placeholder='user@acme.com' - autoComplete='email' + autoComplete='username' required className='mt-1 block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm' value={username} @@ -53,6 +53,7 @@ export const PasswordForm = ({ onClick, initialUserProvidedIdentifier, initialEr id='password' name='password' type='password' + autoComplete='current-password' required className='mt-1 block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm' value={password} diff --git a/playground/connect-next/app/(no-auth)/page.tsx b/playground/connect-next/app/(no-auth)/page.tsx index 990058112..8520dacb5 100644 --- a/playground/connect-next/app/(no-auth)/page.tsx +++ b/playground/connect-next/app/(no-auth)/page.tsx @@ -14,7 +14,7 @@ export default function SignupPage() { const [password, setPassword] = useState(''); const [message, setMessage] = useState(''); - const handleSignup = async (e: FormEvent) => { + const handleSignup = async (e: FormEvent) => { e.preventDefault(); const username = generateRandomString(10); @@ -37,7 +37,14 @@ export default function SignupPage() { console.log(resLogin); router.push('/post-login?post-signup=true'); } catch (err) { - console.error('Error during signup:', err); + if (err instanceof Error) { + if (err.name === 'UserAlreadyAuthenticatedException') { + router.push('/profile'); + return; + } + } + + console.error('Unhandled error during signup:', err); } }; @@ -58,7 +65,10 @@ export default function SignupPage() {

Sign Up

Create an account with your email, phone and password

-
+