From 575774c74ac47a193edc30668f9e95c7f2049829 Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Tue, 22 Oct 2024 16:49:05 +0300 Subject: [PATCH] feat: Add a new native action and state (#815) RELEASE ## Related Issues https://github.com/descope/etc/issues/5992 ## Related PRs https://github.com/descope/orchestrationservice/pull/2787 ## Description The idea here is to add **native options** that will be provided via the mobile SDKs and piped to the BE via the `startOptions`. On the other side - a new `native` action is added and transfers responsibility of handling it to the mobile SDKs. The design aims to make any future updates to take place in the BE, and in the mobile SDKs only, and leaving the `web-component` SDK untouched as much as possible. Hence, whenever a unique response is required by the mobile SDK, a dynamic payload is sent with a corresponding `type` field to allow the mobile SDKs to correctly parse and process it. ## Must - [x] Tests - [ ] Documentation (if applicable) --------- Co-authored-by: Gil Shapira --- packages/sdks/core-js-sdk/src/sdk/types.ts | 20 ++++ .../web-component/src/lib/constants/index.ts | 1 + .../src/lib/descope-wc/DescopeWc.ts | 79 +++++++++++-- packages/sdks/web-component/src/lib/types.ts | 2 + .../web-component/test/descope-wc.test.ts | 106 ++++++++++++++++-- packages/sdks/web-component/test/testUtils.ts | 6 + packages/sdks/web-js-sdk/src/sdk/flow.ts | 1 + 7 files changed, 199 insertions(+), 16 deletions(-) diff --git a/packages/sdks/core-js-sdk/src/sdk/types.ts b/packages/sdks/core-js-sdk/src/sdk/types.ts index 81c1a8758..c85b3f513 100644 --- a/packages/sdks/core-js-sdk/src/sdk/types.ts +++ b/packages/sdks/core-js-sdk/src/sdk/types.ts @@ -14,6 +14,18 @@ type RedirectAuth = { codeChallenge: string; }; +/** Sent in a flow start request when running as a native flow component via a mobile SDK */ +type NativeOptions = { + /** What mobile platform we're running on, used to decide between different behaviors on the backend */ + platform: 'ios' | 'android'; + + /** The name of an OAuth provider that will use native OAuth (Sign in with Apple/Google) instead of web OAuth when running in a mobile app */ + oauthProvider?: string; + + /** An override for web OAuth that sets the address to redirect to after authentication succeeds at the OAuth provider website */ + oauthRedirect?: string; +}; + type AuthMethod = | 'magiclink' | 'enchantedlink' @@ -226,6 +238,7 @@ export enum FlowStatus { * - poll - next action is poll for next after timeout * - redirect - next action is to redirect (redirection details in 'redirect' attribute) * - webauthnCreate/webauthnGet - next action is to prompt webauthn (details in 'webauthn' attribute) + * - nativeBridge - the next action needs to be sent via the native bridge to the native layer * - none - no next action */ export type FlowAction = @@ -234,6 +247,7 @@ export type FlowAction = | 'redirect' | 'webauthnCreate' | 'webauthnGet' + | 'nativeBridge' | 'none'; export type ComponentsConfig = Record; @@ -276,6 +290,11 @@ export type FlowResponse = { options: string; create: boolean; }; + // set if the action is 'nativeBridge' + nativeResponse?: { + type: 'oauthNative' | 'oauthWeb' | 'webauthnGet' | 'webauthnCreate'; + payload: Record; + }; // an error that occurred during flow execution, used for debugging / integrating error?: { code: string; @@ -311,6 +330,7 @@ export type Options = { locale?: string; oidcPrompt?: string; oidcErrorRedirectUri?: string; + nativeOptions?: NativeOptions; }; export type ResponseData = Record; diff --git a/packages/sdks/web-component/src/lib/constants/index.ts b/packages/sdks/web-component/src/lib/constants/index.ts index 05d79ee4d..ad75d361c 100644 --- a/packages/sdks/web-component/src/lib/constants/index.ts +++ b/packages/sdks/web-component/src/lib/constants/index.ts @@ -35,6 +35,7 @@ export const RESPONSE_ACTIONS = { poll: 'poll', webauthnCreate: 'webauthnCreate', webauthnGet: 'webauthnGet', + nativeBridge: 'nativeBridge', loadForm: 'loadForm', }; diff --git a/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts b/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts index f666bffff..5390a526b 100644 --- a/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts +++ b/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts @@ -96,6 +96,26 @@ class DescopeWc extends BaseDescopeWc { } } + // Native bridge version native / web syncing - change this when + // a major change happens that requires some form of compatibility + bridgeVersion = 1; + + // This callback will be initialized once a 'nativeBridge' action is + // received from a start or next request. It will then be called by + // the native layer as a response to a dispatched 'bridge' event. + nativeComplete: (bridgeResponse: string) => Promise; + + // This object is set by the native layer to + // inject native specific data into the 'flowState'. + nativeOptions: + | { + platform: 'ios' | 'android'; + oauthProvider?: string; + oauthRedirect?: string; + origin?: string; + } + | undefined; + async loadSdkScripts() { const flowConfig = await this.getFlowConfig(); const scripts = flowConfig.sdkScripts; @@ -238,6 +258,8 @@ class DescopeWc extends BaseDescopeWc { samlIdpResponseUrl, samlIdpResponseSamlResponse, samlIdpResponseRelayState, + nativeResponseType, + nativePayload, ...ssoQueryParams } = currentState; @@ -247,7 +269,8 @@ class DescopeWc extends BaseDescopeWc { const loginId = this.sdk.getLastUserLoginId(); const flowConfig = await this.getFlowConfig(); const projectConfig = await this.getProjectConfig(); - const flowVersions = Object.entries(projectConfig.flows || {}).reduce( // pass also current versions for all flows, it may be used as a part of the current flow + const flowVersions = Object.entries(projectConfig.flows || {}).reduce( + // pass also current versions for all flows, it may be used as a part of the current flow (acc, [key, value]) => { acc[key] = value.version; return acc; @@ -257,11 +280,18 @@ class DescopeWc extends BaseDescopeWc { const redirectAuth = redirectAuthCallbackUrl && redirectAuthCodeChallenge ? { - callbackUrl: redirectAuthCallbackUrl, - codeChallenge: redirectAuthCodeChallenge, - backupCallbackUri: redirectAuthBackupCallbackUri, - } + callbackUrl: redirectAuthCallbackUrl, + codeChallenge: redirectAuthCodeChallenge, + backupCallbackUri: redirectAuthBackupCallbackUri, + } : undefined; + const nativeOptions = this.nativeOptions + ? { + platform: this.nativeOptions.platform, + oauthProvider: this.nativeOptions.oauthProvider, + oauthRedirect: this.nativeOptions.oauthRedirect, + } + : undefined; // if there is no execution id we should start a new flow if (!executionId) { @@ -304,6 +334,7 @@ class DescopeWc extends BaseDescopeWc { lastAuth: getLastAuth(loginId), abTestingKey, locale: getUserLocale(locale).locale, + nativeOptions, }, conditionInteractionId, '', @@ -448,6 +479,31 @@ class DescopeWc extends BaseDescopeWc { this.#handleSdkResponse(sdkResp); } + if (action === RESPONSE_ACTIONS.nativeBridge) { + // prepare a callback with the current flow state, and accept + // the input to be a JSON, passed down from the native layer. + // this function will be called as an async response to a 'bridge' event + this.nativeComplete = async (bridgeResponse: string) => { + const input = JSON.parse(bridgeResponse); + const sdkResp = await this.sdk.flow.next( + executionId, + stepId, + CUSTOM_INTERACTIONS.submit, + flowConfig.version, + projectConfig.componentsVersion, + input, + ); + this.#handleSdkResponse(sdkResp); + }; + // notify the bridging native layer that a native action is requested via 'bridge' event. + // the response will be in the form of calling the `nativeComplete` callback + this.#dispatch('bridge', { + type: nativeResponseType, + payload: nativePayload, + }); + return; + } + this.#handlePollingResponse( executionId, stepId, @@ -530,6 +586,7 @@ class DescopeWc extends BaseDescopeWc { client: this.client, ...(redirectUrl && { redirectUrl }), locale: getUserLocale(locale).locale, + nativeOptions, }, conditionInteractionId, interactionId, @@ -641,7 +698,7 @@ class DescopeWc extends BaseDescopeWc { // E102004 = Flow requested is in old version // E103205 = Flow timed out - const errorCode = sdkResp?.error?.errorCode + const errorCode = sdkResp?.error?.errorCode; if ( (errorCode === 'E102004' || errorCode === 'E103205') && this.isRestartOnError @@ -690,6 +747,7 @@ class DescopeWc extends BaseDescopeWc { webauthn, error, samlIdpResponse, + nativeResponse, } = sdkResp.data; if (action === RESPONSE_ACTIONS.poll) { @@ -726,6 +784,8 @@ class DescopeWc extends BaseDescopeWc { samlIdpResponseUrl: samlIdpResponse?.url, samlIdpResponseSamlResponse: samlIdpResponse?.samlResponse, samlIdpResponseRelayState: samlIdpResponse?.relayState, + nativeResponseType: nativeResponse?.type, + nativePayload: nativeResponse?.payload, }); }; @@ -1069,8 +1129,11 @@ class DescopeWc extends BaseDescopeWc { ...contextArgs, ...eleDescopeAttrs, ...formData, - // 'origin' is required to start webauthn. For now we'll add it to every request - origin: window.location.origin, + // 'origin' is required to start webauthn. For now we'll add it to every request. + // When running in a native flow in a Android app the webauthn authentication + // is performed in the native app, so a custom origin needs to be injected + // into the webauthn request data. + origin: this.nativeOptions?.origin || window.location.origin, }; const flowConfig = await this.getFlowConfig(); diff --git a/packages/sdks/web-component/src/lib/types.ts b/packages/sdks/web-component/src/lib/types.ts index 23baa4a8b..31ab8d1c3 100644 --- a/packages/sdks/web-component/src/lib/types.ts +++ b/packages/sdks/web-component/src/lib/types.ts @@ -80,6 +80,8 @@ export type FlowState = { samlIdpResponseUrl: string; samlIdpResponseSamlResponse: string; samlIdpResponseRelayState: string; + nativeResponseType: string; + nativePayload: Record; } & SSOQueryParams; export type StepState = { diff --git a/packages/sdks/web-component/test/descope-wc.test.ts b/packages/sdks/web-component/test/descope-wc.test.ts index 1d8b72240..0acd52e52 100644 --- a/packages/sdks/web-component/test/descope-wc.test.ts +++ b/packages/sdks/web-component/test/descope-wc.test.ts @@ -116,7 +116,7 @@ let themeContent = {}; let pageContent = ''; let configContent: any = {}; -class TestClass { } +class TestClass {} const fetchMock: jest.Mock = jest.fn(); global.fetch = fetchMock; @@ -309,7 +309,11 @@ describe('web-component', () => { document.body.innerHTML = `

Custom element test

`; - const flattenConfigFlowVersions = (flows) => Object.entries(flows).reduce((acc, [key, val]) => ({ ...acc, [key]: val.version }), {}); + const flattenConfigFlowVersions = (flows) => + Object.entries(flows).reduce( + (acc, [key, val]) => ({ ...acc, [key]: val.version }), + {}, + ); await waitFor(() => expect(startMock).toBeCalledTimes(1), { timeout: WAIT_TIMEOUT, @@ -323,7 +327,7 @@ describe('web-component', () => { '', '1.2.3', flattenConfigFlowVersions(configContent.flows), - {} + {}, ), { timeout: WAIT_TIMEOUT }, ); @@ -538,7 +542,7 @@ describe('web-component', () => { constructor() { super(); Object.defineProperty(this, 'shadowRoot', { - value: { isConnected: true, appendChild: () => { } }, + value: { isConnected: true, appendChild: () => {} }, }); } @@ -565,7 +569,7 @@ describe('web-component', () => { constructor() { super(); Object.defineProperty(this, 'shadowRoot', { - value: { isConnected: true, appendChild: () => { } }, + value: { isConnected: true, appendChild: () => {} }, }); } @@ -769,7 +773,7 @@ describe('web-component', () => { pageContent = 'ButtonIt works!'; - customElements.define('descope-test-button', class extends HTMLElement { }); + customElements.define('descope-test-button', class extends HTMLElement {}); const DescopeUI = { 'descope-test-button': jest.fn() }; globalThis.DescopeUI = DescopeUI; @@ -1836,7 +1840,7 @@ describe('web-component', () => { Object.defineProperty(this, 'shadowRoot', { value: { isConnected: true, - appendChild: () => { }, + appendChild: () => {}, host: { closest: () => true }, }, }); @@ -2223,6 +2227,92 @@ describe('web-component', () => { }); }); + describe('native', () => { + it('Should prepare a callback for a native bridge response and broadcast an event when receiving a nativeBridge action', async () => { + startMock.mockReturnValueOnce( + generateSdkResponse({ + action: RESPONSE_ACTIONS.nativeBridge, + nativeResponseType: 'oauthNative', + nativeResponsePayload: { start: {} }, + }), + ); + + nextMock.mockReturnValueOnce( + generateSdkResponse({ + status: 'completed', + }), + ); + + pageContent = '
...
It works!'; + document.body.innerHTML = `

Custom element test

`; + + const onSuccess = jest.fn(); + const onBridge = jest.fn(); + + const wcEle = document.getElementsByTagName('descope-wc')[0]; + + // nativeComplete starts as undefined + expect(wcEle.nativeComplete).not.toBeDefined(); + + wcEle.addEventListener('success', onSuccess); + wcEle.addEventListener('bridge', onBridge); + + // after start 'nativeComplete' is initialized and a 'bridge' event should be dispatched + await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1), { + timeout: WAIT_TIMEOUT, + }); + + await waitFor(() => expect(wcEle.nativeComplete).toBeDefined(), { + timeout: WAIT_TIMEOUT, + }); + + await waitFor( + () => + expect(onBridge).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + type: 'oauthNative', + payload: { start: {} }, + }, + }), + ), + { + timeout: WAIT_TIMEOUT, + }, + ); + + // simulate a native complete call and expect the 'next' call + await wcEle.nativeComplete(JSON.stringify({ response: true })); + await waitFor( + () => + expect(nextMock).toHaveBeenCalledWith( + '0', + '0', + CUSTOM_INTERACTIONS.submit, + 1, + '1.2.3', + { response: true }, + ), + { + timeout: WAIT_TIMEOUT, + }, + ); + + await waitFor( + () => + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ detail: 'auth info' }), + ), + { + timeout: WAIT_TIMEOUT, + }, + ); + + wcEle.removeEventListener('success', onSuccess); + wcEle.removeEventListener('bridge', onBridge); + }); + }); + describe('condition', () => { beforeEach(() => { localStorage.removeItem(DESCOPE_LAST_AUTH_LOCAL_STORAGE_KEY); @@ -3898,7 +3988,7 @@ describe('web-component', () => { ); const mockSubmitForm = jest.spyOn(helpers, 'submitForm'); - mockSubmitForm.mockImplementation(() => { }); + mockSubmitForm.mockImplementation(() => {}); document.body.innerHTML = `

Custom element test

`; diff --git a/packages/sdks/web-component/test/testUtils.ts b/packages/sdks/web-component/test/testUtils.ts index f519b6374..d595ff3df 100644 --- a/packages/sdks/web-component/test/testUtils.ts +++ b/packages/sdks/web-component/test/testUtils.ts @@ -21,6 +21,8 @@ export const generateSdkResponse = ({ samlIdpResponseRelayState = '', lastAuth = {}, openInNewTabUrl = '', + nativeResponseType = '', + nativeResponsePayload = {}, } = {}) => ({ ok, data: { @@ -43,6 +45,10 @@ export const generateSdkResponse = ({ }, lastAuth, openInNewTabUrl, + nativeResponse: { + type: nativeResponseType, + payload: nativeResponsePayload, + }, }, error: { errorMessage: requestErrorMessage, diff --git a/packages/sdks/web-js-sdk/src/sdk/flow.ts b/packages/sdks/web-js-sdk/src/sdk/flow.ts index 9ff3fcb84..1a10f1e76 100644 --- a/packages/sdks/web-js-sdk/src/sdk/flow.ts +++ b/packages/sdks/web-js-sdk/src/sdk/flow.ts @@ -18,6 +18,7 @@ type Options = Pick< | 'locale' | 'oidcPrompt' | 'oidcErrorRedirectUri' + | 'nativeOptions' > & { lastAuth?: Omit; };