diff --git a/examples/nextjs/tsconfig.json b/examples/nextjs/tsconfig.json index 80d812d57..5d0690cb1 100644 --- a/examples/nextjs/tsconfig.json +++ b/examples/nextjs/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, "include": [ @@ -37,7 +31,5 @@ "scripts/seed.js", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } diff --git a/packages/connect-react/src/components/login/LoginInitScreen.tsx b/packages/connect-react/src/components/login/LoginInitScreen.tsx index 6727aea52..8efb6e8ad 100644 --- a/packages/connect-react/src/components/login/LoginInitScreen.tsx +++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx @@ -128,7 +128,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { return navigateToScreen(LoginScreenType.PasskeyReLogin); } else if (flags.hasSupportForConditionalUI()) { log.debug('starting conditional UI'); - void startConditionalUI(res.val.conditionalUIChallenge); + void startConditionalUI(res.val.conditionalUIChallenge, flags); } statefulLoader.current.finish(); @@ -143,11 +143,15 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { }; }, [getConnectService]); - const startConditionalUI = async (challenge: string | null) => { + const startConditionalUI = async (challenge: string | null, resolvedFlags: Flags) => { if (!challenge) { return; } + if (resolvedFlags.hasSupportForEventLow()) { + getConnectService().enqueueLowEvent({ eventType: 'cui-ready', timestamp: Date.now() }); + } + let cuiStarted = false; const res = await getConnectService().conditionalUILogin( ac => config.onConditionalLoginStart?.(ac), @@ -187,6 +191,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { } try { + await getConnectService().flushLowEvents(); await config.onComplete( connectLoginFinishToComplete(res.val), getConnectService().encodeClientState(), @@ -204,6 +209,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { setIdentifierBasedLoading(true); setCurrentIdentifier(identifier); + await getConnectService().flushLowEvents(); config.onLoginStart?.(); const resStart = await getConnectService().loginStart(identifier, PasskeyLoginSource.TextField, loadedMs); @@ -242,6 +248,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { } try { + await getConnectService().flushLowEvents(); await config.onComplete( connectLoginFinishToComplete(res.val), getConnectService().encodeClientState(), @@ -328,6 +335,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { // This is needed to enable multiple login instances on the same page however only one should have the autocomplete // Else the conditionalUI won't work const autoComplete = useMemo(() => (flags?.hasSupportForConditionalUI() ? 'username webauthn' : ''), [flags]); + const enableEventLow = useMemo(() => flags?.hasSupportForEventLow() ?? false, [flags]); switch (loginInitState) { case LoginInitState.SilentLoading: @@ -340,6 +348,7 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { isLoading={cuiBasedLoading || identifierBasedLoading} error={error} autoComplete={autoComplete} + enableEventLow={enableEventLow} handleSubmit={() => void handleSubmit()} handleIdentifierUpdate={(v: string) => setIdentifier(v)} /> diff --git a/packages/connect-react/src/components/login/base/LoginInitLoaded.tsx b/packages/connect-react/src/components/login/base/LoginInitLoaded.tsx index f5295e090..d0578bec2 100644 --- a/packages/connect-react/src/components/login/base/LoginInitLoaded.tsx +++ b/packages/connect-react/src/components/login/base/LoginInitLoaded.tsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, { useRef } from 'react'; +import useLoginInputEventLow from '../../../hooks/useLoginInputEventLow'; +import useShared from '../../../hooks/useShared'; import InputField from '../../shared/InputField'; import { LinkButton } from '../../shared/LinkButton'; import { Notification } from '../../shared/Notification'; @@ -9,6 +11,7 @@ interface Props { isLoading: boolean; error: string | undefined; autoComplete: string; + enableEventLow?: boolean; onSignupClick?: () => void; handleSubmit: () => void; handleIdentifierUpdate: (v: string) => void; @@ -19,9 +22,19 @@ const LoginInitLoaded = ({ error, onSignupClick, autoComplete, + enableEventLow = false, handleSubmit, handleIdentifierUpdate, }: Props) => { + const inputRef = useRef(null); + const { getConnectService } = useShared(); + + useLoginInputEventLow({ + inputRef, + connectService: getConnectService(), + enabled: enableEventLow, + }); + return ( <> {error ? ( @@ -37,6 +50,7 @@ const LoginInitLoaded = ({ autoComplete={autoComplete} autoFocus={true} placeholder='' + ref={inputRef} onChange={e => handleIdentifierUpdate(e.target.value)} /> ; + connectService: ConnectService; + enabled: boolean; +}; + +type InputBatch = { + firstTimestamp: number; + lastTimestamp: number; +}; + +const visualViewportBatchTimeoutMs = 150; + +const useLoginInputEventLow = ({ inputRef, connectService, enabled }: Props) => { + const inputBatchRef = useRef(null); + const viewportResizeActiveRef = useRef(false); + const viewportResizeStartTimestampRef = useRef(null); + const viewportResizeEndTimeoutRef = useRef(null); + const viewportScrollActiveRef = useRef(false); + const viewportScrollStartTimestampRef = useRef(null); + const viewportScrollEndTimeoutRef = useRef(null); + + useEffect(() => { + if (!enabled) { + return; + } + + const input = inputRef.current; + if (!input) { + return; + } + + const isInputFocused = () => document.activeElement === input; + + const flushInputBatch = () => { + const batch = inputBatchRef.current; + if (!batch) { + return; + } + + connectService.enqueueLowEvent({ + eventType: 'input', + timestamp: batch.firstTimestamp, + durationMs: batch.lastTimestamp - batch.firstTimestamp, + }); + inputBatchRef.current = null; + }; + + const enqueueNonInputEvent = (eventType: string) => { + flushInputBatch(); + connectService.enqueueLowEvent({ + eventType, + timestamp: Date.now(), + }); + }; + + const flushViewportBatch = ( + eventType: 'visualviewport-resize' | 'visualviewport-scroll', + activeRef: { current: boolean }, + startTimestampRef: { current: number | null }, + timeoutRef: { current: number | null }, + ) => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + + const startTimestamp = startTimestampRef.current; + activeRef.current = false; + startTimestampRef.current = null; + timeoutRef.current = null; + + if (startTimestamp === null) { + return; + } + + connectService.enqueueLowEvent({ + eventType, + timestamp: startTimestamp, + durationMs: Date.now() - startTimestamp, + }); + }; + + const extendViewportBatch = ( + eventType: 'visualviewport-resize' | 'visualviewport-scroll', + activeRef: { current: boolean }, + startTimestampRef: { current: number | null }, + timeoutRef: { current: number | null }, + ) => { + if (!activeRef.current) { + activeRef.current = true; + startTimestampRef.current = Date.now(); + } + + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + + timeoutRef.current = window.setTimeout(() => { + flushViewportBatch(eventType, activeRef, startTimestampRef, timeoutRef); + }, visualViewportBatchTimeoutMs); + }; + + const handleFocus = () => { + enqueueNonInputEvent('focus'); + }; + + const handleBlur = () => { + flushViewportBatch( + 'visualviewport-resize', + viewportResizeActiveRef, + viewportResizeStartTimestampRef, + viewportResizeEndTimeoutRef, + ); + flushViewportBatch( + 'visualviewport-scroll', + viewportScrollActiveRef, + viewportScrollStartTimestampRef, + viewportScrollEndTimeoutRef, + ); + enqueueNonInputEvent('blur'); + }; + + const handlePointerDown = () => { + enqueueNonInputEvent('pointerdown'); + }; + + const handlePointerUp = () => { + enqueueNonInputEvent('pointerup'); + }; + + const handleClick = () => { + enqueueNonInputEvent('click'); + }; + + const handleInput = () => { + const timestamp = Date.now(); + const currentBatch = inputBatchRef.current; + + if (!currentBatch) { + inputBatchRef.current = { + firstTimestamp: timestamp, + lastTimestamp: timestamp, + }; + return; + } + + currentBatch.lastTimestamp = timestamp; + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + + enqueueNonInputEvent('keyup-escape'); + }; + + const handleWindowFocus = () => { + if (!isInputFocused()) { + return; + } + + enqueueNonInputEvent('window-focus'); + }; + + const handleWindowBlur = () => { + if (!isInputFocused()) { + return; + } + + enqueueNonInputEvent('window-blur'); + }; + + const handleVisibilityChange = () => { + if (!isInputFocused()) { + return; + } + + enqueueNonInputEvent('document-visibilitychange'); + }; + + const handleVisualViewportResize = () => { + if (!isInputFocused()) { + return; + } + + extendViewportBatch( + 'visualviewport-resize', + viewportResizeActiveRef, + viewportResizeStartTimestampRef, + viewportResizeEndTimeoutRef, + ); + }; + + const handleVisualViewportScroll = () => { + if (!isInputFocused()) { + return; + } + + extendViewportBatch( + 'visualviewport-scroll', + viewportScrollActiveRef, + viewportScrollStartTimestampRef, + viewportScrollEndTimeoutRef, + ); + }; + + const flushForTeardown = () => { + flushInputBatch(); + flushViewportBatch( + 'visualviewport-resize', + viewportResizeActiveRef, + viewportResizeStartTimestampRef, + viewportResizeEndTimeoutRef, + ); + flushViewportBatch( + 'visualviewport-scroll', + viewportScrollActiveRef, + viewportScrollStartTimestampRef, + viewportScrollEndTimeoutRef, + ); + connectService.flushLowEventsKeepalive(); + }; + + input.addEventListener('focus', handleFocus); + input.addEventListener('blur', handleBlur); + input.addEventListener('pointerdown', handlePointerDown); + input.addEventListener('pointerup', handlePointerUp); + input.addEventListener('click', handleClick); + input.addEventListener('input', handleInput); + input.addEventListener('keyup', handleKeyUp); + + window.addEventListener('focus', handleWindowFocus); + window.addEventListener('blur', handleWindowBlur); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('pagehide', flushForTeardown); + window.visualViewport?.addEventListener('resize', handleVisualViewportResize); + window.visualViewport?.addEventListener('scroll', handleVisualViewportScroll); + + if (document.activeElement === input) { + handleFocus(); + } + + return () => { + flushForTeardown(); + + input.removeEventListener('focus', handleFocus); + input.removeEventListener('blur', handleBlur); + input.removeEventListener('pointerdown', handlePointerDown); + input.removeEventListener('pointerup', handlePointerUp); + input.removeEventListener('click', handleClick); + input.removeEventListener('input', handleInput); + input.removeEventListener('keyup', handleKeyUp); + + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener('blur', handleWindowBlur); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('pagehide', flushForTeardown); + window.visualViewport?.removeEventListener('resize', handleVisualViewportResize); + window.visualViewport?.removeEventListener('scroll', handleVisualViewportScroll); + }; + }, [connectService, enabled, inputRef]); +}; + +export default useLoginInputEventLow; diff --git a/packages/connect-react/src/types/flags.ts b/packages/connect-react/src/types/flags.ts index 0559b8712..78c56c1ac 100644 --- a/packages/connect-react/src/types/flags.ts +++ b/packages/connect-react/src/types/flags.ts @@ -1,5 +1,6 @@ const keyConditionalUI = 'conditional-ui-allowed'; const keyAutoAppend = 'automatic-append'; +const keyEventLow = 'event-low'; export class Flags { readonly items: Record; @@ -21,4 +22,8 @@ export class Flags { hasSupportForAutomaticAppend(): boolean { return this.items[keyAutoAppend] === 'true'; } + + hasSupportForEventLow(): boolean { + return this.items[keyEventLow] === 'true'; + } } diff --git a/packages/web-core/openapi/spec_v2.yaml b/packages/web-core/openapi/spec_v2.yaml index d65191d62..b5d4e5dbd 100644 --- a/packages/web-core/openapi/spec_v2.yaml +++ b/packages/web-core/openapi/spec_v2.yaml @@ -687,6 +687,46 @@ paths: schema: $ref: "#/components/schemas/processResponse" + /v2/sso/saml2/start: + post: + summary: Start SAML2 SSO login + description: Starts a SAML2 SSO login by forwarding the request to Backend API v2. + operationId: SsoSaml2Start + tags: + - Auth + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ssoSaml2StartReq" + responses: + "200": + description: Contains the IdP redirect URL and relay state. + content: + application/json: + schema: + $ref: "#/components/schemas/ssoSaml2StartRsp" + + /v2/sso/saml2/finish: + post: + summary: Finish SAML2 SSO login + description: Completes SAML2 SSO login with IdP form POST data and redirects the user. + operationId: SsoSaml2Finish + tags: + - Auth + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/ssoSaml2FinishReq" + responses: + "302": + description: Redirect to the target URL after successful login. + default: + $ref: "#/components/responses/error" + /v2/auth/events: post: summary: Create authentication event @@ -914,6 +954,22 @@ paths: "204": description: tbd + /v2/connect/eventsLow: + post: + operationId: ConnectEventLowCreate + tags: + - CorbadoConnect + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/connectEventLowCreateReq" + responses: + "204": + description: tbd + + /v2/connect/process/clear: post: summary: Clear connect process @@ -1280,6 +1336,33 @@ components: socialVerifyFinishReq: type: object + ssoSaml2StartReq: + type: object + required: + - email + properties: + email: + type: string + + ssoSaml2StartRsp: + type: object + required: + - idpRedirectURL + properties: + idpRedirectURL: + type: string + + ssoSaml2FinishReq: + type: object + required: + - SAMLResponse + - RelayState + properties: + SAMLResponse: + type: string + RelayState: + type: string + identifierUpdateReq: type: object required: @@ -1351,6 +1434,7 @@ components: shortSession: type: string deprecated: true + x-deprecated-reason: 'Old code' sessionToken: type: string @@ -1689,6 +1773,31 @@ components: challenge: type: string + connectEventLowCreateReq: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/connectEventLow" + + connectEventLow: + type: object + required: + - eventType + - timestamp + properties: + eventType: + type: string + timestamp: + type: integer + format: int64 + durationMs: + type: integer + format: int64 + passkey: type: object required: @@ -1860,6 +1969,7 @@ components: shortSessionCookieConfig: type: object deprecated: true + x-deprecated-reason: 'Old code' required: - domain - secure @@ -2155,6 +2265,7 @@ components: longSession: type: string deprecated: true + x-deprecated-reason: 'Old code' description: This is only set if the project environment is set to 'dev'. If set the UI components will set the longSession in local storage because the cookie dropping will not work in Safari for example ("third-party cookie"). refreshToken: type: string @@ -2162,6 +2273,7 @@ components: shortSession: type: string deprecated: true + x-deprecated-reason: 'Old code' sessionToken: type: string passkeyOperation: diff --git a/packages/web-core/src/api/v2/api.ts b/packages/web-core/src/api/v2/api.ts index 5fe7adffd..6afaa36cf 100644 --- a/packages/web-core/src/api/v2/api.ts +++ b/packages/web-core/src/api/v2/api.ts @@ -689,6 +689,44 @@ export interface ConnectEventCreateReq { } +/** + * + * @export + * @interface ConnectEventLow + */ +export interface ConnectEventLow { + /** + * + * @type {string} + * @memberof ConnectEventLow + */ + 'eventType': string; + /** + * + * @type {number} + * @memberof ConnectEventLow + */ + 'timestamp': number; + /** + * + * @type {number} + * @memberof ConnectEventLow + */ + 'durationMs'?: number; +} +/** + * + * @export + * @interface ConnectEventLowCreateReq + */ +export interface ConnectEventLowCreateReq { + /** + * + * @type {Array} + * @memberof ConnectEventLowCreateReq + */ + 'items': Array; +} /** * * @export @@ -3112,6 +3150,32 @@ export interface SocialVerifyStartReq { } +/** + * + * @export + * @interface SsoSaml2StartReq + */ +export interface SsoSaml2StartReq { + /** + * + * @type {string} + * @memberof SsoSaml2StartReq + */ + 'email': string; +} +/** + * + * @export + * @interface SsoSaml2StartRsp + */ +export interface SsoSaml2StartRsp { + /** + * + * @type {string} + * @memberof SsoSaml2StartRsp + */ + 'idpRedirectURL': string; +} /** * * @export @@ -3990,6 +4054,104 @@ export const AuthApiAxiosParamCreator = function (configuration?: Configuration) localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.data = serializeDataIfNeeded(socialVerifyStartReq, localVarRequestOptions, configuration) + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Completes SAML2 SSO login with IdP form POST data and redirects the user. + * @summary Finish SAML2 SSO login + * @param {string} sAMLResponse + * @param {string} relayState + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + ssoSaml2Finish: async (sAMLResponse: string, relayState: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'sAMLResponse' is not null or undefined + assertParamExists('ssoSaml2Finish', 'sAMLResponse', sAMLResponse) + // verify required parameter 'relayState' is not null or undefined + assertParamExists('ssoSaml2Finish', 'relayState', relayState) + const localVarPath = `/v2/sso/saml2/finish`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + const localVarFormParams = new URLSearchParams(); + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + // authentication projectID required + await setApiKeyToObject(localVarHeaderParameter, "X-Corbado-ProjectID", configuration) + + + if (sAMLResponse !== undefined) { + localVarFormParams.set('SAMLResponse', sAMLResponse as any); + } + + if (relayState !== undefined) { + localVarFormParams.set('RelayState', relayState as any); + } + + + localVarHeaderParameter['Content-Type'] = 'application/x-www-form-urlencoded'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = localVarFormParams.toString(); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Starts a SAML2 SSO login by forwarding the request to Backend API v2. + * @summary Start SAML2 SSO login + * @param {SsoSaml2StartReq} ssoSaml2StartReq + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + ssoSaml2Start: async (ssoSaml2StartReq: SsoSaml2StartReq, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'ssoSaml2StartReq' is not null or undefined + assertParamExists('ssoSaml2Start', 'ssoSaml2StartReq', ssoSaml2StartReq) + const localVarPath = `/v2/sso/saml2/start`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + // authentication projectID required + await setApiKeyToObject(localVarHeaderParameter, "X-Corbado-ProjectID", configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(ssoSaml2StartReq, localVarRequestOptions, configuration) + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -4220,6 +4382,29 @@ export const AuthApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.socialVerifyStart(socialVerifyStartReq, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * Completes SAML2 SSO login with IdP form POST data and redirects the user. + * @summary Finish SAML2 SSO login + * @param {string} sAMLResponse + * @param {string} relayState + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async ssoSaml2Finish(sAMLResponse: string, relayState: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.ssoSaml2Finish(sAMLResponse, relayState, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * Starts a SAML2 SSO login by forwarding the request to Backend API v2. + * @summary Start SAML2 SSO login + * @param {SsoSaml2StartReq} ssoSaml2StartReq + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async ssoSaml2Start(ssoSaml2StartReq: SsoSaml2StartReq, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.ssoSaml2Start(ssoSaml2StartReq, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -4425,6 +4610,27 @@ export const AuthApiFactory = function (configuration?: Configuration, basePath? socialVerifyStart(socialVerifyStartReq: SocialVerifyStartReq, options?: any): AxiosPromise { return localVarFp.socialVerifyStart(socialVerifyStartReq, options).then((request) => request(axios, basePath)); }, + /** + * Completes SAML2 SSO login with IdP form POST data and redirects the user. + * @summary Finish SAML2 SSO login + * @param {string} sAMLResponse + * @param {string} relayState + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + ssoSaml2Finish(sAMLResponse: string, relayState: string, options?: any): AxiosPromise { + return localVarFp.ssoSaml2Finish(sAMLResponse, relayState, options).then((request) => request(axios, basePath)); + }, + /** + * Starts a SAML2 SSO login by forwarding the request to Backend API v2. + * @summary Start SAML2 SSO login + * @param {SsoSaml2StartReq} ssoSaml2StartReq + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + ssoSaml2Start(ssoSaml2StartReq: SsoSaml2StartReq, options?: any): AxiosPromise { + return localVarFp.ssoSaml2Start(ssoSaml2StartReq, options).then((request) => request(axios, basePath)); + }, }; }; @@ -4669,6 +4875,31 @@ export class AuthApi extends BaseAPI { public socialVerifyStart(socialVerifyStartReq: SocialVerifyStartReq, options?: AxiosRequestConfig) { return AuthApiFp(this.configuration).socialVerifyStart(socialVerifyStartReq, options).then((request) => request(this.axios, this.basePath)); } + + /** + * Completes SAML2 SSO login with IdP form POST data and redirects the user. + * @summary Finish SAML2 SSO login + * @param {string} sAMLResponse + * @param {string} relayState + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthApi + */ + public ssoSaml2Finish(sAMLResponse: string, relayState: string, options?: AxiosRequestConfig) { + return AuthApiFp(this.configuration).ssoSaml2Finish(sAMLResponse, relayState, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Starts a SAML2 SSO login by forwarding the request to Backend API v2. + * @summary Start SAML2 SSO login + * @param {SsoSaml2StartReq} ssoSaml2StartReq + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthApi + */ + public ssoSaml2Start(ssoSaml2StartReq: SsoSaml2StartReq, options?: AxiosRequestConfig) { + return AuthApiFp(this.configuration).ssoSaml2Start(ssoSaml2StartReq, options).then((request) => request(this.axios, this.basePath)); + } } @@ -5091,6 +5322,48 @@ export const CorbadoConnectApiAxiosParamCreator = function (configuration?: Conf options: localVarRequestOptions, }; }, + /** + * + * @param {ConnectEventLowCreateReq} connectEventLowCreateReq + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + connectEventLowCreate: async (connectEventLowCreateReq: ConnectEventLowCreateReq, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'connectEventLowCreateReq' is not null or undefined + assertParamExists('connectEventLowCreate', 'connectEventLowCreateReq', connectEventLowCreateReq) + const localVarPath = `/v2/connect/eventsLow`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + // authentication projectID required + await setApiKeyToObject(localVarHeaderParameter, "X-Corbado-ProjectID", configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(connectEventLowCreateReq, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Finishes an initialized [Corbado Connect](https://docs.corbado.com/corbado-connect) login process. * @summary Finish connect login @@ -5446,6 +5719,16 @@ export const CorbadoConnectApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.connectEventCreate(connectEventCreateReq, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {ConnectEventLowCreateReq} connectEventLowCreateReq + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async connectEventLowCreate(connectEventLowCreateReq: ConnectEventLowCreateReq, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.connectEventLowCreate(connectEventLowCreateReq, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * Finishes an initialized [Corbado Connect](https://docs.corbado.com/corbado-connect) login process. * @summary Finish connect login @@ -5573,6 +5856,15 @@ export const CorbadoConnectApiFactory = function (configuration?: Configuration, connectEventCreate(connectEventCreateReq: ConnectEventCreateReq, options?: any): AxiosPromise { return localVarFp.connectEventCreate(connectEventCreateReq, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {ConnectEventLowCreateReq} connectEventLowCreateReq + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + connectEventLowCreate(connectEventLowCreateReq: ConnectEventLowCreateReq, options?: any): AxiosPromise { + return localVarFp.connectEventLowCreate(connectEventLowCreateReq, options).then((request) => request(axios, basePath)); + }, /** * Finishes an initialized [Corbado Connect](https://docs.corbado.com/corbado-connect) login process. * @summary Finish connect login @@ -5701,6 +5993,17 @@ export class CorbadoConnectApi extends BaseAPI { return CorbadoConnectApiFp(this.configuration).connectEventCreate(connectEventCreateReq, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {ConnectEventLowCreateReq} connectEventLowCreateReq + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CorbadoConnectApi + */ + public connectEventLowCreate(connectEventLowCreateReq: ConnectEventLowCreateReq, options?: AxiosRequestConfig) { + return CorbadoConnectApiFp(this.configuration).connectEventLowCreate(connectEventLowCreateReq, options).then((request) => request(this.axios, this.basePath)); + } + /** * Finishes an initialized [Corbado Connect](https://docs.corbado.com/corbado-connect) login process. * @summary Finish connect login diff --git a/packages/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts index f17443bc2..10f5df22e 100644 --- a/packages/web-core/src/services/ConnectService.ts +++ b/packages/web-core/src/services/ConnectService.ts @@ -18,6 +18,8 @@ import type { ConnectAppendInitReq, ConnectAppendStartRsp, ConnectEventCreateReq, + ConnectEventLow, + ConnectEventLowCreateReq, ConnectLoginFinishRsp, ConnectLoginInitReq, ConnectLoginStartReqSourceEnum, @@ -48,6 +50,19 @@ interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig { }; } +type LowEventPayload = Pick; + +type QueuedLowEvent = LowEventPayload & { + processId: string; + frontendApiUrl: string; +}; + +type QueuedLowEventGroup = { + processId: string; + frontendApiUrl: string; + queuedItems: QueuedLowEvent[]; +}; + export class ConnectService { #connectApi: CorbadoConnectApi = new CorbadoConnectApi(); #webAuthnService: WebAuthnService; @@ -58,6 +73,8 @@ export class ConnectService { readonly #frontendApiUrlSuffix: string; readonly #customDomain: string | undefined; #visitorId: string; + #lowEventQueue: QueuedLowEvent[]; + #lowEventFlushPromise: Promise | null; constructor(projectId: string, frontendApiUrlSuffix: string, isDebug: boolean, customDomain?: string) { this.#projectId = projectId; @@ -66,6 +83,8 @@ export class ConnectService { this.#customDomain = customDomain; this.#webAuthnService = new WebAuthnService(); this.#visitorId = ''; + this.#lowEventQueue = []; + this.#lowEventFlushPromise = null; // Initializes the API instances with no authentication token. // Authentication tokens are set in the SessionService. @@ -130,6 +149,16 @@ export class ConnectService { return out; } + #createConnectApi(frontendApiUrl: string, processId: string): CorbadoConnectApi { + const config = new Configuration({ + apiKey: this.#projectId, + basePath: frontendApiUrl, + }); + const axiosInstance = this.#createAxiosInstanceV2(processId); + + return new CorbadoConnectApi(config, frontendApiUrl, axiosInstance); + } + #setApisV2(process?: ConnectProcess): void { let frontendApiUrl = this.#getDefaultFrontendApiUrl(); if (this.#customDomain && this.#customDomain.length > 0) { @@ -470,6 +499,69 @@ export class ConnectService { this.#webAuthnService.abortOngoingOperation(); } + enqueueLowEvent(event: LowEventPayload) { + const existingProcess = ConnectProcess.loadFromStorage(this.#projectId); + if (!existingProcess) { + log.debug('No process found to enqueue low event.'); + return; + } + + this.#lowEventQueue.push({ + ...event, + processId: existingProcess.id, + frontendApiUrl: this.#getFrontendApiUrl(existingProcess), + }); + } + + async flushLowEvents(): Promise { + if (this.#lowEventFlushPromise) { + await this.#lowEventFlushPromise; + } + + const groups = this.#takeQueuedLowEventGroups(); + if (groups.length === 0) { + return; + } + + const flushPromise = (async () => { + const failedItems: QueuedLowEvent[] = []; + + for (const group of groups) { + try { + await this.#sendQueuedLowEventGroup(group); + } catch (error) { + log.debug('failed to flush low events', error); + failedItems.push(...group.queuedItems); + } + } + + if (failedItems.length > 0) { + this.#lowEventQueue = failedItems.concat(this.#lowEventQueue); + } + })(); + + this.#lowEventFlushPromise = flushPromise; + + try { + await flushPromise; + } finally { + if (this.#lowEventFlushPromise === flushPromise) { + this.#lowEventFlushPromise = null; + } + } + } + + flushLowEventsKeepalive() { + const groups = this.#takeQueuedLowEventGroups(); + if (groups.length === 0) { + return; + } + + for (const group of groups) { + void this.#sendQueuedLowEventGroupKeepalive(group); + } + } + async #loginFinish( assertionResponse: string, isConditionalUI: boolean, @@ -707,6 +799,91 @@ export class ConnectService { return this.wrapWithErr(() => this.#connectApi.connectEventCreate(req)); } + #takeQueuedLowEventGroups(): QueuedLowEventGroup[] { + if (this.#lowEventQueue.length === 0) { + return []; + } + + const queuedItems = this.#lowEventQueue; + this.#lowEventQueue = []; + + const groups = new Map(); + for (const item of queuedItems) { + const key = `${item.frontendApiUrl}::${item.processId}`; + const existingGroup = groups.get(key); + if (existingGroup) { + existingGroup.queuedItems.push(item); + continue; + } + + groups.set(key, { + processId: item.processId, + frontendApiUrl: item.frontendApiUrl, + queuedItems: [item], + }); + } + + return Array.from(groups.values()); + } + + async #sendQueuedLowEventGroup(group: QueuedLowEventGroup): Promise { + const api = this.#createConnectApi(group.frontendApiUrl, group.processId); + const req: ConnectEventLowCreateReq = { + items: group.queuedItems.map(({ eventType, timestamp, durationMs }) => ({ + eventType, + timestamp, + durationMs, + })), + }; + + await api.connectEventLowCreate(req); + } + + async #sendQueuedLowEventGroupKeepalive(group: QueuedLowEventGroup): Promise { + const response = await fetch(this.#getConnectEventLowUrl(group.frontendApiUrl), { + method: 'POST', + keepalive: true, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-Corbado-Client-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone, + 'X-Corbado-ProjectID': this.#projectId, + 'X-Corbado-SDK': JSON.stringify({ + name: 'web-core', + sdkVersion: packageVersion, + }), + 'x-corbado-process-id': group.processId, + }, + body: JSON.stringify({ + items: group.queuedItems.map(({ eventType, timestamp, durationMs }) => ({ + eventType, + timestamp, + durationMs, + })), + } as ConnectEventLowCreateReq), + }); + + if (!response.ok) { + throw new Error(`flushLowEventsKeepalive failed with status ${response.status}`); + } + } + + #getConnectEventLowUrl(frontendApiUrl: string): string { + return `${frontendApiUrl.replace(/\/+$/, '')}/v2/connect/eventsLow`; + } + + #getFrontendApiUrl(process?: Pick): string { + if (this.#customDomain && this.#customDomain.length > 0) { + return this.#customDomain; + } + + if (process?.frontendApiUrl && process.frontendApiUrl.length > 0) { + return process.frontendApiUrl; + } + + return this.#getDefaultFrontendApiUrl(); + } + #getDefaultFrontendApiUrl() { return `https://${this.#projectId}.${this.#frontendApiUrlSuffix}`; }