Skip to content

Commit

Permalink
feat: ADDON-67533 implement support for oauth autorize and token urls (
Browse files Browse the repository at this point in the history
…#1009)

Add possibility to use `endpoint_authorize` and `endpoint_token` instead
of single 'endpoint' field for oauth.
Add `defaultValue` and `enable` into scheme for oauth fields
  • Loading branch information
soleksy-splunk authored Jan 25, 2024
1 parent d1090aa commit aada373
Show file tree
Hide file tree
Showing 10 changed files with 243 additions and 9 deletions.
3 changes: 3 additions & 0 deletions docs/advanced/oauth_support.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ Auth can be used inside the entity tag. Use `type: "oauth"` in the entity list a
- `client_secret` this is client secret for the your app for which you want auth
- `redirect_url` this will show redirect url which needs to be put in app's redirect url.
- `endpoint` this will be endpoint for which we want to build oauth support. For example for salesforce that will be either "login.salesforce.com" or "test.salesforce.com" or any other custom endpoint.
- there is also a possibility to specify separate endpoints for authorize and token, to do that instead single 'endpoint' field use two separate ones:
- `endpoint_authorize` - to specify the endpoint used for authorization ie. login.salesforce.com
- `endpoint_token` - to specify the endpoint used for token acqusition ie. api.login.salesforce.com
- `auth_code_endpoint` this must be present and its value should be endpoint value for getting the auth_code using the app. If the url to get auth_code is https://login.salesforce.com/services/oauth2/authorize then this will have value /services/oauth2/authorize
- `access_token_endpoint` this must be present and its value should be endpoint value for getting access_token using the auth_code received. If the url to get access token is https://login.salesforce.com/services/oauth2/token then this will have value /services/oauth2/token
- `auth_label` this allow user to have custom label for Auth Type dropdown
Expand Down
6 changes: 6 additions & 0 deletions splunk_add_on_ucc_framework/schema/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,9 @@
"required": {
"type": "boolean"
},
"defaultValue": {
"type": "string"
},
"options": {
"type": "object",
"properties": {
Expand All @@ -1737,6 +1740,9 @@
},
"disableonEdit": {
"type": "boolean"
},
"enable": {
"type": "boolean"
}
},
"additionalProperties": false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ basic_oauth_text =
client_id =
client_secret =
redirect_url =
endpoint_token =
endpoint_authorize =
oauth_oauth_text =
access_token =
refresh_token =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@
"label": "Redirect url",
"field": "redirect_url",
"help": "Copy and paste this URL into your app."
},
{
"oauth_field": "endpoint_token",
"label": "Token endpoint",
"field": "endpoint_token",
"help": "Put here endpoint used for token acqusition ie. login.salesforce.com"
},
{
"oauth_field": "endpoint_authorize",
"label": "Authorize endpoint",
"field": "endpoint_authorize",
"help": "Put here endpoint used for authorization ie. login.salesforce.com"
}
],
"auth_code_endpoint": "/services/oauth2/authorize",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,20 @@
default=None,
validator=None
),
field.RestField(
'endpoint_token',
required=False,
encrypted=False,
default=None,
validator=None
),
field.RestField(
'endpoint_authorize',
required=False,
encrypted=False,
default=None,
validator=None
),
field.RestField(
'oauth_oauth_text',
required=False,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,27 @@
"field": "redirect_url",
"help": "Copy and paste this URL into your app."
},
{
"oauth_field": "endpoint_token",
"label": "Token endpoint",
"field": "endpoint_token",
"help": "Put here endpoint used for token acqusition ie. login.salesforce.com"
},
{
"oauth_field": "endpoint_authorize",
"label": "Authorize endpoint",
"field": "endpoint_authorize",
"help": "Put here endpoint used for authorization ie. login.salesforce.com"
},
{
"oauth_field": "oauth_some_text",
"label": "Disabled on edit for oauth",
"help": "Enter text for field disabled on edit",
"field": "oauth_oauth_text",
"required": false,
"options": {
"disableonEdit": true
"disableonEdit": true,
"enable": false
}
}
],
Expand Down Expand Up @@ -1375,7 +1388,7 @@
"meta": {
"name": "Splunk_TA_UCCExample",
"restRoot": "splunk_ta_uccexample",
"version": "5.35.1R7fe3d58d",
"version": "5.35.1R537d4508",
"displayName": "Splunk UCC test Add-on",
"schemaVersion": "0.0.3"
}
Expand Down
8 changes: 6 additions & 2 deletions ui/src/components/BaseFormView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,9 @@ class BaseFormView extends PureComponent<BaseFormProps, BaseFormState> {
}

