diff --git a/.gitignore b/.gitignore index 42ea526..a89c861 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,4 @@ typings/ .env # next.js build output -.next \ No newline at end of file +.next diff --git a/docs/README.md b/docs/README.md index 1f73677..dfa4b3f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,6 +65,23 @@ mtLinkSdk.init(clientId, options); | options.mode | `production`, `staging`, `develop`, `local` | false | `production` |

Environment for the SDK to connect to, the SDK will connect to the Moneytree production server by default.

| | options.locale | string | false | Auto detect. | Force Moneytree to load content in this specific locale. A default value will be auto detected based on guest langauges configurations and location if available. Check this [spec](https://www.w3.org/TR/html401/struct/dirlang.html#h-8.1.1) for more information.

Currently supported values are:
`en`, `en-AU`, `ja`. | | options.cobrandClientId (private) | string | false | | NOTE: This is an internal attribute. Please do not use it unless instructed by your integration representative.

Brand Moneytree apps with client's branding. E.g: logo or theme. | +| options.samlSubjectId | string | false | | Sets subject Id for saml session version. | + +### setSamlSubjectId + +The `setSamlSubjectId` method is used to set the value of the `saml_subject_id` parameter. +The `setSamlSubjectId` parameter allows the client to pass a guest identifier to Moneytree so that Moneytree can forward it to the Identity Provider (IdP) via the SAMLRequest. +The `saml_subject_id` parameter will be forwarded to the `authorize`, `logout` and `open-service` methods when defined. + +
Usage:
+ +```javascript +mtLinkSdk.setSamlSubjectId(samlSubjectId); +``` + +| Parameter | Type | Required | Default Value | Description | +| ------------- | ------ | -------- | ------------- | ---------------------------------------- | +| sublSubjectId | string | true | |

Set the saml_subject_id parameter

| ### authorize @@ -320,8 +337,9 @@ These common options are used in multiple APIs. Instead of repeating the same op | options.backTo | string | false | Value set during `init`. | A redirection URL for redirecting a guest back to in the following condition:
  • Guest clicks on `Back to [App Name]` button in any Moneytree screen.
  • Guest refuses to give consent to access permission in the consent screen.
  • Guest logs out from Moneytree via an app with this client id
  • Revoke an app's consent from settings screen opened via an app with this client id


  • NOTE: No `Back to [App Name]` button will be shown if this value is not set, and any of the actions mentioned above will redirect the guest back to login screen by default. | | options.authAction | `login`, `signup` | false | Value set during `init`.

    OR

    `login` | Show login or sign up screen when a session does not exist during an `authorize` call. | | options.showAuthToggle | boolean | false | Value set during `init`.

    OR

    `true` | If you wish to disable the login to sign up form toggle button and vice-versa in the auth screen, set this to `false`. | -| options.showRememberMe | boolean | false | Value set during `init`.

    OR

    `true` | If you wish to disable the `Stay logged in for 30 days` checkbox in the login screen, set this to `false`. | +| options.showRememberMe | boolean | false | Value set during `init`.

    OR

    `true` | If you wish to disable the `Stay logged in for 30 days` checkbox in the login screen, set this to `false`. | | options.isNewTab | boolean | false | Value set during `init`.

    OR

    `false` | Call method and open/render in a new browser tab, by default all views open in the same tab. | +| options.authnMethod | string | | Value set during `init`. | Use different authentication methods. This can be a string with the following values:

    `sso`, `passwordless`, `credentials`. | | options.sdkPlatform (private) | string | false | Generated by the SDK. | NOTE: this is for Moneytree internal use, please do not use it to avoid unintended behavior!

    Indicating sdk platform. | | options.sdkVersion (private) | semver | false | Generated by the SDK. | NOTE: this is for Moneytree internal use, please do not use it to avoid unintended behavior!

    Indicating sdk version. | diff --git a/src/__tests__/helper.test.ts b/src/__tests__/helper.test.ts index c652523..89f41b2 100644 --- a/src/__tests__/helper.test.ts +++ b/src/__tests__/helper.test.ts @@ -1,5 +1,6 @@ import { constructScopes, getIsTabValue, mergeConfigs, generateConfigs } from '../helper'; import packageJson from '../../package.json'; +import { AuthAction, AuthnMethod, ConfigsOptions } from '../typings'; describe('helper', () => { test('constuctScopes', () => { @@ -23,16 +24,18 @@ describe('helper', () => { backTo: 'backTo', authAction: 'signup', showAuthToggle: true, - showRememberMe: true + showRememberMe: true, + authnMethod: 'sso' }, {} ) - ).toMatchObject({ + ).toEqual({ + authAction: 'signup', email: 'email', backTo: 'backTo', - authAction: 'signup', + showRememberMe: true, showAuthToggle: true, - showRememberMe: true + authnMethod: 'sso' }); }); @@ -73,13 +76,14 @@ describe('helper', () => { showAuthToggle: true, showRememberMe: true, // @ts-ignore: set unsupported key - whatIsThis: false + whatIsThis: false, + authnMethod: 'not really valid' as AuthnMethod }, { whatIsThis2: false } ) - ).toMatchObject({ + ).toEqual({ email: 'email', backTo: 'backTo', authAction: 'signup', @@ -116,15 +120,55 @@ describe('helper', () => { describe('generateConfigs', () => { test('with parameter', () => { - expect( - generateConfigs({ - email: 'email', - backTo: 'backTo', - authAction: 'signup', - showAuthToggle: true, - showRememberMe: true - }) - ).toBe( + const configPayload: ConfigsOptions = { + email: 'email', + backTo: 'backTo', + authAction: 'signup', + showAuthToggle: true, + showRememberMe: true, + authnMethod: 'sso' + }; + + expect(generateConfigs(configPayload)).toBe( + `sdk_platform=js&sdk_version=${packageJson.version}&email=email&back_to=backTo&auth_action=signup&show_auth_toggle=true` + + `&show_remember_me=true&authn_method=sso` + ); + }); + + test('query encoding should make sure config params are also encoded', () => { + const configPayload: ConfigsOptions = { + email: 'email&!@#(*)-304should be_encoded', + backTo: 'backTo #!@with []special= chars', + authAction: 'signup', + showAuthToggle: true, + showRememberMe: true, + authnMethod: 'sso' + }; + + expect(generateConfigs(configPayload)).toBe( + `sdk_platform=js&sdk_version=${packageJson.version}&email=email%26%21%40%23%28%2A%29-304should%20be_encoded&back_to=backTo%20%23%21%40with%20%5B%5Dspecial%3D%20chars&auth_action=signup&show_auth_toggle=true&show_remember_me=true&authn_method=sso` + ); + }); + + test('Should raise an error when passing an array in authnMethod', () => { + const configPayload: ConfigsOptions = { + authnMethod: ['oh-not-valid', 'should raise'] as unknown as AuthnMethod + }; + + expect(() => generateConfigs(configPayload)).toThrow(TypeError); + }); + + test('Should reject invalid authnMethod from config', () => { + const configPayload: ConfigsOptions = { + email: 'email', + backTo: 'backTo', + authAction: 'signup', + showAuthToggle: true, + showRememberMe: true, + authnMethod: 'oh-not-valid' as AuthnMethod + }; + + expect(generateConfigs(configPayload)).toBe( `sdk_platform=js&sdk_version=${packageJson.version}&email=email&back_to=backTo&auth_action=signup&show_auth_toggle=true` + `&show_remember_me=true` ); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 3c87635..94d809c 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -22,7 +22,9 @@ describe('index', () => { const instance = new MtLinkSdk(); instance.init('clientId', { - redirectUri: 'redirectUri' + redirectUri: 'redirectUri', + authnMethod: 'sso', + samlSubjectId: 'samlSubjectId' }); const options = instance.storedOptions; @@ -30,7 +32,9 @@ describe('index', () => { clientId: options.clientId, mode: options.mode, redirectUri: options.redirectUri, - state: options.state + state: options.state, + authnMethod: options.authnMethod, + samlSubjectId: options.samlSubjectId }; const result1 = instance.authorize({ scopes: 'scopes' }); @@ -94,4 +98,19 @@ describe('index', () => { expect(mtLinkSdk.storedOptions.mode).toBe('production'); }); + + test('sets the samlSubjectId when calling setSamlSubjectId', () => { + const samlSubjectId = 'samlSubjectId'; + mtLinkSdk.init('clientId', { + samlSubjectId + }); + + expect(mtLinkSdk.storedOptions.samlSubjectId).toBe(samlSubjectId); + + const newSamlSubjectId = 'newSamlSubjectId'; + + mtLinkSdk.setSamlSubjectId(newSamlSubjectId); + + expect(mtLinkSdk.storedOptions.samlSubjectId).toBe(newSamlSubjectId); + }); }); diff --git a/src/api/__tests__/authorize.test.ts b/src/api/__tests__/authorize.test.ts index 4c6b2f9..3540e30 100644 --- a/src/api/__tests__/authorize.test.ts +++ b/src/api/__tests__/authorize.test.ts @@ -43,13 +43,15 @@ describe('api', () => { const scopes = 'points_read'; const cobrandClientId = 'cobrandClientId'; const locale = 'locale'; + const samlSubjectId = 'mySubject'; const mtLinkSdk = new MtLinkSdk(); mtLinkSdk.init(clientId, { redirectUri, scopes, locale, - cobrandClientId + cobrandClientId, + samlSubjectId }); authorize(mtLinkSdk.storedOptions); @@ -64,6 +66,7 @@ describe('api', () => { redirect_uri: redirectUri, country, locale, + saml_subject_id: samlSubjectId, configs: generateConfigs() }); const url = `${MY_ACCOUNT_DOMAINS.production}/oauth/authorize?${query}`; @@ -77,9 +80,10 @@ describe('api', () => { const state = 'state'; const country = 'JP'; const scopes = 'points_read'; + const samlSubjectId = 'mySubject'; const mtLinkSdk = new MtLinkSdk(); - mtLinkSdk.init(clientId); + mtLinkSdk.init(clientId, { samlSubjectId }); authorize(mtLinkSdk.storedOptions, { state, @@ -96,6 +100,7 @@ describe('api', () => { redirect_uri: redirectUri, state, country, + saml_subject_id: samlSubjectId, configs: generateConfigs() }); const url = `${MY_ACCOUNT_DOMAINS.production}/oauth/authorize?${query}`; diff --git a/src/api/__tests__/open-service.test.ts b/src/api/__tests__/open-service.test.ts index e7bcee6..2bac17e 100644 --- a/src/api/__tests__/open-service.test.ts +++ b/src/api/__tests__/open-service.test.ts @@ -197,6 +197,45 @@ describe('api', () => { }).toThrow('[mt-link-sdk] Invalid `serviceId` in `openService`, got: invalid'); }); + test('saml_subject_id is passed when initialized', () => { + open.mockClear(); + + const instance = new MtLinkSdk(); + instance.init('clientId', { samlSubjectId: 'samlSubjectId' }); + + openService(instance.storedOptions, 'myaccount'); + + expect(open).toBeCalledTimes(1); + + const query = qs.stringify({ + client_id: 'clientId', + saml_subject_id: 'samlSubjectId', + configs: generateConfigs() + }); + const url = `${MY_ACCOUNT_DOMAINS.production}/?${query}`; + + expect(open).toBeCalledWith(url, '_self', 'noreferrer'); + }); + + test('undefined saml_subject_id should not be passed down', () => { + open.mockClear(); + + const instance = new MtLinkSdk(); + instance.init('clientId', { samlSubjectId: undefined }); + + openService(instance.storedOptions, 'myaccount'); + + expect(open).toBeCalledTimes(1); + + const query = qs.stringify({ + client_id: 'clientId', + configs: generateConfigs() + }); + const url = `${MY_ACCOUNT_DOMAINS.production}/?${query}`; + + expect(open).toBeCalledWith(url, '_self', 'noreferrer'); + }); + test('without window', () => { const windowSpy = jest.spyOn(global, 'window', 'get'); // @ts-ignore: mocking window object to undefined diff --git a/src/api/authorize.ts b/src/api/authorize.ts index a9a8198..5611dff 100644 --- a/src/api/authorize.ts +++ b/src/api/authorize.ts @@ -23,7 +23,8 @@ export default function authorize(storedOptions: StoredOptions, options: Authori cobrandClientId, locale, scopes: defaultScopes, - redirectUri: defaultRedirectUri + redirectUri: defaultRedirectUri, + samlSubjectId } = storedOptions; if (!clientId) { @@ -61,6 +62,7 @@ export default function authorize(storedOptions: StoredOptions, options: Authori state, country: 'JP', locale, + saml_subject_id: samlSubjectId, configs: generateConfigs(mergeConfigs(storedOptions, rest)) }); diff --git a/src/api/logout.ts b/src/api/logout.ts index 3ee3348..249b535 100644 --- a/src/api/logout.ts +++ b/src/api/logout.ts @@ -9,13 +9,14 @@ export default function logout(storedOptions: StoredOptions, options: LogoutOpti throw new Error(`[mt-link-sdk] \`logout\` only works in the browser.`); } - const { clientId, mode, cobrandClientId, locale } = storedOptions; + const { clientId, mode, cobrandClientId, locale, samlSubjectId } = storedOptions; const { isNewTab, ...rest } = options; const queryString = stringify({ client_id: clientId, cobrand_client_id: cobrandClientId, locale, + saml_subject_id: samlSubjectId, configs: generateConfigs(mergeConfigs(storedOptions, rest)) }); diff --git a/src/api/open-service.ts b/src/api/open-service.ts index d69b039..99c1a66 100644 --- a/src/api/open-service.ts +++ b/src/api/open-service.ts @@ -16,6 +16,7 @@ interface QueryData { cobrand_client_id?: string; locale?: string; configs: string; + saml_subject_id?: string; } export default function openService( @@ -27,7 +28,7 @@ export default function openService( throw new Error('[mt-link-sdk] `openService` only works in the browser.'); } - const { clientId, mode, cobrandClientId, locale } = storedOptions; + const { clientId, mode, cobrandClientId, locale, samlSubjectId } = storedOptions; const { isNewTab, view = '', ...rest } = options; const getQueryValue = (needStringify = true): string | QueryData => { @@ -35,6 +36,7 @@ export default function openService( client_id: clientId, cobrand_client_id: cobrandClientId, locale, + saml_subject_id: samlSubjectId, configs: generateConfigs(mergeConfigs(storedOptions, rest)) }; diff --git a/src/helper.ts b/src/helper.ts index 62b89b9..d0fd8e5 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -7,7 +7,17 @@ import { encode } from 'url-safe-base64'; import { v4 as uuid } from 'uuid'; import storage from './storage'; -import { Scopes, InitOptions, ConfigsOptions, AuthAction } from './typings'; +import { + Scopes, + InitOptions, + ConfigsOptions, + AuthAction, + AuthnMethod, + supportedAuthnMethod, + supportedAuthAction, + supportedConfigsOptions, + SupportedConfigsOptions +} from './typings'; export function constructScopes(scopes: Scopes = ''): string | undefined { return (Array.isArray(scopes) ? scopes.join(' ') : scopes) || undefined; @@ -27,7 +37,8 @@ export function mergeConfigs( backTo: defaultBackTo, authAction: defaultAuthAction, showAuthToggle: defaultShowAuthToggle, - showRememberMe: defaultShowRememberMe + showRememberMe: defaultShowRememberMe, + authnMethod: defaultAuthnMethod } = initValues; const { @@ -36,27 +47,29 @@ export function mergeConfigs( authAction = defaultAuthAction, showAuthToggle = defaultShowAuthToggle, showRememberMe = defaultShowRememberMe, + authnMethod: rawAuthnMethod = defaultAuthnMethod, ...rest } = newValues; + const authnMethod = parseAuthnMethod(rawAuthnMethod); + const configs: ConfigsOptions = { ...rest, email, backTo, authAction, showAuthToggle, - showRememberMe + showRememberMe, + authnMethod }; - if (ignoreKeys.length) { - const keys = Object.keys(configs) as Array; + const keys = Object.keys(configs) as Array; - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; - if (ignoreKeys.indexOf(key) !== -1) { - configs[key] = undefined; - } + if (ignoreKeys.indexOf(key) !== -1 || !supportedConfigsOptions.includes(key)) { + configs[key] = undefined; } } @@ -75,9 +88,18 @@ export function generateConfigs(configs: ConfigsOptions = {}): string { 'isNewTab', 'forceLogout', 'sdkPlatform', - 'sdkVersion' + 'sdkVersion', + 'authnMethod' ]; + if (configs.authnMethod) { + configs.authnMethod = parseAuthnMethod(configs.authnMethod); + } + + if (configs.authAction) { + configs.authAction = parseAuthAction(configs.authAction); + } + for (const key in configs) { if (configKeys.indexOf(key) !== -1) { snakeCaseConfigs[snakeCase(key)] = configs[key as keyof ConfigsOptions]; @@ -91,6 +113,26 @@ export function generateConfigs(configs: ConfigsOptions = {}): string { }); } +function isAuthnMethod(x: unknown): x is AuthnMethod { + return supportedAuthnMethod.includes(x as AuthnMethod); +} + +function parseAuthnMethod(x: unknown): AuthnMethod | undefined { + if (Array.isArray(x)) { + throw new TypeError('Array is not allowed for authnMethod'); + } + + return isAuthnMethod(x) ? x : undefined; +} + +function isAuthAction(x: unknown): x is AuthAction { + return supportedAuthAction.includes(x as AuthAction); +} + +function parseAuthAction(x: unknown): AuthAction | undefined { + return isAuthAction(x) ? x : undefined; +} + export function generateCodeChallenge(): string { const codeVerifier = uuid(); diff --git a/src/index.ts b/src/index.ts index e277b78..9feac49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,10 @@ export class MtLinkSdk { }; } + public setSamlSubjectId(value: string): void { + this.storedOptions.samlSubjectId = value; + } + public authorize(options?: AuthorizeOptions): void { authorize(this.storedOptions, options); } diff --git a/src/typings.ts b/src/typings.ts index 39a2aba..1805fff 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -1,7 +1,9 @@ -export type AuthAction = 'login' | 'signup'; +export const supportedAuthAction = ['login', 'signup'] as const; +export type AuthAction = typeof supportedAuthAction[number]; export interface PrivateParams { cobrandClientId?: string; + samlSubjectId?: string; } export interface PrivateConfigsOptions { @@ -9,6 +11,20 @@ export interface PrivateConfigsOptions { sdkVersion?: string; // semver } +export const supportedAuthnMethod = ['passwordless', 'sso', 'credentials'] as const; +export type AuthnMethod = typeof supportedAuthnMethod[number]; + +export const supportedConfigsOptions = [ + 'email', + 'backTo', + 'authAction', + 'showAuthToggle', + 'showRememberMe', + 'isNewTab', + 'forceLogout', + 'authnMethod' +] as const; +export type SupportedConfigsOptions = typeof supportedConfigsOptions[number]; export interface ConfigsOptions extends PrivateConfigsOptions { email?: string; backTo?: string; @@ -17,6 +33,7 @@ export interface ConfigsOptions extends PrivateConfigsOptions { showRememberMe?: boolean; isNewTab?: boolean; forceLogout?: boolean; + authnMethod?: AuthnMethod; } export type ServicesListType = { @@ -76,11 +93,11 @@ export type InitOptions = Omit, 'code mode?: Mode; locale?: string; }; + export interface StoredOptions extends InitOptions { clientId?: string; mode: Mode; } - export interface ExchangeTokenOptions extends OAuthSharedParams { code?: string; codeVerifier?: string;