diff --git a/.vscode/launch.json b/.vscode/launch.json
index a9e017d4c1..e1e676abe3 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -85,7 +85,8 @@
"args": ["${workspaceRoot}/Composer/packages/electron-server"],
"env": {
"NODE_ENV": "development",
- "DEBUG": "composer*"
+ "DEBUG": "composer*",
+ "COMPOSER_ENABLE_ONEAUTH": "false"
},
"outputCapture": "std"
},
diff --git a/Composer/babel.l10n.config.js b/Composer/babel.l10n.config.js
new file mode 100644
index 0000000000..6bf5abe37a
--- /dev/null
+++ b/Composer/babel.l10n.config.js
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+module.exports = {
+ presets: ['@babel/react', ['@babel/typescript', { allowNamespaces: true }]],
+ plugins: ['@babel/plugin-proposal-class-properties'],
+ ignore: [
+ 'packages/electron-server',
+ 'packages/**/__tests__',
+ 'packages/**/node_modules',
+ 'packages/**/build/**/*.js',
+ ],
+};
diff --git a/Composer/package.json b/Composer/package.json
index 3aabf9c0c2..0d5dd24e03 100644
--- a/Composer/package.json
+++ b/Composer/package.json
@@ -67,7 +67,7 @@
"l10n:extract": "cross-env NODE_ENV=production format-message extract -g underscored_crc32 -o packages/server/src/locales/en-US.json l10ntemp/**/*.js",
"l10n:extractJson": "node scripts/l10n-extractJson.js",
"l10n:transform": "node scripts/l10n-transform.js",
- "l10n:babel": "babel ./packages --extensions \".ts,.tsx,.jsx,.js\" --out-dir l10ntemp --presets=@babel/react,@babel/typescript --plugins=@babel/plugin-proposal-class-properties --ignore \"packages/electron-server\",\"packages/**/__tests__\",\"packages/**/node_modules\",\"packages/**/build/**/*.js\"",
+ "l10n:babel": "babel --config-file ./babel.l10n.config.js --extensions \"ts,.tsx,.jsx,.js\" --out-dir l10ntemp ./packages",
"l10n": "yarn l10n:babel && yarn l10n:extract && yarn l10n:transform packages/server/src/locales/en-US.json && yarn l10n:extractJson packages/server/schemas"
},
"husky": {
diff --git a/Composer/packages/client/public/index.html b/Composer/packages/client/public/index.html
index 047330b0d4..51d64d74e3 100644
--- a/Composer/packages/client/public/index.html
+++ b/Composer/packages/client/public/index.html
@@ -41,6 +41,12 @@
} ?>
<% } %>
+
+ if (__csrf__) { ?>
+
+ } ?>
diff --git a/Composer/packages/client/src/plugins/api.ts b/Composer/packages/client/src/plugins/api.ts
index ab8a560d5b..85134039f2 100644
--- a/Composer/packages/client/src/plugins/api.ts
+++ b/Composer/packages/client/src/plugins/api.ts
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { OAuthClient, OAuthOptions } from '../utils/oauthClient';
+import { AuthParameters } from '@botframework-composer/types';
+
+import { AuthClient } from '../utils/authClient';
interface IAPI {
auth: AuthAPI;
@@ -15,8 +17,7 @@ interface PublishConfig {
}
interface AuthAPI {
- login: (options: OAuthOptions) => Promise; // returns an id token
- getAccessToken: (options: OAuthOptions) => Promise; // returns an access token
+ getAccessToken: (options: AuthParameters) => Promise; // returns an access token
}
interface PublishAPI {
@@ -31,13 +32,8 @@ class API implements IAPI {
constructor() {
this.auth = {
- login: (options: OAuthOptions) => {
- const client = new OAuthClient(options);
- return client.login();
- },
- getAccessToken: (options: OAuthOptions) => {
- const client = new OAuthClient(options);
- return client.getTokenSilently();
+ getAccessToken: (params: AuthParameters) => {
+ return AuthClient.getAccessToken(params);
},
};
this.publish = {
diff --git a/Composer/packages/client/src/types/window.d.ts b/Composer/packages/client/src/types/window.d.ts
index dd5892d0c3..003cfa04d9 100644
--- a/Composer/packages/client/src/types/window.d.ts
+++ b/Composer/packages/client/src/types/window.d.ts
@@ -29,5 +29,10 @@ declare global {
ExtensionClient: typeof ExtensionClient;
Fabric: typeof Fabric;
+
+ /**
+ * Token generated by the server, and sent with certain auth-related requests to the server to be verified and prevent CSRF attacks.
+ */
+ __csrf__?: string;
}
}
diff --git a/Composer/packages/client/src/utils/authClient.ts b/Composer/packages/client/src/utils/authClient.ts
new file mode 100644
index 0000000000..e3ec7ff180
--- /dev/null
+++ b/Composer/packages/client/src/utils/authClient.ts
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { AuthParameters } from '@botframework-composer/types';
+
+async function getAccessToken(options: AuthParameters): Promise {
+ try {
+ const { clientId = '', targetResource = '', scopes = [] } = options;
+ const { __csrf__ = '' } = window;
+
+ let url = '/api/auth/getAccessToken?';
+ const params = new URLSearchParams();
+ if (clientId) {
+ params.append('clientId', clientId);
+ }
+ if (scopes.length) {
+ params.append('scopes', JSON.stringify(scopes));
+ }
+ if (targetResource) {
+ params.append('targetResource', targetResource);
+ }
+ url += params.toString();
+
+ const result = await fetch(url, { method: 'GET', headers: { 'X-CSRF-Token': __csrf__ } });
+ const { accessToken = '' } = await result.json();
+ return accessToken;
+ } catch (e) {
+ // error handling
+ console.error('Did not receive an access token back from the server: ', e);
+ return '';
+ }
+}
+
+export const AuthClient = {
+ getAccessToken,
+};
diff --git a/Composer/packages/client/src/utils/oauthClient.ts b/Composer/packages/client/src/utils/oauthClient.ts
deleted file mode 100644
index 370eb1bfee..0000000000
--- a/Composer/packages/client/src/utils/oauthClient.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import { isElectron } from './electronUtil';
-
-export interface OAuthOptions {
- clientId: string;
- scopes: string[];
-}
-
-interface OAuthConfig extends OAuthOptions {
- redirectUri: string;
-}
-
-interface OAuthTokens {
- accessToken?: string;
- idToken?: string;
-}
-
-export class OAuthClient {
- private config: OAuthConfig;
- private tokens: OAuthTokens;
- private id: number;
- private static clientId = 0;
-
- constructor(config: OAuthOptions) {
- this.config = { ...config, redirectUri: 'bfcomposer://oauth' };
- this.tokens = {};
- // assign an id to the client so we can route responses back to the right one from the main process
- this.id = OAuthClient.clientId++;
- }
-
- /** Logs in the current user and retrieves an id token from Azure */
- public async login(): Promise {
- // we need to perform a login request
- if (isElectron()) {
- return new Promise((resolve, reject) => {
- const { ipcRenderer } = window;
- ipcRenderer.on('oauth-login-complete', (_ev, idToken: string, id: number) => {
- if (id === this.id) {
- // make sure the auth request originated from this client instance
- this.tokens.idToken = idToken;
- resolve(idToken);
- }
- });
- ipcRenderer.on('oauth-login-error', (_ev, error, id) => {
- if (id === this.id) {
- console.error('There was an error while attempting to log the current user in: ', error);
- reject(error);
- }
- });
- ipcRenderer.send('oauth-start-login', this.config, this.id);
- // TODO: after some amount of time we should reject
- });
- }
- return Promise.reject('OAuth flow is currently disabled in the Composer web environment.');
- }
-
- /**
- * Retrieves an Azure access token on behalf of the current signed-in user.
- */
- public async getTokenSilently(): Promise {
- if (isElectron()) {
- if (!this.tokens.idToken) {
- // login
- await this.login();
- }
-
- const { ipcRenderer } = window;
- return new Promise((resolve, reject) => {
- ipcRenderer.on('oauth-get-access-token-complete', (_ev, accessToken: string, id: number) => {
- if (id === this.id) {
- // make sure the auth request originated from this client instance
- this.tokens.accessToken = accessToken;
- resolve(accessToken);
- }
- });
- ipcRenderer.on('oauth-get-access-token-error', (_ev, error, id) => {
- if (id === this.id) {
- console.error('There was an error while attempting to silently get an access token: ', error);
- reject(error);
- }
- });
- // get an access token using the id token
- ipcRenderer.send('oauth-get-access-token', this.config, this.tokens.idToken, this.id);
- // TODO: after some amount of time we should reject
- });
- }
- return Promise.reject('OAuth flow is currently disabled in the Composer web environment.');
- }
-
- // TODO: add token caching
-}
diff --git a/Composer/packages/electron-server/.eslintrc.js b/Composer/packages/electron-server/.eslintrc.js
index 8cbe448349..31e7c86f7f 100644
--- a/Composer/packages/electron-server/.eslintrc.js
+++ b/Composer/packages/electron-server/.eslintrc.js
@@ -7,4 +7,12 @@ module.exports = {
rules: {
'security/detect-non-literal-fs-filename': 'off',
},
+ overrides: [
+ {
+ files: ['scripts/*'],
+ rules: {
+ '@typescript-eslint/no-var-requires': 'off',
+ },
+ },
+ ],
};
diff --git a/Composer/packages/electron-server/.gitignore b/Composer/packages/electron-server/.gitignore
index 03a0ab5993..d5bff0464b 100644
--- a/Composer/packages/electron-server/.gitignore
+++ b/Composer/packages/electron-server/.gitignore
@@ -2,3 +2,4 @@ build/
dist/
locales/en-US-pseudo.json
l10ntemp/
+oneauth-temp
diff --git a/Composer/packages/electron-server/AUTH.md b/Composer/packages/electron-server/AUTH.md
new file mode 100644
index 0000000000..05c048201f
--- /dev/null
+++ b/Composer/packages/electron-server/AUTH.md
@@ -0,0 +1,31 @@
+# Enabling Authentication via OneAuth
+
+## Summary
+
+Authentication in Composer is done using the OneAuth native node library.
+
+This library leverages APIs within the user's OS to store and retrieve credentials in a compliant fashion, and allows Composer to get access tokens on behalf of the user once the user signs in.
+
+We disable this authentication flow by default in the development environment. To use the flow in a dev environment, please follow the steps below to leverage the OneAuth library.
+
+## Requirements
+
+**NOTE:** Authentication on Linux is not (yet) supported. We plan to support this in the future.
+
+When building Composer from source, in order to leverage the OneAuth library you will need to:
+
+- Set the `COMPOSER_ENABLE_ONEAUTH` environment variable to `true` in whatever process you use to start the `electron-server` package
+- Install the `oneauth-win64` or `oneauth-mac` NodeJS module either manually from the private registry, or by downloading it via script
+
+## Installing the OneAuth module
+
+Depending on your OS (Mac vs. Windows), you will need to install the `oneauth-mac` or `oneauth-win64` modules respectively.
+
+### Using the `installOneAuth.js` script
+
+1. Set `npm_config_registry` to `https://office.pkgs.visualstudio.com/_packaging/OneAuth/npm/registry/`
+1. Set `npm_config_username` to anything other than an empty string
+1. Set `npm_config__password` (note the double "_") to a base64-encoded [Personal Access Token you created in Azure DevOps](https://office.visualstudio.com/_usersSettings/tokens) for the Office org that has the Packaging (read) scope enabled
+1. Run `node scripts/installOneAuth.js` from `/electron-server/`
+
+There should now be a `/electron-server/oneauth-temp/` directory containing the contents of the OneAuth module which will be called by Composer assuming you set the `COMPOSER_ENABLE_ONEAUTH` environment variable.
diff --git a/Composer/packages/electron-server/__tests__/auth/oneAuthService.test.ts b/Composer/packages/electron-server/__tests__/auth/oneAuthService.test.ts
new file mode 100644
index 0000000000..9e896129c0
--- /dev/null
+++ b/Composer/packages/electron-server/__tests__/auth/oneAuthService.test.ts
@@ -0,0 +1,150 @@
+import { OneAuthInstance } from '../../src/auth/oneAuthService';
+
+jest.mock('../../src/electronWindow', () => ({
+ getInstance: jest.fn().mockReturnValue({
+ browserWindow: {
+ getNativeWindowHandle: jest.fn(),
+ },
+ }),
+}));
+
+jest.mock('../../src/utility/platform', () => ({
+ isLinux: () => process.env.TEST_IS_LINUX === 'true',
+ isMac: () => false,
+}));
+
+describe('OneAuth Serivce', () => {
+ const INTERACTION_REQUIRED = 'interactionRequired';
+ const mockAccount = {
+ id: 'myAccount',
+ realm: 'myTenant',
+ };
+ const mockCredential = {
+ expiresOn: 9999,
+ value: 'someToken',
+ };
+ const mockOneAuth = {
+ acquireCredentialInteractively: jest.fn().mockResolvedValue({ credential: mockCredential }),
+ acquireCredentialSilently: jest.fn().mockResolvedValue({ credential: mockCredential }),
+ initialize: jest.fn(),
+ setLogCallback: jest.fn(),
+ setLogPiiEnabled: jest.fn(),
+ signInInteractively: jest.fn().mockResolvedValue({ account: mockAccount }),
+ shutdown: jest.fn(),
+ AadConfiguration: class AAD {},
+ AppConfiguration: class App {},
+ AuthParameters: class AP {},
+ Status: {
+ InteractionRequired: INTERACTION_REQUIRED,
+ },
+ };
+ let oneAuthService = new OneAuthInstance(); // bypass the shim logic
+ let processEnvBackup = { ...process.env };
+
+ afterEach(() => {
+ process.env = processEnvBackup;
+ });
+
+ beforeEach(() => {
+ jest.resetModules();
+ oneAuthService = new OneAuthInstance();
+ (oneAuthService as any)._oneAuth = mockOneAuth;
+ mockOneAuth.acquireCredentialInteractively.mockClear();
+ mockOneAuth.acquireCredentialSilently.mockClear();
+ mockOneAuth.initialize.mockClear();
+ mockOneAuth.setLogCallback.mockClear();
+ mockOneAuth.setLogPiiEnabled.mockClear();
+ mockOneAuth.signInInteractively.mockClear();
+ mockOneAuth.shutdown.mockClear();
+ (oneAuthService as any).initialized = false;
+ (oneAuthService as any).signedInAccount = undefined;
+ });
+
+ it('should sign in and get an access token (happy path)', async () => {
+ const result = await oneAuthService.getAccessToken({ targetResource: 'someProtectedResource' });
+
+ // it should have initialized
+ expect(mockOneAuth.setLogPiiEnabled).toHaveBeenCalled();
+ expect(mockOneAuth.setLogCallback).toHaveBeenCalled();
+ expect(mockOneAuth.initialize).toHaveBeenCalled();
+
+ // it should have signed in
+ expect(mockOneAuth.signInInteractively).toHaveBeenCalled();
+ expect((oneAuthService as any).signedInAccount).toEqual(mockAccount);
+
+ // it should have called acquireCredentialSilently
+ expect(mockOneAuth.acquireCredentialSilently).toHaveBeenCalled();
+
+ expect(result.accessToken).toBe(mockCredential.value);
+ expect(result.expiryTime).toBe(mockCredential.expiresOn);
+ });
+
+ it('should try to acquire a token interactively if interaction is required', async () => {
+ mockOneAuth.acquireCredentialSilently.mockReturnValueOnce({ error: { status: INTERACTION_REQUIRED } });
+ const result = await oneAuthService.getAccessToken({ targetResource: 'someProtectedResource' });
+
+ expect(mockOneAuth.acquireCredentialInteractively).toHaveBeenCalled();
+
+ expect(result.accessToken).toBe(mockCredential.value);
+ expect(result.expiryTime).toBe(mockCredential.expiresOn);
+ });
+
+ it('should throw if there is no targetResource passed as an arg', async () => {
+ try {
+ await oneAuthService.getAccessToken({ targetResource: undefined } as any);
+ throw 'Did not throw expected.';
+ } catch (e) {
+ expect(e).toBe('Target resource required to get access token.');
+ }
+ });
+
+ it('should throw if the signed in account does not have an id', async () => {
+ try {
+ mockOneAuth.signInInteractively.mockReturnValueOnce({ account: { id: undefined } });
+ await oneAuthService.getAccessToken({ targetResource: 'someProtectedResource' } as any);
+ throw 'Did not throw expected.';
+ } catch (e) {
+ expect(e).toBe('Signed in account does not have an id.');
+ }
+ });
+
+ it('should sign out', async () => {
+ await oneAuthService.getAccessToken({ targetResource: 'someProtectedResource' });
+
+ // it should have signed in
+ expect((oneAuthService as any).signedInAccount).toEqual(mockAccount);
+
+ oneAuthService.signOut();
+
+ expect((oneAuthService as any).signedInAccount).toBeUndefined();
+ });
+
+ it('should shut down', async () => {
+ await oneAuthService.shutdown();
+
+ expect(mockOneAuth.shutdown).toHaveBeenCalled();
+ });
+
+ it('should return the shim on Linux', async () => {
+ Object.assign(process.env, { ...process.env, COMPOSER_ENABLE_ONEAUTH: 'true', TEST_IS_LINUX: 'true' });
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { OneAuthService: service } = require('../../src/auth/oneAuthService');
+ const result = await service.getAccessToken({});
+
+ expect(result).toEqual({ accessToken: '', acquiredAt: 0, expiryTime: 99999999999 });
+ });
+
+ it('should return the shim in the dev environment without the oneauth env variable set', async () => {
+ Object.assign(process.env, {
+ ...process.env,
+ COMPOSER_ENABLE_ONEAUTH: undefined,
+ NODE_ENV: 'development',
+ TEST_IS_LINUX: 'false',
+ });
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { OneAuthService: service } = require('../../src/auth/oneAuthService');
+ const result = await service.getAccessToken({});
+
+ expect(result).toEqual({ accessToken: '', acquiredAt: 0, expiryTime: 99999999999 });
+ });
+});
diff --git a/Composer/packages/electron-server/__tests__/setupTests.js b/Composer/packages/electron-server/__tests__/setupTests.js
new file mode 100644
index 0000000000..0c94be19e4
--- /dev/null
+++ b/Composer/packages/electron-server/__tests__/setupTests.js
@@ -0,0 +1,7 @@
+process.env.DEBUG = 'composer*';
+
+jest.mock('electron', () => ({
+ app: {
+ getVersion: jest.fn().mockReturnValue('v1.0.0'),
+ },
+}));
diff --git a/Composer/packages/electron-server/babel.l10n.config.js b/Composer/packages/electron-server/babel.l10n.config.js
new file mode 100644
index 0000000000..a3793530d0
--- /dev/null
+++ b/Composer/packages/electron-server/babel.l10n.config.js
@@ -0,0 +1,8 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+module.exports = {
+ presets: ['@babel/react', ['@babel/typescript', { allowNamespaces: true }]],
+ plugins: ['@babel/plugin-proposal-class-properties'],
+ ignore: ['**/__tests__', 'node_modules/**', 'build/**/*.js', 'dist/**'],
+};
diff --git a/Composer/packages/electron-server/jest.config.js b/Composer/packages/electron-server/jest.config.js
index 216428d9b5..f4650bdda3 100644
--- a/Composer/packages/electron-server/jest.config.js
+++ b/Composer/packages/electron-server/jest.config.js
@@ -1,3 +1,6 @@
const { createConfig } = require('@botframework-composer/test-utils');
-module.exports = createConfig('electron-server', 'node');
+module.exports = createConfig('electron-server', 'node', {
+ setupFilesAfterEnv: ['./__tests__/setupTests.js'],
+ testPathIgnorePatterns: ['/node_modules/', '/__tests__/setupTests.js'],
+});
diff --git a/Composer/packages/electron-server/package.json b/Composer/packages/electron-server/package.json
index f3e44e77cb..f2d55d422b 100644
--- a/Composer/packages/electron-server/package.json
+++ b/Composer/packages/electron-server/package.json
@@ -11,11 +11,9 @@
"scripts": {
"build": "tsc -p tsconfig.build.json && ncp src/preload.js build/preload.js",
"clean": "rimraf build && rimraf dist && rimraf l10ntemp",
- "copy-extensions": "node scripts/copy-extensions.js",
- "copy-runtime": "node scripts/copy-runtime.js",
- "copy-form-dialog-templates": "node scripts/copy-form-dialog-templates.js",
+ "copy-artifacts": "node scripts/copy-artifacts.js",
"dist": "node scripts/electronBuilderDist.js",
- "dist:full": "yarn clean && yarn build && yarn run pack && yarn copy-runtime && yarn copy-extensions && yarn copy-form-dialog-templates && yarn dist",
+ "dist:full": "yarn clean && yarn build && yarn run pack && yarn copy-artifacts && yarn dist",
"lint": "eslint --quiet ./src",
"lint:fix": "yarn lint --fix",
"pack": "node scripts/electronBuilderPack.js",
@@ -27,10 +25,13 @@
"l10n:extract": "cross-env NODE_ENV=production format-message extract -g underscored_crc32 -o locales/en-US.json l10ntemp/**/*.js",
"l10n:extractJson": "node ../../scripts/l10n-extractJson.js",
"l10n:transform": "node ../../scripts/l10n-transform.js",
- "l10n:babel": "babel . --extensions \".ts,.tsx,.jsx,.js\" --out-dir l10ntemp --presets=@babel/typescript --plugins=@babel/plugin-proposal-class-properties --ignore \"**/__tests__\",\"**/node_modules\",\"**/build/**/*.js\"",
+ "l10n:babel": "babel --config-file ./babel.l10n.config.js --extensions \".ts,.tsx,.jsx,.js\" --out-dir l10ntemp .",
"l10n": "yarn l10n:babel && yarn l10n:extract && yarn l10n:transform locales/en-US.json && rimraf l10ntemp"
},
"devDependencies": {
+ "@babel/plugin-proposal-class-properties": "7.8.3",
+ "@babel/plugin-transform-runtime": "7.9.6",
+ "@botframework-composer/test-utils": "0.0.1",
"@types/archiver": "^3.1.0",
"@types/body-parser": "^1.17.0",
"@types/compression": "^1.0.1",
@@ -40,6 +41,7 @@
"@types/form-data": "^2.2.1",
"@types/globby": "^9.1.0",
"@types/http-errors": "^1.6.1",
+ "@types/jest": "^26.0.15",
"@types/jsonwebtoken": "^8.3.3",
"@types/lodash": "^4.14.146",
"@types/lru-cache": "^5.1.0",
@@ -49,7 +51,7 @@
"@types/rimraf": "^2.0.2",
"electron": "8.2.4",
"electron-builder": "^22.6.0",
- "fs-extra": "^9.0.0",
+ "globby": "^11.0.1",
"js-yaml": "^3.13.1",
"mock-fs": "^4.10.1",
"ncp": "2.0.0",
@@ -62,6 +64,7 @@
"dependencies": {
"@bfc/server": "*",
"@bfc/shared": "*",
+ "@botframework-composer/types": "*",
"debug": "4.1.1",
"electron-updater": "4.2.5",
"fix-path": "^3.0.0",
@@ -70,5 +73,9 @@
"fs-extra": "^9.0.0",
"lodash": "^4.17.19",
"semver": "7.3.2"
+ },
+ "peerDependencies": {
+ "oneauth-mac": "1.11.0",
+ "oneauth-win64": "1.11.0"
}
}
diff --git a/Composer/packages/electron-server/resources/entitlements-keychain.plist b/Composer/packages/electron-server/resources/entitlements-keychain.plist
new file mode 100644
index 0000000000..f5ec040689
--- /dev/null
+++ b/Composer/packages/electron-server/resources/entitlements-keychain.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+ keychain-access-groups
+
+ UBF8T346G9.com.microsoft.identity.universalstorage
+
+ com.apple.application-identifier
+ UBF8T346G9.com.microsoft.botframework.composer
+ com.apple.developer.team-identifier
+ UBF8T346G9
+
+
diff --git a/Composer/packages/electron-server/scripts/common.js b/Composer/packages/electron-server/scripts/common.js
index dceceb4e61..2177f3a81d 100644
--- a/Composer/packages/electron-server/scripts/common.js
+++ b/Composer/packages/electron-server/scripts/common.js
@@ -55,7 +55,22 @@ function hashFileAsync(file, algorithm = 'sha512', encoding = 'base64', options)
});
}
+const scriptName = path.basename(process.argv[1]);
+function logInfo(...args) {
+ const [formatter, ...rest] = args;
+ console.log(`[${scriptName}] ${formatter}`, ...rest);
+}
+
+function logError(...args) {
+ const [formatter, ...rest] = args;
+ console.error(`[${scriptName}] ${formatter}`, ...rest);
+}
+
module.exports = {
hashFileAsync,
writeToDist,
+ log: {
+ info: logInfo,
+ error: logError,
+ },
};
diff --git a/Composer/packages/electron-server/scripts/copy-artifacts.js b/Composer/packages/electron-server/scripts/copy-artifacts.js
new file mode 100644
index 0000000000..79bce91246
--- /dev/null
+++ b/Composer/packages/electron-server/scripts/copy-artifacts.js
@@ -0,0 +1,92 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+const path = require('path');
+
+const fs = require('fs-extra');
+
+const electronBuildConfig = require('../electron-builder-config.json');
+
+const { log } = require('./common');
+
+const oneauthSource = () => {
+ const oneauthPath = path.resolve(__dirname, '../oneauth-temp');
+ if (['win32', 'darwin'].includes(process.platform) && fs.existsSync(oneauthPath)) {
+ return { source: oneauthPath, dest: 'oneauth', force: true };
+ } else {
+ log.info('Skipping OneAuth. Either on an unsupported platform or it has not been installed to %s.', oneauthPath);
+ }
+};
+
+const sources = [
+ // extensions
+ { source: path.resolve(__dirname, '../../../../extensions'), dest: 'extensions' },
+ // runtimes
+ { source: path.resolve(__dirname, '../../../../runtime'), dest: 'runtime' },
+ // form-dialog templates
+ {
+ source: path.resolve(__dirname, '../../../node_modules/@microsoft/bf-generate-library/templates'),
+ dest: 'form-dialog-templates',
+ },
+ // oneauth
+ oneauthSource(),
+].filter(Boolean);
+
+let destinationDir;
+switch (process.platform) {
+ case 'darwin': {
+ const productName = electronBuildConfig.productName;
+ destinationDir = path.resolve(__dirname, `../dist/mac/${productName}.app/Contents/Resources/app.asar.unpacked`);
+ log.info('Mac detected. Copying artifacts to: ', destinationDir);
+ break;
+ }
+
+ case 'linux':
+ destinationDir = path.resolve(__dirname, '../dist/linux-unpacked/resources/app.asar.unpacked');
+ log.info('Linux detected. Copying artifacts to: ', destinationDir);
+ break;
+
+ case 'win32':
+ destinationDir = path.resolve(__dirname, '../dist/win-unpacked/resources/app.asar.unpacked');
+ log.info(`Windows detected. Copying artifacts to ${destinationDir}`);
+ break;
+
+ default:
+ log.error('Detected platform is not Mac / Linux / Windows');
+ process.exit(1);
+}
+
+const filterOutTS = (src) => {
+ // true keeps the file, false omits it
+ return !src.endsWith('.ts') || !src.endsWith('.ts.map');
+};
+
+async function copyArtifacts(source, dest, force = false) {
+ log.info('-------- %s --------', dest);
+ log.info('Copying %s from: %s', dest, source);
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
+ if (force || entry.isDirectory()) {
+ const extPath = path.join(source, entry.name);
+ const output = path.join(destinationDir, dest, entry.name);
+ log.info('Copying %s', entry.name);
+
+ await fs.copy(extPath, output, { filter: filterOutTS });
+ }
+ }
+}
+
+async function copyAll() {
+ for (const source of sources) {
+ await copyArtifacts(source.source, source.dest, source.force);
+ }
+}
+
+copyAll()
+ .then(() => {
+ log.info('Copied artifacts successfully.');
+ process.exit(0);
+ })
+ .catch((err) => {
+ log.error('Error while copying artifacts: ', err);
+ process.exit(1);
+ });
diff --git a/Composer/packages/electron-server/scripts/copy-extensions.js b/Composer/packages/electron-server/scripts/copy-extensions.js
deleted file mode 100644
index 4d4716ed47..0000000000
--- a/Composer/packages/electron-server/scripts/copy-extensions.js
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-const fs = require('fs-extra');
-const { resolve } = require('path');
-const electronBuildConfig = require('../electron-builder-config.json');
-
-const source = resolve(__dirname, '../../../../extensions');
-console.log('[copy-extensions.js] Copying extensions from: ', source);
-
-let destination;
-switch (process.platform) {
- case 'darwin':
- const productName = electronBuildConfig.productName;
- destination = resolve(__dirname, `../dist/mac/${productName}.app/Contents/Resources/app.asar.unpacked/extensions`);
- console.log('[copy-extensions.js] Mac detected. Copying extensions to: ', destination);
- break;
-
- case 'linux':
- destination = resolve(__dirname, '../dist/linux-unpacked/resources/app.asar.unpacked/extensions');
- console.log('[copy-extensions.js] Linux detected. Copying extensions to: ', destination);
- break;
-
- case 'win32':
- destination = resolve(__dirname, '../dist/win-unpacked/resources/app.asar.unpacked/extensions');
- console.log(`[copy-extensions.js] Windows detected. Copying extensions from ${source} to ${destination}`);
- break;
-
- default:
- console.error('[copy-extensions.js] Detected platform is not Mac / Linux / Windows');
- process.exit(1);
-}
-
-const filterOutTS = (src) => {
- // true keeps the file, false omits it
- return !src.endsWith('.ts');
-};
-
-// copy extensions from /extensions/ to pre-packaged electron app
-fs.copy(source, destination, { filter: filterOutTS }, (err) => {
- if (err) {
- console.error('[copy-extensions.js] Error while copying extensions: ', err);
- process.exit(1);
- return;
- }
- console.log('[copy-extensions.js] Copied extensions successfully.');
- process.exit(0);
-});
diff --git a/Composer/packages/electron-server/scripts/copy-form-dialog-templates.js b/Composer/packages/electron-server/scripts/copy-form-dialog-templates.js
deleted file mode 100644
index 53788aa043..0000000000
--- a/Composer/packages/electron-server/scripts/copy-form-dialog-templates.js
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-const fs = require('fs-extra');
-const { resolve } = require('path');
-const electronBuildConfig = require('../electron-builder-config.json');
-
-const tag = 'copy-form-dialogs-templates.js';
-
-const source = resolve(__dirname, '../../../node_modules/@microsoft/bf-generate-library/templates');
-const unpackedDir = 'app.asar.unpacked/form-dialog-templates';
-console.log(`[${tag}] Copying templates from: ${source}`);
-
-let destination;
-switch (process.platform) {
- case 'darwin':
- const productName = electronBuildConfig.productName;
- destination = resolve(__dirname, `../dist/mac/${productName}.app/Contents/Resources`, unpackedDir);
- console.log(`[${tag}] Mac detected. Copying templates to: ${destination}`);
- break;
-
- case 'linux':
- destination = resolve(__dirname, '../dist/linux-unpacked/resources', unpackedDir);
- console.log(`[${tag}] Linux detected. Copying templates to: ${destination}`);
- break;
-
- case 'win32':
- destination = resolve(__dirname, '../dist/win-unpacked/resources', unpackedDir);
- console.log(`[${tag}] Windows detected. Copying templates to ${destination}`);
- break;
-
- default:
- console.error(`[${tag}] Detected platform is not Mac / Linux / Windows`);
- process.exit(1);
-}
-
-// copy templates from bf-generate-library/templates to asar unpacked dir under packaged electron app
-fs.copy(source, destination, { filter: (src) => !src.endsWith('.md') }, (err) => {
- if (err) {
- console.error(`[${tag}] Error while copying plugins: `, err);
- process.exit(1);
- return;
- }
- console.log(`[${tag}] Copied plugins successfully.`);
- process.exit(0);
-});
diff --git a/Composer/packages/electron-server/scripts/copy-runtime.js b/Composer/packages/electron-server/scripts/copy-runtime.js
deleted file mode 100644
index 6971a2d3d1..0000000000
--- a/Composer/packages/electron-server/scripts/copy-runtime.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-const fs = require('fs-extra');
-const { resolve } = require('path');
-const electronBuildConfig = require('../electron-builder-config.json');
-
-const source = resolve(__dirname, '../../../../runtime');
-
-let destination;
-switch (process.platform) {
- case 'darwin':
- const productName = electronBuildConfig.productName;
- destination = resolve(__dirname, `../dist/mac/${productName}.app/Contents/Resources/app.asar.unpacked/runtime`);
- console.log(`[copy-runtime.js] Mac detected. Copying runtime from ${source} to ${destination}`);
- break;
-
- case 'linux':
- destination = resolve(__dirname, '../dist/linux-unpacked/resources/app.asar.unpacked/runtime');
- console.log(`[copy-runtime.js] Linux detected. Copying runtime from ${source} to ${destination}`);
- break;
-
- case 'win32':
- destination = resolve(__dirname, '../dist/win-unpacked/resources/app.asar.unpacked/runtime');
- console.log(`[copy-runtime.js] Windows detected. Copying runtime from ${source} to ${destination}`);
- break;
-
- default:
- console.error('[copy-runtime.js] Detected platform is not Mac / Linux / Windows');
- process.exit(1);
-}
-
-// copy bot runtime to build directory to be packaged
-fs.copy(source, destination, (err) => {
- if (err) {
- console.error('[copy-runtime.js] Error while copying runtime:');
- process.exit(1);
- return;
- }
- console.log('[copy-runtime.js] Copied runtime successfully.');
-});
diff --git a/Composer/packages/electron-server/scripts/electronBuilderDist.js b/Composer/packages/electron-server/scripts/electronBuilderDist.js
index ff0a911de2..93d93ce2f7 100644
--- a/Composer/packages/electron-server/scripts/electronBuilderDist.js
+++ b/Composer/packages/electron-server/scripts/electronBuilderDist.js
@@ -2,8 +2,11 @@
// Licensed under the MIT License.
const { resolve } = require('path');
+// eslint-disable-next-line security/detect-child-process
const { exec } = require('child_process');
+const { log } = require('./common');
+
/*
* Calls electron-builder to take the pre-packed app contents and turn them into
* a packaged, distributable application for the host OS
@@ -35,21 +38,21 @@ try {
// call electron-builder . --prepackaged --config electron-builder-config.json
const cmd = `"${electronBuilderBinary}" "${electronServerDir}" --${platform} --x64 --prepackaged "${unpackedAppDir}" --config electron-builder-config.json`;
- console.log('[electronBuilderDist.js] Executing command: ', cmd);
+ log.info('Executing command: ', cmd);
const proc = exec(cmd);
- proc.stdout.on('data', data => {
+ proc.stdout.on('data', (data) => {
console.log(data);
});
- proc.stderr.on('data', data => {
+ proc.stderr.on('data', (data) => {
console.error(data);
});
- proc.on('close', code => {
+ proc.on('close', (code) => {
if (code !== 0) {
throw new Error(`[electronBuilderDist.js] electron-builder exited with code ${code}`);
}
});
} catch (e) {
- console.error('[electronBuilderDist.js] Error occurred while using electron-builder --dir: ', e);
+ log.error('Error occurred while using electron-builder --dir: ', e);
process.exit(1);
}
diff --git a/Composer/packages/electron-server/scripts/electronBuilderPack.js b/Composer/packages/electron-server/scripts/electronBuilderPack.js
index 7a86e36687..e58fc5ae8a 100644
--- a/Composer/packages/electron-server/scripts/electronBuilderPack.js
+++ b/Composer/packages/electron-server/scripts/electronBuilderPack.js
@@ -2,14 +2,17 @@
// Licensed under the MIT License.
const { resolve } = require('path');
+// eslint-disable-next-line security/detect-child-process
const { exec } = require('child_process');
+const { log } = require('./common');
+
/*
* Calls electron-builder to pre-pack the app contents into what
* will be packaged inside of the OS-specific distributable application
*/
try {
- const electronBuilderBinary = resolve(__dirname, '../../../node_modules/.bin/electron-builder');
+ const electronBuilderBinary = resolve(__dirname, '../node_modules/.bin/electron-builder');
const electronServerDir = resolve(__dirname, '..');
let platform;
switch (process.platform) {
@@ -31,21 +34,21 @@ try {
// call electron-builder . --dir --config electron-builder-config.json
const cmd = `"${electronBuilderBinary}" "${electronServerDir}" --dir --${platform} --x64 --config electron-builder-config.json`;
- console.log('[electronBuilderPack.js] Executing command: ', cmd);
+ log.info('Executing command: ', cmd);
const proc = exec(cmd);
- proc.stdout.on('data', data => {
+ proc.stdout.on('data', (data) => {
console.log(data);
});
- proc.stderr.on('data', data => {
+ proc.stderr.on('data', (data) => {
console.error(data);
});
- proc.on('close', code => {
+ proc.on('close', (code) => {
if (code !== 0) {
throw new Error(`[electronBuilderPack.js] electron-builder exited with code ${code}`);
}
});
} catch (e) {
- console.error('[electronBuilderPack.js] Error occurred while using electron-builder --dir: ', e);
+ log.error('[electronBuilderPack.js] Error occurred while using electron-builder --dir: ', e);
process.exit(1);
}
diff --git a/Composer/packages/electron-server/scripts/installOneAuth.js b/Composer/packages/electron-server/scripts/installOneAuth.js
new file mode 100644
index 0000000000..b77be1ffe5
--- /dev/null
+++ b/Composer/packages/electron-server/scripts/installOneAuth.js
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+const path = require('path');
+// eslint-disable-next-line security/detect-child-process
+const { execSync } = require('child_process');
+
+const { ensureDir, remove } = require('fs-extra');
+const tar = require('tar');
+const glob = require('globby');
+
+const { log } = require('./common');
+
+/**
+ * USAGE:
+ * Set the following npm config environment variables to install oneauth from the ADO registry.
+ * Then invoke `node scripts/installOneAuth.js`.
+ * - npm_config_username
+ * - npm_config__password (note the double _)
+ * - npm_config_registry
+ */
+
+let packageName = null;
+
+switch (process.platform) {
+ case 'darwin':
+ packageName = 'oneauth-mac';
+ log.info('Mac detected. Using %s package.', packageName);
+ break;
+ case 'win32':
+ packageName = 'oneauth-win64';
+ log.info('Windows detected. Using %s package.', packageName);
+ break;
+ default:
+ log.error('Detected platform is not Mac / Windows.');
+ process.exit(1);
+}
+
+if (packageName === null) {
+ process.exit(1);
+}
+
+const outDir = path.resolve(__dirname, '../oneauth-temp');
+
+// check for env variables
+['username', '_password', 'registry'].forEach((configKey) => {
+ if (!process.env[`npm_config_${configKey}`]) {
+ log.error(`Must set npm_config_${configKey} to use.`);
+ process.exit(1);
+ }
+});
+
+async function downloadPackage() {
+ log.info('Starting download.');
+ await ensureDir(outDir);
+ try {
+ execSync(`cd ${outDir} && npm pack ${packageName}`, { encoding: 'utf-8' });
+ } catch (err) {
+ process.exit(1);
+ return;
+ }
+
+ // find tgz
+ const files = await glob('oneauth*.tgz', { cwd: outDir });
+
+ if (files.length !== 1) {
+ log.error('Did not find exactly 1 archive. Exiting.');
+ process.exit(1);
+ return;
+ }
+
+ return path.join(outDir, files[0]);
+}
+
+async function extractPackage(archivePath) {
+ log.info('Extracting tarball.');
+ await tar.extract({
+ file: archivePath,
+ strip: 1,
+ C: outDir,
+ strict: true,
+ });
+ log.info('Done extracting.');
+ return archivePath;
+}
+
+async function postinstall() {
+ log.info('Running post install tasks.');
+ execSync('npm run postinstall', { cwd: outDir });
+}
+
+async function cleanUp(archivePath) {
+ log.info('Cleaning up archive.');
+ await remove(archivePath);
+}
+
+remove(outDir)
+ .then(ensureDir(outDir))
+ .then(downloadPackage)
+ .then(extractPackage)
+ .then(cleanUp)
+ .then(() => {
+ // oneauth-mac requires us to relink the files
+ if (process.platform === 'darwin') {
+ postinstall();
+ }
+ })
+ .then(() => {
+ log.info('Done installing oneauth.');
+ process.exit(0);
+ })
+ .catch((err) => {
+ log.error('Error downloading oneauth. ', err);
+ process.exit(1);
+ });
diff --git a/Composer/packages/electron-server/scripts/sign-mac.js b/Composer/packages/electron-server/scripts/sign-mac.js
new file mode 100644
index 0000000000..d7b139abf4
--- /dev/null
+++ b/Composer/packages/electron-server/scripts/sign-mac.js
@@ -0,0 +1,158 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+const fs = require('fs');
+const path = require('path');
+// eslint-disable-next-line security/detect-child-process
+const { execSync } = require('child_process');
+const glob = require('globby');
+
+const { log } = require('./common');
+
+if (process.platform !== 'darwin') {
+ log.error('Script can only be run on MacOS');
+ process.exit(1);
+}
+
+const provisionProfilePath = process.argv[2];
+
+if (!provisionProfilePath || !fs.existsSync(provisionProfilePath)) {
+ log.error('Unable to locate provision profile: %s', provisionProfilePath);
+ process.exit(1);
+}
+
+if (!process.env.DEV_CERT_ID || !process.env.DEV_CERT || !process.env.DEV_CERT_PASSWORD) {
+ log.error('Dev certificate not found.');
+ process.exit(1);
+}
+
+const tempDir = process.env.AGENT_TEMPDIRECTORY;
+if (!tempDir) {
+ log.error('No temp dir set. (AGENT_TEMPDIRECTORY)');
+ process.exit(1);
+}
+
+// sign each app bundle with correct entitlements
+const baseBundlePath = path.resolve(__dirname, '../dist/mac/Bot Framework Composer.app');
+const baseEntitlementsPath = path.resolve(__dirname, '../resources/entitlements.plist');
+const keychainEntitlementsPath = path.resolve(__dirname, '../resources/entitlements-keychain.plist');
+
+const bundles = [
+ {
+ path: path.join(baseBundlePath, 'Contents/Frameworks/Bot Framework Composer Helper (GPU).app'),
+ entitlements: baseEntitlementsPath,
+ },
+ {
+ path: path.join(baseBundlePath, 'Contents/Frameworks/Bot Framework Composer Helper (Plugin).app'),
+ entitlements: baseEntitlementsPath,
+ },
+ {
+ path: path.join(baseBundlePath, 'Contents/Frameworks/Bot Framework Composer Helper (Renderer).app'),
+ entitlements: baseEntitlementsPath,
+ },
+ {
+ path: path.join(baseBundlePath, 'Contents/Frameworks/Bot Framework Composer Helper.app'),
+ entitlements: keychainEntitlementsPath,
+ },
+ {
+ path: baseBundlePath,
+ entitlements: keychainEntitlementsPath,
+ },
+];
+
+// first copy the provision profile into each app bundle
+try {
+ for (const bundle of bundles) {
+ fs.copyFileSync(provisionProfilePath, path.join(bundle.path, 'Contents/embedded.provisionprofile'));
+ }
+} catch (err) {
+ log.error('Error copying provision profile. %O', err);
+ process.exit(1);
+}
+
+// second step is to setup a dev cert to use to sign
+try {
+ log.info('-------- Setting up keychain. --------\n');
+ const keychainPath = `${tempDir}/buildagent.keychain`;
+ const certPath = `${tempDir}/cert.p12`;
+
+ log.info(`security create-keychain -p pwd ${keychainPath}`);
+ execSync(`security create-keychain -p pwd ${keychainPath}`, { stdio: 'inherit' });
+
+ log.info(`security default-keychain -s ${keychainPath}`);
+ execSync(`security default-keychain -s ${keychainPath}`, { stdio: 'inherit' });
+
+ log.info(`security unlock-keychain -p pwd ${keychainPath}`);
+ execSync(`security unlock-keychain -p pwd ${keychainPath}`, { stdio: 'inherit' });
+
+ log.info(`echo ********* | base64 -D > ${certPath}`);
+ execSync(`echo ${process.env.DEV_CERT} | base64 -D > ${certPath}`, { stdio: 'inherit' });
+
+ log.info(`security import ${certPath} -k ${keychainPath} -P "*********" -T /usr/bin/codesign`);
+ execSync(
+ `security import ${certPath} -k ${keychainPath} -P "${process.env.DEV_CERT_PASSWORD}" -T /usr/bin/codesign`,
+ { stdio: 'inherit' }
+ );
+
+ log.info(`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k pwd ${keychainPath}`);
+ execSync(`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k pwd ${keychainPath}`, {
+ stdio: 'inherit',
+ });
+} catch (err) {
+ log.error('Error setting up dev certificate and keychain. %O', err);
+ process.exit(1);
+}
+
+try {
+ log.info('-------- Signing frameworks. --------\n');
+ const fwsPath = path.join(baseBundlePath, 'Contents/Frameworks');
+ const frameworks = glob.sync('*.framework', { cwd: fwsPath, onlyFiles: false });
+
+ for (const fw of frameworks) {
+ const fwPath = path.join(baseBundlePath, 'Contents/Frameworks', fw, 'Versions/A');
+ const dylibs = glob.sync('Libraries/*.dylib', { cwd: fwPath });
+
+ for (const lib of dylibs) {
+ log.info(`codesign -s ******* --timestamp=none --force "${path.join(fwPath, lib)}"`);
+ execSync(`codesign -s ${process.env.DEV_CERT_ID} --timestamp=none --force "${path.join(fwPath, lib)}"`, {
+ stdio: 'inherit',
+ });
+ }
+
+ log.info(`codesign -s ******* --timestamp=none --force "${fwPath}"`);
+ execSync(`codesign -s ${process.env.DEV_CERT_ID} --timestamp=none --force "${fwPath}"`, {
+ stdio: 'inherit',
+ });
+ }
+} catch (err) {
+ log.error('Error signing frameworks. %O', err);
+ process.exit(1);
+}
+
+try {
+ log.info('-------- Signing bundles. --------\n');
+ for (const bundle of bundles) {
+ log.info(
+ `codesign -s ******* --timestamp=none --force --options runtime --entitlements "${bundle.entitlements}" "${bundle.path}"`
+ );
+ execSync(
+ `codesign -s ${process.env.DEV_CERT_ID} --timestamp=none --force --options runtime --entitlements "${bundle.entitlements}" "${bundle.path}"`,
+ { stdio: 'inherit' }
+ );
+ }
+} catch (err) {
+ log.error('Error setting signing app bundles. %O', err);
+ process.exit(1);
+}
+
+// verify codesign
+try {
+ log.info('-------- Verifying codesigning. --------\n');
+ for (const bundle of bundles) {
+ log.info(`codesign -dv --verbose=4 "${bundle.path}"`);
+ execSync(`codesign -dv --verbose=4 "${bundle.path}"`);
+ }
+} catch (err) {
+ log.error('Error verifying codesign. %O', err);
+ process.exit(1);
+}
diff --git a/Composer/packages/electron-server/src/auth/oneAuthBase.ts b/Composer/packages/electron-server/src/auth/oneAuthBase.ts
new file mode 100644
index 0000000000..86dd760ffb
--- /dev/null
+++ b/Composer/packages/electron-server/src/auth/oneAuthBase.ts
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { ElectronAuthParameters } from '@botframework-composer/types';
+
+export abstract class OneAuthBase {
+ abstract async getAccessToken(
+ params: ElectronAuthParameters
+ ): Promise<{ accessToken: string; acquiredAt: number; expiryTime: number }>;
+ abstract shutdown();
+ abstract signOut();
+}
diff --git a/Composer/packages/electron-server/src/auth/oneAuthService.ts b/Composer/packages/electron-server/src/auth/oneAuthService.ts
new file mode 100644
index 0000000000..cd1ebdb829
--- /dev/null
+++ b/Composer/packages/electron-server/src/auth/oneAuthService.ts
@@ -0,0 +1,227 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+import path from 'path';
+
+import { ElectronAuthParameters } from '@botframework-composer/types';
+import { app } from 'electron';
+
+import ElectronWindow from '../electronWindow';
+import { isLinux, isMac } from '../utility/platform';
+import logger from '../utility/logger';
+import { getUnpackedAsarPath } from '../utility/getUnpackedAsarPath';
+import { isDevelopment } from '../utility/env';
+
+import { OneAuth } from './oneauth';
+import { OneAuthShim } from './oneAuthShim';
+import { OneAuthBase } from './oneAuthBase';
+
+const log = logger.extend('one-auth');
+
+const COMPOSER_APP_ID = 'com.microsoft.BotFrameworkComposer';
+const COMPOSER_APP_NAME = 'BotFrameworkComposer';
+const COMPOSER_APP_VERSION = app.getVersion();
+const COMPOSER_CLIENT_ID = 'ce48853e-0605-4f77-8746-d70ac63cc6bc';
+const COMPOSER_REDIRECT_URI = 'ms-appx-web://Microsoft.AAD.BrokerPlugin/ce48853e-0605-4f77-8746-d70ac63cc6bc';
+const GRAPH_RESOURCE = 'https://graph.microsoft.com';
+const DEFAULT_LOCALE = 'en'; // TODO: get this from settings?
+const DEFAULT_AUTH_SCHEME = 2; // bearer token
+const DEFAULT_AUTH_AUTHORITY = 'https://login.microsoftonline.com/common'; // work and school accounts
+
+export class OneAuthInstance extends OneAuthBase {
+ private initialized: boolean;
+ private _oneAuth: typeof OneAuth | null = null; //eslint-disable-line
+ private signedInAccount: OneAuth.Account | undefined;
+
+ constructor() {
+ super();
+ log('Using genuine OneAuth.');
+ // will wait until called to initialize (so that we're sure we have a browser window)
+ this.initialized = false;
+ }
+
+ private initialize() {
+ const window = ElectronWindow.getInstance().browserWindow;
+ if (window) {
+ const isDevelopment = Boolean(process.env.NODE_ENV && process.env.NODE_ENV === 'development');
+ log('PII logging enabled: %s', isDevelopment);
+ this.oneAuth.setLogPiiEnabled(isDevelopment);
+ this.oneAuth.setLogCallback((logLevel, message) => {
+ log('%s %s', logLevel, message);
+ });
+ log('Initializing...');
+ const appConfig = new this.oneAuth.AppConfiguration(
+ COMPOSER_APP_ID,
+ COMPOSER_APP_NAME,
+ COMPOSER_APP_VERSION,
+ DEFAULT_LOCALE,
+ 'Please login',
+ window.getNativeWindowHandle()
+ );
+ const aadConfig = new this.oneAuth.AadConfiguration(
+ COMPOSER_CLIENT_ID,
+ COMPOSER_REDIRECT_URI,
+ GRAPH_RESOURCE,
+ false // prefer broker
+ );
+ this.oneAuth.initialize(appConfig, undefined, aadConfig, undefined);
+ this.initialized = true;
+ log('Service initialized.');
+ } else {
+ log('Electron window did exist not at time of initialization.');
+ }
+ }
+
+ public async getAccessToken(
+ params: ElectronAuthParameters
+ ): Promise<{ accessToken: string; acquiredAt: number; expiryTime: number }> {
+ try {
+ if (!this.initialized) {
+ this.initialize();
+ }
+ log('Getting access token...');
+ if (!params.targetResource) {
+ throw 'Target resource required to get access token.';
+ }
+
+ // Temporary until we properly configure local Mac dev experience
+ if (isMac() && isDevelopment) {
+ log('Mac development env detected. Getting access token using interactive sign in instead of silently.');
+ return this.TEMPORARY_getAccessTokenOnMacDev(params);
+ }
+
+ if (!this.signedInAccount) {
+ // we need to sign in
+ log('No signed in account found. Signing user in before getting access token.');
+ await this.signIn();
+ }
+ if (!this.signedInAccount?.id) {
+ throw 'Signed in account does not have an id.';
+ }
+ // use the signed in account to acquire a token
+ const reqParams = new this.oneAuth.AuthParameters(
+ DEFAULT_AUTH_SCHEME,
+ DEFAULT_AUTH_AUTHORITY,
+ params.targetResource,
+ this.signedInAccount.realm,
+ ''
+ );
+ let result = await this.oneAuth.acquireCredentialSilently(this.signedInAccount?.id, reqParams, '');
+ if (result.credential && result.credential.value) {
+ log('Acquired access token. %s', result.credential.value);
+ return {
+ accessToken: result.credential.value,
+ acquiredAt: Date.now(),
+ expiryTime: result.credential.expiresOn,
+ };
+ }
+ if (result.error) {
+ if (result.error.status === this.oneAuth.Status.InteractionRequired) {
+ // try again but interactively
+ log('Interaction required. Trying again interactively to get access token.');
+ result = await this.oneAuth.acquireCredentialInteractively(this.signedInAccount?.id, reqParams, '');
+ if (result.credential && result.credential.value) {
+ log('Acquired access token interactively. %s', result.credential.value);
+ return {
+ accessToken: result.credential.value,
+ acquiredAt: Date.now(),
+ expiryTime: result.credential.expiresOn,
+ };
+ }
+ }
+ throw result.error;
+ }
+ throw 'Could not acquire an access token.';
+ } catch (e) {
+ log('Error while trying to get an access token: %O', e);
+ throw e;
+ }
+ }
+
+ public shutdown() {
+ log('Shutting down...');
+ this.oneAuth.shutdown();
+ log('Shut down.');
+ }
+
+ /**
+ * Clears the account saved in memory.
+ */
+ public signOut() {
+ log('Signing out user...');
+ this.signedInAccount = undefined;
+ log('Signed out user.');
+ }
+
+ /**
+ * Sign the user in and save the account in memory.
+ */
+ private async signIn(): Promise {
+ try {
+ log('Signing in...');
+ const result: OneAuth.AuthResult = await this.oneAuth.signInInteractively('', undefined, '');
+ this.signedInAccount = result.account;
+ log('Signed in successfully. Got account: %O', result.account);
+ } catch (e) {
+ log('There was an error trying to sign in: %O', e);
+ throw e;
+ }
+ }
+
+ /** Temporary workaround on Mac until we figure out how to enable keychain access on a dev build. */
+ // eslint-disable-next-line
+ private async TEMPORARY_getAccessTokenOnMacDev(
+ params: ElectronAuthParameters
+ ): Promise<{ accessToken: string; acquiredAt: number; expiryTime: number }> {
+ try {
+ // sign-in every time with auth parameters to get a token
+ const reqParams = new this.oneAuth.AuthParameters(
+ DEFAULT_AUTH_SCHEME,
+ DEFAULT_AUTH_AUTHORITY,
+ params.targetResource,
+ '',
+ ''
+ );
+ const result = await this.oneAuth.signInInteractively('', reqParams, '');
+ if (result && result.credential && result.credential.value) {
+ log('Acquired access token. %s', result.credential.value);
+ return {
+ accessToken: result.credential.value,
+ acquiredAt: Date.now(),
+ expiryTime: result.credential.expiresOn,
+ };
+ }
+ throw 'Could not acquire an access token.';
+ } catch (e) {
+ log('There was an error trying to acquire a token on Mac by signing in interactively: %O', e);
+ throw e;
+ }
+ }
+
+ private get oneAuth() {
+ if (!this._oneAuth) {
+ log('Attempting to load oneauth module from %s.', this.oneauthPath);
+ try {
+ // eslint-disable-next-line security/detect-non-literal-require
+ this._oneAuth = require(this.oneauthPath) as typeof OneAuth;
+ } catch (e) {
+ log('Error loading oneauth module. %O', e);
+ throw e;
+ }
+ }
+
+ return this._oneAuth;
+ }
+
+ private get oneauthPath() {
+ if (process.env.NODE_ENV === 'production') {
+ return path.join(getUnpackedAsarPath(), 'oneauth');
+ } else {
+ return path.resolve(__dirname, '../../oneauth-temp');
+ }
+ }
+}
+
+// only use the shim in Linux, or dev environment without flag enabled
+const useShim = (isDevelopment && process.env.COMPOSER_ENABLE_ONEAUTH !== 'true') || isLinux();
+
+export const OneAuthService = useShim ? new OneAuthShim() : new OneAuthInstance();
diff --git a/Composer/packages/electron-server/src/auth/oneAuthShim.ts b/Composer/packages/electron-server/src/auth/oneAuthShim.ts
new file mode 100644
index 0000000000..4667522b3e
--- /dev/null
+++ b/Composer/packages/electron-server/src/auth/oneAuthShim.ts
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { ElectronAuthParameters } from '@botframework-composer/types';
+
+import logger from '../utility/logger';
+
+import { OneAuthBase } from './oneAuthBase';
+
+const log = logger.extend('one-auth-shim');
+
+export class OneAuthShim extends OneAuthBase {
+ constructor() {
+ super();
+ log('Using OneAuth shim.');
+ log('To use genuine OneAuth library please read AUTH.md');
+ }
+
+ public async getAccessToken(params: ElectronAuthParameters) {
+ return { accessToken: '', acquiredAt: 0, expiryTime: 99999999999 };
+ }
+ public shutdown() {}
+ public signOut() {}
+}
diff --git a/Composer/packages/electron-server/src/auth/oneauth.d.ts b/Composer/packages/electron-server/src/auth/oneauth.d.ts
new file mode 100644
index 0000000000..5fd78ec6e0
--- /dev/null
+++ b/Composer/packages/electron-server/src/auth/oneauth.d.ts
@@ -0,0 +1,576 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace OneAuth {
+ /// Levels of logging. Defines the priority of the logged message
+ export enum LogLevel {
+ /// Available to fully disable logging
+ LogLevelNoLog = 1,
+ /// Default
+ LogLevelError = 2,
+ LogLevelWarning = 3,
+ LogLevelInfo = 4,
+ LogLevelVerbose = 5,
+ }
+
+ /// Flights reflecting the values from OneAuthFlight.hpp
+ export enum Flight {
+ UseMsalforMsa = 2,
+ // Windows Only Flights
+ UseWamforMSA = 1002,
+ UseWamforAAD = 1003,
+ }
+
+ export enum AccountType {
+ /// Microsoft personal accounts
+ Msa = 1,
+ /// Microsoft work or school accounts
+ Aad = 2,
+ /// On-premises accounts
+ OnPremises = 3,
+ }
+
+ export enum CredentialType {
+ /// Access token - short-lived, used to access one resource
+ AccessToken = 1,
+ /// An opaque reference to account password. Can be used to retrieve cleartext password
+ PasswordReference = 2,
+ /// Opaque Kerberos credential reference. This is the indication that Kerberos is being used for auhentication.
+ KerberosReference = 3,
+ }
+
+ export enum Status {
+ /// This indicates a bug in our code, in one of our dependencies, or on the server.
+ /// It may be caused by an unexpected error, exception, or bad data.
+ /// This may also indicate an API attempting to return an invalid status, in which case the attempted return
+ /// will be appended to the context.
+ /// Retrying this request will have undefined behavior. You can still call other APIs, which may succeed. Do so at
+ /// your own risk.
+ Unexpected = 0,
+
+ /// This status is reserved for future use.
+ Reserved = 1,
+
+ /// Authentication is possible, but it requires direct user interaction.
+ /// Please find an appropriate time to show a dialogue to the user by calling SignIn() or
+ /// AcquireTokenInteractively(). Retrying this request may return the same error, unless another application has
+ /// already done the above.
+ InteractionRequired = 2,
+
+ /// There is no network available on the host machine. This status indicates that a network request was attempted.
+ /// Retrying this request may return the same error, if the network state has not been fixed.
+ /// You can retry this request as soon as you believe the network has changed.
+ /// Please inform the user that their network is unavailable, and that they cannot be signed in because of it.
+ NoNetwork = 3,
+
+ /// The network is temporarily unavailable on the host machine. This status indicates that a network request was
+ /// attempted. Retrying this request may succeed without any additional effort by the user.
+ NetworkTemporarilyUnavailable = 4,
+
+ /// The authentication server is unavailable. This status indicates that a network request was attempted.
+ /// Retrying this request must be handled at exponential backoff to avoid further stressing the server.
+ ServerTemporarilyUnavailable = 5,
+
+ /// This request violates our API contract. This includes invalid parameters, calling a UI method on a non-UI
+ /// std::thread, or attempting to authenticate with an unregistered application. See the result context for
+ /// additional information. Retrying this request will return the same error.
+ ApiContractViolation = 6,
+
+ /// The request was cancelled by the user.
+ UserCanceled = 7,
+
+ /// The request was cancelled by the application.
+ ApplicationCanceled = 8,
+
+ /// The application is not able to complete this request.
+ /// The user should contact their IT administrator for help resolving this issue.
+ IncorrectConfiguration = 9,
+
+ /// The passed in buffer is too small.
+ InsufficientBuffer = 10,
+
+ /// The provided authority is not trusted to authenticate against.
+ AuthorityUntrusted = 11,
+ }
+
+ export enum AuthScheme {
+ /// HTTP Basic
+ Basic = 1,
+ /// OAuth2
+ Bearer = 2,
+ /// LiveId authentication using RPS tokens
+ LiveId = 3,
+ /// SPNEGO
+ Negotiate = 4,
+ /// Windows Challenge/Response (NTLM)
+ Ntlm = 5,
+ }
+
+ /// AppId for OneDrive and Sharepoint
+ export const CommonAppIdsODSP = 'com.microsoft.ODSP';
+
+ /// AppId for Office Apps: Word, Excel, Powerpoint
+ export const CommonAppIdsOffice = 'com.microsoft.Office';
+
+ /// AppId for Outlook
+ export const CommonAppIdsOutlook = 'com.microsoft.Outlook';
+
+ /// AppId for Bing
+ export const CommonAppIdsBing = 'com.microsoft.Bing';
+
+ /// AppId for Edge
+ export const CommonAppIdsEdge = 'com.microsoft.Edge';
+
+ /// AppId for Microsoft To-Do
+ export const CommonAppIdsToDo = 'com.microsoft.to-do';
+
+ /// AppId for Microsoft Intune Company Portal
+ export const CommonAppIdsCompanyPortal = 'com.microsoft.CompanyPortal';
+
+ export interface AuthResult {
+ readonly account: Account;
+ readonly credential: Credential;
+ readonly error: Error;
+ readonly correlationId: string;
+ }
+
+ export interface Account {
+ readonly id: string;
+ readonly accountType: AccountType;
+ readonly authority: string;
+ readonly sovereignty: string;
+ readonly environment: string;
+ readonly loginName: string;
+ readonly displayName: string;
+ readonly providerId: string;
+ readonly realm: string;
+ readonly givenName: string;
+ readonly middleName: string;
+ readonly familyName: string;
+ readonly email: string;
+ readonly phoneNumber: string;
+ readonly sid: string;
+ readonly accountHints: string[];
+ readonly hosts: string[];
+ }
+
+ export interface Credential {
+ readonly id: string;
+ readonly credentialType: CredentialType;
+ readonly accountId: string;
+ readonly authority: string;
+ readonly value: string;
+ readonly target: string;
+ readonly expiresOn: number;
+ }
+
+ export interface Error {
+ readonly status: Status;
+ readonly diagnostics: {
+ readonly errorCode: string | undefined;
+ readonly tag: string | undefined;
+ readonly description: string | undefined;
+ };
+ readonly toString: string | undefined;
+ }
+
+ export class AuthParameters {
+ constructor(authScheme: AuthScheme, authority: string, target: string, realm: string, accessTokenToRenew: string);
+ readonly authScheme: AuthScheme;
+ readonly authority: string;
+ readonly realm: string;
+ readonly target: string;
+ readonly accessTokenToRenew: string;
+ SetAdditionalParameter(key: string, value: string): void;
+ }
+
+ export class AppConfiguration {
+ constructor(
+ appId: string,
+ appName: string,
+ appVersion: string,
+ languageCode: string,
+ signInWindowTitle: string | undefined,
+ parentWindow: any | undefined
+ );
+ readonly appId: string;
+ readonly appName: string;
+ readonly appVersion: string;
+ readonly languageCode: string | undefined;
+ readonly signInWindowTitle: string | undefined; // Windows only
+ readonly parentWindow: any | undefined; // Windows only
+ }
+
+ export class MsaConfiguration {
+ /// Constructor for MsaConfiguration
+ /// @param clientId
+ /// @param redirectUri
+ /// @param defaultSignInScope
+ /// @deprecated @param useMsalFlight no longer used and has no effect @see {@link SetFlights}
+ constructor(
+ clientId: string,
+ redirectUri: string,
+ defaultSignInScope: string,
+ useMsalFlight: boolean | undefined // deprecated
+ );
+ readonly clientId: string;
+ readonly redirectUri: string;
+ readonly defaultSignInScope: string;
+ }
+
+ export class AadConfiguration {
+ constructor(clientId: string, redirectUri: string, defaultSignInResource: string, preferBroker: boolean);
+ readonly clientId: string;
+ readonly redirectUri: string;
+ readonly defaultSignInResource: string;
+ }
+
+ /// Configures the OneAuth module.
+ ///
+ /// @param appConfiguration The OneAuth app configuration.
+ /// @param msaConfiguration The MSA configuration.
+ /// @param aadConfiguration The AAD configuration.
+ /// @param telemetryConfiguration The Telemetry configuration to receive telemetry dispatched events.
+ /// @return false if Startup fails, true otherwise.
+ export function initialize(
+ appConfiguration: AppConfiguration,
+ msaConfiguration: MsaConfiguration | undefined,
+ aadConfiguration: AadConfiguration | undefined,
+ telemetryConfiguration: TelemetryConfiguration | undefined
+ ): boolean;
+
+ /// Cancels all outstanding tasks, closes the authentication UI if any, and shuts down the OneAuth authenticator.
+ /// Once called, acquiring authenticator instance(s) is only possible after calling the configuration API.
+ export function shutdown(): void;
+
+ /// Sets the language to be used within dialogs inside of OneAuth so that we may display locale correctly
+ /// @param code that represents the locale to be used
+ export function setLanguageCode(code: string): void;
+
+ /// Lists the supported locale codes within OneAuth
+ export function supportedLanguageCodes(): string[];
+
+ /// Returns the current language code being used
+ export function getLanguageCode(): string;
+
+ /// Sets Logging level for OneAuth and ADAL
+ /// @param level Desired logging level.
+ /// @return Previous logging level. If unable to return the previous logging value, will return "error" level by default.
+ export function setLogLevel(level: LogLevel): LogLevel;
+
+ /// The OneAuth Log Callback
+ /// @param level The level of the log message
+ /// @param message A log message describing the event that occurred
+ /// @param containsPii If the message might contain Personally Identifiable Information (PII) this will be true. Log messages possibly containing PII will not be sent to the callback unless piiEnabled is set to YES on the logger.
+ export type LogCallback = (level: LogLevel, message: string, containsPii: boolean) => void;
+
+ /// OneAuth provides a logging callback to assist diagnostics. If piiEnabled is set to NO, the callback will not be triggered for log messages that contain any user information. By default this is NO for release builds and YES for debug builds.
+ /// @param enabled PII on/off flag.
+ /// @return Previous PII on/off flag value.
+ export function setLogPiiEnabled(enabled: boolean): boolean;
+
+ /// Sets OneAuth Log Callback which will be called on every tracing event that meets log level and PII settings.
+ /// Callback implementers must ensure its performance and reliability (e.g. do not throw exceptions, do handle concurrent calls, etc).
+ /// @param callback Callback that is called by OneAuth logging facility.
+ export function setLogCallback(callback: LogCallback): void;
+
+ /// Present the sign-in UI and signs in a user to the application.
+ ///
+ /// A successful sign-in returns an account. A new account is created unless there is already a matching one in the
+ /// local account store. Additionally, an account profile and image are read from a profile endpoint if one is
+ /// available and the credentials are sufficient for the operation.
+ ///
+ /// **Threading:** calls from a thread other than the main thread, as well as concurrent calls, will result in a
+ /// {@link Status::ApiContractViolation} error.
+ ///
+ /// @param accountHint Initial account hint, if you already know what account the user wants to sign in to. If this
+ /// argument is empty, the user will be able to supply the account hint via account collection UI.
+ /// @param authParameters What authentication scheme, authority, etc. should be used for authentication. This can
+ /// either be created from an HTTP authentication challenge (using the CreateAuthParameters API), or manually.
+ /// AuthParameters implicitly define the credential type. If this argument is nullptr, then modern
+ /// authentication is implied (e.g. AAD, MSA), and the authentication parameters are inferred from the account hint
+ /// provided.
+ /// @param correlationId An identifier that correlates other telemetry events to this event.
+ /// @param completion Completion (callback) that will be called once this sign in operation has been completed, for
+ /// both success and failure. It receives an AuthResult instance that contains either an error or contains
+ /// an account and a credential.
+ ///
+ /// **Error statuses returned via completion**
+ /// - Status::ApiContractViolation
+ /// - Status::ApplicationCanceled
+ /// - Status::IncorrectConfiguration
+ /// - Status::NetworkTemporarilyUnavailable
+ /// - Status::NoNetwork
+ /// - Status::ServerTemporarilyUnavailable
+ /// - Status::UserCanceled
+ /// - Status::Unexpected
+ ///
+ /// @see {@link AuthCompletion}
+ /// @see {@link AuthParameters}
+ /// @see {@link AuthResult}
+
+ export function signInInteractively(
+ accountHint: string | undefined,
+ authParameters: AuthParameters | undefined,
+ correlationId: string
+ ): Promise;
+
+ /// Sign in a user to the app silently with an account inferred from the underlying OS infrastructure, if such
+ /// an inference is possible.
+ ///
+ /// A successful sign-in produces a new account unless the account already exists in the local account store.
+ /// Additionally, an account profile and image are read from a profile endpoint if one is available and the
+ /// credentials are sufficient.
+ ///
+ /// At this moment, SignInSilently API is of limited use as it only supports Kerberos scenarios on macOS. Support for
+ /// other scenarios will be added soon.
+ ///
+ /// @param authParameters What authentication scheme, authority, etc. should be used for authentication. This can
+ /// either be created from an HTTP authentication challenge (using the CreateAuthParameters API), or manually.
+ /// AuthParameters implicitly define the credential type. If this argument is nullptr, then default parameters will
+ /// be used based on the information inferred from the account hint provided, unless the sign-in is intended for an
+ /// on-premises resource. On-premises resources cannot be inferred automatically. The developer must specify
+ /// authentication parameters explicitly for on-premises sign-in scenarios.
+ /// @param correlationId An idetifier that correlates other telemetry events to this event.
+ /// @param completion Completion (callback) that will be called once this sign in operation has been completed, for
+ /// both success and failure. It receives an AuthResult instance that contains either an error or contains
+ /// an account and a credential.
+ ///
+ /// **Error statuses returned via completion**
+ /// - Status::ApiContractViolation
+ /// - Status::ApplicationCanceled
+ /// - Status::NetworkTemporarilyUnavailable
+ /// - Status::NoNetwork
+ /// - Status::ServerTemporarilyUnavailable
+ /// - Status::Unexpected
+ ///
+ /// @see {@link AuthCompletion}
+ /// @see {@link AuthParameters}
+ /// @see {@link AuthResult}
+ export function signInSilently(
+ authParameters: AuthParameters | undefined,
+ correlationId: string
+ ): Promise;
+
+ /// Show a prompt for the given account and parameters.
+ ///
+ /// If credential acquisition is successful, an account profile and image are read from a profile endpoint if one
+ /// is available and the credentials are sufficient.
+ ///
+ /// **Threading:** calls from a thread other than the main thread, as well as concurrent calls, will result in a
+ /// {@link Status::ApiContractViolation} error.
+ ///
+ /// @param accountId The account id to acquire credentials for.
+ /// @param authParameters What authentication scheme, authority, etc. should be used for authentication. This can
+ /// either be created from an HTTP authentication challenge (using the CreateAuthParameters API), or manually.
+ /// AuthParameters implicitly define the credential type. If this argument is nullptr, then modern
+ /// authentication is implied (e.g. AAD, MSA), and the authentication parameters are inferred from the account hint
+ /// provided.
+ /// @param correlationId An idetifier that correlates other telemetry events to this event.
+ /// @param completion Completion (callback) that will be called once this sign in operation has been completed, for
+ /// both success and failure. It receives an AuthResult instance that contains either an error or contains
+ /// an account and a credential.
+ ///
+ /// **Error statuses returned via completion**
+ /// - Status::ApiContractViolation
+ /// - Status::ApplicationCanceled
+ /// - Status::IncorrectConfiguration
+ /// - Status::NetworkTemporarilyUnavailable
+ /// - Status::NoNetwork
+ /// - Status::ServerTemporarilyUnavailable
+ /// - Status::UserCanceled
+ /// - Status::Unexpected
+ ///
+ /// @see {@link AuthCompletion}
+ /// @see {@link AuthParameters}
+ /// @see {@link AuthResult}
+ export function acquireCredentialInteractively(
+ accountId: string,
+ authParameters: AuthParameters,
+ correlationId: string
+ ): Promise;
+
+ /// Acquire a credential silently for the given account and parameters.
+ ///
+ /// This method will never prompt, but may fail if it cannot silently acquire a credential. If it does fail, the
+ /// error status may suggest resorting to interactive credential aquisition (see {@link
+ /// Status::InteractionRequired}).
+ ///
+ /// @param accountId The account id to acquire credentials for.
+ /// @param authParameters What authentication scheme, authority, etc. should be used for authentication. This can
+ /// either be created from an HTTP authentication challenge (using the CreateAuthParameters API), or manually.
+ /// AuthParameters implicitly define the credential type. If this argument is nullptr, then modern
+ /// authentication is implied (e.g. AAD, MSA), and the authentication parameters are inferred from the account hint
+ /// provided.
+ /// @param correlationId An idetifier that correlates other telemetry events to this event.
+ /// @param completion Completion (callback) that will be called once this sign in operation has been completed, for
+ /// both success and failure. It receives an AuthResult instance that contains either an error or contains
+ /// an account and a credential.
+ ///
+ /// **Error statuses returned via completion**
+ /// - Status::ApiContractViolation
+ /// - Status::ApplicationCanceled
+ /// - Status::IncorrectConfiguration
+ /// - Status::InteractionRequired
+ /// - Status::NetworkTemporarilyUnavailable
+ /// - Status::NoNetwork
+ /// - Status::ServerTemporarilyUnavailable
+ /// - Status::Unexpected
+ ///
+ /// @see {@link AuthCompletion}
+ /// @see {@link AuthParameters}
+ /// @see {@link AuthResult}
+ export function acquireCredentialSilently(
+ accountId: string,
+ authParameters: AuthParameters,
+ correlationId: string
+ ): Promise;
+
+ /// Cancels all ongoing tasks and dismisses the UI (if any).
+ /// Completions for all ongoing tasks are called synchronously, i.e. before ```CancelAllTasks``` returns.
+ /// Completions for tasks scheduled after ```CancelAllTasks``` was called will not be executed until
+ /// CancelAllTasks returns control to the caller.
+ export function cancelAllTasks(): void;
+
+ /// Get an account for a given account id.
+ ///
+ /// This API is likely to result in a blocking read from a local persistent store.
+ ///
+ /// @param accountId The account id for the desired account
+ /// @return A shared pointer to the account.
+ /// @see {@link Account}
+ export function readAccountById(accountId: string): Promise;
+
+ /// Get all accounts known to OneAuth from local persistent store(s).
+ ///
+ /// This API will get accounts from the OneAuth store and any "external" account stores, e.g. the Office or ODSP
+ /// identity cache. The accounts from "external" stores are de-duplicated against the OneAuth account store. Multiple
+ /// blocking reads from local persistent stores are likely to result from this call.
+ ///
+ /// @return All accounts that OneAuth knows about.
+ /// @see {@link Account}
+ /// @see {@link GetAssociatedAccounts}
+ export function readAllAccounts(): Promise;
+
+ /// Associate an account with a specified application group.
+ ///
+ /// Accounts can be associated with applications via application group identifiers. Application group identifiers are
+ /// arbitrary strings, each associated with a set of applications that choose to help each other identify accounts
+ /// that they use.
+ ///
+ /// This call may result in multiple blocking local I/O operations.
+ ///
+ /// @param accountId The account id belonging to the account to associate.
+ /// @see {@link Account}
+ /// @see {@link DisassociateAccount}
+ export function associateAccount(accountId: string): Promise;
+
+ /// Disassociate an account from a specified application group.
+ ///
+ /// This call may result in multiple blocking local I/O operations.
+ ///
+ /// @param accountId The account id belonging to the account to disassociate.
+ /// @see {@link Account}
+ /// @see {@link AssociateAccount}
+ export function disassociateAccount(accountId: string): Promise;
+
+ /// Get all accounts associated with the specified application groups.
+ ///
+ /// This will read accounts from local OneAuth as well as "external" (e.g. Office or ODSP) stores, if any. The
+ /// accounts from "external" stores are de-duplicated against the OneAuth account store. This call may result in
+ /// multiple blocking reads from local persistent stores.
+ ///
+ /// @param appGroup A list of application groups identifiers.
+ /// @return An array of account objects. An empty array is returned if no known accounts associated with the
+ /// specified app groups could be read.
+ /// @see {@link Account}
+ /// @see {@link AssociateAccount}
+ /// @see {@link DisassociateAccount}
+ /// @see {@link GetAllAccounts}
+ export function readAssociatedAccounts(appGroup: string[]): Promise;
+
+ /// Get a profile image associated with the specified account.
+ ///
+ /// This API does not perform network calls to retrieve the image; the image, if any, is read from a local cache
+ /// only. This API may result in multiple blocking local I/O operations.
+ ///
+ /// @param accountId The account id belonging to the account to get the profile image for.
+ /// @return An image blob as it was returned by the profile endpoint. If no image associated with the account is
+ /// locally available, the returned blob is empty.
+ /// @see {@link Account}
+ export function readProfileImage(accountId: string): Promise;
+
+ /// Delete all the accounts that OneAuth knows about
+ ///
+ /// This is a TEST API and should not be used in production scenarios
+ /// This is due to the API deleting data that is not exclusive to the calling application.
+ /// There are no network calls involved in deleting all accounts and as such should not be blocking, allthough
+ /// given a lot of accounts, it may hang a few milliseconds before returning.
+ export function testDeleteAllAccounts(): void;
+
+ /// Set the current flights for OneAuth
+ /// This API needs to be called before initializing OneAuth
+ /// Once OneAuth has been initialized this will have no effect on the flights that are set till it is
+ /// shut down and started again.
+ /// This will override anything that has already been set.
+ ///
+ /// This call should not be blocking and should return instantly
+ /// @param should be an array containing the corresponding @see {@link Flights} to be activated based on the enum defined here
+ export function setFlights(flights: Array): void;
+
+ /// Get the current flights that have been set on OneAuth
+ ///
+ /// This call should not be blocking and should return instantly
+ /// @return will be an array of flights corresponding to the correct enum of Flights defined here
+ export function getFlights(): Promise>;
+
+ export enum AudienceType {
+ Automation = 0,
+ Preproduction = 1,
+ Production = 2,
+ }
+
+ /// Callback function that receives data from OneAuth library that is meant to be sent using Aria SDK.
+ /// https://authtelemetry.visualstudio.com/Microsoft%20Auth%20Telemetry%20System/_wiki/wikis/Microsoft%20Auth%20Telemetry%20System.wiki/28/Win32?anchor=setting-up-your-telemetry-dispatcher
+ ///
+ /// @param data The data that needs to be sent to the telemetry server.
+ export type TelemetryDispatcher = (data: Data) => void;
+
+ export interface TelemetryConfigurationConstructor {
+ new (
+ audienceType: AudienceType,
+ sessionId: string,
+ dispatcher: TelemetryDispatcher | undefined,
+ allowedResources: string[]
+ ): TelemetryConfiguration;
+ }
+
+ export interface TelemetryConfiguration extends TelemetryConfigurationConstructor {
+ readonly audienceType: AudienceType;
+ readonly sessionId: string;
+ readonly telemetryDispatcher: TelemetryDispatcher | undefined;
+ readonly allowedResources: string[];
+ }
+
+ export interface StringMap {
+ readonly [key: string]: string;
+ }
+
+ export interface IntMap {
+ readonly [property: string]: number;
+ }
+
+ export interface BooleanMap {
+ readonly [property: string]: boolean;
+ }
+
+ export interface Data {
+ readonly name: string;
+ readonly isInstrumentationError: boolean;
+ readonly stringMap: StringMap;
+ readonly intMap: IntMap;
+ readonly int64Map: IntMap;
+ readonly boolMap: BooleanMap;
+ }
+}
diff --git a/Composer/packages/electron-server/src/main.ts b/Composer/packages/electron-server/src/main.ts
index 8243be9cf6..bf0e36771f 100644
--- a/Composer/packages/electron-server/src/main.ts
+++ b/Composer/packages/electron-server/src/main.ts
@@ -19,9 +19,9 @@ import { isDevelopment } from './utility/env';
import { getUnpackedAsarPath } from './utility/getUnpackedAsarPath';
import { loadLocale, getAppLocale, updateAppLocale } from './utility/locale';
import log from './utility/logger';
-import { getAccessToken, loginAndGetIdToken, OAuthLoginOptions } from './utility/oauthImplicitFlowHelper';
import { isMac, isWindows } from './utility/platform';
import { parseDeepLinkUrl } from './utility/url';
+import { OneAuthService } from './auth/oneAuthService';
const microsoftLogoPath = join(__dirname, '../resources/ms_logo.svg');
let currentAppLocale = getAppLocale().appLocale;
@@ -119,25 +119,6 @@ function initializeAppUpdater(settings: AppUpdaterSettings) {
log('App updater initialized.');
}
-function initAuthListeners(window: Electron.BrowserWindow) {
- ipcMain.on('oauth-start-login', async (_ev, options: OAuthLoginOptions, id: number) => {
- try {
- const idToken = await loginAndGetIdToken(options);
- window.webContents.send('oauth-login-complete', idToken, id);
- } catch (e) {
- window.webContents.send('oauth-login-error', e, id);
- }
- });
- ipcMain.on('oauth-get-access-token', async (_ev, options: OAuthLoginOptions, idToken: string, id: number) => {
- try {
- const accessToken = await getAccessToken({ ...options, idToken });
- window.webContents.send('oauth-get-access-token-complete', accessToken, id);
- } catch (e) {
- window.webContents.send('oauth-get-access-token-error', e, id);
- }
- });
-}
-
async function loadServer() {
if (!isDevelopment) {
// only change paths if packaged electron app
@@ -154,7 +135,9 @@ async function loadServer() {
log('Starting server...');
const { start } = await import('@bfc/server');
- serverPort = await start();
+ serverPort = await start({
+ getAccessToken: OneAuthService.getAccessToken.bind(OneAuthService),
+ });
log(`Server started at port: ${serverPort}`);
}
@@ -166,7 +149,6 @@ async function main(show = false) {
if (process.env.COMPOSER_DEV_TOOLS) {
mainWindow.webContents.openDevTools();
}
- initAuthListeners(mainWindow);
if (isWindows()) {
deeplinkUrl = processArgsForWindows(process.argv);
diff --git a/Composer/packages/electron-server/src/utility/oauthImplicitFlowHelper.ts b/Composer/packages/electron-server/src/utility/oauthImplicitFlowHelper.ts
deleted file mode 100644
index e8655f868e..0000000000
--- a/Composer/packages/electron-server/src/utility/oauthImplicitFlowHelper.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import { randomBytes } from 'crypto';
-
-import { BrowserWindow } from 'electron';
-
-const composerRedirectUri = 'bfcomposer://oauth';
-const baseUrl = 'https://login.microsoftonline.com/organizations/';
-const implicitEndpoint = 'oauth2/v2.0/authorize';
-
-function parseJwt(token: string) {
- const base64Payload = token.split('.')[1];
- const payload = Buffer.from(base64Payload, 'base64');
- return JSON.parse(payload.toString());
-}
-
-function generateNonce(): string {
- return randomBytes(32).toString('base64');
-}
-
-function generateState(clientId: string) {
- const state = {
- clientId,
- guid: randomBytes(32).toString('base64'),
- time: Date.now(),
- };
- return JSON.stringify(state);
-}
-
-export interface OAuthLoginOptions {
- clientId: string;
- redirectUri: string;
- scopes?: string[];
-}
-
-export interface OAuthTokenOptions extends OAuthLoginOptions {
- idToken: string;
-}
-
-function getLoginUrl(options: OAuthLoginOptions): string {
- const { clientId, redirectUri, scopes = [] } = options;
- if (scopes.indexOf('openid') === -1) {
- scopes.push('openid');
- }
- if (scopes.indexOf('profile') === -1) {
- scopes.push('profile');
- }
- const params = [
- `client_id=${encodeURIComponent(clientId)}`,
- `response_type=id_token`,
- `redirect_uri=${encodeURIComponent(redirectUri)}`,
- `scope=${encodeURIComponent(scopes.join(' '))}`,
- `response_mode=fragment`,
- `state=${encodeURIComponent(generateState(clientId))}`,
- `nonce=${encodeURIComponent(generateNonce())}`,
- ].join('&');
-
- const url = `${baseUrl}${implicitEndpoint}?${params}`;
- return url;
-}
-
-export function getAccessTokenUrl(options: OAuthTokenOptions): string {
- const { clientId, idToken, redirectUri, scopes = [] } = options;
- const params = [
- `client_id=${encodeURIComponent(clientId)}`,
- `response_type=token`,
- `redirect_uri=${encodeURIComponent(redirectUri)}`,
- `scope=${encodeURIComponent(scopes.join(' '))}`,
- `response_mode=fragment`,
- `state=${encodeURIComponent(generateState(clientId))}`,
- `nonce=${encodeURIComponent(generateNonce())}`,
- `prompt=none`,
- ];
- const jwt = parseJwt(idToken);
- if (jwt.preferred_username) {
- params.push(`login_hint=${encodeURIComponent(jwt.preferred_username)}`);
- }
-
- const url = `${baseUrl}${implicitEndpoint}?${params.join('&')}`;
- return url;
-}
-
-async function createAccessTokenWindow(url: string): Promise {
- const tokenWindow = new BrowserWindow({ width: 400, height: 600, show: false });
- const waitingForAccessToken = monitorWindowForQueryParam(tokenWindow, 'access_token');
- tokenWindow.loadURL(url);
- return waitingForAccessToken;
-}
-
-async function createLoginWindow(url: string): Promise {
- const loginWindow = new BrowserWindow({ width: 400, height: 600, show: true });
- const waitingForIdToken = monitorWindowForQueryParam(loginWindow, 'id_token');
- loginWindow.loadURL(url);
- return waitingForIdToken;
-}
-
-/** Will wait until the specified window redirects to a URL starting with bfcomposer://oauth,
- * and then resolve with the desired parameter value or reject with an error message.
- *
- * @param window The Electron browser window to monitor for redirect events
- * @param queryParam The query string parameter to be ripped off the final URL after all redirects are finished
- */
-async function monitorWindowForQueryParam(window: BrowserWindow, queryParam: string): Promise {
- return new Promise((resolve, reject) => {
- window.webContents.on('will-redirect', (event, redirectUrl) => {
- if (redirectUrl.startsWith(composerRedirectUri)) {
- // We have reached the end of the oauth flow; don't actually complete the redirect.
- // Just rip the desired parameters from the url and close the window.
- event.preventDefault();
- const parsedUrl = new URL(redirectUrl.replace('#', '?'));
- const param = parsedUrl.searchParams.get(queryParam);
- if (param) {
- window.close();
- resolve(param);
- }
- const error = parsedUrl.searchParams.get('error');
- const errorDescription = parsedUrl.searchParams.get('error_description');
- if (error || errorDescription) {
- window.close();
- reject({ error, errorDescription });
- }
- reject({ error: `Unknown error retrieving ${param} from OAuth window` });
- }
- });
- });
-}
-
-/**
- * Logs the user in using the OAuth implicit flow and returns an id token
- *
- * @param id Internal id used by Composer to route the OAuth response back to the client that it originated from
- * @returns An object containing the id token and the id of the OAuth client that originated the request
- */
-export async function loginAndGetIdToken(options: OAuthLoginOptions): Promise {
- const loginUrl = getLoginUrl(options);
- const res = await createLoginWindow(loginUrl);
- return res;
-}
-
-/**
- * Uses an id token to request an access token on behalf of the user and returns token
- *
- * @param id Internal id used by Composer to route the OAuth response back to the client that it originated from
- * @returns An object containing the access token and the id of the OAuth client that originated the request
- */
-export async function getAccessToken(options: OAuthTokenOptions): Promise {
- const tokenUrl = getAccessTokenUrl(options);
- const res = await createAccessTokenWindow(tokenUrl);
- return res;
-}
diff --git a/Composer/packages/electron-server/tsconfig.build.json b/Composer/packages/electron-server/tsconfig.build.json
index 831a29d8a2..399e7db994 100644
--- a/Composer/packages/electron-server/tsconfig.build.json
+++ b/Composer/packages/electron-server/tsconfig.build.json
@@ -1,4 +1,5 @@
{
/* Options used for building production code (tests excluded) */
- "extends": "./tsconfig.json"
+ "extends": "./tsconfig.json",
+ "exclude": ["scripts/*", "__tests__*"]
}
diff --git a/Composer/packages/electron-server/tsconfig.json b/Composer/packages/electron-server/tsconfig.json
index 7fdc0ce4f6..e9d9bf617d 100644
--- a/Composer/packages/electron-server/tsconfig.json
+++ b/Composer/packages/electron-server/tsconfig.json
@@ -9,5 +9,10 @@
"@src/*": ["src/*"]
}
},
- "include": ["src/main.ts", "src/preload.js"],
+ "include": [
+ "src/**/*.ts",
+ "scripts/*.js", // include scripts to geting proper linting
+ "__tests__", // intellisense
+ "src/preload.js"
+ ]
}
diff --git a/Composer/packages/extension-client/src/auth/index.ts b/Composer/packages/extension-client/src/auth/index.ts
index 7acb67114c..e6c0f67b4c 100644
--- a/Composer/packages/extension-client/src/auth/index.ts
+++ b/Composer/packages/extension-client/src/auth/index.ts
@@ -1,18 +1,19 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+import { AuthParameters } from '@botframework-composer/types';
+
import { ComposerGlobalName } from '../common/constants';
-import { OAuthOptions } from './types';
/** Logs the user into Azure for a given client ID with the provided scopes. Returns an ID token. */
-export function login(options: OAuthOptions): Promise {
- return window[ComposerGlobalName].login(options);
+export function login(params: AuthParameters): Promise {
+ return window[ComposerGlobalName].login(params);
}
/** Requests an access token from Azure for a given client ID with the provided scopes.
* Returns an access token that can be used to call APIs on behalf of the user.
*
*/
-export function getAccessToken(options: OAuthOptions): Promise {
- return window[ComposerGlobalName].getAccessToken(options);
+export function getAccessToken(params: AuthParameters): Promise {
+ return window[ComposerGlobalName].getAccessToken(params);
}
diff --git a/Composer/packages/extension-client/src/auth/types.ts b/Composer/packages/extension-client/src/auth/types.ts
deleted file mode 100644
index d50d60d864..0000000000
--- a/Composer/packages/extension-client/src/auth/types.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-export interface OAuthOptions {
- /** Client ID of the AAD app that the user is authenticating against. */
- clientId: string;
- /** List of OAuth scopes that will be granted once the user has authenticated. */
- scopes: string[];
-}
diff --git a/Composer/packages/server/package.json b/Composer/packages/server/package.json
index 658b4cd8e3..5a83f7aafd 100644
--- a/Composer/packages/server/package.json
+++ b/Composer/packages/server/package.json
@@ -62,6 +62,7 @@
"@bfc/lg-languageserver": "*",
"@bfc/lu-languageserver": "*",
"@bfc/shared": "*",
+ "@botframework-composer/types": "*",
"@microsoft/bf-dialog": "4.11.0-dev.20201025.69cf2b9",
"@microsoft/bf-dispatcher": "^4.11.0-beta.20201016.393c6b2",
"@microsoft/bf-generate-library": "^4.10.0-daily.20201026.178799",
@@ -72,7 +73,6 @@
"body-parser": "^1.18.3",
"chalk": "^4.0.0",
"compression": "^1.7.4",
- "cookie-parser": "^1.4.4",
"debug": "^4.1.1",
"dotenv": "^8.1.0",
"ejs": "^2.7.1",
diff --git a/Composer/packages/server/src/controllers/__tests__/auth.test.ts b/Composer/packages/server/src/controllers/__tests__/auth.test.ts
new file mode 100644
index 0000000000..95fa51ded1
--- /dev/null
+++ b/Composer/packages/server/src/controllers/__tests__/auth.test.ts
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { AuthController } from '../auth';
+
+const mockGetAccessToken = jest.fn().mockResolvedValue('accessToken');
+jest.mock('../../services/auth/auth', () => ({
+ authService: {
+ getAccessToken: (params) => mockGetAccessToken(params),
+ },
+}));
+
+let mockIsElectron = true;
+jest.mock('../../utility/isElectron', () => ({
+ get isElectron() {
+ return mockIsElectron;
+ },
+}));
+
+describe('auth controller', () => {
+ const chainedRes = {
+ json: jest.fn(),
+ send: jest.fn(),
+ };
+ const mockRes: any = {
+ status: jest.fn().mockReturnValue(chainedRes),
+ };
+
+ beforeEach(() => {
+ mockRes.status.mockClear();
+ chainedRes.json.mockClear();
+ chainedRes.send.mockClear();
+ });
+
+ it('should return a 400 if no targetResource is passed in Electron env', async () => {
+ mockIsElectron = true;
+ const mockReq: any = {
+ query: {
+ targetResource: undefined,
+ },
+ };
+ await AuthController.getAccessToken(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(chainedRes.send).toHaveBeenCalledWith(
+ 'Must pass a "targetResource" parameter to perform authentication in Electron environment.'
+ );
+ });
+
+ it('should return a 400 if no clientId is passed in web env', async () => {
+ mockIsElectron = false;
+ const mockReq: any = {
+ query: {
+ clientId: undefined,
+ },
+ };
+ await AuthController.getAccessToken(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(chainedRes.send).toHaveBeenCalledWith(
+ 'Must pass a "clientId" parameter to perform authentication in a Web environment.'
+ );
+ });
+
+ it('should return an access token from the auth service', async () => {
+ mockIsElectron = true;
+ const mockReq: any = {
+ query: {
+ targetResource: 'https://graph.microsoft.com/',
+ },
+ };
+ await AuthController.getAccessToken(mockReq, mockRes);
+
+ expect(mockGetAccessToken).toHaveBeenCalledWith({
+ clientId: undefined,
+ targetResource: 'https://graph.microsoft.com/',
+ scopes: [],
+ });
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(chainedRes.json).toHaveBeenCalledWith({ accessToken: 'accessToken' });
+ });
+});
diff --git a/Composer/packages/server/src/controllers/auth.ts b/Composer/packages/server/src/controllers/auth.ts
new file mode 100644
index 0000000000..23634573a0
--- /dev/null
+++ b/Composer/packages/server/src/controllers/auth.ts
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { Request, Response } from 'express';
+
+import { authService } from '../services/auth/auth';
+import { isElectron } from '../utility/isElectron';
+
+type GetAccessTokenRequest = Request & {
+ query: {
+ // used for OAuth flow (web)
+ clientId?: string;
+ // used for OAuth flow (web)
+ scopes: string;
+ // used for native flow (electron)
+ targetResource?: string;
+ };
+};
+
+async function getAccessToken(req: GetAccessTokenRequest, res: Response) {
+ const { clientId, targetResource, scopes = '[]' } = req.query;
+ if (isElectron && !targetResource) {
+ return res
+ .status(400)
+ .send('Must pass a "targetResource" parameter to perform authentication in Electron environment.');
+ }
+ if (!isElectron && !clientId) {
+ return res.status(400).send('Must pass a "clientId" parameter to perform authentication in a Web environment.');
+ }
+ const parsedScopes: string[] = JSON.parse(scopes);
+
+ const accessToken = await authService.getAccessToken({ clientId, targetResource, scopes: parsedScopes });
+
+ res.status(200).json({
+ accessToken,
+ });
+}
+
+export const AuthController = {
+ getAccessToken,
+};
diff --git a/Composer/packages/server/src/middleware/__tests__/csrfProtection.test.ts b/Composer/packages/server/src/middleware/__tests__/csrfProtection.test.ts
new file mode 100644
index 0000000000..b40d60fa67
--- /dev/null
+++ b/Composer/packages/server/src/middleware/__tests__/csrfProtection.test.ts
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { csrfProtection } from '../csrfProtection';
+
+jest.mock('../../services/auth/auth', () => ({
+ authService: {
+ csrfToken: 'csrfToken',
+ },
+}));
+
+describe('CSRF protection middleware', () => {
+ const chainedRes = {
+ send: jest.fn(),
+ };
+ const mockRes: any = {
+ status: jest.fn().mockReturnValue(chainedRes),
+ };
+ const mockNext = jest.fn();
+
+ beforeEach(() => {
+ chainedRes.send.mockClear();
+ mockRes.status.mockClear();
+ mockNext.mockClear();
+ });
+
+ it('should fail if the CSRF token is missing from the request', () => {
+ const mockReq: any = {
+ get: jest.fn().mockReturnValue(undefined),
+ };
+ csrfProtection(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(403);
+ expect(chainedRes.send).toHaveBeenCalledWith({ message: 'CSRF token required.' });
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should fail if the CSRF token provided does not match the generated token', () => {
+ const mockReq: any = {
+ get: jest.fn().mockReturnValue('nonMatchingToken'),
+ };
+ csrfProtection(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(403);
+ expect(chainedRes.send).toHaveBeenCalledWith({ message: `CSRF token did not match server's token.` });
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should call the next req handler if the CSRF token matches', () => {
+ const mockReq: any = {
+ get: jest.fn().mockReturnValue('csrfToken'),
+ };
+ csrfProtection(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).not.toHaveBeenCalled();
+ expect(chainedRes.send).not.toHaveBeenCalled();
+ expect(mockNext).toHaveBeenCalled();
+ });
+});
diff --git a/Composer/packages/server/src/middleware/csrfProtection.ts b/Composer/packages/server/src/middleware/csrfProtection.ts
new file mode 100644
index 0000000000..f01585541c
--- /dev/null
+++ b/Composer/packages/server/src/middleware/csrfProtection.ts
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { NextFunction, Request, Response } from 'express';
+
+import { authService } from '../services/auth/auth';
+
+/**
+ * Middleware that verifies if the server-generated CSRF token is sent with the incoming request via header.
+ */
+export const csrfProtection = (req: Request, res: Response, next: NextFunction) => {
+ // the CSRF token will only be generated in the production environment
+ if (authService.csrfToken) {
+ const csrfToken = req.get('X-CSRF-Token');
+ if (!csrfToken) {
+ // do not complete the request, the client did not provide the server-generated token
+ return res.status(403).send({ message: 'CSRF token required.' });
+ }
+ if (csrfToken !== authService.csrfToken) {
+ // do not complete the request, the client provided a token that did not match the server-generated token
+ return res.status(403).send({ message: `CSRF token did not match server's token.` });
+ }
+ }
+
+ // check passed, continue to next handler
+ next();
+};
diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts
index 7c5e384fc4..758db184fe 100644
--- a/Composer/packages/server/src/router/api.ts
+++ b/Composer/packages/server/src/router/api.ts
@@ -13,6 +13,8 @@ import { EjectController } from '../controllers/eject';
import { FormDialogController } from '../controllers/formDialog';
import * as ExtensionsController from '../controllers/extensions';
import { FeatureFlagController } from '../controllers/featureFlags';
+import { AuthController } from '../controllers/auth';
+import { csrfProtection } from '../middleware/csrfProtection';
import { UtilitiesController } from './../controllers/utilities';
@@ -84,6 +86,9 @@ router.get('/extensions/:id/:bundleId', ExtensionsController.getBundleForView);
// proxy route for extensions (allows extension client code to make fetch calls using the Composer server as a proxy -- avoids browser blocking request due to CORS)
router.post('/extensions/proxy/:url', ExtensionsController.performExtensionFetch);
+// authentication from client
+router.get('/auth/getAccessToken', csrfProtection, AuthController.getAccessToken);
+
//FeatureFlags
router.get('/featureFlags', FeatureFlagController.getFeatureFlags);
router.post('/featureFlags', FeatureFlagController.updateFeatureFlags);
diff --git a/Composer/packages/server/src/server.ts b/Composer/packages/server/src/server.ts
index d9eeb146ab..f71e91d744 100644
--- a/Composer/packages/server/src/server.ts
+++ b/Composer/packages/server/src/server.ts
@@ -26,11 +26,16 @@ import { BASEURL } from './constants';
import { attachLSPServer } from './utility/attachLSP';
import log from './logger';
import { setEnvDefault } from './utility/setEnvDefault';
+import { ElectronContext, setElectronContext } from './utility/electronContext';
+import { authService } from './services/auth/auth';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const session = require('express-session');
-export async function start(): Promise {
+export async function start(electronContext?: ElectronContext): Promise {
+ if (electronContext) {
+ setElectronContext(electronContext);
+ }
const clientDirectory = path.resolve(require.resolve('@bfc/client'), '..');
const app: Express = express();
app.set('view engine', 'ejs');
@@ -121,7 +126,10 @@ export async function start(): Promise {
});
app.get('*', (req, res) => {
- res.render(path.resolve(clientDirectory, 'index.ejs'), { __nonce__: req.__nonce__ });
+ res.render(path.resolve(clientDirectory, 'index.ejs'), {
+ __nonce__: req.__nonce__,
+ __csrf__: authService.csrfToken,
+ });
});
const preferredPort = process.env.PORT || 5000;
diff --git a/Composer/packages/server/src/services/__tests__/auth.test.ts b/Composer/packages/server/src/services/__tests__/auth.test.ts
new file mode 100644
index 0000000000..ebaed6616b
--- /dev/null
+++ b/Composer/packages/server/src/services/__tests__/auth.test.ts
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+describe('auth service', () => {
+ const nodeEnvBackup = process.env.NODE_ENV;
+
+ afterAll(() => {
+ Object.assign(process.env, { ...process.env, NODE_ENV: nodeEnvBackup });
+ });
+
+ beforeEach(() => {
+ jest.resetModules();
+ });
+
+ it('should generate a CSRF token in the production environment', () => {
+ Object.assign(process.env, { ...process.env, NODE_ENV: 'production' });
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { authService } = require('../auth/auth');
+
+ // eslint-disable-next-line no-underscore-dangle
+ expect((authService as any)._csrfToken).toBeTruthy();
+ });
+
+ it('should not generate a CSRF token in the development environment', () => {
+ Object.assign(process.env, { ...process.env, NODE_ENV: 'development' });
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { authService } = require('../auth/auth');
+
+ // eslint-disable-next-line no-underscore-dangle
+ expect((authService as any)._csrfToken).not.toBeTruthy();
+ });
+
+ it('should get an access token', async () => {
+ const mockProvider = {
+ getAccessToken: jest.fn().mockResolvedValue('accessToken'),
+ };
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { authService } = require('../auth/auth');
+ (authService as any).provider = mockProvider;
+ const token = await authService.getAccessToken({});
+
+ expect(token).toBe('accessToken');
+ });
+});
diff --git a/Composer/packages/server/src/services/__tests__/electronAuthProvider.test.ts b/Composer/packages/server/src/services/__tests__/electronAuthProvider.test.ts
new file mode 100644
index 0000000000..c797396f94
--- /dev/null
+++ b/Composer/packages/server/src/services/__tests__/electronAuthProvider.test.ts
@@ -0,0 +1,64 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { ElectronAuthProvider } from '../auth/electronAuthProvider';
+
+let mockIsLinux = false;
+jest.mock('../../utility/platform', () => ({
+ isLinux: () => mockIsLinux,
+}));
+
+describe('Electron auth provider', () => {
+ beforeEach(() => {
+ mockIsLinux = false;
+ });
+
+ it('should not return an access token on Linux', async () => {
+ mockIsLinux = true;
+ const provider = new ElectronAuthProvider({});
+ // eslint-disable-next-line no-underscore-dangle
+ (provider as any)._electronContext = {
+ getAccessToken: jest.fn().mockResolvedValue('accessToken'),
+ };
+ const token = await provider.getAccessToken({} as any);
+
+ expect(token).toBe('');
+ });
+
+ it('should return a fresh access token on Win / Mac', async () => {
+ const provider = new ElectronAuthProvider({});
+ const mockElectronContext = {
+ getAccessToken: jest.fn().mockResolvedValue({
+ accessToken: 'accessToken',
+ }),
+ };
+ // eslint-disable-next-line no-underscore-dangle
+ (provider as any)._electronContext = mockElectronContext;
+ const token = await provider.getAccessToken({ targetResource: 'https://graph.microsoft.com/' });
+
+ expect(mockElectronContext.getAccessToken).toHaveBeenCalledWith({ targetResource: 'https://graph.microsoft.com/' });
+ expect(token).toBe('accessToken');
+ });
+
+ it('should return a cached token', async () => {
+ const targetResource = 'https://graph.microsoft.com/';
+ const provider = new ElectronAuthProvider({});
+ const mockElectronContext = {
+ getAccessToken: jest.fn().mockResolvedValue({
+ accessToken: 'accessToken',
+ }),
+ };
+ // eslint-disable-next-line no-underscore-dangle
+ (provider as any)._electronContext = mockElectronContext;
+ (provider as any).tokenCache = {
+ [targetResource]: {
+ accessToken: 'cachedToken',
+ expiryTime: Date.now() + 1000 * 60 * 30, // expires 30 minutes from now
+ },
+ };
+ const token = await provider.getAccessToken({ targetResource });
+
+ expect(mockElectronContext.getAccessToken).not.toHaveBeenCalled();
+ expect(token).toBe('cachedToken');
+ });
+});
diff --git a/Composer/packages/server/src/services/auth/auth.ts b/Composer/packages/server/src/services/auth/auth.ts
new file mode 100644
index 0000000000..ec3c1ef40a
--- /dev/null
+++ b/Composer/packages/server/src/services/auth/auth.ts
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { v4 as uuid } from 'uuid';
+import { AuthParameters } from '@botframework-composer/types';
+
+import { isElectron } from '../../utility/isElectron';
+import logger from '../../logger';
+
+import { AuthProvider } from './authProvider';
+import { ElectronAuthProvider } from './electronAuthProvider';
+import { WebAuthProvider } from './webAuthProvider';
+
+const log = logger.extend('auth-service');
+
+class AuthService {
+ private provider: AuthProvider;
+ private _csrfToken: string;
+
+ constructor() {
+ if (isElectron) {
+ // desktop scenario
+ this.provider = new ElectronAuthProvider({});
+ log('Initialized in Electron context.');
+ } else {
+ // hosted / web scenario
+ this.provider = new WebAuthProvider({});
+ log('Initialized in Web context.');
+ }
+ // generate anti-csrf token in production environment
+ if (process.env.NODE_ENV === 'production') {
+ log('Production environment detected. Generating CSRF token.');
+ this._csrfToken = uuid();
+ } else {
+ this._csrfToken = '';
+ }
+ }
+
+ async getAccessToken(params: AuthParameters): Promise {
+ return this.provider.getAccessToken(params);
+ }
+
+ get csrfToken(): string {
+ return this._csrfToken;
+ }
+}
+
+export const authService = new AuthService();
diff --git a/Composer/packages/server/src/services/auth/authProvider.ts b/Composer/packages/server/src/services/auth/authProvider.ts
new file mode 100644
index 0000000000..fb1e417d3f
--- /dev/null
+++ b/Composer/packages/server/src/services/auth/authProvider.ts
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { AuthParameters } from '@botframework-composer/types';
+
+export type AuthConfig = {};
+
+export abstract class AuthProvider {
+ constructor(protected config: AuthConfig) {}
+
+ abstract async getAccessToken(params: AuthParameters): Promise;
+}
diff --git a/Composer/packages/server/src/services/auth/electronAuthProvider.ts b/Composer/packages/server/src/services/auth/electronAuthProvider.ts
new file mode 100644
index 0000000000..936a85dcc6
--- /dev/null
+++ b/Composer/packages/server/src/services/auth/electronAuthProvider.ts
@@ -0,0 +1,114 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { AuthParameters } from '@botframework-composer/types';
+
+import logger from '../../logger';
+import { ElectronContext, useElectronContext } from '../../utility/electronContext';
+import { isLinux } from '../../utility/platform';
+
+import { AuthConfig, AuthProvider } from './authProvider';
+
+const log = logger.extend('electron-auth-provider');
+
+type TokenRecord = {
+ expiryTime: number;
+ accessToken: string;
+};
+
+type TokenCache = Record;
+
+export class ElectronAuthProvider extends AuthProvider {
+ private _electronContext: ElectronContext | undefined;
+ private tokenRefreshFactor = 0.75; // refresh the token after 75% of the expiry time has passed
+ private tokenCache: TokenCache;
+
+ constructor(config: AuthConfig) {
+ super(config);
+ this.tokenCache = {};
+ log('Initialized.');
+ }
+
+ async getAccessToken(params: AuthParameters): Promise {
+ const { getAccessToken } = this.electronContext;
+ const { targetResource = '' } = params;
+
+ if (isLinux()) {
+ log('Auth login flow is currently unsupported in Linux.');
+ return '';
+ }
+
+ log('Getting access token.');
+
+ // try to get a cached token
+ const cachedToken = this.getCachedToken(params);
+ if (!!cachedToken && Date.now() <= cachedToken.expiryTime.valueOf()) {
+ log('Returning cached token.');
+ return cachedToken.accessToken;
+ }
+
+ try {
+ // otherwise get a fresh token
+ log('Did not find cached token. Getting fresh token.');
+ const { accessToken, acquiredAt, expiryTime } = await getAccessToken({ targetResource });
+ this.cacheTokens({ accessToken, acquiredAt, expiryTime }, params);
+
+ return accessToken;
+ } catch (e) {
+ log('Error while trying to get access token: %O', e);
+ return '';
+ }
+ }
+
+ private get electronContext() {
+ if (!this._electronContext) {
+ this._electronContext = useElectronContext();
+ }
+ return this._electronContext;
+ }
+
+ private getCachedToken(params: AuthParameters): TokenRecord | undefined {
+ const tokenHash = this.getTokenHash(params);
+ const cachedToken = this.tokenCache[tokenHash];
+ return cachedToken;
+ }
+
+ private cacheTokens(
+ tokenInfo: { accessToken: string; acquiredAt: number; expiryTime: number },
+ params: AuthParameters
+ ): void {
+ const { accessToken, acquiredAt, expiryTime } = tokenInfo;
+ const tokenHash = this.getTokenHash(params);
+ const expiresIn = expiryTime - acquiredAt;
+
+ log('Caching token...');
+
+ // cache token
+ this.tokenCache[tokenHash] = {
+ accessToken,
+ expiryTime,
+ };
+
+ log('Token cached.');
+
+ // setup timer to refresh token
+ const timeUntilRefresh = this.tokenRefreshFactor * expiresIn;
+ setTimeout(() => this.refreshAccessToken(params), timeUntilRefresh);
+ }
+
+ private async refreshAccessToken(params: AuthParameters) {
+ log('Refreshing access token...');
+ const { getAccessToken } = this.electronContext;
+ const { targetResource = '' } = params;
+ const cachedToken = this.tokenCache[this.getTokenHash(params)];
+ if (cachedToken) {
+ const { accessToken, acquiredAt, expiryTime } = await getAccessToken({ targetResource });
+ this.cacheTokens({ accessToken, acquiredAt, expiryTime }, params);
+ log('Access token refreshed.');
+ }
+ }
+
+ private getTokenHash(params: AuthParameters): string {
+ return params.targetResource || '';
+ }
+}
diff --git a/Composer/packages/server/src/services/auth/webAuthProvider.ts b/Composer/packages/server/src/services/auth/webAuthProvider.ts
new file mode 100644
index 0000000000..7413571024
--- /dev/null
+++ b/Composer/packages/server/src/services/auth/webAuthProvider.ts
@@ -0,0 +1,19 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { AuthParameters } from '@botframework-composer/types';
+
+import { AuthConfig, AuthProvider } from './authProvider';
+
+export class WebAuthProvider extends AuthProvider {
+ constructor(config: AuthConfig) {
+ super(config);
+ }
+
+ // TODO (toanzian / ccastro): implement
+ async getAccessToken(params: AuthParameters): Promise {
+ throw new Error(
+ 'WebAuthProvider has not been implemented yet. Implicit auth flow currently only works in Electron.'
+ );
+ }
+}
diff --git a/Composer/packages/server/src/utility/electronContext.ts b/Composer/packages/server/src/utility/electronContext.ts
new file mode 100644
index 0000000000..26f42be5c5
--- /dev/null
+++ b/Composer/packages/server/src/utility/electronContext.ts
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { ElectronAuthParameters } from '@botframework-composer/types';
+
+export type ElectronContext = {
+ getAccessToken: (
+ params: ElectronAuthParameters
+ ) => Promise<{ accessToken: string; acquiredAt: number; expiryTime: number }>;
+};
+
+let context;
+
+export const useElectronContext = (): ElectronContext => context;
+
+export const setElectronContext = (c: ElectronContext) => {
+ context = c;
+};
diff --git a/Composer/packages/server/src/utility/isElectron.ts b/Composer/packages/server/src/utility/isElectron.ts
new file mode 100644
index 0000000000..d0b063f43e
--- /dev/null
+++ b/Composer/packages/server/src/utility/isElectron.ts
@@ -0,0 +1,4 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+export const isElectron: boolean = process.versions && (process.versions as any).electron;
diff --git a/Composer/packages/server/src/utility/platform.ts b/Composer/packages/server/src/utility/platform.ts
new file mode 100644
index 0000000000..7b71987430
--- /dev/null
+++ b/Composer/packages/server/src/utility/platform.ts
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+export function isMac() {
+ return process.platform === 'darwin';
+}
+
+export function isLinux() {
+ return process.platform === 'linux';
+}
+
+export function isWindows() {
+ return process.platform === 'win32';
+}
diff --git a/Composer/packages/types/src/auth.ts b/Composer/packages/types/src/auth.ts
new file mode 100644
index 0000000000..75661f28cb
--- /dev/null
+++ b/Composer/packages/types/src/auth.ts
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+export type AuthParameters = {
+ /** (Web) Client ID of the AAD app that the user is authenticating against. */
+ clientId?: string;
+ /** (Web) List of OAuth scopes that will be granted once the user has authenticated. */
+ scopes?: string[];
+
+ /**
+ * (Desktop) The resource for which we want to get a token for.
+ *
+ * ex: https://microsoft.graph.com/ or 1a3e55f-a8503cf-32bfde0
+ */
+ targetResource?: string;
+};
+
+export type ElectronAuthParameters = {
+ /**
+ * The resource for which we want to get a token for.
+ *
+ * ex: https://microsoft.graph.com/ or 1a3e55f-a8503cf-32bfde0
+ */
+ targetResource: string;
+};
+
+export type WebAuthParameters = {
+ /** Client ID of the AAD app that the user is authenticating against. */
+ clientId: string;
+ /** List of OAuth scopes that will be granted once the user has authenticated. */
+ scopes?: string[];
+};
diff --git a/Composer/packages/types/src/index.ts b/Composer/packages/types/src/index.ts
index 7e2863d243..5b27ece516 100644
--- a/Composer/packages/types/src/index.ts
+++ b/Composer/packages/types/src/index.ts
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+export * from './auth';
export * from './diagnostic';
export * from './dialogUtils';
export * from './extension';