diff --git a/.github/workflows/deploy-playground-and-test.yml b/.github/workflows/deploy-playground-and-test.yml index 21ac3a698..d684f4599 100644 --- a/.github/workflows/deploy-playground-and-test.yml +++ b/.github/workflows/deploy-playground-and-test.yml @@ -103,7 +103,7 @@ jobs: test: needs: deploy timeout-minutes: 60 - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/packages/connect-react/src/components/CorbadoConnectDemo.tsx b/packages/connect-react/src/components/CorbadoConnectDemo.tsx index 555f0339d..fdb336732 100644 --- a/packages/connect-react/src/components/CorbadoConnectDemo.tsx +++ b/packages/connect-react/src/components/CorbadoConnectDemo.tsx @@ -3,6 +3,7 @@ import type { Passkey } from '@corbado/web-core'; import type { FC } from 'react'; import React from 'react'; +import AppendAfterError from './append/append-init/AppendAfterError'; import AppendInitLoaded2 from './append/append-init/AppendInitLoaded2'; import AppendInitLoading from './append/append-init/AppendInitLoading'; import AppendSuccessScreen from './append/AppendSuccessScreen'; @@ -247,6 +248,18 @@ const CorbadoConnectDemo: FC = _ => { /> ), }, + { + headline: 'Append screen after error', + description: 'This screen is shown to the user after having issues with passkeys.', + reactElement: ( + console.log('Submit')} + handleSkip={() => console.log('Skip')} + /> + ), + }, ]; const login: Element[] = [ diff --git a/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx b/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx index c47987bba..4df7a866c 100644 --- a/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx +++ b/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx @@ -1,21 +1,19 @@ import { ExcludeCredentialsMatchError, PasskeyChallengeCancelledError } from '@corbado/web-core'; import log from 'loglevel'; -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import useAppendProcess from '../../hooks/useAppendProcess'; import useShared from '../../hooks/useShared'; import { AppendScreenType } from '../../types/screenTypes'; import { AppendSituationCode, getAppendErrorMessage } from '../../types/situations'; -import { PasskeyIssueIcon } from '../shared/icons/PasskeyIssueIcon'; -import { LinkButton } from '../shared/LinkButton'; -import { Notification } from '../shared/Notification'; -import { PrimaryButton } from '../shared/PrimaryButton'; +import AppendAfterError from './append-init/AppendAfterError'; const AppendAfterErrorScreen = ({ attestationOptions }: { attestationOptions: string }) => { const { navigateToScreen, handleErrorSoft, handleErrorHard, handleCredentialExistsError, handleSkip } = useAppendProcess(); const [errorMessage, setErrorMessage] = useState(undefined); const [loading, setLoading] = useState(false); + const [skipping, setSkipping] = useState(false); const { getConnectService } = useShared(); const onSubmitClick = async () => { @@ -39,7 +37,10 @@ const AppendAfterErrorScreen = ({ attestationOptions }: { attestationOptions: st } setLoading(false); - navigateToScreen(AppendScreenType.Success); + navigateToScreen(AppendScreenType.Success, { + aaguidName: res.val.passkeyOperation.aaguidDetails?.name, + aaguidIcon: res.val.passkeyOperation.aaguidDetails?.iconLight, + }); }; const handleSituation = (situationCode: AppendSituationCode) => { @@ -57,7 +58,8 @@ const AppendAfterErrorScreen = ({ attestationOptions }: { attestationOptions: st void handleErrorHard(situationCode, false); break; case AppendSituationCode.ClientPasskeyOperationCancelled: - void handleErrorSoft(situationCode, true); + setLoading(false); + void handleErrorSoft(situationCode, true, true); break; case AppendSituationCode.ClientExcludeCredentialsMatch: void handleCredentialExistsError(); @@ -68,36 +70,22 @@ const AppendAfterErrorScreen = ({ attestationOptions }: { attestationOptions: st } }; + const onSkip = useCallback(() => { + if (skipping || loading) { + return; + } + + setSkipping(true); + void handleSituation(AppendSituationCode.ExplicitSkipByUser); + }, [skipping, loading]); + return ( -
-
Issues using passkeys?
- {errorMessage ? ( - - ) : null} -
- -
-
We detected you had an issue using your passkey.
-
Try adding another passkey to resolve the problem.
-
- void onSubmitClick()} - className='cb-append-after-error-button' - > - Add passkey - - void handleSituation(AppendSituationCode.ExplicitSkipByUser)} - className='cb-append-after-error-fallback' - > - Skip - -
-
+ void onSubmitClick()} + handleSkip={onSkip} + /> ); }; diff --git a/packages/connect-react/src/components/append/AppendAfterHybridLoginScreen.tsx b/packages/connect-react/src/components/append/AppendAfterHybridLoginScreen.tsx deleted file mode 100644 index 5f0e0394e..000000000 --- a/packages/connect-react/src/components/append/AppendAfterHybridLoginScreen.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { ExcludeCredentialsMatchError, PasskeyChallengeCancelledError } from '@corbado/web-core'; -import log from 'loglevel'; -import React, { useState } from 'react'; - -import useAppendProcess from '../../hooks/useAppendProcess'; -import useShared from '../../hooks/useShared'; -import { AppendScreenType } from '../../types/screenTypes'; -import { AppendSituationCode, getAppendErrorMessage } from '../../types/situations'; -import { LockIcon } from '../shared/icons/LockIcon'; -import { PasskeyAddIcon } from '../shared/icons/PasskeyAddIcon'; -import { LinkButton } from '../shared/LinkButton'; -import { Notification } from '../shared/Notification'; -import { PrimaryButton } from '../shared/PrimaryButton'; - -const AppendAfterHybridLoginScreen = ({ attestationOptions }: { attestationOptions: string }) => { - const { navigateToScreen, handleErrorSoft, handleErrorHard, handleCredentialExistsError, handleSkip } = - useAppendProcess(); - const [errorMessage, setErrorMessage] = useState(undefined); - const [loading, setLoading] = useState(false); - const { getConnectService } = useShared(); - - const onSubmitClick = async () => { - if (loading) { - return; - } - - setLoading(true); - setErrorMessage(undefined); - - const res = await getConnectService().completeAppend(attestationOptions); - if (res.err) { - if (res.val instanceof ExcludeCredentialsMatchError) { - return handleSituation(AppendSituationCode.ClientExcludeCredentialsMatch); - } - - if (res.val instanceof PasskeyChallengeCancelledError) { - return handleSituation(AppendSituationCode.ClientPasskeyOperationCancelled); - } - - return handleSituation(AppendSituationCode.CboApiNotAvailablePostAuthenticator); - } - - setLoading(false); - navigateToScreen(AppendScreenType.Success); - }; - - const handleSituation = (situationCode: AppendSituationCode) => { - log.debug(`situation: ${situationCode}`); - - const message = getAppendErrorMessage(situationCode); - if (message) { - setErrorMessage(message); - } - - switch (situationCode) { - case AppendSituationCode.CtApiNotAvailablePreAuthenticator: - case AppendSituationCode.CboApiNotAvailablePreAuthenticator: - case AppendSituationCode.CboApiNotAvailablePostAuthenticator: - void handleErrorHard(situationCode, false); - break; - case AppendSituationCode.ClientPasskeyOperationCancelled: - void handleErrorSoft(situationCode, true); - break; - case AppendSituationCode.ClientExcludeCredentialsMatch: - void handleCredentialExistsError(); - break; - case AppendSituationCode.ExplicitSkipByUser: - void handleSkip(situationCode, true); - break; - } - }; - - return ( -
-
Add a passkey to this device
- {errorMessage ? ( - - ) : null} -
- -
-
- -
Add a passkey on this device in order to not require any other devices for login
-
-
- void onSubmitClick()} - className='cb-append-after-hybrid-login-button' - > - Add new passkey - - handleSituation(AppendSituationCode.ExplicitSkipByUser)} - className='cb-append-after-hybrid-login-fallback' - > - Continue without new passkey - -
-
- ); -}; - -export default AppendAfterHybridLoginScreen; diff --git a/packages/connect-react/src/components/append/AppendInitScreen.tsx b/packages/connect-react/src/components/append/AppendInitScreen.tsx index e775ff3ca..9460c91f9 100644 --- a/packages/connect-react/src/components/append/AppendInitScreen.tsx +++ b/packages/connect-react/src/components/append/AppendInitScreen.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import useAppendProcess from '../../hooks/useAppendProcess'; import useShared from '../../hooks/useShared'; +import { Flags } from '../../types/flags'; import { AppendScreenType } from '../../types/screenTypes'; import { AppendSituationCode, getAppendErrorMessage } from '../../types/situations'; import { StatefulLoader } from '../../utils/statefulLoader'; @@ -27,8 +28,9 @@ const AppendInitScreen = () => { handleSkip, handleCredentialExistsError, onReadMoreClick, + setFlags, } = useAppendProcess(); - const { getConnectService } = useShared(); + const { sharedConfig, getConnectService } = useShared(); const [attestationOptions, setAttestationOptions] = useState(''); const [errorMessage, setErrorMessage] = useState(undefined); const [appendLoading, setAppendLoading] = useState(false); @@ -85,6 +87,13 @@ const AppendInitScreen = () => { return handleSituation(AppendSituationCode.CboApiNotAvailablePreAuthenticator); } + // we load flags from backend first, then we override them with the ones that are specified in the component's config + const flags = new Flags(res.val.flags); + if (sharedConfig.flags) { + flags.addFlags(sharedConfig.flags); + } + setFlags(flags); + if (!res.val.appendAllowed) { return handleSituation(AppendSituationCode.DeniedByPartialRollout); } @@ -123,6 +132,10 @@ const AppendInitScreen = () => { setAttestationOptions(startAppendRes.val.attestationOptions); statefulLoader.current.finish(); + + if (flags?.hasSupportForAutomaticAppend()) { + await handleSubmit(startAppendRes.val.attestationOptions, false); + } }; log.debug('init AppendInitScreen'); @@ -137,33 +150,41 @@ const AppendInitScreen = () => { }; }, []); - const handleSubmit = useCallback(async () => { - if (appendLoading || skipping) { - return; - } + const handleSubmit = useCallback( + async (attestationOptions: string, showErrorIfCancelled: boolean) => { + console.log('handleSubmit', attestationOptions); + if (appendLoading || skipping) { + return; + } - setAppendLoading(true); - setErrorMessage(undefined); + setAppendLoading(true); + setErrorMessage(undefined); - const res = await getConnectService().completeAppend(attestationOptions); - if (res.err) { - if (res.val instanceof ExcludeCredentialsMatchError) { - return handleSituation(AppendSituationCode.ClientExcludeCredentialsMatch); - } + const res = await getConnectService().completeAppend(attestationOptions); + if (res.err) { + if (res.val instanceof ExcludeCredentialsMatchError) { + return handleSituation(AppendSituationCode.ClientExcludeCredentialsMatch); + } - if (res.val instanceof PasskeyChallengeCancelledError) { - return handleSituation(AppendSituationCode.ClientPasskeyOperationCancelled); - } + if (res.val instanceof PasskeyChallengeCancelledError) { + if (showErrorIfCancelled) { + return handleSituation(AppendSituationCode.ClientPasskeyOperationCancelled); + } else { + return handleSituation(AppendSituationCode.ClientPasskeyOperationCancelledSilent); + } + } - return handleSituation(AppendSituationCode.CboApiNotAvailablePostAuthenticator); - } + return handleSituation(AppendSituationCode.CboApiNotAvailablePostAuthenticator); + } - setAppendLoading(false); - navigateToScreen(AppendScreenType.Success, { - aaguidName: res.val.passkeyOperation.aaguidDetails?.name, - aaguidIcon: res.val.passkeyOperation.aaguidDetails?.iconLight, - }); - }, [attestationOptions, config, getConnectService, appendLoading, skipping]); + setAppendLoading(false); + navigateToScreen(AppendScreenType.Success, { + aaguidName: res.val.passkeyOperation.aaguidDetails?.name, + aaguidIcon: res.val.passkeyOperation.aaguidDetails?.iconLight, + }); + }, + [config, getConnectService, appendLoading, skipping], + ); const handleSituation = async (situationCode: AppendSituationCode) => { log.debug(`situation: ${situationCode}`); @@ -182,7 +203,11 @@ const AppendInitScreen = () => { statefulLoader.current.finishWithError(); break; case AppendSituationCode.ClientPasskeyOperationCancelled: - void handleErrorSoft(situationCode, true); + void handleErrorSoft(situationCode, true, true); + setAppendLoading(false); + break; + case AppendSituationCode.ClientPasskeyOperationCancelledSilent: + void handleErrorSoft(situationCode, true, false); setAppendLoading(false); break; case AppendSituationCode.ClientExcludeCredentialsMatch: @@ -224,7 +249,7 @@ const AppendInitScreen = () => { void onReadMoreClick(); setAppendInitState(AppendInitState.ShowBenefits); }} - handleSubmit={() => void handleSubmit()} + handleSubmit={() => void handleSubmit(attestationOptions, true)} handleSkip={() => onSkip()} /> ); diff --git a/packages/connect-react/src/components/append/CorbadoConnectAppendContainer.tsx b/packages/connect-react/src/components/append/CorbadoConnectAppendContainer.tsx index df74b4b40..fa236f7ae 100644 --- a/packages/connect-react/src/components/append/CorbadoConnectAppendContainer.tsx +++ b/packages/connect-react/src/components/append/CorbadoConnectAppendContainer.tsx @@ -3,7 +3,6 @@ import React, { useMemo } from 'react'; import useAppendProcess from '../../hooks/useAppendProcess'; import { AppendScreenType } from '../../types/screenTypes'; import AppendAfterErrorScreen from './AppendAfterErrorScreen'; -import AppendAfterHybridLoginScreen from './AppendAfterHybridLoginScreen'; import AppendInitScreen from './AppendInitScreen'; import AppendSuccessScreen from './AppendSuccessScreen'; @@ -15,7 +14,7 @@ const CorbadoConnectAppendContainer = () => { case AppendScreenType.Init: return ; case AppendScreenType.AfterHybridLogin: - return ; + return ; case AppendScreenType.AfterError: return ; case AppendScreenType.Success: diff --git a/packages/connect-react/src/components/append/append-init/AppendAfterError.tsx b/packages/connect-react/src/components/append/append-init/AppendAfterError.tsx new file mode 100644 index 000000000..2565fcc2a --- /dev/null +++ b/packages/connect-react/src/components/append/append-init/AppendAfterError.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { Button } from '../../shared/Button'; +import { PasskeyAppendIcon } from '../../shared/icons/PasskeyAppendIcon'; +import { Notification } from '../../shared/Notification'; +import { PrimaryButton } from '../../shared/PrimaryButton'; + +type Props = { + errorMessage?: string; + appendLoading: boolean; + handleSubmit: () => void; + handleSkip: () => void; +}; + +const AppendAfterError = ({ errorMessage, appendLoading, handleSubmit, handleSkip }: Props) => { + return ( +
+
+

