Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2702/expose new btp destination creation flow #2760

Closed
wants to merge 11 commits into from
5 changes: 5 additions & 0 deletions .changeset/thirty-trains-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sap-ux/btp-utils': minor
---

new functionality to generate OAuth2TokenExchange BTP destination using cf-tools
150 changes: 147 additions & 3 deletions packages/btp-utils/src/app-studio.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { cfGetInstanceKeyParameters } from '@sap/cf-tools';
import {
apiCreateServiceInstance,
apiGetInstanceCredentials,
apiGetServicesInstancesFilteredByType,
cfGetInstanceKeyParameters,
cfGetTarget
} from '@sap/cf-tools';
import type { Logger } from '@sap-ux/logger';
import { ENV } from './app-studio.env';
import { isS4HC, type Destination, type ListDestinationOpts } from './destination';
import {
Authentication,
type Destination,
DestinationType,
isS4HC,
type ListDestinationOpts,
OAuthUrlType,
type CloudFoundryServiceInfo,
type OAuth2Destination
} from './destination';
import type { ServiceInfo } from './service-info';

/**
* ABAP Cloud destination instance name.
*/
const DESTINATION_INSTANCE_NAME: string = 'abap-cloud-destination-instance';

/**
* HTTP header that is to be used for encoded credentials when communicating with a destination service instance.
*/
export const BAS_DEST_INSTANCE_CRED_HEADER = 'bas-destination-instance-cred';

/**
* Check if this is exectued in SAP Business Application Studio.
* Check if this is executed in SAP Business Application Studio.
*
* @returns true if yes
*/
Expand Down Expand Up @@ -127,3 +149,125 @@ export async function exposePort(port: number, logger?: Logger): Promise<string>
return '';
}
}

/**
* Transform a destination object into a TokenExchangeDestination destination, appended with UAA properties.
*
* @param destination destination info
* @param credentials object representing the Client ID and Client Secret and token endpoint
* @returns Populated OAuth destination
*/
export function transformToOAuthUserTokenExchange(
destination: Destination,
credentials: ServiceInfo['uaa']
): OAuth2Destination {
const oauthDestination = {
...destination,
Type: DestinationType.HTTP,
Authentication: Authentication.OAUTH2_USER_TOKEN_EXCHANGE,
URL: credentials.url,
WebIDEEnabled: 'true',
WebIDEUsage: 'odata_abap,dev_abap,abap_cloud',
'HTML5.Timeout': '60000',
'HTML5.DynamicDestination': 'true',
tokenServiceURLType: OAuthUrlType.DEDICATED,
tokenServiceURL: `${credentials.url}/oauth/token`,
clientSecret: credentials.clientsecret,
clientId: credentials.clientid
} as OAuth2Destination;
// Will be added as an additional property in BTP if not removed
delete (oauthDestination as { Host?: string }).Host;
return oauthDestination;
}

/**
* Generate a destination name representing the CF target the user is logged into i.e. abap-cloud-mydestination-myorg-mydevspace.
*
* @param name destination name
* @returns formatted destination name using target space and target organisation
*/
export async function generateABAPCloudDestinationName(name: string): Promise<string> {
const target = await cfGetTarget(true);
if (!target.space) {
throw new Error(`No Dev Space has been created for the subaccount.`);
}
const formattedInstanceName = `${name}-${target.org}-${target.space}`.replace(/\W/gi, '-').toLowerCase();
return `abap-cloud-${formattedInstanceName}`.substring(0, 199);
}

/**
* Generate a new object representing an OAuth2 token exchange BTP destination.
*
* @param destination destination info
* @param logger Logger
* @returns Preconfigured OAuth destination
*/
async function generateOAuthTokenExchangeDestination(
destination: Destination,
logger?: Logger
): Promise<OAuth2Destination> {
const destinationName: string = await generateABAPCloudDestinationName(destination.Name);
const instances: CloudFoundryServiceInfo[] = await apiGetServicesInstancesFilteredByType(['destination']);
const destinationInstance = instances.find(
(instance: CloudFoundryServiceInfo) => instance.label === DESTINATION_INSTANCE_NAME
);

if (!destinationInstance) {
// Create a new abap-cloud destination instance on the target CF subaccount
await apiCreateServiceInstance('destination', 'lite', DESTINATION_INSTANCE_NAME, null);
logger?.info(`New ABAP destination instance ${DESTINATION_INSTANCE_NAME} created on subaccount.`);
}

const instanceDetails = await apiGetInstanceCredentials(DESTINATION_INSTANCE_NAME);
if (!instanceDetails?.credentials) {
throw new Error(`Could not retrieve SAP BTP credentials.`);
}
return transformToOAuthUserTokenExchange(
{
...destination,
Description: `Destination generated by App Studio for '${destination.Name}', Do not remove.`,
Name: destinationName
},
instanceDetails.credentials as ServiceInfo['uaa']
);
}

