Skip to content

Commit

Permalink
feat: Add a new native action and state (#815) RELEASE
Browse files Browse the repository at this point in the history
## Related Issues

descope/etc#5992

## Related PRs
descope/orchestrationservice#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 <[email protected]>
  • Loading branch information
itaihanski and shilgapira authored Oct 22, 2024
1 parent 9aa29e2 commit 575774c
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 16 deletions.
20 changes: 20 additions & 0 deletions packages/sdks/core-js-sdk/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 =
Expand All @@ -234,6 +247,7 @@ export type FlowAction =
| 'redirect'
| 'webauthnCreate'
| 'webauthnGet'
| 'nativeBridge'
| 'none';

export type ComponentsConfig = Record<string, any>;
Expand Down Expand Up @@ -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<string, any>;
};
// an error that occurred during flow execution, used for debugging / integrating
error?: {
code: string;
Expand Down Expand Up @@ -311,6 +330,7 @@ export type Options = {
locale?: string;
oidcPrompt?: string;
oidcErrorRedirectUri?: string;
nativeOptions?: NativeOptions;
};

export type ResponseData = Record<string, any>;
Expand Down
1 change: 1 addition & 0 deletions packages/sdks/web-component/src/lib/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const RESPONSE_ACTIONS = {
poll: 'poll',
webauthnCreate: 'webauthnCreate',
webauthnGet: 'webauthnGet',
nativeBridge: 'nativeBridge',
loadForm: 'loadForm',
};

Expand Down
79 changes: 71 additions & 8 deletions packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

// 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;
Expand Down Expand Up @@ -238,6 +258,8 @@ class DescopeWc extends BaseDescopeWc {
samlIdpResponseUrl,
samlIdpResponseSamlResponse,
samlIdpResponseRelayState,
nativeResponseType,
nativePayload,
...ssoQueryParams
} = currentState;

Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -304,6 +334,7 @@ class DescopeWc extends BaseDescopeWc {
lastAuth: getLastAuth(loginId),
abTestingKey,
locale: getUserLocale(locale).locale,
nativeOptions,
},
conditionInteractionId,
'',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -530,6 +586,7 @@ class DescopeWc extends BaseDescopeWc {
client: this.client,
...(redirectUrl && { redirectUrl }),
locale: getUserLocale(locale).locale,
nativeOptions,
},
conditionInteractionId,
interactionId,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -690,6 +747,7 @@ class DescopeWc extends BaseDescopeWc {
webauthn,
error,
samlIdpResponse,
nativeResponse,
} = sdkResp.data;

if (action === RESPONSE_ACTIONS.poll) {
Expand Down Expand Up @@ -726,6 +784,8 @@ class DescopeWc extends BaseDescopeWc {
samlIdpResponseUrl: samlIdpResponse?.url,
samlIdpResponseSamlResponse: samlIdpResponse?.samlResponse,
samlIdpResponseRelayState: samlIdpResponse?.relayState,
nativeResponseType: nativeResponse?.type,
nativePayload: nativeResponse?.payload,
});
};

Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions packages/sdks/web-component/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export type FlowState = {
samlIdpResponseUrl: string;
samlIdpResponseSamlResponse: string;
samlIdpResponseRelayState: string;
nativeResponseType: string;
nativePayload: Record<string, any>;
} & SSOQueryParams;

export type StepState = {
Expand Down
106 changes: 98 additions & 8 deletions packages/sdks/web-component/test/descope-wc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ let themeContent = {};
let pageContent = '';
let configContent: any = {};

class TestClass { }
class TestClass {}

const fetchMock: jest.Mock = jest.fn();
global.fetch = fetchMock;
Expand Down Expand Up @@ -309,7 +309,11 @@ describe('web-component', () => {

document.body.innerHTML = `<h1>Custom element test</h1> <descope-wc flow-id="otpSignInEmail" project-id="1" restart-on-error="true"></descope-wc>`;

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,
Expand All @@ -323,7 +327,7 @@ describe('web-component', () => {
'',
'1.2.3',
flattenConfigFlowVersions(configContent.flows),
{}
{},
),
{ timeout: WAIT_TIMEOUT },
);
Expand Down Expand Up @@ -538,7 +542,7 @@ describe('web-component', () => {
constructor() {
super();
Object.defineProperty(this, 'shadowRoot', {
value: { isConnected: true, appendChild: () => { } },
value: { isConnected: true, appendChild: () => {} },
});
}

Expand All @@ -565,7 +569,7 @@ describe('web-component', () => {
constructor() {
super();
Object.defineProperty(this, 'shadowRoot', {
value: { isConnected: true, appendChild: () => { } },
value: { isConnected: true, appendChild: () => {} },
});
}

Expand Down Expand Up @@ -769,7 +773,7 @@ describe('web-component', () => {
pageContent =
'<descope-test-button id="email">Button</descope-test-button><span>It works!</span>';

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;
Expand Down Expand Up @@ -1836,7 +1840,7 @@ describe('web-component', () => {
Object.defineProperty(this, 'shadowRoot', {
value: {
isConnected: true,
appendChild: () => { },
appendChild: () => {},
host: { closest: () => true },
},
});
Expand Down Expand Up @@ -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 = '<div data-type="polling">...</div><span>It works!</span>';
document.body.innerHTML = `<h1>Custom element test</h1> <descope-wc flow-id="otpSignInEmail" project-id="1"></descope-wc>`;

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);
Expand Down Expand Up @@ -3898,7 +3988,7 @@ describe('web-component', () => {
);

const mockSubmitForm = jest.spyOn(helpers, 'submitForm');
mockSubmitForm.mockImplementation(() => { });
mockSubmitForm.mockImplementation(() => {});

document.body.innerHTML = `<h1>Custom element test</h1><descope-wc flow-id="versioned-flow" project-id="1"></descope-wc>`;

Expand Down
Loading

0 comments on commit 575774c

Please sign in to comment.