Create a passkey

+
on this device
+
+ {errorMessage ? ( + + ) : null} +
+ +
+

Speed up your sign-in next time by creating a new passkey on this device.

+

Only create a passkey if this is your device.

+
+ void handleSubmit()} + className='cb-append-activate-button' + > + Continue + + +
+
+ ); +}; + +export default AppendAfterError; diff --git a/packages/connect-react/src/components/login/LoginInitScreen.tsx b/packages/connect-react/src/components/login/LoginInitScreen.tsx index 70c4f92b5..0d1e8489c 100644 --- a/packages/connect-react/src/components/login/LoginInitScreen.tsx +++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx @@ -90,6 +90,11 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { getConnectService().setInvitation(invitationToken); } + const na = url.searchParams.get('not_authenticated'); + if (na === '1') { + getConnectService().handleNa(); + } + const res = await getConnectService().loginInit(ac); if (res.err) { if (res.val.ignore) { diff --git a/packages/connect-react/src/contexts/AppendProcessContext.ts b/packages/connect-react/src/contexts/AppendProcessContext.ts index 300de0de7..2b24cdbc4 100644 --- a/packages/connect-react/src/contexts/AppendProcessContext.ts +++ b/packages/connect-react/src/contexts/AppendProcessContext.ts @@ -1,6 +1,7 @@ import type { CorbadoConnectAppendConfig } from '@corbado/types'; import { createContext } from 'react'; +import type { Flags } from '../types/flags'; import { AppendScreenType } from '../types/screenTypes'; import type { AppendSituationCode } from '../types/situations'; @@ -13,11 +14,13 @@ export interface AppendProcessContextProps { currentScreenOptions: any; config: CorbadoConnectAppendConfig; navigateToScreen: (s: AppendScreenType, options?: any) => void; - handleErrorSoft: (situation: AppendSituationCode, expected: boolean) => Promise; + handleErrorSoft: (situation: AppendSituationCode, expected: boolean, showError: boolean) => Promise; handleErrorHard: (situation: AppendSituationCode, expected: boolean) => Promise; handleCredentialExistsError: () => Promise; handleSkip: (situation: AppendSituationCode, explicit?: boolean) => Promise; onReadMoreClick: () => Promise; + flags: Flags | undefined; + setFlags: (f: Flags) => void; } export const initialContext: AppendProcessContextProps = { @@ -30,6 +33,8 @@ export const initialContext: AppendProcessContextProps = { handleCredentialExistsError: missingImplementation, handleSkip: missingImplementation, onReadMoreClick: missingImplementation, + flags: undefined, + setFlags: missingImplementation, }; const AppendProcessContext = createContext(initialContext); diff --git a/packages/connect-react/src/contexts/AppendProcessProvider.tsx b/packages/connect-react/src/contexts/AppendProcessProvider.tsx index f8e38bb26..57cef4503 100644 --- a/packages/connect-react/src/contexts/AppendProcessProvider.tsx +++ b/packages/connect-react/src/contexts/AppendProcessProvider.tsx @@ -4,6 +4,7 @@ import type { FC, PropsWithChildren } from 'react'; import React, { useCallback, useMemo, useState } from 'react'; import useShared from '../hooks/useShared'; +import type { Flags } from '../types/flags'; import type { AppendScreenType } from '../types/screenTypes'; import type { AppendSituationCode } from '../types/situations'; import type { AppendProcessContextProps } from './AppendProcessContext'; @@ -18,6 +19,7 @@ export const AppendProcessProvider: FC> = ({ children, const [currentScreenType, setCurrentScreenType] = useState(initialScreenType); const [currentScreenOptions, setCurrentScreenOptions] = useState(); const { getConnectService } = useShared(); + const [flags, setFlags] = useState(); const navigateToScreen = useCallback((screenType: AppendScreenType, options?: any) => { setCurrentScreenType(screenType); @@ -25,14 +27,16 @@ export const AppendProcessProvider: FC> = ({ children, }, []); const handleErrorSoft = useCallback( - async (situationCode: AppendSituationCode, expected: boolean) => { + async (situationCode: AppendSituationCode, expected: boolean, showError: boolean) => { if (expected) { await getConnectService().recordEventAppendError(); } else { await getConnectService().recordEventAppendErrorUnexpected(`situation: ${situationCode}`); } - config.onError?.(situationCode.toString()); + if (showError) { + config.onError?.(situationCode.toString()); + } }, [getConnectService, config], ); @@ -86,8 +90,10 @@ export const AppendProcessProvider: FC> = ({ children, handleCredentialExistsError, handleSkip, onReadMoreClick, + flags, + setFlags, }), - [currentScreenType, navigateToScreen, config], + [currentScreenType, navigateToScreen, config, flags], ); return {children}; diff --git a/packages/connect-react/src/types/flags.ts b/packages/connect-react/src/types/flags.ts index f70f4036f..0559b8712 100644 --- a/packages/connect-react/src/types/flags.ts +++ b/packages/connect-react/src/types/flags.ts @@ -1,4 +1,5 @@ const keyConditionalUI = 'conditional-ui-allowed'; +const keyAutoAppend = 'automatic-append'; export class Flags { readonly items: Record; @@ -16,4 +17,8 @@ export class Flags { hasSupportForConditionalUI(): boolean { return this.items[keyConditionalUI] === 'true'; } + + hasSupportForAutomaticAppend(): boolean { + return this.items[keyAutoAppend] === 'true'; + } } diff --git a/packages/connect-react/src/types/situations.ts b/packages/connect-react/src/types/situations.ts index 5c3ad2e24..075ec5873 100644 --- a/packages/connect-react/src/types/situations.ts +++ b/packages/connect-react/src/types/situations.ts @@ -26,6 +26,7 @@ export enum AppendSituationCode { DeniedByPartialRollout, DeniedByPasskeyIntel, ExplicitSkipByUser, + ClientPasskeyOperationCancelledSilent, } export enum PasskeyListSituationCode { diff --git a/packages/tests-e2e/src/complete/scenarios/corbado-auth-general/socials.spec.ts b/packages/tests-e2e/src/complete/scenarios/corbado-auth-general/socials.spec.ts index b6e880355..f4b1dbe19 100644 --- a/packages/tests-e2e/src/complete/scenarios/corbado-auth-general/socials.spec.ts +++ b/packages/tests-e2e/src/complete/scenarios/corbado-auth-general/socials.spec.ts @@ -19,7 +19,7 @@ import { test.describe('social logins', () => { let projectId: string; - // Microsoft social login requires longer timeout + // Google social login requires longer timeout test.describe.configure({ timeout: socialTotalTimeout }); test.use({ actionTimeout: socialOperationTimeout, @@ -121,7 +121,7 @@ test.describe('social logins', () => { await model.expectScreen(ScreenNames.InitLogin); }); - test('login with social should be possible (account does not exist)', async ({ model }) => { + test.skip('login with social should be possible (account does not exist)', async ({ model }) => { // redirects to passkey append screen await model.load(projectId, true, 'login-init'); @@ -133,7 +133,7 @@ test.describe('social logins', () => { await model.expectScreen(ScreenNames.PasskeyAppend2); }); - test('login with social should be possible (account exists, social has been linked)', async ({ model }) => { + test.skip('login with social should be possible (account exists, social has been linked)', async ({ model }) => { await model.load(projectId, true, 'signup-init'); const email = process.env.PLAYWRIGHT_GOOGLE_EMAIL ?? ''; diff --git a/packages/tests-e2e/src/complete/utils/constants.ts b/packages/tests-e2e/src/complete/utils/constants.ts index e348e74fd..7a194f6d1 100644 --- a/packages/tests-e2e/src/complete/utils/constants.ts +++ b/packages/tests-e2e/src/complete/utils/constants.ts @@ -54,8 +54,8 @@ export enum AuthType { export const emailLinkUrlToken = 'UaTwjBJwyDLMGVbR7WHh'; -export const totalTimeout = process.env.CI ? 30000 : 40000; -export const operationTimeout = process.env.CI ? 5000 : 7000; +export const totalTimeout = 40000; +export const operationTimeout = 7000; export const socialTotalTimeout = 45000; export const socialOperationTimeout = 10000; export const waitAfterLoad = 600; // timeout to reduce flakiness due to repetitive reloads diff --git a/packages/tests-e2e/src/connect/models/BaseModel.ts b/packages/tests-e2e/src/connect/models/BaseModel.ts index 362c8b2fc..4d891e6a9 100644 --- a/packages/tests-e2e/src/connect/models/BaseModel.ts +++ b/packages/tests-e2e/src/connect/models/BaseModel.ts @@ -8,6 +8,7 @@ import type { VirtualAuthenticator } from '../utils/VirtualAuthenticator'; import { AppendModel } from './AppendModel'; import { HomeModel } from './HomeModel'; import { LoginModel } from './LoginModel'; +import { MFAModel } from './MFAModel'; import { PasskeyListModel } from './PasskeyListModel'; import { SignupModel } from './SignupModel'; import { StorageModel } from './StorageModel'; @@ -24,6 +25,7 @@ export class BaseModel { passkeyList: PasskeyListModel; webhook: WebhookModel; storage: StorageModel; + mfa: MFAModel; email = ''; constructor(page: Page, authenticator: VirtualAuthenticator, blocker: NetworkRequestBlocker) { @@ -37,6 +39,7 @@ export class BaseModel { this.passkeyList = new PasskeyListModel(page, authenticator); this.webhook = new WebhookModel(page); this.storage = new StorageModel(page); + this.mfa = new MFAModel(page); } loadSignup() { @@ -62,6 +65,7 @@ export class BaseModel { async createUser(invited: boolean, append: boolean) { this.email = await this.signup.autofillCredentials(); await this.signup.submit(); + this.mfa.registerTokenUsed(); if (invited) { await this.expectScreen(ScreenNames.PasskeyAppend); if (append) { diff --git a/packages/tests-e2e/src/connect/models/MFAModel.ts b/packages/tests-e2e/src/connect/models/MFAModel.ts new file mode 100644 index 000000000..d5131c723 --- /dev/null +++ b/packages/tests-e2e/src/connect/models/MFAModel.ts @@ -0,0 +1,27 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +export class MFAModel { + page: Page; + timestamp: number; + + constructor(page: Page) { + this.page = page; + this.timestamp = Date.now(); + } + + registerTokenUsed() { + this.timestamp = Date.now(); + } + + async autofillTOTP() { + await this.page.waitForTimeout(31000 - (Date.now() - this.timestamp)); + await this.page.getByRole('button', { name: 'Autofill TOTP' }).click(); + await expect(this.page.getByPlaceholder('TOTP')).toHaveValue(/.+/); + this.registerTokenUsed(); + } + + submit() { + return this.page.getByRole('button', { name: 'Submit' }).click(); + } +} diff --git a/packages/tests-e2e/src/connect/scenarios/append.spec.ts b/packages/tests-e2e/src/connect/scenarios/append.spec.ts index b4da3cdb5..3add83f22 100644 --- a/packages/tests-e2e/src/connect/scenarios/append.spec.ts +++ b/packages/tests-e2e/src/connect/scenarios/append.spec.ts @@ -1,8 +1,8 @@ import { expect } from '@playwright/test'; import { test } from '../fixtures/BaseTest'; -import { password, ScreenNames, WebhookTypes } from '../utils/Constants'; -import { loadPasskeyAppend, setupNetworkBlocker, setupUser, setupVirtualAuthenticator, setupWebhooks } from './hooks'; +import { password, ScreenNames } from '../utils/Constants'; +import { loadPasskeyAppend, setupNetworkBlocker, setupUser, setupVirtualAuthenticator } from './hooks'; test.describe('append component', () => { setupVirtualAuthenticator(test); @@ -30,24 +30,6 @@ test.describe('append component', () => { }); }); -test.describe('append component (webhook)', () => { - setupVirtualAuthenticator(test); - setupNetworkBlocker(test); - setupUser(test, true, false); - loadPasskeyAppend(test); - setupWebhooks(test, [WebhookTypes.Create]); - - test('successful passkey append on login (+ webhook)', async ({ model }) => { - await model.append.appendPasskey(true); - await model.expectScreen(ScreenNames.PasskeyAppended); - - await model.append.confirmAppended(); - await model.expectScreen(ScreenNames.Home); - - model.webhook.expectWebhookRequest(WebhookTypes.Create); - }); -}); - test.describe('skip append component', () => { setupVirtualAuthenticator(test); setupNetworkBlocker(test); @@ -63,6 +45,11 @@ test.describe('skip append component', () => { await model.blocker.blockCorbadoFAPI(); await model.login.submitFallbackCredentials(model.email, password, true); + await model.expectScreen(ScreenNames.MFA); + + await model.mfa.autofillTOTP(); + await model.mfa.submit(); + await model.expectScreen(ScreenNames.Home); }); @@ -77,6 +64,10 @@ test.describe('skip append component', () => { await model.storage.setAppendLifetime(Math.floor(Date.now() / 1000) - 1); await model.storage.deleteInvitationToken(); await model.login.submitFallbackCredentials(model.email, password, true); + await model.expectScreen(ScreenNames.MFA); + + await model.mfa.autofillTOTP(); + await model.mfa.submit(); await model.expectScreen(ScreenNames.Home); }); }); diff --git a/packages/tests-e2e/src/connect/scenarios/hooks.ts b/packages/tests-e2e/src/connect/scenarios/hooks.ts index b6e47a19e..90efe88d0 100644 --- a/packages/tests-e2e/src/connect/scenarios/hooks.ts +++ b/packages/tests-e2e/src/connect/scenarios/hooks.ts @@ -97,6 +97,10 @@ export function loadPasskeyAppend( await model.expectScreen(ScreenNames.InitLoginFallback); await model.login.submitFallbackCredentials(model.email, password, true); + await model.expectScreen(ScreenNames.MFA); + + await model.mfa.autofillTOTP(); + await model.mfa.submit(); await model.expectScreen(ScreenNames.PasskeyAppend); }); } diff --git a/packages/tests-e2e/src/connect/scenarios/login.spec.ts b/packages/tests-e2e/src/connect/scenarios/login.spec.ts index a90f528c9..a8eada8c4 100644 --- a/packages/tests-e2e/src/connect/scenarios/login.spec.ts +++ b/packages/tests-e2e/src/connect/scenarios/login.spec.ts @@ -1,8 +1,8 @@ import { expect } from '@playwright/test'; import { test } from '../fixtures/BaseTest'; -import { ErrorTexts, password, ScreenNames, WebhookTypes } from '../utils/Constants'; -import { loadInvitationToken, setupNetworkBlocker, setupUser, setupVirtualAuthenticator, setupWebhooks } from './hooks'; +import { ErrorTexts, password, ScreenNames } from '../utils/Constants'; +import { loadInvitationToken, setupNetworkBlocker, setupUser, setupVirtualAuthenticator } from './hooks'; test.describe('login component (without invitation token)', () => { setupUser(test, false); @@ -12,6 +12,10 @@ test.describe('login component (without invitation token)', () => { await model.expectScreen(ScreenNames.InitLoginFallback); await model.login.submitFallbackCredentials(model.email, password); + await model.expectScreen(ScreenNames.MFA); + + await model.mfa.autofillTOTP(); + await model.mfa.submit(); await model.expectScreen(ScreenNames.Home); }); }); @@ -28,6 +32,10 @@ test.describe('login component (with invitation token, without passkeys)', () => await model.expectScreen(ScreenNames.InitLoginFallback); await model.login.submitFallbackCredentials(model.email, password, true); + await model.expectScreen(ScreenNames.MFA); + + await model.mfa.autofillTOTP(); + await model.mfa.submit(); await model.expectScreen(ScreenNames.PasskeyAppend); await model.append.skipAppend(); @@ -184,23 +192,3 @@ test.describe('login component (without user)', () => { await model.expectScreen(ScreenNames.InitLoginFallback); }); }); - -test.describe('login component (webhook)', () => { - setupVirtualAuthenticator(test); - setupNetworkBlocker(test); - setupUser(test, true, true); - setupWebhooks(test, [WebhookTypes.Login]); - - test('successful login with passkey (+ webhook)', async ({ model }) => { - await model.home.logout(); - await model.expectScreen(ScreenNames.InitLoginOneTap); - - await model.login.removePasskeyButton(); - await model.expectScreen(ScreenNames.InitLogin); - - await model.login.submitEmail(model.email, true); - await model.expectScreen(ScreenNames.Home); - - model.webhook.expectWebhookRequest(WebhookTypes.Login); - }); -}); diff --git a/packages/tests-e2e/src/connect/scenarios/misc.spec.ts b/packages/tests-e2e/src/connect/scenarios/misc.spec.ts index e69de29bb..36156d7da 100644 --- a/packages/tests-e2e/src/connect/scenarios/misc.spec.ts +++ b/packages/tests-e2e/src/connect/scenarios/misc.spec.ts @@ -0,0 +1,69 @@ +import { test } from '../fixtures/BaseTest'; +import { ScreenNames, WebhookTypes } from '../utils/Constants'; +import { + loadPasskeyAppend, + loadPasskeyList, + setupNetworkBlocker, + setupUser, + setupVirtualAuthenticator, + setupWebhooks, +} from './hooks'; + +test.describe.serial('webhook tests', () => { + test.describe('login component (webhook)', () => { + setupVirtualAuthenticator(test); + setupNetworkBlocker(test); + setupUser(test, true, true); + setupWebhooks(test, [WebhookTypes.Login]); + + test('successful login with passkey (+ webhook)', async ({ model }) => { + await model.home.logout(); + await model.expectScreen(ScreenNames.InitLoginOneTap); + + await model.login.removePasskeyButton(); + await model.expectScreen(ScreenNames.InitLogin); + + await model.login.submitEmail(model.email, true); + await model.expectScreen(ScreenNames.Home); + + model.webhook.expectWebhookRequest(WebhookTypes.Login); + }); + }); + + test.describe('append component (webhook)', () => { + setupVirtualAuthenticator(test); + setupNetworkBlocker(test); + setupUser(test, true, false); + loadPasskeyAppend(test); + setupWebhooks(test, [WebhookTypes.Create]); + + test('successful passkey append on login (+ webhook)', async ({ model }) => { + await model.append.appendPasskey(true); + await model.expectScreen(ScreenNames.PasskeyAppended); + + await model.append.confirmAppended(); + await model.expectScreen(ScreenNames.Home); + + model.webhook.expectWebhookRequest(WebhookTypes.Create); + }); + }); + + test.describe('passkey-list component (webhook)', () => { + setupVirtualAuthenticator(test); + setupNetworkBlocker(test); + setupUser(test, true, false); + loadPasskeyList(test); + setupWebhooks(test, [WebhookTypes.Create, WebhookTypes.Delete]); + + test('list, delete, create passkey (+ webhook)', async ({ model }) => { + await model.passkeyList.expectPasskeys(0); + await model.passkeyList.createPasskey(true); + await model.passkeyList.expectPasskeys(1); + model.webhook.expectWebhookRequest(WebhookTypes.Create); + + await model.passkeyList.deletePasskey(0); + await model.passkeyList.expectPasskeys(0); + model.webhook.expectWebhookRequest(WebhookTypes.Delete); + }); + }); +}); diff --git a/packages/tests-e2e/src/connect/scenarios/passkey-list.spec.ts b/packages/tests-e2e/src/connect/scenarios/passkey-list.spec.ts index e0c29aad3..880d0b8a2 100644 --- a/packages/tests-e2e/src/connect/scenarios/passkey-list.spec.ts +++ b/packages/tests-e2e/src/connect/scenarios/passkey-list.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '../fixtures/BaseTest'; -import { ErrorTexts, ScreenNames, WebhookTypes } from '../utils/Constants'; -import { loadPasskeyList, setupNetworkBlocker, setupUser, setupVirtualAuthenticator, setupWebhooks } from './hooks'; +import { ErrorTexts, ScreenNames } from '../utils/Constants'; +import { loadPasskeyList, setupNetworkBlocker, setupUser, setupVirtualAuthenticator } from './hooks'; test.describe('passkey-list component', () => { setupVirtualAuthenticator(test); @@ -81,25 +81,6 @@ test.describe('passkey-list component', () => { }); }); -test.describe('passkey-list component (webhook)', () => { - setupVirtualAuthenticator(test); - setupNetworkBlocker(test); - setupUser(test, true, false); - loadPasskeyList(test); - setupWebhooks(test, [WebhookTypes.Create, WebhookTypes.Delete]); - - test('list, delete, create passkey (+ webhook)', async ({ model }) => { - await model.passkeyList.expectPasskeys(0); - await model.passkeyList.createPasskey(true); - await model.passkeyList.expectPasskeys(1); - model.webhook.expectWebhookRequest(WebhookTypes.Create); - - await model.passkeyList.deletePasskey(0); - await model.passkeyList.expectPasskeys(0); - model.webhook.expectWebhookRequest(WebhookTypes.Delete); - }); -}); - test.describe('skip passkey-list component', () => { setupVirtualAuthenticator(test); setupNetworkBlocker(test); diff --git a/packages/tests-e2e/src/connect/utils/Constants.ts b/packages/tests-e2e/src/connect/utils/Constants.ts index 4790db49e..cc5bfd6a8 100644 --- a/packages/tests-e2e/src/connect/utils/Constants.ts +++ b/packages/tests-e2e/src/connect/utils/Constants.ts @@ -9,6 +9,7 @@ export enum ScreenNames { PasskeyList, PasskeyError1, PasskeyError2, + MFA, } export enum ErrorTexts { @@ -31,5 +32,5 @@ export enum WebhookTypes { export const phone = '+4915121609839'; export const password = 'asdfasdf'; -export const totalTimeout = process.env.CI ? 30000 : 40000; +export const totalTimeout = 45000; export const operationTimeout = 10000; diff --git a/packages/tests-e2e/src/connect/utils/ExpectScreen.ts b/packages/tests-e2e/src/connect/utils/ExpectScreen.ts index 76389f231..0d629c216 100644 --- a/packages/tests-e2e/src/connect/utils/ExpectScreen.ts +++ b/packages/tests-e2e/src/connect/utils/ExpectScreen.ts @@ -54,6 +54,10 @@ export const expectScreen = async (page: Page, screenName: ScreenNames): Promise ); return; + case ScreenNames.MFA: + await expect(page.locator('div.font-bold.text-xl')).toHaveText('MFA'); + return; + default: throw new Error('Invalid screen'); } diff --git a/packages/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts index 3c23535c9..4a1465caa 100644 --- a/packages/web-core/src/services/ConnectService.ts +++ b/packages/web-core/src/services/ConnectService.ts @@ -561,6 +561,21 @@ export class ConnectService { invitation.persistToStorage(); } + handleNa() { + const storedNa = localStorage.getItem('na_ipr'); + if (storedNa) { + const parsed = parseInt(storedNa); + if (Date.now() < parsed + 1000 * 60) { + return; + } + + localStorage.removeItem('na_ipr'); + } + + localStorage.setItem('na_ipr', Date.now().toString()); + location.reload(); + } + recordEventLoginError(messageCode: string) { return this.#recordEvent(PasskeyEventType.LoginError, messageCode); } diff --git a/playground/connect-next/app/home/client.tsx b/playground/connect-next/app/home/client.tsx new file mode 100644 index 000000000..991fa5117 --- /dev/null +++ b/playground/connect-next/app/home/client.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +type Props = { + maybeSecretCode?: string; +}; + +export default function Home({ maybeSecretCode }: Props) { + const router = useRouter(); + + return ( + <> +
+
+
+
Home
+
+

Great, you are logged in.

+ + + + {maybeSecretCode ? ( +
{maybeSecretCode}
+ ) : null} +
+
+
+
+ + ); +} diff --git a/playground/connect-next/app/home/page.tsx b/playground/connect-next/app/home/page.tsx index b437e53ea..7f4f41c84 100644 --- a/playground/connect-next/app/home/page.tsx +++ b/playground/connect-next/app/home/page.tsx @@ -1,39 +1,8 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { createAccount } from '@/app/signup/actions'; +import { cookies } from 'next/headers'; +import Home from '@/app/home/client'; export default function Page() { - const router = useRouter(); + const maybeSecretCode = cookies().get('secretCode'); - return ( - <> -
-
-
-
Home
-
-

Great, you are logged in.

- - -
-
-
-
- - ); + return ; } diff --git a/playground/connect-next/app/login/ConventionalLogin.tsx b/playground/connect-next/app/login/ConventionalLogin.tsx index b2c70227e..69b01c4fa 100644 --- a/playground/connect-next/app/login/ConventionalLogin.tsx +++ b/playground/connect-next/app/login/ConventionalLogin.tsx @@ -17,6 +17,7 @@ export default function ConventionalLogin({ initialEmail, initialError }: Props) const onSubmit = async () => { setError(''); const res = await startConventionalLogin(email, password); + console.log(res); if (!res.success) { setError(res.message ?? 'An unknown error occurred. Please try again later.'); @@ -24,7 +25,11 @@ export default function ConventionalLogin({ initialEmail, initialError }: Props) return; } - router.push('/post-login'); + if (res.screen === 'MFA_SOFTWARE_TOKEN') { + router.push('/mfa-software-token'); + } else { + router.push('/post-login'); + } }; return ( diff --git a/playground/connect-next/app/login/actions.ts b/playground/connect-next/app/login/actions.ts index 2238fee22..ef92333ab 100644 --- a/playground/connect-next/app/login/actions.ts +++ b/playground/connect-next/app/login/actions.ts @@ -6,41 +6,8 @@ import { CognitoIdentityProviderClient, InitiateAuthCommand, } from '@aws-sdk/client-cognito-identity-provider'; -import jwt from 'jsonwebtoken'; -import jwksClient from 'jwks-rsa'; import crypto from 'crypto'; - -const jwksUrl = `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.AWS_COGNITO_USER_POOL_ID}/.well-known/jwks.json`; -const client = jwksClient({ jwksUri: jwksUrl }); - -type DecodedToken = { - username: string; -}; - -type TokenWrapper = { - AccessToken: string; -}; - -const getKey = (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => { - client.getSigningKey(header.kid, (err, key) => { - const signingKey = key?.getPublicKey(); - callback(err, signingKey); - }); -}; - -const verifyToken = async (token: string): Promise => { - return new Promise((resolve, reject) => { - jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => { - if (err) { - return reject(err); - } - - const typed = decoded as DecodedToken; - - resolve(typed); - }); - }); -}; +import { TokenWrapper, verifyToken } from '@/app/utils'; // Here we validate the JWT token (validation is too simple, don't use this in production) // Then we extract the cognitoID and retrieve the user's email from the user pool @@ -92,6 +59,7 @@ export async function postPasskeyLoginNew(signedPasskeyData: string) { }); const out = await response.json(); + console.log(out); await postPasskeyLogin(out.session); } @@ -109,6 +77,8 @@ export async function startConventionalLogin(email: string, password: string) { throw new Error('Email and password are required.'); } + cookies().set('displayName', email); + const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION!, credentials: { @@ -132,20 +102,27 @@ export async function startConventionalLogin(email: string, password: string) { }); const response = await client.send(command); + console.log(response); - if (!response.AuthenticationResult?.AccessToken) { - throw new Error('Authentication failed. Please check your credentials and try again.'); + if (response.AuthenticationResult?.AccessToken) { + // no MFA has been set up yet + + const decoded = await verifyToken(response.AuthenticationResult.AccessToken); + const username = decoded.username; + if (email) { + cookies().set('identifier', username); + } + + return { success: true }; } - const decoded = await verifyToken(response.AuthenticationResult.AccessToken); - const username = decoded.username; + if (response.Session && response.ChallengeName === 'SOFTWARE_TOKEN_MFA') { + cookies().set('mfa_session', response.Session); - if (email) { - cookies().set('displayName', email); - cookies().set('identifier', username); + return { success: true, screen: 'MFA_SOFTWARE_TOKEN' }; } - return { success: true }; + return { success: false, message: 'An error occurred. Please try again later.' }; } catch (err) { if (err instanceof Error) { if (err.name === 'NotAuthorizedException') return { success: false, message: 'Incorrect username or password.' }; diff --git a/playground/connect-next/app/mfa-software-token/actions.ts b/playground/connect-next/app/mfa-software-token/actions.ts new file mode 100644 index 000000000..0a4ea8c3b --- /dev/null +++ b/playground/connect-next/app/mfa-software-token/actions.ts @@ -0,0 +1,89 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { + CognitoIdentityProviderClient, + RespondToAuthChallengeCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import crypto from 'crypto'; +import { verifyToken } from '@/app/utils'; +import { TOTP } from 'totp-generator'; + +function createSecretHash(username: string, clientId: string, clientSecret: string) { + return crypto + .createHmac('sha256', clientSecret) + .update(username + clientId) + .digest('base64'); +} + +export async function startMFASoftwareToken(totp: string) { + try { + const session = cookies().get('mfa_session'); + const displayName = cookies().get('displayName'); + + if (!totp || !session || !displayName) { + throw new Error('Missing required fields.'); + } + + 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 challengeResponseCommand = new RespondToAuthChallengeCommand({ + ClientId: process.env.AWS_COGNITO_CLIENT_ID!, + ChallengeName: 'SOFTWARE_TOKEN_MFA', + Session: session.value, + ChallengeResponses: { + USERNAME: displayName.value, + SOFTWARE_TOKEN_MFA_CODE: totp, + SECRET_HASH: createSecretHash( + displayName.value, + process.env.AWS_COGNITO_CLIENT_ID!, + process.env.AWS_COGNITO_CLIENT_SECRET!, + ), + }, + }); + + const mfaResult = await client.send(challengeResponseCommand); + console.log('MFA login complete', mfaResult); + + if (mfaResult.AuthenticationResult?.AccessToken) { + // no MFA has been set up yet + + const decoded = await verifyToken(mfaResult.AuthenticationResult.AccessToken); + if (decoded.username) { + return { success: true }; + } + + return { success: false, message: 'An error occurred. Please try again later.' }; + } + + return { success: true, screen: 'MFA_SOFTWARE_TOKEN' }; + } catch (err) { + if (err instanceof Error) { + if (err.name === 'NotAuthorizedException') return { success: false, message: 'Incorrect username or password.' }; + + return { success: false, message: err.message }; + } + + return { success: false, message: 'An error occurred. Please try again later.' }; + } +} + +export async function generateTOTP() { + const secretCode = cookies().get('secretCode'); + if (!secretCode) { + return { + success: false, + message: 'Secret code not found. Autofill only works as long as the cookie set during signup is still there.', + }; + } + + const { otp } = TOTP.generate(secretCode.value!); + + return { success: true, otp }; +} diff --git a/playground/connect-next/app/mfa-software-token/page.tsx b/playground/connect-next/app/mfa-software-token/page.tsx new file mode 100644 index 000000000..f244a2029 --- /dev/null +++ b/playground/connect-next/app/mfa-software-token/page.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { generateTOTP, startMFASoftwareToken } from '@/app/mfa-software-token/actions'; + +export default function LoginPage() { + const router = useRouter(); + const [conventionalLoginVisible, setConventionalLoginVisible] = useState(false); + const [totp, setTotp] = useState(''); + const [error, setError] = useState(''); + + const onSubmit = async () => { + setError(''); + const res = await startMFASoftwareToken(totp); + + if (!res.success) { + setError(res.message ?? 'An unknown error occurred. Please try again later.'); + + return; + } + + if (res.screen === 'MFA_SOFTWARE_TOKEN') { + router.push('/mfa-software-token'); + } else { + router.push('/post-login'); + } + }; + + const onAutofillTOTP = async () => { + setError(''); + const res = await generateTOTP(); + + if (!res.success) { + setError(res.message ?? 'An unknown error occurred. Please try again later.'); + + return; + } + + setTotp(res.otp ?? ''); + }; + + return ( +
+
+
+
MFA
+ {error &&
{error}
} + setTotp(e.target.value)} + /> +
+ +
+
+ +
+
+
+
+ ); +} diff --git a/playground/connect-next/app/signup/actions.ts b/playground/connect-next/app/signup/actions.ts index f8c386b85..72502915a 100644 --- a/playground/connect-next/app/signup/actions.ts +++ b/playground/connect-next/app/signup/actions.ts @@ -5,6 +5,7 @@ import { generateRandomString } from '@/utils/random'; import { AdminCreateUserCommand, AdminInitiateAuthCommand, + AdminSetUserMFAPreferenceCommand, AdminSetUserPasswordCommand, AssociateSoftwareTokenCommand, CognitoIdentityProviderClient, @@ -88,10 +89,12 @@ export const createAccount = async (email: string, phone: string, password: stri }); const associateSoftwareTokenRes = await client.send(associateSoftwareTokenCommand); + console.log('associateSoftwareTokenRes', associateSoftwareTokenRes); cookies().set('secretCode', associateSoftwareTokenRes.SecretCode!); const { otp } = TOTP.generate(associateSoftwareTokenRes.SecretCode!); + console.log('otp', otp); const verifySoftwareTokenCommand = new VerifySoftwareTokenCommand({ Session: initiateAuthRes.Session, AccessToken: initiateAuthRes.AuthenticationResult?.AccessToken, @@ -99,7 +102,18 @@ export const createAccount = async (email: string, phone: string, password: stri }); const verifySoftwareTokenRes = await client.send(verifySoftwareTokenCommand); - console.log(verifySoftwareTokenRes); + console.log('verifySoftwareTokenRes', verifySoftwareTokenRes); + + const setMfaPreferenceCommand = new AdminSetUserMFAPreferenceCommand({ + UserPoolId: cognitoUserPoolId, + Username: randomUsername, + SoftwareTokenMfaSettings: { + Enabled: true, + PreferredMfa: true, + }, + }); + const setMfaPreferenceCommandRes = await client.send(setMfaPreferenceCommand); + console.log('setMfaPreferenceCommandRes', setMfaPreferenceCommandRes); return; }; diff --git a/playground/connect-next/app/utils.ts b/playground/connect-next/app/utils.ts new file mode 100644 index 000000000..e67c5f8f0 --- /dev/null +++ b/playground/connect-next/app/utils.ts @@ -0,0 +1,34 @@ +import jwt from 'jsonwebtoken'; +import jwksClient from 'jwks-rsa'; + +const jwksUrl = `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.AWS_COGNITO_USER_POOL_ID}/.well-known/jwks.json`; +const client = jwksClient({ jwksUri: jwksUrl }); + +export type DecodedToken = { + username: string; +}; + +export type TokenWrapper = { + AccessToken: string; +}; + +const getKey = (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => { + client.getSigningKey(header.kid, (err, key) => { + const signingKey = key?.getPublicKey(); + callback(err, signingKey); + }); +}; + +export const verifyToken = async (token: string): Promise => { + return new Promise((resolve, reject) => { + jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => { + if (err) { + return reject(err); + } + + const typed = decoded as DecodedToken; + + resolve(typed); + }); + }); +};