/**
* Create a new SAP BTP subaccount destination of type 'OAuth2UserTokenExchange' using cf-tools to populate the UAA properties.
* This will overwrite the existing destination its associated properties, if already present on the CF subaccount.
*
* @param destination destination info
* @param logger Logger
* @returns Newly generated OAuth destination
*/
export async function createBTPOAuthExchangeDestination(
destination: Destination,
logger?: Logger
): Promise<OAuth2Destination> {
if (!isAppStudio()) {
throw new Error(`Creating SAP BTP destinations is only supported on SAP Business Application Studio.`);
}
const btpDestination = await generateOAuthTokenExchangeDestination(destination, logger);
await createBTPDestination(btpDestination);
return btpDestination;
}

/**
* Create or update a SAP BTP subaccount destination.
* If the destination already exists, there is no exception thrown and the existing properties defined in the destination are not updated nor removed by this request.
*
* @param destination destination info
*/
async function createBTPDestination(destination: Destination | OAuth2Destination): Promise<void> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'Bas'
};
const reqConfig: AxiosRequestConfig = {
method: 'post',
url: `${getAppStudioBaseURL()}/api/createDestination`,
headers,
data: destination
};
await axios.request(reqConfig);
}
41 changes: 41 additions & 0 deletions packages/btp-utils/src/destination.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
/**
* Support different Token Service URL Types
*/
export enum DestinationType {
HTTP = 'HTTP',
LDAP = 'LDAP',
MAIL = 'MAIL',
RFC = 'RFC'
}

/**
* Support different Token Service URL Types
*/
export enum OAuthUrlType {
DEDICATED = 'Dedicated',
COMMON = 'Common'
}

/**
* Support destination authentication types
*/
Expand Down Expand Up @@ -76,6 +94,7 @@ export interface Destination extends Partial<AdditionalDestinationProperties> {
Authentication: string;
ProxyType: string;
Description: string;

/**
* N.B. Not the host but the full destination URL property!
*/
Expand Down Expand Up @@ -286,3 +305,25 @@ export const AbapEnvType = {
} as const;

export type AbapEnvType = (typeof AbapEnvType)[keyof typeof AbapEnvType];

/**
* OAuth destination properties.
*/
export interface OAuth2Destination extends Omit<Destination, 'Host'>, Partial<AdditionalDestinationProperties> {
URL: string; // Required for creation flow
clientSecret: string;
clientId: string;
tokenServiceURL: string;
tokenServiceURLType?: 'Dedicated'; // Optional for OAuth2Password destinations
}

export interface CloudFoundryServiceInfo {
label: string;
serviceName: string;
guid?: string;
tags?: string[];
alwaysShow?: boolean;
plan_guid?: string;
plan?: string;
credentials?: any;
}
102 changes: 98 additions & 4 deletions packages/btp-utils/test/app-studio.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import nock from 'nock';
import { join } from 'path';
import type { Destination } from '../src';
import { Destination } from '../src';
import {
getAppStudioProxyURL,
getAppStudioBaseURL,
isAppStudio,
getDestinationUrlForAppStudio,
listDestinations,
getCredentialsForDestinationService,
exposePort
exposePort,
createBTPOAuthExchangeDestination
} from '../src';
import { ENV } from '../src/app-studio.env';
import destinationList from './mockResponses/destinations.json';
import { type ServiceInstanceInfo } from '@sap/cf-tools';

const destinations: { [key: string]: Destination } = {};
destinationList.forEach((dest) => {
Expand All @@ -23,6 +25,15 @@ const mockInstanceSettings = {
clientsecret: 'CLIENT_SECRET'
};

let cfDiscoveredAbapEnvsMock: ServiceInstanceInfo[] = [];
let uaaCredentialsMock = {
credentials: {
clientid: 'CLIENT_ID/WITH/STH/TO/ENCODE',
clientsecret: 'CLIENT_SECRET',
url: 'http://my-server'
}
};
let cfTargetMock = { org: 'testOrg', space: 'testSpace' };
jest.mock('@sap/cf-tools', () => {
const original = jest.requireActual('@sap/cf-tools');
return {
Expand All @@ -39,7 +50,11 @@ jest.mock('@sap/cf-tools', () => {
? { credentials: { uaa: mockInstanceSettings } }
: { credentials: mockInstanceSettings };
}
})
}),
cfGetTarget: jest.fn(() => Promise.resolve(cfTargetMock)),
apiGetServicesInstancesFilteredByType: jest.fn().mockImplementation(() => cfDiscoveredAbapEnvsMock),
apiCreateServiceInstance: jest.fn().mockImplementation((name?) => {}),
apiGetInstanceCredentials: jest.fn(() => Promise.resolve(uaaCredentialsMock))
};
});