let host = encodeURI(
`https://${this.datadict.endpoint}${this.oauthConf?.authCodeEndpoint}${parameters}`
`https://${this.datadict.endpoint || this.datadict.endpoint_authorize}${
this.oauthConf?.authCodeEndpoint
}${parameters}`
);
const redirectURI = new URLSearchParams(host).get('redirect_uri');
if (redirectURI) {
Expand Down Expand Up @@ -1053,7 +1055,9 @@ class BaseFormView extends PureComponent<BaseFormProps, BaseFormState> {
const code = decodeURIComponent(message.code);
const data: Record<string, AcceptableFormValueOrNullish> = {
method: 'POST',
url: `https://${this.datadict.endpoint}${this.oauthConf?.accessTokenEndpoint}`,
url: `https://${this.datadict.endpoint || this.datadict.endpoint_token}${
this.oauthConf?.accessTokenEndpoint
}`,
grant_type: 'authorization_code',
client_id: this.datadict.client_id,
client_secret: this.datadict.client_secret,
Expand Down
9 changes: 6 additions & 3 deletions ui/src/components/BaseFormView3.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,9 +515,10 @@ class BaseFormView extends PureComponent {
if (this.oauthConf.authEndpointAccessTokenType) {
parameters = `${parameters}&token_access_type=${this.oauthConf.authEndpointAccessTokenType}`;
}

let host = encodeURI(
`https://${this.datadict.endpoint}${this.oauthConf.authCodeEndpoint}${parameters}`
`https://${this.datadict.endpoint || this.datadict.endpoint_authorize}${
this.oauthConf.authCodeEndpoint
}${parameters}`
);
const redirectURI = new URLSearchParams(host).get('redirect_uri');
host = host.replace(redirectURI, encodeURIComponent(redirectURI));
Expand Down Expand Up @@ -897,7 +898,9 @@ class BaseFormView extends PureComponent {
const code = decodeURIComponent(message.code);
const data = {
method: 'POST',
url: `https://${this.datadict.endpoint}${this.oauthConf.accessTokenEndpoint}`,
url: `https://${this.datadict.endpoint || this.datadict.endpoint_token}${
this.oauthConf.accessTokenEndpoint
}`,
grant_type: 'authorization_code',
client_id: this.datadict.client_id,
client_secret: this.datadict.client_secret,
Expand Down
110 changes: 108 additions & 2 deletions ui/src/components/EntityModal/EntityModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AxiosResponse } from 'axios';
import EntityModal, { EntityModalProps } from './EntityModal';
import { setUnifiedConfig } from '../../util/util';
import {
Expand All @@ -13,9 +14,11 @@ import {
getConfigOauthOauthDisableonEdit,
getConfigWithOauthDefaultValue,
getConfigWarningMessage,
getConfigWithSeparatedEndpointsOAuth,
} from './TestConfig';
import { ERROR_AUTH_PROCESS_TERMINATED_TRY_AGAIN } from '../../constants/oAuthErrorMessage';
import { Mode } from '../../constants/modes';
import * as axiosWrapper from '../../util/axiosCallWrapper';

describe('Oauth field disabled on edit - diableonEdit property', () => {
const handleRequestClose = jest.fn();
Expand Down Expand Up @@ -223,10 +226,10 @@ describe('EntityModal - auth_endpoint_token_access_type', () => {

renderModalWithProps(props);

const cliendIdField = document.querySelector('.client_id')?.querySelector('input');
const cliendIdField = document.querySelector('.client_id input');
expect(cliendIdField).toBeInTheDocument();

const secretField = document.querySelector('.client_secret')?.querySelector('input');
const secretField = document.querySelector('.client_secret input');
expect(secretField).toBeInTheDocument();

const redirectField = document.querySelector('.redirect_url');
Expand Down Expand Up @@ -341,3 +344,106 @@ describe('Default value', () => {
expect(component).toHaveValue(DEFAULT_VALUE);
});
});

describe('Oauth - separated endpoint authorization', () => {
const handleRequestClose = jest.fn();
const setUpConfigWithSeparatedEndpoints = () => {
const newConfig = getConfigWithSeparatedEndpointsOAuth();
setUnifiedConfig(newConfig);
};

const renderModalWithProps = (props: EntityModalProps) => {
render(<EntityModal {...props} handleRequestClose={handleRequestClose} />);
};

const getFilledOauthFields = async () => {
const endpointAuth = document.querySelector('.endpoint_authorize input');
const endpointToken = document.querySelector('.endpoint_token input');

if (endpointAuth) {
await userEvent.type(endpointAuth, 'authendpoint');
}
if (endpointToken) {
await userEvent.type(endpointToken, 'tokenendpoint');
}
return [endpointAuth, endpointToken];
};

const spyOnWindowOpen = async (addButton: HTMLElement) => {
const windowOpenSpy = jest.spyOn(window, 'open') as jest.Mock;

// mock opening verification window
windowOpenSpy.mockImplementation((url) => {
expect(url).toEqual(
'https://authendpoint/services/oauth2/authorize?response_type=code&client_id=Client%20Id&redirect_uri=http%3A%2F%2Flocalhost%2F'
);

return { closed: true };
});

await userEvent.click(addButton);
windowOpenSpy.mockRestore();
};

const props = {
serviceName: 'account',
mode: 'create',
stanzaName: undefined,
formLabel: 'formLabel',
page: 'configuration',
groupName: '',
open: true,
handleRequestClose: () => {},
} satisfies EntityModalProps;

it('render modal with separated oauth fields', async () => {
setUpConfigWithSeparatedEndpoints();
renderModalWithProps(props);

const [endpointAuth, endpointToken] = await getFilledOauthFields();

expect(endpointAuth).toBeInTheDocument();
expect(endpointAuth).toHaveValue('authendpoint');
expect(endpointToken).toBeInTheDocument();
expect(endpointToken).toHaveValue('tokenendpoint');
});

it('check if correct authorization endpoint called', async () => {
setUpConfigWithSeparatedEndpoints();
renderModalWithProps(props);

await getFilledOauthFields();

const addButton = screen.getByRole('button', { name: /add/i });
expect(addButton).toBeInTheDocument();

await spyOnWindowOpen(addButton);
});

it('check if correct auth token endpoint created', async () => {
setUpConfigWithSeparatedEndpoints();
renderModalWithProps(props);
const backendTokenFunction = jest.fn();

await getFilledOauthFields();
const addButton = screen.getByRole('button', { name: /add/i });
expect(addButton).toBeInTheDocument();

await spyOnWindowOpen(addButton);

// token is aquired on backend side so only thing we can check is if there is correct url created
jest.spyOn(axiosWrapper, 'axiosCallWrapper').mockImplementation((params) => {
backendTokenFunction((params?.body as unknown as URLSearchParams)?.get('url'));
return new Promise((r) => r({} as unknown as PromiseLike<AxiosResponse>));
});

// triggering manually external oauth window behaviour after success authorization
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).getMessage({ code: 200, msg: 'testing message for oauth' });

// only purpose is to check if backend function receinved correct token url
expect(backendTokenFunction).toHaveBeenCalledWith(
'https://tokenendpoint/services/oauth2/token'
);
});
});
71 changes: 71 additions & 0 deletions ui/src/components/EntityModal/TestConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,74 @@ export const getConfigWithOauthDefaultValue = () => {
};
return newConfig satisfies z.infer<typeof GlobalConfigSchema>;
};

const entityOauthOauthSeparatedEndpoints = [
{
type: 'oauth',
field: 'oauth_jest_test',
label: 'Not used',
required: true,
encrypted: false,
options: {
auth_type: ['oauth'],
oauth: [
{
oauth_field: 'client_id',
label: 'Client Id',
field: 'client_id',
help: 'Enter the Client Id for this account.',
defaultValue: 'Client Id',
},
{
oauth_field: 'client_secret',
label: 'Client Secret',
field: 'client_secret',
encrypted: true,
help: 'Enter the Client Secret key for this account.',
defaultValue: 'Client Secret',
},
{
oauth_field: 'redirect_url',
label: 'Redirect url',
field: 'redirect_url',
help: 'Copy and paste this URL into your app.',
defaultValue: 'Redirect url',
},
{
oauth_field: 'endpoint_token',
label: 'Token endpoint',
field: 'endpoint_token',
help: 'Put here endpoint used for token acqusition ie. login.salesforce.com',
},
{
oauth_field: 'endpoint_authorize',
label: 'Authorize endpoint',
field: 'endpoint_authorize',
help: 'Put here endpoint used for authorization ie. login.salesforce.com',
},
],
auth_code_endpoint: '/services/oauth2/authorize',
access_token_endpoint: '/services/oauth2/token',
oauth_timeout: 3000,
oauth_state_enabled: false,
display: true,
disableonEdit: false,
enable: true,
},
} satisfies z.infer<typeof OAuthEntity>,
];

export const getConfigWithSeparatedEndpointsOAuth = () => {
const globalConfig = getGlobalConfigMock();
const newConfig = {
...globalConfig,
pages: {
...globalConfig.pages,
configuration: {
...globalConfig.pages.configuration,
tabs: [{ entity: entityOauthOauthSeparatedEndpoints, ...defaultTableProps }],
},
},
};
return newConfig satisfies z.infer<typeof GlobalConfigSchema>;
};

0 comments on commit aada373

Please sign in to comment.