From c5cf4b2cafc903ed7edfa1fcb4564dfef7ee3dc5 Mon Sep 17 00:00:00 2001 From: Incorbador Date: Fri, 20 Dec 2024 17:43:27 +0100 Subject: [PATCH 1/3] Handle signedPasskeyData in connect --- .../components/login/LoginErrorScreenHard.tsx | 3 +- .../components/login/LoginErrorScreenSoft.tsx | 3 +- .../components/login/LoginHybridScreen.tsx | 3 +- .../src/components/login/LoginInitScreen.tsx | 13 +++- .../login/LoginPasskeyReLoginScreen.tsx | 3 +- packages/types/src/connect/config.ts | 2 +- packages/web-core/openapi/spec_v2.yaml | 5 ++ packages/web-core/src/api/v2/api.ts | 12 ++++ playground/connect-next/app/actions.ts | 1 - playground/connect-next/app/login/actions.ts | 72 +++++++++++-------- playground/connect-next/app/login/page.tsx | 9 ++- 11 files changed, 83 insertions(+), 43 deletions(-) diff --git a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx index c4f89d7f0..3c2d82ff4 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx @@ -7,6 +7,7 @@ import useShared from '../../hooks/useShared'; import { LoginScreenType } from '../../types/screenTypes'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; import LoginErrorHard from './base/LoginErrorHard'; +import { connectLoginFinishToComplete } from './LoginInitScreen'; const LoginErrorScreenHard = () => { const { config, navigateToScreen, currentIdentifier, loadedMs } = useLoginProcess(); @@ -41,7 +42,7 @@ const LoginErrorScreenHard = () => { setLoading(false); try { - await config.onComplete(resFinish.val.session); + await config.onComplete(connectLoginFinishToComplete(resFinish.val)); } catch { return handleSituation(LoginSituationCode.CtApiNotAvailablePostAuthenticator); } diff --git a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx index 9f83ac295..277632665 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx @@ -7,6 +7,7 @@ import useShared from '../../hooks/useShared'; import { LoginScreenType } from '../../types/screenTypes'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; import LoginErrorSoft from './base/LoginErrorSoft'; +import { connectLoginFinishToComplete } from './LoginInitScreen'; const LoginErrorScreenSoft = () => { const { config, navigateToScreen, currentIdentifier, loadedMs } = useLoginProcess(); @@ -34,7 +35,7 @@ const LoginErrorScreenSoft = () => { } try { - await config.onComplete(resFinish.val.session); + await config.onComplete(connectLoginFinishToComplete(resFinish.val)); setLoading(false); } catch { handleSituation(LoginSituationCode.CtApiNotAvailablePostAuthenticator); diff --git a/packages/connect-react/src/components/login/LoginHybridScreen.tsx b/packages/connect-react/src/components/login/LoginHybridScreen.tsx index 8178cb41a..be3fde553 100644 --- a/packages/connect-react/src/components/login/LoginHybridScreen.tsx +++ b/packages/connect-react/src/components/login/LoginHybridScreen.tsx @@ -8,6 +8,7 @@ import useShared from '../../hooks/useShared'; import { LoginScreenType } from '../../types/screenTypes'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; import LoginHybrid from './base/LoginHybrid'; +import { connectLoginFinishToComplete } from './LoginInitScreen'; const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { const { config, navigateToScreen, currentIdentifier } = useLoginProcess(); @@ -30,7 +31,7 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { } try { - await config.onComplete(res.val.session); + await config.onComplete(connectLoginFinishToComplete(res.val)); } catch { return handleSituation(LoginSituationCode.CtApiNotAvailablePostAuthenticator); } diff --git a/packages/connect-react/src/components/login/LoginInitScreen.tsx b/packages/connect-react/src/components/login/LoginInitScreen.tsx index cea349ff3..62b795bc2 100644 --- a/packages/connect-react/src/components/login/LoginInitScreen.tsx +++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx @@ -4,6 +4,7 @@ import { PasskeyChallengeCancelledError, PasskeyLoginSource, } from '@corbado/web-core'; +import type { ConnectLoginFinishRsp } from '@corbado/web-core/dist/api/v2'; import log from 'loglevel'; import type { FC } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -28,6 +29,14 @@ interface Props { prefilledIdentifier?: string; } +export const connectLoginFinishToComplete = (v: ConnectLoginFinishRsp): string => { + if (v.session.length > 0) { + return v.session; + } + + return v.signedPasskeyData; +}; + const LoginInitScreen: FC = ({ showFallback = false }) => { const { config, navigateToScreen, setCurrentIdentifier, setFlags, flags, loadedMs } = useLoginProcess(); const { sharedConfig, getConnectService } = useShared(); @@ -159,7 +168,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { } try { - await config.onComplete(res.val.session); + await config.onComplete(connectLoginFinishToComplete(res.val)); } catch { return handleSituation(LoginSituationCode.CtApiNotAvailablePostAuthenticator); } @@ -199,7 +208,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { } try { - await config.onComplete(res.val.session); + await config.onComplete(connectLoginFinishToComplete(res.val)); } catch { void getConnectService().recordEventLoginErrorUntyped(); return handleSituation(LoginSituationCode.CtApiNotAvailablePostAuthenticator); diff --git a/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx b/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx index a295b07b1..5f0510294 100644 --- a/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx +++ b/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx @@ -7,6 +7,7 @@ import useShared from '../../hooks/useShared'; import { LoginScreenType } from '../../types/screenTypes'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; import LoginOneTap from './base/LoginOneTap'; +import { connectLoginFinishToComplete } from './LoginInitScreen'; export const LoginPasskeyReLoginScreen = () => { const { config, navigateToScreen, setCurrentIdentifier, currentIdentifier, loadedMs } = useLoginProcess(); @@ -41,7 +42,7 @@ export const LoginPasskeyReLoginScreen = () => { } try { - await config.onComplete(resFinish.val.session); + await config.onComplete(connectLoginFinishToComplete(resFinish.val)); } catch { return handleSituation(LoginSituationCode.CtApiNotAvailablePostAuthenticator); } diff --git a/packages/types/src/connect/config.ts b/packages/types/src/connect/config.ts index 34c816712..2bc4340fc 100644 --- a/packages/types/src/connect/config.ts +++ b/packages/types/src/connect/config.ts @@ -2,7 +2,7 @@ export type CorbadoConnectLoginConfig = { onFallback(identifier: string, errorMessage: string | null): void; onError?(error: string): void; onLoaded(message: string, isFallBackTriggered: boolean): void; - onComplete(session: string): Promise; + onComplete(signedPasskeyData: string): Promise; onConditionalLoginStart?(ac: AbortController): void; onLoginStart?(): void; onHelpClick?(): void; diff --git a/packages/web-core/openapi/spec_v2.yaml b/packages/web-core/openapi/spec_v2.yaml index d61284b47..60cea3045 100644 --- a/packages/web-core/openapi/spec_v2.yaml +++ b/packages/web-core/openapi/spec_v2.yaml @@ -1246,11 +1246,14 @@ components: required: - passkeyOperation - session + - signedPasskeyData properties: passkeyOperation: $ref: '#/components/schemas/passkeyOperation' session: type: string + signedPasskeyData: + type: string connectAppendInitReq: type: object @@ -1847,6 +1850,8 @@ components: type: boolean webdriver: type: boolean + privateMode: + type: boolean clientCapabilities: type: object diff --git a/packages/web-core/src/api/v2/api.ts b/packages/web-core/src/api/v2/api.ts index 1dd1b1d4e..5a08122e9 100644 --- a/packages/web-core/src/api/v2/api.ts +++ b/packages/web-core/src/api/v2/api.ts @@ -240,6 +240,12 @@ export interface ClientInformation { * @memberof ClientInformation */ 'webdriver'?: boolean; + /** + * + * @type {boolean} + * @memberof ClientInformation + */ + 'privateMode'?: boolean; } /** * @@ -452,6 +458,12 @@ export interface ConnectLoginFinishRsp { * @memberof ConnectLoginFinishRsp */ 'session': string; + /** + * + * @type {string} + * @memberof ConnectLoginFinishRsp + */ + 'signedPasskeyData': string; } /** * diff --git a/playground/connect-next/app/actions.ts b/playground/connect-next/app/actions.ts index c57aa222d..ee83124eb 100644 --- a/playground/connect-next/app/actions.ts +++ b/playground/connect-next/app/actions.ts @@ -38,7 +38,6 @@ export async function getAppendToken() { }); const out = await response.json(); - console.log(out); return out.secret; } diff --git a/playground/connect-next/app/login/actions.ts b/playground/connect-next/app/login/actions.ts index b8c1f5bfd..2238fee22 100644 --- a/playground/connect-next/app/login/actions.ts +++ b/playground/connect-next/app/login/actions.ts @@ -5,7 +5,6 @@ import { AdminGetUserCommand, CognitoIdentityProviderClient, InitiateAuthCommand, - RespondToAuthChallengeCommand, } from '@aws-sdk/client-cognito-identity-provider'; import jwt from 'jsonwebtoken'; import jwksClient from 'jwks-rsa'; @@ -47,41 +46,54 @@ const verifyToken = async (token: string): Promise => { // Then we extract the cognitoID and retrieve the user's email from the user pool // Both values will then be set as a cookie export async function postPasskeyLogin(session: string) { - // validate session - try { - const tokenWrapper = JSON.parse(session) as TokenWrapper; - const decoded = await verifyToken(tokenWrapper.AccessToken); - const username = decoded.username; + const tokenWrapper = JSON.parse(session) as TokenWrapper; + const decoded = await verifyToken(tokenWrapper.AccessToken); + const username = decoded.username; + + // create client that loads profile from ~/.aws/credentials or environment variables + const client = new CognitoIdentityProviderClient({ + region: process.env.AWS_REGION!, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + }); - // create client that loads profile from ~/.aws/credentials or environment variables - const client = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION!, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, - }, - }); + const command = new AdminGetUserCommand({ + UserPoolId: process.env.AWS_COGNITO_USER_POOL_ID!, + Username: username, + }); - const command = new AdminGetUserCommand({ - UserPoolId: process.env.AWS_COGNITO_USER_POOL_ID!, - Username: username, - }); + const response = await client.send(command); - const response = await client.send(command); + const email = response.UserAttributes?.find(attr => attr.Name === 'email')?.Value; + if (email) { + cookies().set('displayName', email); + cookies().set('identifier', username); + } - const email = response.UserAttributes?.find(attr => attr.Name === 'email')?.Value; - console.log(email); + return; +} - if (email) { - cookies().set('displayName', email); - cookies().set('identifier', username); - } +export async function postPasskeyLoginNew(signedPasskeyData: string) { + const url = `${process.env.CORBADO_BACKEND_API_URL}/v2/passkey/postLogin`; + const body = JSON.stringify({ + signedPasskeyData: signedPasskeyData, + }); - return; - } catch (err) { - console.error('Token is invalid:', err); - return; - } + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Basic ${process.env.CORBADO_BACKEND_API_BASIC_AUTH}`, + 'Content-Type': 'application/json', + }, + cache: 'no-cache', + body: body, + }); + + const out = await response.json(); + + await postPasskeyLogin(out.session); } function createSecretHash(username: string, clientId: string, clientSecret: string) { diff --git a/playground/connect-next/app/login/page.tsx b/playground/connect-next/app/login/page.tsx index 295c90291..46b2dee8a 100644 --- a/playground/connect-next/app/login/page.tsx +++ b/playground/connect-next/app/login/page.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation'; import { CorbadoConnectLogin } from '@corbado/connect-react'; import { useState } from 'react'; import ConventionalLogin from '@/app/login/ConventionalLogin'; -import { postPasskeyLogin } from '@/app/login/actions'; +import { postPasskeyLogin, postPasskeyLoginNew } from '@/app/login/actions'; export default function LoginPage() { const router = useRouter(); @@ -25,11 +25,10 @@ export default function LoginPage() { setConventionalLoginVisible(true); console.log('onFallback', identifier); }} - onError={error => console.log('error', error)} + onError={(error: string) => console.log('error', error)} onLoaded={(msg: string) => console.log('component has loaded: ' + msg)} - onComplete={async session => { - console.log(session); - await postPasskeyLogin(session); + onComplete={async (signedPasskeyData: string) => { + await postPasskeyLoginNew(signedPasskeyData); router.push('/post-login'); }} onSignupClick={() => router.push('/signup')} From 44df50b072ed4d13d784fa3f1aa3d17305ae4b09 Mon Sep 17 00:00:00 2001 From: Incorbador Date: Fri, 3 Jan 2025 18:05:01 +0100 Subject: [PATCH 2/3] Improve fallback handling --- .../login-second-factor/InitScreen.tsx | 2 +- .../components/login/LoginErrorScreenHard.tsx | 8 +-- .../components/login/LoginErrorScreenSoft.tsx | 6 +- .../components/login/LoginHybridScreen.tsx | 6 +- .../src/components/login/LoginInitScreen.tsx | 47 ++++++++++---- .../login/LoginPasskeyReLoginScreen.tsx | 4 +- .../src/contexts/LoginProcessContext.ts | 4 ++ .../src/contexts/LoginProcessProvider.tsx | 24 +++++++ .../connect-react/src/types/situations.ts | 11 +++- packages/types/src/connect/config.ts | 4 +- packages/web-core/openapi/spec_v2.yaml | 2 + packages/web-core/src/api/v2/api.ts | 6 ++ .../web-core/src/services/ConnectService.ts | 5 ++ packages/web-core/src/utils/errors/errors.ts | 30 ++++++++- .../app/login/ConventionalLogin.tsx | 5 +- playground/connect-next/app/login/page.tsx | 17 ++++- playground/connect-next/app/signup/actions.ts | 63 ++++++++++++++++--- playground/connect-next/app/signup/page.tsx | 7 ++- playground/connect-next/package.json | 5 +- 19 files changed, 215 insertions(+), 41 deletions(-) diff --git a/packages/connect-react/src/components/login-second-factor/InitScreen.tsx b/packages/connect-react/src/components/login-second-factor/InitScreen.tsx index 78dcaf755..200eced50 100644 --- a/packages/connect-react/src/components/login-second-factor/InitScreen.tsx +++ b/packages/connect-react/src/components/login-second-factor/InitScreen.tsx @@ -80,7 +80,7 @@ const InitScreen = () => { } if (resStart.val instanceof ConnectUserNotFound) { - return handleSituation(LoginSituationCode.UserNotFound); + return handleSituation(LoginSituationCode.PreAuthenticatorUserNotFound); } return handleSituation(LoginSituationCode.CboApiNotAvailablePreAuthenticator); diff --git a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx index 3c2d82ff4..ef838b7d2 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx @@ -10,7 +10,7 @@ import LoginErrorHard from './base/LoginErrorHard'; import { connectLoginFinishToComplete } from './LoginInitScreen'; const LoginErrorScreenHard = () => { - const { config, navigateToScreen, currentIdentifier, loadedMs } = useLoginProcess(); + const { config, navigateToScreen, currentIdentifier, loadedMs, fallback } = useLoginProcess(); const { getConnectService } = useShared(); const [loading, setLoading] = useState(false); const [hardErrorCount, setHardErrorCount] = useState(1); @@ -59,14 +59,14 @@ const LoginErrorScreenHard = () => { case LoginSituationCode.CtApiNotAvailablePostAuthenticator: case LoginSituationCode.CboApiNotAvailablePostAuthenticator: navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); + fallback(identifier, message); void getConnectService().recordEventLoginErrorUnexpected(messageCode); setLoading(false); break; case LoginSituationCode.ClientPasskeyOperationCancelledTooManyTimes: navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); + fallback(identifier, message); void getConnectService().recordEventLoginError(messageCode); setLoading(false); @@ -80,7 +80,7 @@ const LoginErrorScreenHard = () => { break; case LoginSituationCode.ExplicitFallbackByUser: navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); + fallback(identifier, null); void getConnectService().recordEventLoginExplicitAbort(); break; diff --git a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx index 277632665..d3d0ac715 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx @@ -10,7 +10,7 @@ import LoginErrorSoft from './base/LoginErrorSoft'; import { connectLoginFinishToComplete } from './LoginInitScreen'; const LoginErrorScreenSoft = () => { - const { config, navigateToScreen, currentIdentifier, loadedMs } = useLoginProcess(); + const { config, navigateToScreen, currentIdentifier, loadedMs, fallback } = useLoginProcess(); const { getConnectService } = useShared(); const [loading, setLoading] = useState(false); @@ -53,7 +53,7 @@ const LoginErrorScreenSoft = () => { case LoginSituationCode.CtApiNotAvailablePostAuthenticator: case LoginSituationCode.CboApiNotAvailablePostAuthenticator: navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); + fallback(identifier, message); void getConnectService().recordEventLoginErrorUnexpected(messageCode); setLoading(false); @@ -67,7 +67,7 @@ const LoginErrorScreenSoft = () => { break; case LoginSituationCode.ExplicitFallbackByUser: navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); + fallback(identifier, null); void getConnectService().recordEventLoginExplicitAbort(); break; diff --git a/packages/connect-react/src/components/login/LoginHybridScreen.tsx b/packages/connect-react/src/components/login/LoginHybridScreen.tsx index be3fde553..7e8582fc6 100644 --- a/packages/connect-react/src/components/login/LoginHybridScreen.tsx +++ b/packages/connect-react/src/components/login/LoginHybridScreen.tsx @@ -11,7 +11,7 @@ import LoginHybrid from './base/LoginHybrid'; import { connectLoginFinishToComplete } from './LoginInitScreen'; const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { - const { config, navigateToScreen, currentIdentifier } = useLoginProcess(); + const { config, navigateToScreen, currentIdentifier, fallback } = useLoginProcess(); const [loading, setLoading] = useState(false); const { getConnectService } = useShared(); @@ -48,7 +48,7 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { case LoginSituationCode.CtApiNotAvailablePostAuthenticator: case LoginSituationCode.CboApiNotAvailablePostAuthenticator: navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); + fallback(identifier, message); void getConnectService().recordEventLoginErrorUnexpected(messageCode); setLoading(false); @@ -62,7 +62,7 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { break; case LoginSituationCode.ExplicitFallbackByUser: navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); + fallback(identifier, null); void getConnectService().recordEventLoginExplicitAbort(); break; diff --git a/packages/connect-react/src/components/login/LoginInitScreen.tsx b/packages/connect-react/src/components/login/LoginInitScreen.tsx index 62b795bc2..f33dc3b75 100644 --- a/packages/connect-react/src/components/login/LoginInitScreen.tsx +++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx @@ -1,5 +1,7 @@ import { ConnectConditionalUIPasskeyDeleted, + ConnectCustomError, + ConnectExistingPasskeysNotAvailable, ConnectUserNotFound, PasskeyChallengeCancelledError, PasskeyLoginSource, @@ -13,6 +15,7 @@ import useLoginProcess from '../../hooks/useLoginProcess'; import useShared from '../../hooks/useShared'; import { Flags } from '../../types/flags'; import { LoginScreenType } from '../../types/screenTypes'; +import type { PreAuthenticatorCustomErrorData } from '../../types/situations'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; import { StatefulLoader } from '../../utils/statefulLoader'; import LoginInitLoaded from './base/LoginInitLoaded'; @@ -38,7 +41,8 @@ export const connectLoginFinishToComplete = (v: ConnectLoginFinishRsp): string = }; const LoginInitScreen: FC = ({ showFallback = false }) => { - const { config, navigateToScreen, setCurrentIdentifier, setFlags, flags, loadedMs } = useLoginProcess(); + const { config, navigateToScreen, setCurrentIdentifier, setFlags, flags, loadedMs, fallback, fallbackCustom } = + useLoginProcess(); const { sharedConfig, getConnectService } = useShared(); const [cuiBasedLoading, setCuiBasedLoading] = useState(false); const [identifierBasedLoading, setIdentifierBasedLoading] = useState(false); @@ -186,7 +190,13 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { const resStart = await getConnectService().loginStart(identifier, PasskeyLoginSource.TextField, loadedMs); if (resStart.err) { if (resStart.val instanceof ConnectUserNotFound) { - return handleSituation(LoginSituationCode.UserNotFound); + return handleSituation(LoginSituationCode.PreAuthenticatorUserNotFound); + } + if (resStart.val instanceof ConnectCustomError) { + return handleSituation(LoginSituationCode.PreAuthenticatorCustomError, resStart.val); + } + if (resStart.val instanceof ConnectExistingPasskeysNotAvailable) { + return handleSituation(LoginSituationCode.PreAuthenticatorExistingPasskeysNotAvailable); } return handleSituation(LoginSituationCode.CboApiNotAvailablePreAuthenticator); @@ -215,13 +225,18 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { } }, [getConnectService, config, loadedMs, identifier]); - const fallback = (identifier: string, message: string | null) => { + const automaticFallback = (identifier: string, message: string | null) => { navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); setIsFallbackInitiallyTriggered(true); + fallback(identifier, message); + }; + + const explicitFallback = () => { + navigateToScreen(LoginScreenType.Invisible); + fallback(identifier, null); }; - const handleSituation = (situationCode: LoginSituationCode) => { + const handleSituation = (situationCode: LoginSituationCode, data?: unknown) => { const messageCode = `situation: ${situationCode}`; log.debug(messageCode); @@ -229,13 +244,13 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { switch (situationCode) { case LoginSituationCode.CboApiNotAvailablePreAuthenticator: - fallback(identifier, message); + automaticFallback(identifier, message); void getConnectService().recordEventLoginErrorUnexpected(messageCode); statefulLoader.current.finish(); break; case LoginSituationCode.DeniedByPartialRollout: - fallback(identifier, message); + automaticFallback(identifier, message); statefulLoader.current.finish(); break; @@ -244,7 +259,8 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { case LoginSituationCode.CboApiNotAvailablePreConditionalAuthenticator: case LoginSituationCode.CtApiNotAvailablePostAuthenticator: case LoginSituationCode.CboApiNotAvailablePostAuthenticator: - fallback(identifier, message); + case LoginSituationCode.PreAuthenticatorExistingPasskeysNotAvailable: + automaticFallback(identifier, message); void getConnectService().recordEventLoginErrorUnexpected(messageCode); setIdentifierBasedLoading(false); @@ -256,18 +272,27 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { setIdentifierBasedLoading(false); break; - case LoginSituationCode.UserNotFound: + case LoginSituationCode.PreAuthenticatorUserNotFound: setError(message ?? ''); void getConnectService().recordEventLoginErrorUnexpected(messageCode); setIdentifierBasedLoading(false); break; case LoginSituationCode.ExplicitFallbackByUser: - navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); + explicitFallback(); void getConnectService().recordEventLoginExplicitAbort(); break; + case LoginSituationCode.PreAuthenticatorCustomError: { + navigateToScreen(LoginScreenType.Invisible); + void getConnectService().recordEventLoginErrorUnexpected(messageCode); + if (!data) { + return fallback(identifier, null); + } + + const typed = data as PreAuthenticatorCustomErrorData; + fallbackCustom(identifier, typed.code, typed.message); + } } }; diff --git a/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx b/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx index 5f0510294..a9f4d44c8 100644 --- a/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx +++ b/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx @@ -10,7 +10,7 @@ import LoginOneTap from './base/LoginOneTap'; import { connectLoginFinishToComplete } from './LoginInitScreen'; export const LoginPasskeyReLoginScreen = () => { - const { config, navigateToScreen, setCurrentIdentifier, currentIdentifier, loadedMs } = useLoginProcess(); + const { config, navigateToScreen, setCurrentIdentifier, currentIdentifier, loadedMs, fallback } = useLoginProcess(); const { getConnectService } = useShared(); const [loading, setLoading] = useState(false); @@ -65,7 +65,7 @@ export const LoginPasskeyReLoginScreen = () => { case LoginSituationCode.CboApiNotAvailablePostAuthenticator: case LoginSituationCode.CboApiNotAvailablePreAuthenticator: navigateToScreen(LoginScreenType.Invisible); - config.onFallback(identifier, message); + fallback(identifier, message); void getConnectService().recordEventLoginErrorUnexpected(messageCode); setLoading(false); diff --git a/packages/connect-react/src/contexts/LoginProcessContext.ts b/packages/connect-react/src/contexts/LoginProcessContext.ts index 924b59261..aa6759e8a 100644 --- a/packages/connect-react/src/contexts/LoginProcessContext.ts +++ b/packages/connect-react/src/contexts/LoginProcessContext.ts @@ -12,6 +12,8 @@ export interface LoginProcessContextProps { currentScreenType: LoginScreenType; currentScreenOptions: any; config: CorbadoConnectLoginConfig; + fallback: (identifier: string, message: string | null) => void; + fallbackCustom: (identifier: string, code: string, payload: string) => void; navigateToScreen: (s: LoginScreenType, options?: any) => void; setCurrentIdentifier: (s: string) => void; currentIdentifier: string; @@ -25,6 +27,8 @@ export const initialContext: LoginProcessContextProps = { config: {} as CorbadoConnectLoginConfig, navigateToScreen: missingImplementation, setCurrentIdentifier: missingImplementation, + fallback: missingImplementation, + fallbackCustom: missingImplementation, currentIdentifier: '', currentScreenOptions: undefined, flags: undefined, diff --git a/packages/connect-react/src/contexts/LoginProcessProvider.tsx b/packages/connect-react/src/contexts/LoginProcessProvider.tsx index 057d45ade..f161a8d38 100644 --- a/packages/connect-react/src/contexts/LoginProcessProvider.tsx +++ b/packages/connect-react/src/contexts/LoginProcessProvider.tsx @@ -24,6 +24,28 @@ export const LoginProcessProvider: FC> = ({ children, i setCurrentScreenOptions(options); }, []); + const fallback = useCallback( + (identifier: string, message: string | null) => { + if (!message || !config.onFallbackSilent) { + config.onFallback(identifier, message ?? ''); + } else { + config.onFallbackSilent(identifier); + } + }, + [config], + ); + + const fallbackCustom = useCallback( + (identifier: string, code: string, payload: string) => { + if (!config.onFallbackCustom) { + config.onFallback(identifier, code); + } else { + config.onFallbackCustom(identifier, code, payload); + } + }, + [config], + ); + const contextValue = useMemo( () => ({ currentScreenType, @@ -35,6 +57,8 @@ export const LoginProcessProvider: FC> = ({ children, i setFlags, currentScreenOptions, loadedMs, + fallback, + fallbackCustom, }), [currentScreenType, navigateToScreen, config, currentIdentifier, currentScreenOptions, flags], ); diff --git a/packages/connect-react/src/types/situations.ts b/packages/connect-react/src/types/situations.ts index a219bd914..2c1a5a96d 100644 --- a/packages/connect-react/src/types/situations.ts +++ b/packages/connect-react/src/types/situations.ts @@ -9,8 +9,10 @@ export enum LoginSituationCode { CboApiNotAvailablePostAuthenticator, CtApiNotAvailablePostAuthenticator, ExplicitFallbackByUser, - UserNotFound, + PreAuthenticatorUserNotFound, DeniedByPartialRollout, + PreAuthenticatorCustomError, + PreAuthenticatorExistingPasskeysNotAvailable, } export enum AppendSituationCode { @@ -37,6 +39,11 @@ export enum PasskeyListSituationCode { ClientExcludeCredentialsMatch, } +export type PreAuthenticatorCustomErrorData = { + code: string; + message: string; +}; + export const getLoginErrorMessage = (code: LoginSituationCode): string | null => { switch (code) { case LoginSituationCode.CboApiNotAvailablePostAuthenticator: @@ -48,7 +55,7 @@ export const getLoginErrorMessage = (code: LoginSituationCode): string | null => case LoginSituationCode.PasskeyNotAvailablePostConditionalAuthenticator: return 'You previously deleted this passkey. Use your password to log in instead.'; - case LoginSituationCode.UserNotFound: + case LoginSituationCode.PreAuthenticatorUserNotFound: return 'There is no account registered to that email address.'; default: diff --git a/packages/types/src/connect/config.ts b/packages/types/src/connect/config.ts index 2bc4340fc..dd5a1f37f 100644 --- a/packages/types/src/connect/config.ts +++ b/packages/types/src/connect/config.ts @@ -1,5 +1,7 @@ export type CorbadoConnectLoginConfig = { - onFallback(identifier: string, errorMessage: string | null): void; + onFallback(identifier: string, errorMessage: string): void; + onFallbackSilent?(identifier: string): void; + onFallbackCustom?(identifier: string, code: string, payload: string): void; onError?(error: string): void; onLoaded(message: string, isFallBackTriggered: boolean): void; onComplete(signedPasskeyData: string): Promise; diff --git a/packages/web-core/openapi/spec_v2.yaml b/packages/web-core/openapi/spec_v2.yaml index 60cea3045..a906f13c2 100644 --- a/packages/web-core/openapi/spec_v2.yaml +++ b/packages/web-core/openapi/spec_v2.yaml @@ -1229,6 +1229,8 @@ components: type: string isCDA: type: boolean + error: + $ref: '#/components/schemas/requestError' connectLoginFinishReq: type: object diff --git a/packages/web-core/src/api/v2/api.ts b/packages/web-core/src/api/v2/api.ts index 5a08122e9..fd93bad02 100644 --- a/packages/web-core/src/api/v2/api.ts +++ b/packages/web-core/src/api/v2/api.ts @@ -598,6 +598,12 @@ export interface ConnectLoginStartRsp { * @memberof ConnectLoginStartRsp */ 'isCDA': boolean; + /** + * + * @type {RequestError} + * @memberof ConnectLoginStartRsp + */ + 'error'?: RequestError; } /** * diff --git a/packages/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts index fd3d95905..c674f4eb8 100644 --- a/packages/web-core/src/services/ConnectService.ts +++ b/packages/web-core/src/services/ConnectService.ts @@ -242,6 +242,11 @@ export class ConnectService { return res; } + if (res.val.error) { + this.clearLastLogin(); + return Err(CorbadoError.fromConnectErrorResponse(res.val.error)); + } + if (!res.val.assertionOptions) { this.clearLastLogin(); return Err(CorbadoError.noPasskeyAvailable()); diff --git a/packages/web-core/src/utils/errors/errors.ts b/packages/web-core/src/utils/errors/errors.ts index 67d497bec..80054d896 100644 --- a/packages/web-core/src/utils/errors/errors.ts +++ b/packages/web-core/src/utils/errors/errors.ts @@ -2,6 +2,7 @@ import type { AxiosError } from 'axios'; import log from 'loglevel'; import type { ErrorRsp } from '../../api/v1'; +import { RequestError } from '../../api/v2'; /** General Errors */ export type GetProcessError = ProcessNotFound; @@ -83,6 +84,17 @@ export class CorbadoError extends Error { return NonRecoverableError.unhandledBackendError(errorResp.type); } + static fromConnectErrorResponse(error: RequestError): RecoverableError { + switch (error.code) { + case 'user_not_found': + return new ConnectUserNotFound(); + case 'existing_passkeys_not_available': + return new ConnectExistingPasskeysNotAvailable(); + default: + return new ConnectCustomError(error.code, error.message); + } + } + static fromConnectAxiosError(error: AxiosError): RecoverableError | NonRecoverableError { log.debug('axios error', error); @@ -99,7 +111,6 @@ export class CorbadoError extends Error { } const url = error.config?.url; - console.log(url); if (error.response.status === 404 && url) { if (url.endsWith('/connect/login/finish')) { return new ConnectConditionalUIPasskeyDeleted(); @@ -337,6 +348,23 @@ export class ConnectConditionalUIPasskeyDeleted extends RecoverableError { } } +export class ConnectCustomError extends RecoverableError { + code: string; + message: string; + + constructor(code: string, message: string) { + super('Custom error'); + this.code = code; + this.message = message; + } +} + +export class ConnectExistingPasskeysNotAvailable extends RecoverableError { + constructor() { + super('Existing passkeys not available'); + } +} + export class SessionManagementNotEnabled extends RecoverableError { constructor() { super('Session management is not enabled'); diff --git a/playground/connect-next/app/login/ConventionalLogin.tsx b/playground/connect-next/app/login/ConventionalLogin.tsx index b82c1d696..b2c70227e 100644 --- a/playground/connect-next/app/login/ConventionalLogin.tsx +++ b/playground/connect-next/app/login/ConventionalLogin.tsx @@ -5,11 +5,12 @@ import { startConventionalLogin } from './actions'; export type Props = { initialEmail: string; + initialError: string; }; -export default function ConventionalLogin({ initialEmail }: Props) { +export default function ConventionalLogin({ initialEmail, initialError }: Props) { const [password, setPassword] = useState(''); - const [error, setError] = useState(''); + const [error, setError] = useState(initialError); const [email, setEmail] = useState(initialEmail); const router = useRouter(); diff --git a/playground/connect-next/app/login/page.tsx b/playground/connect-next/app/login/page.tsx index 46b2dee8a..a4591c83c 100644 --- a/playground/connect-next/app/login/page.tsx +++ b/playground/connect-next/app/login/page.tsx @@ -10,6 +10,7 @@ export default function LoginPage() { const router = useRouter(); const [conventionalLoginVisible, setConventionalLoginVisible] = useState(false); const [email, setEmail] = useState(''); + const [fallbackErrorMessage, setFallbackErrorMessage] = useState(''); console.log('conventionalLoginVisible', conventionalLoginVisible); @@ -17,14 +18,26 @@ export default function LoginPage() {
- {conventionalLoginVisible ? : null} + {conventionalLoginVisible ? ( + + ) : null}
{ + onFallback={(identifier: string, message: string) => { setEmail(identifier); setConventionalLoginVisible(true); + setFallbackErrorMessage(message); console.log('onFallback', identifier); }} + onFallbackCustom={(identifier: string, code: string, _: string) => { + setEmail(identifier); + setConventionalLoginVisible(true); + setFallbackErrorMessage(code); + console.log('onFallbackCustom', identifier, code); + }} onError={(error: string) => console.log('error', error)} onLoaded={(msg: string) => console.log('component has loaded: ' + msg)} onComplete={async (signedPasskeyData: string) => { diff --git a/playground/connect-next/app/signup/actions.ts b/playground/connect-next/app/signup/actions.ts index 529745921..f8c386b85 100644 --- a/playground/connect-next/app/signup/actions.ts +++ b/playground/connect-next/app/signup/actions.ts @@ -4,11 +4,23 @@ import { cookies } from 'next/headers'; import { generateRandomString } from '@/utils/random'; import { AdminCreateUserCommand, + AdminInitiateAuthCommand, AdminSetUserPasswordCommand, + AssociateSoftwareTokenCommand, CognitoIdentityProviderClient, + VerifySoftwareTokenCommand, } from '@aws-sdk/client-cognito-identity-provider'; +import { TOTP } from 'totp-generator'; +import CryptoJS from 'crypto-js'; -export async function createAccount(email: string, phone: string, password: string) { +const cognitoUserPoolId = process.env.AWS_COGNITO_USER_POOL_ID!; +const cognitoClientId = process.env.AWS_COGNITO_CLIENT_ID!; +const cognitoClientSecret = process.env.AWS_COGNITO_CLIENT_SECRET!; +const awsRegion = process.env.AWS_REGION!; +const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID!; +const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY!; + +export const createAccount = async (email: string, phone: string, password: string) => { // of course this is not secure, but it's just a demo ;) const randomUsername = generateRandomString(10); @@ -18,15 +30,15 @@ export async function createAccount(email: string, phone: string, password: stri // create client that loads profile from ~/.aws/credentials or environment variables const client = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION!, + region: awsRegion, credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + accessKeyId: awsAccessKeyId, + secretAccessKey: awsSecretAccessKey, }, }); const command = new AdminCreateUserCommand({ - UserPoolId: process.env.AWS_COGNITO_USER_POOL_ID!, + UserPoolId: cognitoUserPoolId, Username: randomUsername, ForceAliasCreation: true, MessageAction: 'SUPPRESS', @@ -49,7 +61,7 @@ export async function createAccount(email: string, phone: string, password: stri await client.send(command); const passwordCommand = new AdminSetUserPasswordCommand({ - UserPoolId: process.env.AWS_COGNITO_USER_POOL_ID!, + UserPoolId: cognitoUserPoolId, Username: randomUsername, Password: password, Permanent: true, @@ -57,5 +69,42 @@ export async function createAccount(email: string, phone: string, password: stri await client.send(passwordCommand); + const initiateAuthCommand = new AdminInitiateAuthCommand({ + AuthFlow: 'ADMIN_USER_PASSWORD_AUTH', + ClientId: cognitoClientId, + UserPoolId: cognitoUserPoolId, + AuthParameters: { + USERNAME: randomUsername, + PASSWORD: password, + SECRET_HASH: await createSecretHash(randomUsername, cognitoClientId, cognitoClientSecret), + }, + }); + + const initiateAuthRes = await client.send(initiateAuthCommand); + + const associateSoftwareTokenCommand = new AssociateSoftwareTokenCommand({ + Session: initiateAuthRes.Session, + AccessToken: initiateAuthRes.AuthenticationResult?.AccessToken, + }); + + const associateSoftwareTokenRes = await client.send(associateSoftwareTokenCommand); + + cookies().set('secretCode', associateSoftwareTokenRes.SecretCode!); + + const { otp } = TOTP.generate(associateSoftwareTokenRes.SecretCode!); + const verifySoftwareTokenCommand = new VerifySoftwareTokenCommand({ + Session: initiateAuthRes.Session, + AccessToken: initiateAuthRes.AuthenticationResult?.AccessToken, + UserCode: otp, + }); + + const verifySoftwareTokenRes = await client.send(verifySoftwareTokenCommand); + console.log(verifySoftwareTokenRes); + return; -} +}; + +const createSecretHash = async (username: string, clientId: string, clientSecret: string) => { + const hmac = CryptoJS.HmacSHA256(username + clientId, clientSecret); + return hmac.toString(CryptoJS.enc.Base64); +}; diff --git a/playground/connect-next/app/signup/page.tsx b/playground/connect-next/app/signup/page.tsx index fdbb4bd1d..0a74d50bd 100644 --- a/playground/connect-next/app/signup/page.tsx +++ b/playground/connect-next/app/signup/page.tsx @@ -1,4 +1,5 @@ 'use client'; + export const runtime = 'edge'; import { useState } from 'react'; @@ -12,6 +13,10 @@ export default function SignupPage() { const [phone, setPhone] = useState(''); const [password, setPassword] = useState(''); + const signUp = async (email: string, phone: string, password: string) => { + await createAccount(email, phone, password); + }; + return (
@@ -59,7 +64,7 @@ export default function SignupPage() {