Expand All @@ -55,7 +70,7 @@ describe('App Studio', () => {
expect(isAppStudio()).toBeFalsy();
});

it('returns true when env variable is ""', () => {
it('returns true when env variable is ', () => {
process.env[ENV.H2O_URL] = '';
expect(isAppStudio()).toBeFalsy();
});
Expand Down Expand Up @@ -189,4 +204,83 @@ describe('App Studio', () => {
expect(url).toStrictEqual('');
});
});

describe('createBTPABAPCloudDestination', () => {
const server = 'https://destinations.example';
// Some settings are toggled or incorrect, to ensure the correct params are posted to BTP
const destination: Destination = {
Name: 'my-abap-env',
Type: 'MAIL',
ProxyType: 'Internet',
Authentication: 'NoAuthentication',
WebIDEEnabled: 'false',
Description: 'This should be removed during the regeneration of the BTP destination',
'HTML5.DynamicDestination': 'false',
Host: 'https://658bd07a-eda6-40bc-b17a-b9a8b79b646b.abap.canaryaws.hanavlab.ondemand.com/'
};

beforeAll(() => {
nock(server).get('/reload').reply(200).persist();
process.env[ENV.H2O_URL] = server;
});

afterAll(() => {
delete process.env[ENV.H2O_URL];
jest.resetAllMocks();
});

test('creation is only supported on BAS', async () => {
delete process.env[ENV.H2O_URL];
await expect(createBTPOAuthExchangeDestination(destination)).rejects.toThrow(
/SAP Business Application Studio/
);
});

test('generate new SAP BTP destination', async () => {
process.env[ENV.H2O_URL] = server;
const result = `
Object {
"Authentication": "OAuth2UserTokenExchange",
"Description": "Destination generated by App Studio for 'my-abap-env', Do not remove.",
"HTML5.DynamicDestination": "true",
"HTML5.Timeout": "60000",
"Name": "abap-cloud-my-abap-env-testorg-testspace",
"ProxyType": "Internet",
"Type": "HTTP",
"URL": "http://my-server",
"WebIDEEnabled": "true",
"WebIDEUsage": "odata_abap,dev_abap,abap_cloud",
"clientId": "CLIENT_ID/WITH/STH/TO/ENCODE",
"clientSecret": "CLIENT_SECRET",
"tokenServiceURL": "http://my-server/oauth/token",
"tokenServiceURLType": "Dedicated",
}
`;
let bodyParam;
nock(server)
.post('/api/createDestination', (body) => {
bodyParam = body;
return true;
})
.reply(200);
await expect(createBTPOAuthExchangeDestination(destination)).resolves.toMatchInlineSnapshot(result);
expect(bodyParam).toMatchInlineSnapshot(result);
});

test('throw exception if no UAA credentials found', async () => {
process.env[ENV.H2O_URL] = server;
uaaCredentialsMock = {} as any;
await expect(createBTPOAuthExchangeDestination(destination)).rejects.toThrow(
/Could not retrieve SAP BTP credentials./
);
});

test('throw exception if no dev space is created for the respective subaccount', async () => {
process.env[ENV.H2O_URL] = server;
cfTargetMock = {} as any;
await expect(createBTPOAuthExchangeDestination(destination)).rejects.toThrow(
/No Dev Space has been created for the subaccount./
);
});
});
});
Loading