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/append/AppendSuccessScreen.tsx b/packages/connect-react/src/components/append/AppendSuccessScreen.tsx index dc902339e..5d2183cd5 100644 --- a/packages/connect-react/src/components/append/AppendSuccessScreen.tsx +++ b/packages/connect-react/src/components/append/AppendSuccessScreen.tsx @@ -1,6 +1,7 @@ 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'; @@ -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..26ddebce6 100644 --- a/packages/connect-react/src/components/login/LoginInitScreen.tsx +++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx @@ -90,10 +90,7 @@ 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); if (res.err) { @@ -180,7 +177,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 +223,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..40a085f8c 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'; 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/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..0f105bad6 --- /dev/null +++ b/packages/web-core/src/services/ClientStateService.ts @@ -0,0 +1,141 @@ +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}`; + +export 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; +}; + +export 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): ClientStateEntry | undefined { + const entry = this.#getEntry(getStorageKeyLastLogin(projectId)); + if (entry) { + return entry; + } + + const compatValue = localStorage.getItem(getStorageKeyLastLogin(projectId)); + if (compatValue) { + this.setLastLogin(projectId, JSON.parse(compatValue) as LastLogin); + return this.#getEntry(getStorageKeyLastLogin(projectId)); + } + + return; + } + + 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): ClientStateEntry | undefined { + const entry = this.#getEntry(getStorageKeyClientHandle(projectId)); + + if (entry) { + return entry; + } + + const compatValue = localStorage.getItem(getStorageKeyClientHandleCompat()); + if (compatValue) { + this.setClientEnvHandle(projectId, compatValue); + return this.#getEntry(getStorageKeyClientHandle(projectId)); + } + + 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; + } + } + + 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 4a1465caa..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, @@ -24,11 +25,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 +167,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); @@ -235,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( { @@ -243,6 +254,7 @@ export class ConnectService { loadedMs, loginConnectToken: connectToken, identifierHintAvailable: identifierHintAvailable, + oneTapMeta: oneTapMeta, }, { signal: ac?.signal }, ), @@ -322,7 +334,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 +425,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 +455,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 +486,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 +673,23 @@ export class ConnectService { } getLastLogin() { - return ConnectLastLogin.loadFromStorage(this.#projectId); + return ClientStateService.getLastLogin(this.#projectId)?.data; } 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 +707,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..11db68eb9 100644 --- a/packages/web-core/src/services/ProcessService.ts +++ b/packages/web-core/src/services/ProcessService.ts @@ -38,6 +38,7 @@ import { AuthProcess } from '../models/authProcess'; import { EmailVerifyFromUrl } from '../models/emailVerifyFromUrl'; import type { LastIdentifier } from '../models/lastIdentifier'; import { CorbadoError, PasskeyChallengeCancelledError, skipPasskeyAppendAfterHybridKey } from '../utils'; +import { ClientStateService } from './ClientStateService'; import { WebAuthnService } from './WebAuthnService'; const packageVersion = process.env.FE_LIBRARY_VERSION; @@ -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..9e60a4c0b 100644 --- a/packages/web-core/src/services/SessionService.ts +++ b/packages/web-core/src/services/SessionService.ts @@ -26,6 +26,7 @@ import { PasskeysNotSupported, SessionManagementNotEnabled, } from '../utils'; +import { ClientStateService } from './ClientStateService'; import { WebAuthnService } from './WebAuthnService'; const sessionTokenKey = 'cbo_session_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..14acdd57d 100644 --- a/packages/web-core/src/services/WebAuthnService.ts +++ b/packages/web-core/src/services/WebAuthnService.ts @@ -10,10 +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'; - -const clientHandleKey = 'cbo_client_handle'; +import type { ClientStateEntry } from './ClientStateService'; +import { ClientStateService } from './ClientStateService'; /** * AuthenticatorService handles all interactions with webAuthn platform authenticators. @@ -74,12 +74,11 @@ export class WebAuthnService { } } - async getClientInformation(): Promise { + async getClientInformation(maybeClientHandle: ClientStateEntry | 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(); @@ -94,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 ?? undefined, + clientEnvHandle: maybeClientHandle?.data, visitorId: currentVisitorId, javaScriptHighEntropy: javaScriptHighEntropy, clientCapabilities, webdriver: WebAuthnService.getWebdriver(), privateMode: await WebAuthnService.isPrivateMode(), + clientEnvHandleMeta: clientEnvHandleMeta, }; } @@ -160,10 +168,6 @@ export class WebAuthnService { } } - static getClientHandle(): string | null { - return localStorage.getItem(clientHandleKey); - } - static async getHighEntropyValues(): Promise { try { if (!navigator.userAgentData) { @@ -189,10 +193,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'); + }} />