From cb7d2324ec5d3709464cb2a196dbc5c21bf0da0a Mon Sep 17 00:00:00 2001 From: Incorbador Date: Tue, 11 Mar 2025 19:28:01 +0100 Subject: [PATCH 1/4] Connect: enrich client-side state --- .../components/append/AppendSuccessScreen.tsx | 4 +- .../components/login/LoginErrorScreenHard.tsx | 2 +- .../components/login/LoginErrorScreenSoft.tsx | 2 +- .../components/login/LoginHybridScreen.tsx | 2 +- .../src/components/login/LoginInitScreen.tsx | 6 +- .../login/LoginPasskeyReLoginScreen.tsx | 2 +- .../src/contexts/AppendProcessProvider.tsx | 2 +- packages/types/src/connect/config.ts | 8 +- packages/web-core/src/index.ts | 2 - .../src/models/connect/connectLastLogin.ts | 54 -------- .../src/services/ClientStateService.ts | 128 ++++++++++++++++++ .../web-core/src/services/ConnectService.ts | 36 +++-- .../web-core/src/services/ProcessService.ts | 6 +- .../web-core/src/services/SessionService.ts | 6 +- .../web-core/src/services/WebAuthnService.ts | 15 +- .../connect-next/app/login/LoginComponent.tsx | 60 ++++++++ playground/connect-next/app/login/actions.ts | 7 +- playground/connect-next/app/login/page.tsx | 58 ++------ .../connect-next/app/post-login/actions.ts | 8 ++ .../connect-next/app/post-login/page.tsx | 8 +- 20 files changed, 269 insertions(+), 147 deletions(-) delete mode 100644 packages/web-core/src/models/connect/connectLastLogin.ts create mode 100644 packages/web-core/src/services/ClientStateService.ts create mode 100644 playground/connect-next/app/login/LoginComponent.tsx create mode 100644 playground/connect-next/app/post-login/actions.ts diff --git a/packages/connect-react/src/components/append/AppendSuccessScreen.tsx b/packages/connect-react/src/components/append/AppendSuccessScreen.tsx index dc902339e..8bffb8e6d 100644 --- a/packages/connect-react/src/components/append/AppendSuccessScreen.tsx +++ b/packages/connect-react/src/components/append/AppendSuccessScreen.tsx @@ -3,6 +3,7 @@ import React from 'react'; import useAppendProcess from '../../hooks/useAppendProcess'; import { PasskeySuccessIcon } from '../shared/icons/PasskeySuccessIcon'; import { PrimaryButton } from '../shared/PrimaryButton'; +import useShared from '../../hooks/useShared'; type Props = { aaguidName?: string; @@ -11,6 +12,7 @@ type Props = { const AppendSuccessScreen = ({ aaguidName }: Props) => { const { config } = useAppendProcess(); + const { getConnectService } = useShared(); const [completing, setCompleting] = React.useState(false); @@ -40,7 +42,7 @@ const AppendSuccessScreen = ({ aaguidName }: Props) => { } setCompleting(true); - void config.onComplete('complete'); + void config.onComplete('complete', getConnectService().encodeClientState()); }} > Continue diff --git a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx index 200529c7c..1d73757ee 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx @@ -60,7 +60,7 @@ const LoginErrorScreenHard = ({ previousAssertionOptions }: Props) => { setLoading(false); try { - await config.onComplete(connectLoginFinishToComplete(resFinish.val)); + await config.onComplete(connectLoginFinishToComplete(resFinish.val), getConnectService().encodeClientState()); } 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 61d6646b3..0164a241a 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx @@ -51,7 +51,7 @@ const LoginErrorScreenSoft = ({ previousAssertionOptions }: Props) => { } try { - await config.onComplete(connectLoginFinishToComplete(resFinish.val)); + await config.onComplete(connectLoginFinishToComplete(resFinish.val), getConnectService().encodeClientState()); 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 2eab9c595..9962ff7b8 100644 --- a/packages/connect-react/src/components/login/LoginHybridScreen.tsx +++ b/packages/connect-react/src/components/login/LoginHybridScreen.tsx @@ -31,7 +31,7 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { } try { - await config.onComplete(connectLoginFinishToComplete(res.val)); + await config.onComplete(connectLoginFinishToComplete(res.val), getConnectService().encodeClientState()); } 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 0d1e8489c..d2ab916d0 100644 --- a/packages/connect-react/src/components/login/LoginInitScreen.tsx +++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx @@ -95,6 +95,8 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { getConnectService().handleNa(); } + getConnectService().enrichClientState(config.clientState); + const res = await getConnectService().loginInit(ac); if (res.err) { if (res.val.ignore) { @@ -180,7 +182,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { } try { - await config.onComplete(connectLoginFinishToComplete(res.val)); + await config.onComplete(connectLoginFinishToComplete(res.val), getConnectService().encodeClientState()); } catch { return handleSituation(LoginSituationCode.CtApiNotAvailablePostAuthenticator); } @@ -226,7 +228,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { } try { - await config.onComplete(connectLoginFinishToComplete(res.val)); + await config.onComplete(connectLoginFinishToComplete(res.val), getConnectService().encodeClientState()); } 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 1233d766f..38c5e1b01 100644 --- a/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx +++ b/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx @@ -52,7 +52,7 @@ export const LoginPasskeyReLoginScreen = () => { } try { - await config.onComplete(connectLoginFinishToComplete(resFinish.val)); + await config.onComplete(connectLoginFinishToComplete(resFinish.val), getConnectService().encodeClientState()); } catch { return handleSituation(LoginSituationCode.CtApiNotAvailablePostAuthenticator); } diff --git a/packages/connect-react/src/contexts/AppendProcessProvider.tsx b/packages/connect-react/src/contexts/AppendProcessProvider.tsx index 57cef4503..1fbb66b2c 100644 --- a/packages/connect-react/src/contexts/AppendProcessProvider.tsx +++ b/packages/connect-react/src/contexts/AppendProcessProvider.tsx @@ -76,7 +76,7 @@ export const AppendProcessProvider: FC> = ({ children, log.debug('error (credential-exists)'); await getConnectService().recordEventAppendCredentialExistsError(); - void config.onComplete('complete-noop'); + void config.onComplete('complete-noop', getConnectService().encodeClientState()); }, [getConnectService, config]); const contextValue = useMemo( diff --git a/packages/types/src/connect/config.ts b/packages/types/src/connect/config.ts index 801173a28..3ee6f0447 100644 --- a/packages/types/src/connect/config.ts +++ b/packages/types/src/connect/config.ts @@ -4,11 +4,12 @@ export type CorbadoConnectLoginConfig = { onFallbackCustom?(identifier: string, code: string, payload: string): void; onError?(error: string): void; onLoaded?(message: string, isFallBackTriggered: boolean): void; - onComplete(signedPasskeyData: string): Promise; + onComplete(signedPasskeyData: string, clientState: string): Promise; onConditionalLoginStart?(ac: AbortController): void; onLoginStart?(): void; onHelpClick?(): void; onSignupClick?(): void; + clientState?: string; }; export type CorbadoConnectLoginSecondFactorConfig = { @@ -23,7 +24,7 @@ export type CorbadoConnectAppendConfig = { appendTokenProvider(): Promise; onError?(error: string): void; onSkip(status: AppendStatus): Promise; - onComplete(status: AppendStatus): Promise; + onComplete(status: AppendStatus, clientState: string): Promise; }; export type AppendStatus = 'skip-implicit' | 'skip-explicit' | 'complete' | 'complete-noop'; @@ -34,8 +35,11 @@ export enum ConnectTokenType { PasskeyDelete = 'passkey-delete', } +export type PasskeyListStatus = 'append-complete' | 'delete-complete'; + export type CorbadoConnectPasskeyListConfig = { connectTokenProvider: (type: ConnectTokenType) => Promise; + onComplete(status: PasskeyListStatus, clientState: string): Promise; }; export type CorbadoConnectConfig = { diff --git a/packages/web-core/src/index.ts b/packages/web-core/src/index.ts index 41eb434b3..14dc6ca29 100644 --- a/packages/web-core/src/index.ts +++ b/packages/web-core/src/index.ts @@ -6,6 +6,4 @@ export * from './api'; export * from './models/emailVerifyFromUrl'; export * from './models/lastIdentifier'; -export * from './models/connect/connectLastLogin'; - export { CredentialRequestOptionsJSON } from '@corbado/webauthn-json'; diff --git a/packages/web-core/src/models/connect/connectLastLogin.ts b/packages/web-core/src/models/connect/connectLastLogin.ts deleted file mode 100644 index 39ed43b46..000000000 --- a/packages/web-core/src/models/connect/connectLastLogin.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { LoginIdentifierType, PasskeyCeremonyType } from '@corbado/types'; - -const getStorageKey = (projectId: string) => `cbo_connect_last_login-${projectId}`; - -export class ConnectLastLogin { - readonly identifierType: LoginIdentifierType; - readonly identifierValue: string; - readonly ceremonyType: PasskeyCeremonyType; - readonly operationType: string; - - constructor({ - identifierType, - identifierValue, - ceremonyType, - operationType, - }: { - identifierType: LoginIdentifierType; - identifierValue: string; - ceremonyType: PasskeyCeremonyType; - operationType: string; - }) { - this.identifierType = identifierType; - this.identifierValue = identifierValue; - this.operationType = operationType; - this.ceremonyType = ceremonyType; - } - - static loadFromStorage(projectId: string): ConnectLastLogin | undefined { - const serialized = localStorage.getItem(getStorageKey(projectId)); - if (!serialized) { - return undefined; - } - - const process = new ConnectLastLogin(JSON.parse(serialized)); - - return process; - } - - persistToStorage(projectId: string) { - localStorage.setItem( - getStorageKey(projectId), - JSON.stringify({ - identifierType: this.identifierType, - identifierValue: this.identifierValue, - ceremonyType: this.ceremonyType, - operationType: this.operationType, - }), - ); - } - - static clearStorage(projectId: string) { - localStorage.removeItem(getStorageKey(projectId)); - } -} diff --git a/packages/web-core/src/services/ClientStateService.ts b/packages/web-core/src/services/ClientStateService.ts new file mode 100644 index 000000000..0933fbe0a --- /dev/null +++ b/packages/web-core/src/services/ClientStateService.ts @@ -0,0 +1,128 @@ +import type { LoginIdentifierType, PasskeyCeremonyType } from '@corbado/types'; + +import { base64decode, base64encode } from '../utils'; + +const getStorageKeyClientHandle = (projectId: string) => `cbo_client_handle-${projectId}`; +const getStorageKeyClientHandleCompat = () => `cbo_client_handle`; +const getStorageKeyLastLogin = (projectId: string) => `cbo_connect_last_login-${projectId}`; + +enum Source { + LocalStorage = 'LocalStorage', + URL = 'URL', +} + +type CombinedData = { + clientEnvHandle: ClientStateEntry | undefined; + lastLogin: ClientStateEntry | undefined; +}; + +export type LastLogin = { + identifierType: LoginIdentifierType; + identifierValue: string; + ceremonyType: PasskeyCeremonyType; + operationType: string; +}; + +type ClientStateEntry = { + data: T; + source: Source; + ts: number; +}; + +export class ClientStateService { + static enrichFromURL(projectId: string, encoded: string): void { + const decoded = JSON.parse(base64decode(encoded)) as CombinedData; + const existingClientEnvHandle = this.#getEntry(getStorageKeyClientHandle(projectId)); + + if ( + decoded.clientEnvHandle && + ((existingClientEnvHandle && decoded.clientEnvHandle.ts > existingClientEnvHandle.ts) || !existingClientEnvHandle) + ) { + this.#setClientEnvHandle(projectId, decoded.clientEnvHandle.data, Source.URL, decoded.clientEnvHandle.ts); + } + + const existingLastLogin = this.#getEntry(getStorageKeyLastLogin(projectId)); + if ( + decoded.lastLogin && + ((existingLastLogin && decoded.lastLogin.ts > existingLastLogin.ts) || !existingLastLogin) + ) { + this.#setLastLogin(projectId, decoded.lastLogin.data, Source.URL, decoded.lastLogin.ts); + } + } + + static encodeToURL(projectId: string): string { + const data: CombinedData = { + lastLogin: this.#getEntry(getStorageKeyLastLogin(projectId)), + clientEnvHandle: this.#getEntry(getStorageKeyClientHandle(projectId)), + }; + + return base64encode(JSON.stringify(data)); + } + + static getLastLogin(projectId: string): LastLogin | undefined { + const entry = this.#getEntry(getStorageKeyLastLogin(projectId)); + if (entry) { + return entry.data; + } + + const serialized = localStorage.getItem(getStorageKeyLastLogin(projectId)); + if (!serialized) { + return undefined; + } + + return JSON.parse(serialized) as LastLogin; + } + + static setLastLogin(projectId: string, lastLogin: LastLogin): void { + this.#setLastLogin(projectId, lastLogin, Source.LocalStorage, Date.now()); + } + + static #setLastLogin(projectId: string, data: LastLogin | null, source: Source, ts: number): void { + const entry: ClientStateEntry = { data, source, ts }; + + localStorage.setItem(getStorageKeyLastLogin(projectId), JSON.stringify(entry)); + } + + static clearLastLogin(projectId: string): void { + this.#setLastLogin(projectId, null, Source.LocalStorage, Date.now()); + } + + static getClientEnvHandle(projectId: string): string | undefined { + const entry = this.#getEntry(getStorageKeyClientHandle(projectId)); + + if (entry) { + return entry.data; + } + + const compatEntry = this.#getEntry(getStorageKeyClientHandleCompat()); + if (compatEntry) { + this.#setClientEnvHandle(projectId, compatEntry.data, Source.LocalStorage, compatEntry.ts); + return compatEntry.data; + } + + return; + } + + static setClientEnvHandle(projectId: string, clientEnvHandle: string): void { + this.#setClientEnvHandle(projectId, clientEnvHandle, Source.LocalStorage, Date.now()); + } + + static #setClientEnvHandle(projectId: string, data: string, source: Source, ts: number): void { + const entry: ClientStateEntry = { data, source, ts }; + + localStorage.setItem(getStorageKeyClientHandle(projectId), JSON.stringify(entry)); + } + + static #getEntry(key: string): ClientStateEntry | undefined { + const serialized = localStorage.getItem(key); + if (!serialized) { + return undefined; + } + + try { + return JSON.parse(serialized) as ClientStateEntry; + } catch { + return; + } + } +} diff --git a/packages/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts index 4a1465caa..d7d18a4f6 100644 --- a/packages/web-core/src/services/ConnectService.ts +++ b/packages/web-core/src/services/ConnectService.ts @@ -24,11 +24,12 @@ import type { import { CorbadoConnectApi, PasskeyEventType } from '../api/v2'; import { ConnectFlags } from '../models/connect/connectFlags'; import { ConnectInvitation } from '../models/connect/connectInvitation'; -import { ConnectLastLogin } from '../models/connect/connectLastLogin'; import { ConnectProcess } from '../models/connect/connectProcess'; import type { ConnectAppendInitData, ConnectLoginInitData, ConnectManageInitData } from '../models/connect/login'; import type { PasskeyLoginSource } from '../utils'; import { CorbadoError } from '../utils'; +import type { LastLogin } from './ClientStateService'; +import { ClientStateService } from './ClientStateService'; import { WebAuthnService } from './WebAuthnService'; const packageVersion = process.env.FE_LIBRARY_VERSION; @@ -165,7 +166,7 @@ export class ConnectService { // if the backend decides that a new client handle is needed, we store it in local storage if (res.val.newClientEnvHandle) { - WebAuthnService.setClientHandle(res.val.newClientEnvHandle); + ClientStateService.setClientEnvHandle(this.#projectId, res.val.newClientEnvHandle); } flags.addItemsObject(res.val.flags); @@ -322,7 +323,7 @@ export class ConnectService { // if the backend decides that a new client handle is needed, we store it in local storage if (res.val.newClientEnvHandle) { - WebAuthnService.setClientHandle(res.val.newClientEnvHandle); + ClientStateService.setClientEnvHandle(this.#projectId, res.val.newClientEnvHandle); } flags.addItemsObject(res.val.flags); @@ -413,8 +414,8 @@ export class ConnectService { this.#connectApi.connectAppendFinish({ attestationResponse: res.val }), ); if (finishRes.ok) { - const latestLogin = new ConnectLastLogin(finishRes.val.passkeyOperation); - latestLogin.persistToStorage(this.#projectId); + const latestLogin = finishRes.val.passkeyOperation as LastLogin; + ClientStateService.setLastLogin(this.#projectId, latestLogin); } return finishRes; @@ -443,8 +444,8 @@ export class ConnectService { } if (res.ok) { - const latestLogin = new ConnectLastLogin(res.val.passkeyOperation); - latestLogin.persistToStorage(this.#projectId); + const latestLogin = res.val.passkeyOperation as LastLogin; + ClientStateService.setLastLogin(this.#projectId, latestLogin); } return res; @@ -474,7 +475,7 @@ export class ConnectService { // if the backend decides that a new client handle is needed, we store it in local storage if (res.val.newClientEnvHandle) { - WebAuthnService.setClientHandle(res.val.newClientEnvHandle); + ClientStateService.setClientEnvHandle(this.#projectId, res.val.newClientEnvHandle); } flags.addItemsObject(res.val.flags); @@ -661,11 +662,23 @@ export class ConnectService { } getLastLogin() { - return ConnectLastLogin.loadFromStorage(this.#projectId); + return ClientStateService.getLastLogin(this.#projectId); } clearLastLogin() { - ConnectLastLogin.clearStorage(this.#projectId); + ClientStateService.clearLastLogin(this.#projectId); + } + + enrichClientState(encoded?: string) { + if (!encoded) { + return; + } + + ClientStateService.enrichFromURL(this.#projectId, encoded); + } + + encodeClientState(): string { + return ClientStateService.encodeToURL(this.#projectId); } async #getInitReq(): Promise<{ @@ -683,7 +696,8 @@ export class ConnectService { } const flags = ConnectFlags.loadFromStorage(this.#projectId); - const clientInformation = await this.#webAuthnService.getClientInformation(); + const maybeClientHandle = ClientStateService.getClientEnvHandle(this.#projectId); + const clientInformation = await this.#webAuthnService.getClientInformation(maybeClientHandle); const invitationToken = ConnectInvitation.loadFromStorage()?.token; const req = { diff --git a/packages/web-core/src/services/ProcessService.ts b/packages/web-core/src/services/ProcessService.ts index 9eed55773..f7e668b26 100644 --- a/packages/web-core/src/services/ProcessService.ts +++ b/packages/web-core/src/services/ProcessService.ts @@ -39,6 +39,7 @@ import { EmailVerifyFromUrl } from '../models/emailVerifyFromUrl'; import type { LastIdentifier } from '../models/lastIdentifier'; import { CorbadoError, PasskeyChallengeCancelledError, skipPasskeyAppendAfterHybridKey } from '../utils'; import { WebAuthnService } from './WebAuthnService'; +import { ClientStateService } from './ClientStateService'; const packageVersion = process.env.FE_LIBRARY_VERSION; const passkeyAppendShownKey = 'cbo_passkey_append_shown'; @@ -239,7 +240,7 @@ export class ProcessService { // if the backend decides that a new client handle is needed, we store it in local storage if (res.val.newClientEnvHandle) { - WebAuthnService.setClientHandle(res.val.newClientEnvHandle); + ClientStateService.setClientEnvHandle(this.#projectId, res.val.newClientEnvHandle); } return res; @@ -263,8 +264,9 @@ export class ProcessService { passkeyAppendShown: number | null, frontendPreferredBlockType?: BlockType, ): Promise> { + const maybeClientHandle = ClientStateService.getClientEnvHandle(this.#projectId); const req: ProcessInitReq = { - clientInformation: await this.#webAuthnService.getClientInformation(), + clientInformation: await this.#webAuthnService.getClientInformation(maybeClientHandle), passkeyAppendShown: passkeyAppendShown ?? undefined, preferredBlock: frontendPreferredBlockType, optOutOfPasskeyAppendAfterHybrid: localStorage.getItem(skipPasskeyAppendAfterHybridKey) === 'true', diff --git a/packages/web-core/src/services/SessionService.ts b/packages/web-core/src/services/SessionService.ts index 6a6bc90fe..9ce2905e3 100644 --- a/packages/web-core/src/services/SessionService.ts +++ b/packages/web-core/src/services/SessionService.ts @@ -27,6 +27,7 @@ import { SessionManagementNotEnabled, } from '../utils'; import { WebAuthnService } from './WebAuthnService'; +import { ClientStateService } from './ClientStateService'; const sessionTokenKey = 'cbo_session_token'; const refreshTokenKey = 'cbo_refresh_token'; @@ -174,13 +175,14 @@ export class SessionService { } async appendPasskey(): Promise> { - const clientInformation = await this.#webAuthnService.getClientInformation(); + const maybeClientHandle = ClientStateService.getClientEnvHandle(this.#projectId); + const clientInformation = await this.#webAuthnService.getClientInformation(maybeClientHandle); const respStart = await this.#usersApi.currentUserPasskeyAppendStart({ clientInformation: clientInformation, }); if (respStart.data.newClientEnvHandle) { - WebAuthnService.setClientHandle(respStart.data.newClientEnvHandle); + ClientStateService.setClientEnvHandle(this.#projectId, respStart.data.newClientEnvHandle); } if (respStart.data.appendNotAllowedReason) { diff --git a/packages/web-core/src/services/WebAuthnService.ts b/packages/web-core/src/services/WebAuthnService.ts index 2c9db7b45..378736679 100644 --- a/packages/web-core/src/services/WebAuthnService.ts +++ b/packages/web-core/src/services/WebAuthnService.ts @@ -13,8 +13,6 @@ import { Err, Ok } from 'ts-results'; import type { ClientInformation, JavaScriptHighEntropy } from '../api/v2'; import { CorbadoError } from '../utils'; -const clientHandleKey = 'cbo_client_handle'; - /** * AuthenticatorService handles all interactions with webAuthn platform authenticators. * Currently, this includes the creation of passkeys and the login with existing passkeys. @@ -74,12 +72,11 @@ export class WebAuthnService { } } - async getClientInformation(): Promise { + async getClientInformation(maybeClientHandle: string | undefined): Promise { const bluetoothAvailable = await WebAuthnService.canUseBluetooth(); const isUserVerifyingPlatformAuthenticatorAvailable = await WebAuthnService.doesBrowserSupportPasskeys(); const javaScriptHighEntropy = await WebAuthnService.getHighEntropyValues(); const canUseConditionalUI = await WebAuthnService.doesBrowserSupportConditionalUI(); - const maybeClientHandle = WebAuthnService.getClientHandle(); // iOS & macOS Only so far const clientCapabilities = await WebAuthnService.getClientCapabilities(); @@ -98,7 +95,7 @@ export class WebAuthnService { bluetoothAvailable: bluetoothAvailable, isUserVerifyingPlatformAuthenticatorAvailable: isUserVerifyingPlatformAuthenticatorAvailable, isConditionalMediationAvailable: canUseConditionalUI, - clientEnvHandle: maybeClientHandle ?? undefined, + clientEnvHandle: maybeClientHandle, visitorId: currentVisitorId, javaScriptHighEntropy: javaScriptHighEntropy, clientCapabilities, @@ -160,10 +157,6 @@ export class WebAuthnService { } } - static getClientHandle(): string | null { - return localStorage.getItem(clientHandleKey); - } - static async getHighEntropyValues(): Promise { try { if (!navigator.userAgentData) { @@ -189,10 +182,6 @@ export class WebAuthnService { } } - static setClientHandle(clientHandle: string) { - localStorage.setItem(clientHandleKey, clientHandle); - } - public abortOngoingOperation(): AbortController { if (this.#abortController) { this.#abortController.abort(); diff --git a/playground/connect-next/app/login/LoginComponent.tsx b/playground/connect-next/app/login/LoginComponent.tsx new file mode 100644 index 000000000..ea2b4d336 --- /dev/null +++ b/playground/connect-next/app/login/LoginComponent.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { CorbadoConnectLogin } from '@corbado/connect-react'; +import { useState } from 'react'; +import ConventionalLogin from '@/app/login/ConventionalLogin'; +import { postPasskeyLoginNew } from '@/app/login/actions'; + +export type Props = { + clientState: string | undefined; +}; + +export default function LoginComponent({ clientState }: Props) { + const router = useRouter(); + const [conventionalLoginVisible, setConventionalLoginVisible] = useState(false); + const [email, setEmail] = useState(''); + const [fallbackErrorMessage, setFallbackErrorMessage] = useState(''); + + console.log('conventionalLoginVisible', conventionalLoginVisible); + + return ( +
+
+
+ {conventionalLoginVisible ? ( + + ) : null} +
+ { + 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, clientState: string) => { + await postPasskeyLoginNew(signedPasskeyData, clientState); + router.push('/post-login'); + }} + onSignupClick={() => router.push('/signup')} + onHelpClick={() => alert('help requested')} + clientState={clientState} + /> +
+
+
+
+ ); +} diff --git a/playground/connect-next/app/login/actions.ts b/playground/connect-next/app/login/actions.ts index ef92333ab..8b4a37187 100644 --- a/playground/connect-next/app/login/actions.ts +++ b/playground/connect-next/app/login/actions.ts @@ -13,6 +13,7 @@ import { TokenWrapper, verifyToken } from '@/app/utils'; // 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) { + console.log('postPasskeyLogin', session); const tokenWrapper = JSON.parse(session) as TokenWrapper; const decoded = await verifyToken(tokenWrapper.AccessToken); const username = decoded.username; @@ -42,7 +43,7 @@ export async function postPasskeyLogin(session: string) { return; } -export async function postPasskeyLoginNew(signedPasskeyData: string) { +export async function postPasskeyLoginNew(signedPasskeyData: string, clientState: string) { const url = `${process.env.CORBADO_BACKEND_API_URL}/v2/passkey/postLogin`; const body = JSON.stringify({ signedPasskeyData: signedPasskeyData, @@ -59,9 +60,11 @@ export async function postPasskeyLoginNew(signedPasskeyData: string) { }); const out = await response.json(); - console.log(out); await postPasskeyLogin(out.session); + + // update client side state + cookies().set({ name: 'cbo_client_state', value: clientState, httpOnly: true }); } 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 a4591c83c..61c748c28 100644 --- a/playground/connect-next/app/login/page.tsx +++ b/playground/connect-next/app/login/page.tsx @@ -1,55 +1,13 @@ -'use client'; +import LoginComponent from '@/app/login/LoginComponent'; +import { cookies } from 'next/headers'; -import { useRouter } from 'next/navigation'; -import { CorbadoConnectLogin } from '@corbado/connect-react'; -import { useState } from 'react'; -import ConventionalLogin from '@/app/login/ConventionalLogin'; -import { postPasskeyLogin, postPasskeyLoginNew } from '@/app/login/actions'; +export type Props = { + clientState: string | undefined; +}; export default function LoginPage() { - const router = useRouter(); - const [conventionalLoginVisible, setConventionalLoginVisible] = useState(false); - const [email, setEmail] = useState(''); - const [fallbackErrorMessage, setFallbackErrorMessage] = useState(''); + const clientState = cookies().get('cbo_client_state'); + console.log('clientState', clientState); - console.log('conventionalLoginVisible', conventionalLoginVisible); - - return ( -
-
-
- {conventionalLoginVisible ? ( - - ) : null} -
- { - 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) => { - await postPasskeyLoginNew(signedPasskeyData); - router.push('/post-login'); - }} - onSignupClick={() => router.push('/signup')} - onHelpClick={() => alert('help requested')} - /> -
-
-
-
- ); + return ; } diff --git a/playground/connect-next/app/post-login/actions.ts b/playground/connect-next/app/post-login/actions.ts new file mode 100644 index 000000000..7cf6dc571 --- /dev/null +++ b/playground/connect-next/app/post-login/actions.ts @@ -0,0 +1,8 @@ +'use server'; + +import { cookies } from 'next/headers'; + +export async function postPasskeyAppend(_: string, clientState: string) { + // update client side state + cookies().set({ name: 'cbo_client_state', value: clientState, httpOnly: true }); +} diff --git a/playground/connect-next/app/post-login/page.tsx b/playground/connect-next/app/post-login/page.tsx index 700e87c63..6d7e1d35a 100644 --- a/playground/connect-next/app/post-login/page.tsx +++ b/playground/connect-next/app/post-login/page.tsx @@ -1,4 +1,6 @@ 'use client'; +import { postPasskeyAppend } from '@/app/post-login/actions'; + export const runtime = 'edge'; import { CorbadoConnectAppend } from '@corbado/connect-react'; @@ -20,7 +22,11 @@ export default function PostLoginPage() { return t; }} - onComplete={async () => router.push('/home')} + onComplete={async (_, clientSideState: string) => { + console.log('onComplete', clientSideState); + await postPasskeyAppend('', clientSideState); + router.push('/home'); + }} /> From 1118c316ee7d1394cd28e743dbc370227f094cea Mon Sep 17 00:00:00 2001 From: Incorbador Date: Tue, 11 Mar 2025 19:30:01 +0100 Subject: [PATCH 2/4] Linter --- .../connect-react/src/components/append/AppendSuccessScreen.tsx | 2 +- packages/web-core/src/services/ProcessService.ts | 2 +- packages/web-core/src/services/SessionService.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/connect-react/src/components/append/AppendSuccessScreen.tsx b/packages/connect-react/src/components/append/AppendSuccessScreen.tsx index 8bffb8e6d..5d2183cd5 100644 --- a/packages/connect-react/src/components/append/AppendSuccessScreen.tsx +++ b/packages/connect-react/src/components/append/AppendSuccessScreen.tsx @@ -1,9 +1,9 @@ import React from 'react'; import useAppendProcess from '../../hooks/useAppendProcess'; +import useShared from '../../hooks/useShared'; import { PasskeySuccessIcon } from '../shared/icons/PasskeySuccessIcon'; import { PrimaryButton } from '../shared/PrimaryButton'; -import useShared from '../../hooks/useShared'; type Props = { aaguidName?: string; diff --git a/packages/web-core/src/services/ProcessService.ts b/packages/web-core/src/services/ProcessService.ts index f7e668b26..11db68eb9 100644 --- a/packages/web-core/src/services/ProcessService.ts +++ b/packages/web-core/src/services/ProcessService.ts @@ -38,8 +38,8 @@ import { AuthProcess } from '../models/authProcess'; import { EmailVerifyFromUrl } from '../models/emailVerifyFromUrl'; import type { LastIdentifier } from '../models/lastIdentifier'; import { CorbadoError, PasskeyChallengeCancelledError, skipPasskeyAppendAfterHybridKey } from '../utils'; -import { WebAuthnService } from './WebAuthnService'; import { ClientStateService } from './ClientStateService'; +import { WebAuthnService } from './WebAuthnService'; const packageVersion = process.env.FE_LIBRARY_VERSION; const passkeyAppendShownKey = 'cbo_passkey_append_shown'; diff --git a/packages/web-core/src/services/SessionService.ts b/packages/web-core/src/services/SessionService.ts index 9ce2905e3..9e60a4c0b 100644 --- a/packages/web-core/src/services/SessionService.ts +++ b/packages/web-core/src/services/SessionService.ts @@ -26,8 +26,8 @@ import { PasskeysNotSupported, SessionManagementNotEnabled, } from '../utils'; -import { WebAuthnService } from './WebAuthnService'; import { ClientStateService } from './ClientStateService'; +import { WebAuthnService } from './WebAuthnService'; const sessionTokenKey = 'cbo_session_token'; const refreshTokenKey = 'cbo_refresh_token'; From 69293e036e34e7b0abd9e72bffaa9f442abbd25d Mon Sep 17 00:00:00 2001 From: Incorbador Date: Tue, 11 Mar 2025 19:31:12 +0100 Subject: [PATCH 3/4] cleanup --- packages/types/src/connect/config.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/types/src/connect/config.ts b/packages/types/src/connect/config.ts index 3ee6f0447..40a085f8c 100644 --- a/packages/types/src/connect/config.ts +++ b/packages/types/src/connect/config.ts @@ -35,11 +35,8 @@ export enum ConnectTokenType { PasskeyDelete = 'passkey-delete', } -export type PasskeyListStatus = 'append-complete' | 'delete-complete'; - export type CorbadoConnectPasskeyListConfig = { connectTokenProvider: (type: ConnectTokenType) => Promise; - onComplete(status: PasskeyListStatus, clientState: string): Promise; }; export type CorbadoConnectConfig = { From 815f20dcc236d4256dacac68c24da277f1bb6d9f Mon Sep 17 00:00:00 2001 From: Incorbador Date: Fri, 14 Mar 2025 13:20:50 +0100 Subject: [PATCH 4/4] New FAPI contract for auto append --- .../components/append/AppendInitScreen.tsx | 2 +- .../src/components/login/LoginInitScreen.tsx | 5 --- packages/web-core/openapi/spec_v2.yaml | 19 ++++++++ packages/web-core/src/api/v2/api.ts | 45 +++++++++++++++++++ .../src/services/ClientStateService.ts | 41 +++++++++++------ .../web-core/src/services/ConnectService.ts | 13 +++++- .../web-core/src/services/WebAuthnService.ts | 17 +++++-- 7 files changed, 118 insertions(+), 24 deletions(-) diff --git a/packages/connect-react/src/components/append/AppendInitScreen.tsx b/packages/connect-react/src/components/append/AppendInitScreen.tsx index 9460c91f9..a382872b6 100644 --- a/packages/connect-react/src/components/append/AppendInitScreen.tsx +++ b/packages/connect-react/src/components/append/AppendInitScreen.tsx @@ -133,7 +133,7 @@ const AppendInitScreen = () => { setAttestationOptions(startAppendRes.val.attestationOptions); statefulLoader.current.finish(); - if (flags?.hasSupportForAutomaticAppend()) { + if (startAppendRes.val.autoAppend) { await handleSubmit(startAppendRes.val.attestationOptions, false); } }; diff --git a/packages/connect-react/src/components/login/LoginInitScreen.tsx b/packages/connect-react/src/components/login/LoginInitScreen.tsx index d2ab916d0..26ddebce6 100644 --- a/packages/connect-react/src/components/login/LoginInitScreen.tsx +++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx @@ -90,11 +90,6 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { getConnectService().setInvitation(invitationToken); } - const na = url.searchParams.get('not_authenticated'); - if (na === '1') { - getConnectService().handleNa(); - } - getConnectService().enrichClientState(config.clientState); const res = await getConnectService().loginInit(ac); diff --git a/packages/web-core/openapi/spec_v2.yaml b/packages/web-core/openapi/spec_v2.yaml index a1a4cd677..3403960b1 100644 --- a/packages/web-core/openapi/spec_v2.yaml +++ b/packages/web-core/openapi/spec_v2.yaml @@ -1248,6 +1248,8 @@ components: type: string identifierHintAvailable: type: boolean + oneTapMeta: + $ref: '#/components/schemas/clientStateMeta' connectLoginStartRsp: type: object @@ -1352,6 +1354,7 @@ components: - attestationOptions - variant - isRestrictedBrowser + - autoAppend properties: attestationOptions: type: string @@ -1363,6 +1366,8 @@ components: - after-error isRestrictedBrowser: type: boolean + autoAppend: + type: boolean connectAppendFinishReq: type: object @@ -1945,6 +1950,20 @@ components: type: boolean privateMode: type: boolean + clientEnvHandleMeta: + $ref: '#/components/schemas/clientStateMeta' + + clientStateMeta: + type: object + required: + - ts + - source + properties: + ts: + type: integer + source: + type: string + enum: [ 'ls', 'url' ] clientCapabilities: type: object diff --git a/packages/web-core/src/api/v2/api.ts b/packages/web-core/src/api/v2/api.ts index bdf88351e..0946a78af 100644 --- a/packages/web-core/src/api/v2/api.ts +++ b/packages/web-core/src/api/v2/api.ts @@ -246,7 +246,40 @@ export interface ClientInformation { * @memberof ClientInformation */ 'privateMode'?: boolean; + /** + * + * @type {ClientStateMeta} + * @memberof ClientInformation + */ + 'clientEnvHandleMeta'?: ClientStateMeta; +} +/** + * + * @export + * @interface ClientStateMeta + */ +export interface ClientStateMeta { + /** + * + * @type {number} + * @memberof ClientStateMeta + */ + 'ts': number; + /** + * + * @type {string} + * @memberof ClientStateMeta + */ + 'source': ClientStateMetaSourceEnum; } + +export const ClientStateMetaSourceEnum = { + Ls: 'ls', + Url: 'url' +} as const; + +export type ClientStateMetaSourceEnum = typeof ClientStateMetaSourceEnum[keyof typeof ClientStateMetaSourceEnum]; + /** * * @export @@ -390,6 +423,12 @@ export interface ConnectAppendStartRsp { * @memberof ConnectAppendStartRsp */ 'isRestrictedBrowser': boolean; + /** + * + * @type {boolean} + * @memberof ConnectAppendStartRsp + */ + 'autoAppend': boolean; } export const ConnectAppendStartRspVariantEnum = { @@ -593,6 +632,12 @@ export interface ConnectLoginStartReq { * @memberof ConnectLoginStartReq */ 'identifierHintAvailable'?: boolean; + /** + * + * @type {ClientStateMeta} + * @memberof ConnectLoginStartReq + */ + 'oneTapMeta'?: ClientStateMeta; } export const ConnectLoginStartReqSourceEnum = { diff --git a/packages/web-core/src/services/ClientStateService.ts b/packages/web-core/src/services/ClientStateService.ts index 0933fbe0a..0f105bad6 100644 --- a/packages/web-core/src/services/ClientStateService.ts +++ b/packages/web-core/src/services/ClientStateService.ts @@ -1,12 +1,13 @@ import type { LoginIdentifierType, PasskeyCeremonyType } from '@corbado/types'; +import type { ClientStateMeta } from '../api/v2'; import { base64decode, base64encode } from '../utils'; const getStorageKeyClientHandle = (projectId: string) => `cbo_client_handle-${projectId}`; const getStorageKeyClientHandleCompat = () => `cbo_client_handle`; const getStorageKeyLastLogin = (projectId: string) => `cbo_connect_last_login-${projectId}`; -enum Source { +export enum Source { LocalStorage = 'LocalStorage', URL = 'URL', } @@ -23,7 +24,7 @@ export type LastLogin = { operationType: string; }; -type ClientStateEntry = { +export type ClientStateEntry = { data: T; source: Source; ts: number; @@ -59,18 +60,19 @@ export class ClientStateService { return base64encode(JSON.stringify(data)); } - static getLastLogin(projectId: string): LastLogin | undefined { + static getLastLogin(projectId: string): ClientStateEntry | undefined { const entry = this.#getEntry(getStorageKeyLastLogin(projectId)); if (entry) { - return entry.data; + return entry; } - const serialized = localStorage.getItem(getStorageKeyLastLogin(projectId)); - if (!serialized) { - return undefined; + const compatValue = localStorage.getItem(getStorageKeyLastLogin(projectId)); + if (compatValue) { + this.setLastLogin(projectId, JSON.parse(compatValue) as LastLogin); + return this.#getEntry(getStorageKeyLastLogin(projectId)); } - return JSON.parse(serialized) as LastLogin; + return; } static setLastLogin(projectId: string, lastLogin: LastLogin): void { @@ -87,17 +89,17 @@ export class ClientStateService { this.#setLastLogin(projectId, null, Source.LocalStorage, Date.now()); } - static getClientEnvHandle(projectId: string): string | undefined { + static getClientEnvHandle(projectId: string): ClientStateEntry | undefined { const entry = this.#getEntry(getStorageKeyClientHandle(projectId)); if (entry) { - return entry.data; + return entry; } - const compatEntry = this.#getEntry(getStorageKeyClientHandleCompat()); - if (compatEntry) { - this.#setClientEnvHandle(projectId, compatEntry.data, Source.LocalStorage, compatEntry.ts); - return compatEntry.data; + const compatValue = localStorage.getItem(getStorageKeyClientHandleCompat()); + if (compatValue) { + this.setClientEnvHandle(projectId, compatValue); + return this.#getEntry(getStorageKeyClientHandle(projectId)); } return; @@ -125,4 +127,15 @@ export class ClientStateService { return; } } + + static parseClientStateSource(source: Source): ClientStateMeta['source'] { + switch (source) { + case Source.LocalStorage: + return 'ls'; + case Source.URL: + return 'url'; + default: + return 'ls'; + } + } } diff --git a/packages/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts index d7d18a4f6..ac8343d1a 100644 --- a/packages/web-core/src/services/ConnectService.ts +++ b/packages/web-core/src/services/ConnectService.ts @@ -7,6 +7,7 @@ import { Err, Ok } from 'ts-results'; import { Configuration } from '../api/v1'; import type { + ClientStateMeta, ConnectAppendFinishRsp, ConnectAppendInitReq, ConnectAppendStartRsp, @@ -236,6 +237,15 @@ export class ConnectService { identifierHintAvailable = true; } + let oneTapMeta: ClientStateMeta | undefined; + const lastLogin = ClientStateService.getLastLogin(this.#projectId); + if (lastLogin) { + oneTapMeta = { + source: ClientStateService.parseClientStateSource(lastLogin.source), + ts: lastLogin.ts, + }; + } + const res = await this.wrapWithErr(() => this.#connectApi.connectLoginStart( { @@ -244,6 +254,7 @@ export class ConnectService { loadedMs, loginConnectToken: connectToken, identifierHintAvailable: identifierHintAvailable, + oneTapMeta: oneTapMeta, }, { signal: ac?.signal }, ), @@ -662,7 +673,7 @@ export class ConnectService { } getLastLogin() { - return ClientStateService.getLastLogin(this.#projectId); + return ClientStateService.getLastLogin(this.#projectId)?.data; } clearLastLogin() { diff --git a/packages/web-core/src/services/WebAuthnService.ts b/packages/web-core/src/services/WebAuthnService.ts index 378736679..14acdd57d 100644 --- a/packages/web-core/src/services/WebAuthnService.ts +++ b/packages/web-core/src/services/WebAuthnService.ts @@ -10,8 +10,10 @@ import log from 'loglevel'; import type { Result } from 'ts-results'; import { Err, Ok } from 'ts-results'; -import type { ClientInformation, JavaScriptHighEntropy } from '../api/v2'; +import type { ClientInformation, ClientStateMeta, JavaScriptHighEntropy } from '../api/v2'; import { CorbadoError } from '../utils'; +import type { ClientStateEntry } from './ClientStateService'; +import { ClientStateService } from './ClientStateService'; /** * AuthenticatorService handles all interactions with webAuthn platform authenticators. @@ -72,7 +74,7 @@ export class WebAuthnService { } } - async getClientInformation(maybeClientHandle: string | undefined): Promise { + async getClientInformation(maybeClientHandle: ClientStateEntry | undefined): Promise { const bluetoothAvailable = await WebAuthnService.canUseBluetooth(); const isUserVerifyingPlatformAuthenticatorAvailable = await WebAuthnService.doesBrowserSupportPasskeys(); const javaScriptHighEntropy = await WebAuthnService.getHighEntropyValues(); @@ -91,16 +93,25 @@ export class WebAuthnService { this.#visitorId = visitorId; } + let clientEnvHandleMeta: ClientStateMeta | undefined = undefined; + if (maybeClientHandle) { + clientEnvHandleMeta = { + source: ClientStateService.parseClientStateSource(maybeClientHandle.source), + ts: maybeClientHandle.ts, + }; + } + return { bluetoothAvailable: bluetoothAvailable, isUserVerifyingPlatformAuthenticatorAvailable: isUserVerifyingPlatformAuthenticatorAvailable, isConditionalMediationAvailable: canUseConditionalUI, - clientEnvHandle: maybeClientHandle, + clientEnvHandle: maybeClientHandle?.data, visitorId: currentVisitorId, javaScriptHighEntropy: javaScriptHighEntropy, clientCapabilities, webdriver: WebAuthnService.getWebdriver(), privateMode: await WebAuthnService.isPrivateMode(), + clientEnvHandleMeta: clientEnvHandleMeta, }; }