├── src ├── version.ts ├── MsalAuth │ ├── MsalConfig.ts │ ├── SimpleLogger.ts │ ├── MsalCachePlugin.ts │ ├── InteractiveAuthenticate.ts │ └── MsalNodeAuth.ts ├── index.ts ├── __tests__ │ └── DataverseAuthArgs.test.ts ├── DataverseAuthArgs.ts └── dataverse-auth.ts ├── .prettierrc.json ├── jest.config.js ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .gitignore ├── tsconfig.json ├── .eslintrc.json ├── LICENSE ├── package.json └── README.md /src/version.ts: -------------------------------------------------------------------------------- 1 | // Generated by genversion. 2 | export const version = "2.0.8"; 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 120, 6 | "tabWidth": 2, 7 | "endOfLine":"auto" 8 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | roots: ["/src/"], 5 | globals: { 6 | "ts-jest": { 7 | tsconfig: "tsconfig.json", 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "contoso", 4 | "genversion", 5 | "haschrome", 6 | "msal", 7 | "postversion", 8 | "preversion", 9 | "sonarjs" 10 | ] 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/* 2 | !.vscode/settings.json 3 | !.vscode/tasks.json 4 | !.vscode/launch.json 5 | !.vscode/extensions.json 6 | *.code-workspace 7 | 8 | # Local History for Visual Studio Code 9 | .history/ 10 | 11 | dist 12 | node_modules 13 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "group": { 8 | "kind": "build", 9 | "isDefault": true 10 | } 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /src/MsalAuth/MsalConfig.ts: -------------------------------------------------------------------------------- 1 | // Keep this separate as an import to prevent cross-polluting electron with msal-node-extensions since the versions of node API will mismatch. 2 | export const msalConfig = { 3 | clientId: "51f81489-12ee-4a9e-aaae-a2591f45987d", 4 | redirectUrl: "app://58145b91-0c36-4500-8554-080854f2ac97", 5 | }; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2015", 8 | "dom" 9 | ], 10 | "rootDir": "src", 11 | "outDir": "dist", 12 | "strict": true, 13 | "alwaysStrict": true, 14 | "strictFunctionTypes": true, 15 | "strictNullChecks": true, 16 | "strictPropertyInitialization": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "noImplicitAny": true, 19 | "noImplicitReturns": true, 20 | "noImplicitThis": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "emitDecoratorMetadata": true, 25 | "experimentalDecorators": true, 26 | "downlevelIteration": true, 27 | "declaration": true, 28 | "sourceMap": true, 29 | "pretty": true, 30 | "esModuleInterop": true 31 | } 32 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:prettier/recommended", 10 | "prettier", 11 | "plugin:sonarjs/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": 12, 19 | "sourceType": "module" 20 | }, 21 | "plugins": [ 22 | "@typescript-eslint", 23 | "prettier", 24 | "sonarjs" 25 | ], 26 | "ignorePatterns": ["**/generated/*.ts"], 27 | "rules": { 28 | "eqeqeq": [2, "smart"], 29 | "prettier/prettier": "error", 30 | "arrow-body-style": "off", 31 | "prefer-arrow-callback": "off", 32 | "linebreak-style": [ 33 | "error", 34 | "windows" 35 | ], 36 | "quotes": [ 37 | "error", 38 | "double" 39 | ], 40 | "semi": [ 41 | "error", 42 | "always" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Scott Durow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/MsalAuth/SimpleLogger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from "@azure/msal-common"; 2 | 3 | export interface LogEntry { 4 | Level: LogLevel; 5 | Message: string; 6 | } 7 | export class SimpleLogger { 8 | private logToConsole = false; 9 | public output: LogEntry[] = []; 10 | constructor(logToConsole = false) { 11 | this.logToConsole = logToConsole; 12 | } 13 | AppendLog(log: LogEntry[]): void { 14 | this.output = this.output.concat(log); 15 | } 16 | Log = (level: LogLevel, message: string): void => { 17 | this.output.push({ Level: level, Message: message }); 18 | if (this.logToConsole) { 19 | if ((level as LogLevel) === LogLevel.Error) { 20 | console.error(message); 21 | } else { 22 | console.debug(message); 23 | } 24 | } 25 | }; 26 | 27 | OutputToConsole(verbose: boolean): void { 28 | this.output 29 | .filter((l) => (l.Level as LogLevel) === LogLevel.Error || verbose) 30 | .forEach((l) => { 31 | if (l.Level === LogLevel.Error) { 32 | console.error(l.Message); 33 | } else { 34 | console.log(l.Message); 35 | } 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { LogLevel } from "@azure/msal-node"; 3 | import { interactiveAcquireAuthCode, InteractiveAcquireAuthCodeResult } from "./MsalAuth/InteractiveAuthenticate"; 4 | import { SimpleLogger } from "./MsalAuth/SimpleLogger"; 5 | import { exit } from "process"; 6 | import minimist from "minimist"; 7 | 8 | const args = minimist(process.argv.slice(2)); 9 | const argTenant: string | undefined = args.t; 10 | const argEnvUrl: string = args.e; 11 | 12 | function outputResult(result: InteractiveAcquireAuthCodeResult): void { 13 | console.log(JSON.stringify(result)); 14 | } 15 | 16 | const logger = new SimpleLogger(); 17 | process.setUncaughtExceptionCaptureCallback((error) => { 18 | logger.Log(LogLevel.Error, error.message); 19 | outputResult({ log: logger?.output }); 20 | exit(1); 21 | }); 22 | 23 | if (!argEnvUrl) { 24 | throw "Please supply an environment url. E.g. npx dataverse-auth contoso.crm.dynamics.com"; 25 | } 26 | // This is called via the bin command dataverse-auth 27 | // Either can be provided 28 | // Or just and we lookup the 29 | 30 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 31 | interactiveAcquireAuthCode(logger.Log, argEnvUrl, argTenant) 32 | .then((authCode) => { 33 | outputResult({ authCode: authCode, log: logger?.output }); 34 | //exit(0); 35 | }) 36 | .catch((ex) => { 37 | logger.Log(LogLevel.Error, ex as string); 38 | outputResult({ log: logger?.output }); 39 | //exit(1); 40 | }); 41 | -------------------------------------------------------------------------------- /src/__tests__/DataverseAuthArgs.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable quotes */ 2 | /* eslint-disable sonarjs/no-duplicate-string */ 3 | import { DataverseAuthArgs, DataverseAuthCommands } from "../DataverseAuthArgs"; 4 | 5 | describe("DataverseAuthArgs", () => { 6 | it("Extracts test-connection", () => { 7 | const args = new DataverseAuthArgs([DataverseAuthCommands.List]); 8 | expect(args.command).toBe(DataverseAuthCommands.List); 9 | }); 10 | 11 | it("Extracts test-connection with verbose", () => { 12 | const args = new DataverseAuthArgs(["org.dynamics.com", DataverseAuthCommands.TestConnection, "--verbose"]); 13 | expect(args.environmentUrl).toBe("org.dynamics.com"); 14 | expect(args.command).toBe(DataverseAuthCommands.TestConnection); 15 | expect(args.verboseLogging).toBe(true); 16 | }); 17 | 18 | it("Extracts test-connection with verbose (short)", () => { 19 | const args = new DataverseAuthArgs(["org.dynamics.com", DataverseAuthCommands.TestConnection, "-v"]); 20 | expect(args.environmentUrl).toBe("org.dynamics.com"); 21 | expect(args.command).toBe(DataverseAuthCommands.TestConnection); 22 | expect(args.verboseLogging).toBe(true); 23 | }); 24 | 25 | it("Environment Url only", () => { 26 | const args = new DataverseAuthArgs(["org.dynamics.com"]); 27 | expect(args.environmentUrl).toBe("org.dynamics.com"); 28 | expect(args.verboseLogging).toBe(false); 29 | }); 30 | 31 | it("Environment Url and tenant Url", () => { 32 | const args = new DataverseAuthArgs(["contoso.com", "org.dynamics.com"]); 33 | expect(args.tenantUrl).toBe("contoso.com"); 34 | expect(args.environmentUrl).toBe("org.dynamics.com"); 35 | expect(args.verboseLogging).toBe(false); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dataverse-auth", 3 | "version": "2.0.8", 4 | "description": "Performs auth against a Microsoft Dataverse environment. Stores the token for use in other NodeJS applications.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "bin": { 8 | "dataverse-auth": "dist/dataverse-auth.js" 9 | }, 10 | "scripts": { 11 | "build": "tsc", 12 | "dist": "genversion --es6 --semi src/version.ts & tsc", 13 | "prepublishOnly": "npm version patch", 14 | "version": "genversion --es6 --semi --double src/version.ts && npm run build && git add src/version.ts", 15 | "postversion": "git push && git push --tags", 16 | "start": "tsc & node dist/dataverse-auth.js", 17 | "electron": "tsc & electron .", 18 | "genversion": "genversion", 19 | "lint": "eslint src --ext .ts", 20 | "lint:fix": "npm run lint -- --fix", 21 | "test": "jest" 22 | }, 23 | "author": "", 24 | "license": "MIT", 25 | "dependencies": { 26 | "@azure/msal-common": "^7.1.0", 27 | "@azure/msal-node": "^1.11.0", 28 | "@types/node-fetch": "^2.6.1", 29 | "chalk": "^4.1.0", 30 | "cryptr": "^6.0.3", 31 | "electron": "^33.2.1", 32 | "enquirer": "^2.3.6", 33 | "eslint-plugin-sonarjs": "^0.13.0", 34 | "minimist": "^1.2.6", 35 | "node-fetch": "^2.6.7" 36 | }, 37 | "devDependencies": { 38 | "@types/cryptr": "^4.0.1", 39 | "@types/jest": "^28.1.4", 40 | "@types/minimist": "^1.2.2", 41 | "@typescript-eslint/eslint-plugin": "^5.27.1", 42 | "@typescript-eslint/parser": "^5.27.1", 43 | "eslint": "^8.17.0", 44 | "eslint-config-prettier": "^8.5.0", 45 | "eslint-plugin-prettier": "^4.0.0", 46 | "genversion": "^3.1.1", 47 | "jest": "^28.1.2", 48 | "prettier": "^2.6.2", 49 | "ts-jest": "^28.0.5", 50 | "typescript": "^4.7.3", 51 | "typescript-eslint": "^0.0.1-alpha.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "program": "${workspaceFolder}/dist/dataverse-auth.js", 9 | "name": "Run npm start", 10 | "preLaunchTask": "${defaultBuildTask}", 11 | "request": "launch", 12 | "console": "integratedTerminal", 13 | "type": "node", 14 | "autoAttachChildProcesses": true, 15 | "smartStep": true 16 | }, 17 | 18 | // { 19 | // "name": "Electron Main", 20 | // "program": "${workspaceFolder}/dist/index.js", 21 | // "request": "launch", 22 | // "args": ["org1bfe9950.crm3.dynamics.com"], 23 | // "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", 24 | // "skipFiles": [ 25 | // "/**" 26 | // ], 27 | // "type": "node" 28 | // } 29 | { 30 | "type": "node", 31 | "name": "vscode-jest-tests", 32 | "request": "launch", 33 | "args": ["${fileBasename}", "--runInBand", "--code-coverage=false" 34 | ], 35 | "cwd": "${workspaceFolder}", 36 | "console": "integratedTerminal", 37 | "smartStep": true, 38 | "internalConsoleOptions": "neverOpen", 39 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 40 | "skipFiles": ["node_modules/**/*.js", "/**"], 41 | "runtimeArgs": [ 42 | "--harmony", 43 | "--no-deprecation" 44 | ], 45 | "sourceMaps": true 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /src/MsalAuth/MsalCachePlugin.ts: -------------------------------------------------------------------------------- 1 | import { ICachePlugin, TokenCacheContext } from "@azure/msal-node"; 2 | import { constants } from "fs"; 3 | import fs from "fs/promises"; 4 | import os from "os"; 5 | import path from "path"; 6 | import Cryptr from "cryptr"; 7 | 8 | // This is a encrypted auth token cache 9 | // IDeally we should use msal-node-extensions to provide a secure storage of tokens 10 | // See https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-node-migration#enable-token-caching 11 | // However - this library does not come with pre-compiled native libraries 12 | // See https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/3332 13 | function getAuthCachePath(): string { 14 | const homeDirPath = os.homedir(); 15 | return path.join(homeDirPath, "dataverse-auth-cache"); 16 | } 17 | 18 | function getCrypto(): Cryptr { 19 | return new Cryptr(os.userInfo().username); 20 | } 21 | 22 | // Call back APIs which automatically write and read into a .json file - example implementation 23 | const beforeCacheAccess = async (cacheContext: TokenCacheContext): Promise => { 24 | const cachePath = getAuthCachePath(); 25 | let exists = true; 26 | try { 27 | await fs.access(cachePath, constants.R_OK | constants.W_OK); 28 | } catch { 29 | exists = false; 30 | } 31 | if (exists) { 32 | try { 33 | const cache = await fs.readFile(cachePath, "utf-8"); 34 | cacheContext.tokenCache.deserialize(getCrypto().decrypt(cache)); 35 | } catch (e) { 36 | console.warn(e); 37 | } 38 | } 39 | }; 40 | 41 | const afterCacheAccess = async (cacheContext: TokenCacheContext): Promise => { 42 | if (cacheContext.cacheHasChanged) { 43 | const data = getCrypto().encrypt(cacheContext.tokenCache.serialize()); 44 | await fs.writeFile(getAuthCachePath(), data); 45 | } 46 | }; 47 | 48 | // Cache Plugin 49 | export const MsalCachePlugin: ICachePlugin = { 50 | beforeCacheAccess, 51 | afterCacheAccess, 52 | }; 53 | -------------------------------------------------------------------------------- /src/DataverseAuthArgs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/cognitive-complexity */ 2 | import minimist from "minimist"; 3 | export enum DataverseAuthCommands { 4 | TestConnection = "test-connection", 5 | List = "list", 6 | Remove = "remove", 7 | DeviceCode = "device-code", 8 | ClientSecretAuth = "client-secret-auth", 9 | Help = "help", 10 | } 11 | const commands: string[] = [ 12 | DataverseAuthCommands.List, 13 | DataverseAuthCommands.TestConnection, 14 | DataverseAuthCommands.DeviceCode, 15 | DataverseAuthCommands.Remove, 16 | DataverseAuthCommands.ClientSecretAuth, 17 | DataverseAuthCommands.Help, 18 | ]; 19 | 20 | export class DataverseAuthArgs { 21 | constructor(processArgs: string[]) { 22 | const args = minimist(processArgs, { 23 | alias: { tenant: "t", applicationId: "a", clientSecret: "s" }, 24 | }); 25 | this.verboseLogging = args.verbose || args.v || false; 26 | this.environmentUrl = args.environment || args.e; 27 | this.tenantUrl = args.tenantUrl || args.tu; 28 | this.command = processArgs.find((a) => commands.indexOf(a) > -1); 29 | 30 | // Strip out all commands to leave just the environment and tenant urls. 31 | const justUrls = args._.filter((a) => commands.indexOf(a) === -1); 32 | if (!this.environmentUrl && justUrls.length === 1) { 33 | this.environmentUrl = args._[0]; 34 | } else if (!this.environmentUrl && justUrls.length === 2) { 35 | this.tenantUrl = args._[0]; 36 | this.environmentUrl = args._[1]; 37 | } 38 | } 39 | 40 | public outputHelp() { 41 | console.log(`Usage: npx dataverse-auth [EnvUrl] [test-connection] [remove] [list] [device-code] 42 | 43 | [EnvUrl] The url of the environment to authenticate against (e.g. contoso.dynamics.com) 44 | test-connection Tests a pre-created authentication profile using the environment url as the key 45 | remove Removes an authentication profile using the environment url as the key 46 | list Lists all the authentication profiles 47 | device-code Uses the device code flow to authenticate against the given environment 48 | `); 49 | } 50 | 51 | public environmentUrl: string; 52 | public tenantUrl?: string; 53 | public command?: string; 54 | public verboseLogging = false; 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dataverse-auth 2 | Cross-platform pure NodeJS On-behalf-of authentication against Microsoft dataverse Pro. Stores the token for use with NodeJS applications such as [dataverseify](https://github.com/scottdurow/dataverse-ify) 3 | 4 | > **Note:** Version 2 of dataverse-auth is not compatible with version Version 1 of dataverse-ify and dataverse-gen. 5 | Use npx dataverse-auth@1 instead if you want to continue to use the older version 6 | 7 | ## Usage 8 | `~$ npx dataverse-auth [environment]`\ 9 | E.g.\ 10 | `~$ npx dataverse-auth contosoorg.crm.dynamics.com` 11 | 12 | ### Optional - specify tenant url 13 | You you want to specify the tenant Url rather that it be looked up automatically 14 | `~$ npx dataverse-auth [tennant] [environment]`\ 15 | E.g.\ 16 | `~$ npx dataverse-auth contoso.onmicrosoft.com contosoorg.crm.dynamics.com` 17 | For more information see the [dataverse-ify project](https://github.com/scottdurow/dataverse-ify) 18 | 19 | ## Other commands 20 | - `npx dataverse-auth list` : Lists the currently authenticated environments 21 | - `npx dataverse auth [environmentUrl] test-connection` : Tests a previously authenticated environment 22 | - `npx dataverse auth [environmentUrl] remove` : Removes the stored token for an authenticated environment 23 | - `npx dataverse auth [environmentUrl] device-code` : Adds an authentication profile using the device-code flow. Use this if you are having trouble authenticating using the interactive prompt. 24 | 25 | ## Tested on 26 | - Linux 27 | - ✔ Manjaro 28 | - ✔ Ubuntu 29 | - ✔ Debian (see workaround below) 30 | - MacOS 31 | - ✔ 10.15 32 | - Windows 33 | - ✔ 10 34 | 35 | ## Debian install 36 | By default the Debian kernel is hardened and proactively deny unprivileged user namespaces. This causes an issue when you install electron or packages depending on it, and there are (at least) two ways to bypass that. 37 | 38 | ### Method1, enable unprivileged namespaces 39 | For NPX to work you will have to enable unprivileged user namespaces. Instructions on how to do this is found in the [this article](https://wiki.debian.org/LXC#Configuration_of_the_host_system) 40 | 41 | ### Method2, install and modify permissions 42 | First, install the NPM package, globally or in a dedicated project. After the install navigate to $NPM_PACKAGES/lib/node_modules/dataverse-auth/node_modules/electron/dist (tip: if you try to run dataverse-auth the full path will be in the error message) 43 | Change the owner of chrome-sandbox to root and chmod it to 4755: 44 | `~$ sudo chown root chrome-sandbox && sudo chmod 4755 chrome-sandbox` 45 | 46 | Now you can run it like any other package: 47 | `~$ dataverse-auth myorg.crm.dynamics.com` 48 | 49 | ## MacOS, Apple Silicon usage 50 | Version 2 is required to work on MacOS Apple Silicon - `npx dataverse-auth@2 `. 51 | 52 | ### Build & Test 53 | `dataverse-auth` uses electron which uses node-gyp. You will need to install Python and Visual Studio C++ core features. 54 | To build & test locally, use: 55 | ``` 56 | npm run start org.api.crm3.dynamics.com 57 | npm run start list 58 | npm run start org.api.crm3.dynamics.com test-connection 59 | ``` 60 | 61 | ### ADAL -> MSAL 62 | As of version 2, dataverse-ify now uses MSAL for all authentication based on guidance given by https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-node-migration 63 | -------------------------------------------------------------------------------- /src/MsalAuth/InteractiveAuthenticate.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from "electron"; 2 | import https from "https"; 3 | import { ILoggerCallback, LogLevel } from "@azure/msal-common"; 4 | import { LogEntry } from "./SimpleLogger"; 5 | import { msalConfig } from "./MsalConfig"; 6 | 7 | export interface InteractiveAcquireAuthCodeResult { 8 | authCode?: string; 9 | log: LogEntry[]; 10 | } 11 | 12 | // Create the browser window. 13 | function getTenantUrl(logger: ILoggerCallback, envUrl: string): Promise { 14 | return new Promise((resolve, reject) => { 15 | let url = envUrl.endsWith("/") ? `${envUrl}api/data` : `${envUrl}/api/data`; 16 | url = 17 | !url.startsWith("http://") && !url.startsWith("https://") ? `https://${url}` : url.replace("http://", "https://"); 18 | 19 | logger(LogLevel.Verbose, `getTenantUrl: ${url}`, false); 20 | 21 | https.get(url, (response) => { 22 | const wwwAuthenticateHeader = response.headers["www-authenticate"]; 23 | logger(LogLevel.Verbose, `getTenantUrl: www-authenticate=${wwwAuthenticateHeader}`, false); 24 | if (wwwAuthenticateHeader) { 25 | try { 26 | const tenantAuthUrl = wwwAuthenticateHeader.split("=")[1].split(",")[0]; 27 | logger(LogLevel.Verbose, `getTenantUrl: ${tenantAuthUrl}`, false); 28 | resolve(tenantAuthUrl); 29 | } catch (err) { 30 | const message = `Failed to parse 'www-authenticate' header from Environment ${wwwAuthenticateHeader}`; 31 | logger(LogLevel.Error, `getTenantUrl: ${message}`, false); 32 | reject(message); 33 | } 34 | } else { 35 | const message = `${envUrl} is not valid Environment`; 36 | logger(LogLevel.Error, `getTenantUrl: ${message}`, false); 37 | reject(message); 38 | } 39 | }); 40 | }); 41 | } 42 | 43 | export async function interactiveAcquireAuthCode( 44 | logger: ILoggerCallback, 45 | envUrl: string, 46 | tenantUrl?: string, 47 | ): Promise { 48 | let authUrl = `https://login.microsoftonline.com/${tenantUrl}/oauth2/v2.0/authorize`; 49 | 50 | logger(LogLevel.Verbose, `interactiveGetAuthCode: envUrl=${envUrl} tenantUrl=${tenantUrl}`, true); 51 | try { 52 | if (!tenantUrl) { 53 | logger(LogLevel.Verbose, "interactiveGetAuthCode: No tenant provided - calling getTenantUrl", false); 54 | authUrl = await getTenantUrl(logger, envUrl); 55 | } else { 56 | await app.whenReady(); 57 | } 58 | } catch (error) { 59 | logger(LogLevel.Error, error as string, false); 60 | return ""; 61 | } 62 | 63 | return openBrowserWaitForRedirect(authUrl, logger); 64 | } 65 | 66 | function openBrowserWaitForRedirect(authUrl: string, logger: ILoggerCallback): Promise { 67 | return new Promise((resolve, reject) => { 68 | let loginComplete = false; 69 | 70 | // Create the browser window. 71 | const win = new BrowserWindow({ 72 | width: 800, 73 | height: 600, 74 | alwaysOnTop: true, 75 | autoHideMenuBar: true, 76 | titleBarStyle: "default", 77 | title: "Sign in to your account", 78 | webPreferences: { 79 | nodeIntegration: true, 80 | }, 81 | }); 82 | 83 | // Navigate to the get code page 84 | const url = `${authUrl}?client_id=${ 85 | msalConfig.clientId 86 | }&response_type=code&haschrome=1&redirect_uri=${encodeURIComponent(msalConfig.redirectUrl)}&scope=openid`; 87 | logger(LogLevel.Verbose, `loadURL ${url}`, true); 88 | win.loadURL(url).catch((error) => { 89 | logger(LogLevel.Error, error, false); 90 | if (!loginComplete) { 91 | reject(error); 92 | } 93 | }); 94 | win.on("closed", function () { 95 | logger(LogLevel.Verbose, `closed loginComplete=${loginComplete}`, false); 96 | if (!loginComplete) { 97 | reject("Login Closed: Authentication was not completed"); 98 | } 99 | }); 100 | 101 | win.webContents.on("will-redirect", function (event, redirectUrl) { 102 | logger(LogLevel.Verbose, `interactiveGetAuthCode: will-redirect redirectUrl=${redirectUrl}`, true); 103 | // Check if this is the success callback 104 | if (redirectUrl.toLowerCase().startsWith(msalConfig.redirectUrl.toLowerCase())) { 105 | // Stop the redirect to the app: endpoint 106 | event.preventDefault(); 107 | loginComplete = true; 108 | const regex = /(?<=code=)[^&]*/gm; 109 | const codeMatch = regex.exec(redirectUrl); 110 | 111 | if (!codeMatch) { 112 | const message = "Cannot find code in redirect"; 113 | logger(LogLevel.Error, `interactiveGetAuthCode: ${message}`, false); 114 | throw new Error(message); 115 | } 116 | 117 | logger(LogLevel.Verbose, `interactiveGetAuthCode: will-redirect codeMatch=${codeMatch}`, true); 118 | win.close(); 119 | resolve(codeMatch[0]); 120 | } 121 | }); 122 | }); 123 | } 124 | -------------------------------------------------------------------------------- /src/dataverse-auth.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @typescript-eslint/no-use-before-define */ 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const electron = require("electron/"); 5 | import proc from "child_process"; 6 | import { exit } from "process"; 7 | import { InteractiveAcquireAuthCodeResult } from "./MsalAuth/InteractiveAuthenticate"; 8 | import { 9 | acquireToken, 10 | acquireTokenByCodeMsal, 11 | acquireTokenUsingDeviceCode, 12 | getAllUsers, 13 | removeToken, 14 | } from "./MsalAuth/MsalNodeAuth"; 15 | import { version } from "./version"; 16 | import { prompt } from "enquirer"; 17 | import { SimpleLogger } from "./MsalAuth/SimpleLogger"; 18 | import chalk from "chalk"; 19 | import { DataverseAuthArgs, DataverseAuthCommands } from "./DataverseAuthArgs"; 20 | console.log(chalk.yellow(`dataverse-auth v${version}`)); 21 | console.log( 22 | chalk.yellow(` 23 | NOTE: `) + 24 | chalk.gray(`Version 2 of dataverse-auth is not compatible with version Version 1 of dataverse-ify and dataverse-gen. 25 | Use npx dataverse-auth@1 instead if you want to continue to use the older version. 26 | `), 27 | ); 28 | 29 | const currentDir = __dirname; 30 | 31 | main(); 32 | 33 | async function main(): Promise { 34 | try { 35 | const args = new DataverseAuthArgs(process.argv.slice(2)); 36 | 37 | if (args.verboseLogging) { 38 | console.log("Verbose logging ON"); 39 | } 40 | 41 | // Which command to run? 42 | switch (args.command) { 43 | case DataverseAuthCommands.Help: 44 | args.outputHelp(); 45 | break; 46 | case DataverseAuthCommands.TestConnection: 47 | await testAuth(args); 48 | break; 49 | case DataverseAuthCommands.List: 50 | listAuth(); 51 | break; 52 | case DataverseAuthCommands.Remove: 53 | removeAuth(args); 54 | break; 55 | case DataverseAuthCommands.DeviceCode: 56 | await deviceCodeAuth(args); 57 | break; 58 | default: 59 | await interactiveAuth(args); 60 | break; 61 | } 62 | } catch (e) { 63 | console.log(chalk.red(e)); 64 | } 65 | } 66 | 67 | async function getEnvironmentUrl(args: DataverseAuthArgs): Promise { 68 | if (!args.environmentUrl) { 69 | try { 70 | const response = await prompt<{ env: string }>({ 71 | type: "input", 72 | name: "env", 73 | message: "Enter environment url (e.g. org.crm.dynamics.com)", 74 | }); 75 | 76 | if (response && response.env) { 77 | args.environmentUrl = response.env; 78 | } 79 | } catch { 80 | //noop 81 | } 82 | } 83 | if (!args.environmentUrl) { 84 | throw "Please provide an environment url. (e.g. org.crm.dynamics.com)"; 85 | } 86 | 87 | // Normalize environment Url 88 | if (!args.environmentUrl.toLowerCase().startsWith("http")) { 89 | args.environmentUrl = "https://" + args.environmentUrl; 90 | } 91 | args.environmentUrl = new URL(args.environmentUrl).hostname; 92 | 93 | return args.environmentUrl; 94 | } 95 | 96 | function listAuth(): void { 97 | console.log("Current Microsoft Dataverse user profiles:"); 98 | getAllUsers().forEach((a, i) => console.log(`[${i}] ${a.userName}\t: ${a.environment}`)); 99 | exit(); 100 | } 101 | 102 | function removeAuth(args: DataverseAuthArgs): void { 103 | removeToken(args.environmentUrl) 104 | .catch((error) => { 105 | console.error(error); 106 | exit(1); 107 | }) 108 | .then(() => { 109 | console.log(`Authentication profile removed for ${args.environmentUrl}`); 110 | exit(0); 111 | }); 112 | } 113 | 114 | async function testAuth(args: DataverseAuthArgs): Promise { 115 | const logger = new SimpleLogger(); 116 | try { 117 | const bearerToken = await acquireToken(args.environmentUrl, logger.Log); 118 | logger.OutputToConsole(args.verboseLogging); 119 | console.log(`\nBearer ${bearerToken}`); 120 | console.log(`\nAuthentication successful for ${args.environmentUrl}`); 121 | exit(0); 122 | } catch (error) { 123 | logger.OutputToConsole(args.verboseLogging); 124 | console.error(`Authentication failed: ${error}`); 125 | exit(1); 126 | } 127 | } 128 | 129 | async function deviceCodeAuth(args: DataverseAuthArgs): Promise { 130 | try { 131 | await getEnvironmentUrl(args); 132 | console.log(`Authenticating for environment (using device code flow): '${args.environmentUrl}'`); 133 | 134 | const result = await acquireTokenUsingDeviceCode(args.environmentUrl); 135 | 136 | if (result) { 137 | console.log(`Authentication successful for ${result.account?.name} (${result.account?.username})`); 138 | } else throw "Authentication cancelled"; 139 | exit(0); 140 | } catch (error) { 141 | console.error(`Authentication failed: ${error}`); 142 | exit(1); 143 | } 144 | } 145 | 146 | async function interactiveAuth(args: DataverseAuthArgs): Promise { 147 | try { 148 | await getEnvironmentUrl(args); 149 | 150 | console.log(`Authenticating for environment: '${args.environmentUrl}'`); 151 | 152 | // We create a child process to perform the interactive authentication using the electron process 153 | // This returns the auth code which is then used to get the token from MSAL 154 | const processArgs = [currentDir + "/."]; 155 | if (args.tenantUrl) { 156 | processArgs.push("-t"); 157 | processArgs.push(args.tenantUrl); 158 | } 159 | if (args.environmentUrl) { 160 | processArgs.push("-e"); 161 | processArgs.push(args.environmentUrl); 162 | } 163 | const child = proc.spawn(electron, [currentDir + "/.", ...processArgs], { 164 | windowsHide: false, 165 | }); 166 | let authResult = ""; 167 | 168 | if (child.stdout) { 169 | child.stdout.on("data", function (data) { 170 | authResult += data.toString(); 171 | }); 172 | } 173 | 174 | child.on("close", function () { 175 | onCloseCallback(authResult, args); 176 | }); 177 | 178 | // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-function-return-type 179 | const handleTerminationSignal = function (signal: any) { 180 | process.on(signal, function signalHandler() { 181 | if (!child.killed) { 182 | child.kill(signal); 183 | } 184 | }); 185 | }; 186 | 187 | handleTerminationSignal("SIGINT"); 188 | handleTerminationSignal("SIGTERM"); 189 | } catch (error) { 190 | console.error(`Authentication failed: ${error}`); 191 | exit(1); 192 | } 193 | } 194 | 195 | async function onCloseCallback(authResult: string, args: DataverseAuthArgs): Promise { 196 | const logger = new SimpleLogger(); 197 | try { 198 | // Auth using msal 199 | // Get the first { to mark start of JSON 200 | const startIndex = authResult.indexOf("{"); 201 | if (startIndex === -1) { 202 | throw "Unexpected result:" + authResult; 203 | } 204 | 205 | const result = JSON.parse(authResult.substring(startIndex)) as InteractiveAcquireAuthCodeResult; 206 | 207 | logger.AppendLog(result.log); 208 | 209 | if (result.authCode) { 210 | const msalResult = await acquireTokenByCodeMsal(args.environmentUrl, result.authCode, logger.Log); 211 | if (msalResult) { 212 | if (args.verboseLogging) { 213 | // output log, but don't output the token for brevity 214 | logger.OutputToConsole(args.verboseLogging); 215 | console.log({ ...msalResult, ...{ idToken: "****", accessToken: "****" } }); 216 | } 217 | console.log(`Authentication successful for ${msalResult.account?.name} (${msalResult.account?.username})`); 218 | } 219 | exit(0); 220 | } else { 221 | // get last error 222 | const errorLog = result.log && result.log.find((l) => l.Level === 0); 223 | throw errorLog?.Message ?? "Unknown error"; 224 | } 225 | } catch (error) { 226 | logger.OutputToConsole(args.verboseLogging); 227 | console.error(`Authentication failed: ${error}`); 228 | exit(1); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/MsalAuth/MsalNodeAuth.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { 3 | AccountInfo, 4 | AuthenticationResult, 5 | AuthorizationCodeRequest, 6 | ClientCredentialRequest, 7 | ConfidentialClientApplication, 8 | Configuration, 9 | DeviceCodeRequest, 10 | NodeAuthOptions, 11 | PublicClientApplication, 12 | SilentFlowRequest, 13 | } from "@azure/msal-node"; 14 | import fetch from "node-fetch"; 15 | import * as fs from "fs"; 16 | import * as os from "os"; 17 | import { msalConfig } from "./MsalConfig"; 18 | import { ILoggerCallback, LogLevel } from "@azure/msal-common"; 19 | import { MsalCachePlugin } from "./MsalCachePlugin"; 20 | 21 | export interface UserLookup { 22 | [index: string]: string; 23 | } 24 | let userNameLookup: UserLookup | undefined; 25 | let msalClient: PublicClientApplication | undefined; 26 | 27 | function getLookupPath(): string { 28 | const homeDirPath = os.homedir(); 29 | return path.join(homeDirPath, "./dataverse-auth-users"); 30 | } 31 | 32 | function loadLookup(): UserLookup { 33 | if (!userNameLookup) { 34 | userNameLookup = {}; 35 | // Lookup the saved account using the environment url 36 | const authCachePath = getLookupPath(); 37 | // Load existing file if there is one 38 | if (fs.existsSync(authCachePath)) { 39 | const tokenCacheJSON = fs.readFileSync(authCachePath); 40 | userNameLookup = JSON.parse(tokenCacheJSON.toString()) as unknown as UserLookup; 41 | } 42 | } 43 | return userNameLookup as UserLookup; 44 | } 45 | 46 | function saveLookup(): void { 47 | fs.writeFileSync(getLookupPath(), JSON.stringify(userNameLookup)); 48 | } 49 | 50 | export function getUserNameByEnvUrl(environmentUrl: string): string | undefined { 51 | const env = environmentUrl.toLowerCase(); 52 | const lookup = loadLookup(); 53 | if (Object.keys(lookup).indexOf(env) > -1) { 54 | return lookup[env]; 55 | } 56 | return undefined; 57 | } 58 | 59 | export function saveAccountByEnvUrl(environmentUrl: string, account: AccountInfo): void { 60 | const lookup = loadLookup(); 61 | const env = environmentUrl.toLowerCase(); 62 | lookup[env] = account.username; 63 | saveLookup(); 64 | } 65 | 66 | export function removeAccountByEnvUrl(environmentUrl: string): void { 67 | const lookup = loadLookup(); 68 | const env = environmentUrl.toLowerCase(); 69 | delete lookup[env]; 70 | saveLookup(); 71 | } 72 | 73 | export function getAllUsers(): { userName: string; environment: string }[] { 74 | const lookup = loadLookup(); 75 | return Object.keys(lookup).map((environment) => { 76 | return { userName: lookup[environment], environment }; 77 | }); 78 | } 79 | function normalizeEnvUrl(environmentUrl: string) { 80 | return environmentUrl.toLowerCase().trim().replace(/\/$/, ""); 81 | } 82 | export function getAccountByEnvUrl(accounts: AccountInfo[], environmentUrl: string): AccountInfo | undefined { 83 | const user = getUserNameByEnvUrl(normalizeEnvUrl(environmentUrl)); 84 | if (user) { 85 | return accounts.find((a) => a.username === user); 86 | } 87 | return undefined; 88 | } 89 | 90 | async function getMsalClient( 91 | logger?: ILoggerCallback, 92 | additionalConfig?: Partial, 93 | ): Promise { 94 | if (!msalClient) { 95 | const publicClientConfig = { 96 | auth: { 97 | ...{ 98 | clientId: msalConfig.clientId, 99 | authority: "https://login.windows.net/common", 100 | }, 101 | ...additionalConfig, 102 | }, 103 | system: { 104 | loggerOptions: { 105 | loggerCallback: logger, 106 | logLevel: logger ? LogLevel.Verbose : undefined, 107 | piiLoggingEnabled: true, 108 | }, 109 | }, 110 | cache: { 111 | cachePlugin: MsalCachePlugin, 112 | }, 113 | } as Configuration; 114 | msalClient = new PublicClientApplication(publicClientConfig); 115 | } 116 | 117 | return msalClient; 118 | } 119 | 120 | export async function acquireTokenByCodeMsal( 121 | environmentUrl: string, 122 | authCode: string, 123 | logger?: ILoggerCallback, 124 | ): Promise { 125 | const client = await getMsalClient(logger); 126 | const envUrl = normalizeEnvUrl(environmentUrl); 127 | const response = await client.acquireTokenByCode({ 128 | code: authCode, 129 | redirectUri: msalConfig.redirectUrl, 130 | scopes: ["openid", `https://${envUrl}/.default`], 131 | } as AuthorizationCodeRequest); 132 | if (response && response.account) { 133 | // Test connection to environment 134 | const userId = await whoAmI(envUrl, response); 135 | if (logger) logger(LogLevel.Verbose, `Dataverse userId ${userId}`, false); 136 | // Save user profile 137 | saveAccountByEnvUrl(envUrl, response.account); 138 | } else { 139 | throw `Authentication for ${envUrl} was unsuccessful`; 140 | } 141 | 142 | return response; 143 | } 144 | 145 | export async function acquireTokenByClientSecret( 146 | environmentUrl: string, 147 | tenantID: string, 148 | clientId: string, 149 | clientSecret: string, 150 | logger?: ILoggerCallback, 151 | ): Promise { 152 | const clientConfig = { 153 | auth: { 154 | clientId: clientId, 155 | authority: `https://login.windows.net/${tenantID}`, 156 | clientSecret: clientSecret, 157 | }, 158 | system: { 159 | loggerOptions: { 160 | loggerCallback: logger, 161 | logLevel: logger ? LogLevel.Verbose : undefined, 162 | piiLoggingEnabled: true, 163 | }, 164 | }, 165 | }; 166 | const cca = new ConfidentialClientApplication(clientConfig); 167 | 168 | const envUrl = normalizeEnvUrl(environmentUrl); 169 | 170 | const clientCredentialRequest = { 171 | scopes: [`https://${envUrl}/.default`], // replace with your resource 172 | } as ClientCredentialRequest; 173 | 174 | const response = await cca.acquireTokenByClientCredential(clientCredentialRequest); 175 | 176 | if (response && response.accessToken) { 177 | // Test connection to environment 178 | const userId = await whoAmI(envUrl, response); 179 | if (logger) logger(LogLevel.Verbose, `Dataverse userId ${userId}`, false); 180 | 181 | return response.accessToken; 182 | } else { 183 | throw `Authentication for ${envUrl} was unsuccessful`; 184 | } 185 | } 186 | 187 | export async function acquireTokenUsingDeviceCode( 188 | environmentUrl: string, 189 | logger?: ILoggerCallback, 190 | ): Promise { 191 | const client = await getMsalClient(logger); 192 | const envUrl = normalizeEnvUrl(environmentUrl); 193 | const response = await client.acquireTokenByDeviceCode({ 194 | redirectUri: msalConfig.redirectUrl, 195 | scopes: ["openid", `https://${envUrl}/.default`], 196 | deviceCodeCallback: (response) => { 197 | console.log(response.message); 198 | }, 199 | } as DeviceCodeRequest); 200 | if (response && response.account) { 201 | // Test connection to environment 202 | const userId = await whoAmI(envUrl, response); 203 | if (logger) logger(LogLevel.Verbose, `Dataverse userId ${userId}`, false); 204 | // Save user profile 205 | saveAccountByEnvUrl(envUrl, response.account); 206 | } else { 207 | throw `Authentication for ${envUrl} was unsuccessful`; 208 | } 209 | return response; 210 | } 211 | 212 | export async function removeToken(environmentUrl: string): Promise { 213 | const client = await getMsalClient(); 214 | const envUrl = normalizeEnvUrl(environmentUrl); 215 | const accounts = await client.getTokenCache().getAllAccounts(); 216 | const account = getAccountByEnvUrl(accounts, envUrl); 217 | if (!account) throw "Cannot find profile for environment."; 218 | client.getTokenCache().removeAccount(account); 219 | removeAccountByEnvUrl(envUrl); 220 | } 221 | 222 | export async function acquireToken(environmentUrl: string, logger?: ILoggerCallback): Promise { 223 | // Find the account for the given environment 224 | const client = await getMsalClient(logger); 225 | const envUrl = normalizeEnvUrl(environmentUrl); 226 | const accounts = await client.getTokenCache().getAllAccounts(); 227 | // Find the account for the given environment 228 | const account = getAccountByEnvUrl(accounts, envUrl); 229 | if (!account) throw "Cannot find profile for environment. Re-run npx dataverse-auth for this environment."; 230 | 231 | const scopes = ["openid", `https://${envUrl}/.default`]; 232 | const response = await client.acquireTokenSilent({ account: account, scopes: scopes } as SilentFlowRequest); 233 | if (response && response.account) { 234 | // Test connection to environment 235 | const userId = await whoAmI(envUrl, response); 236 | if (logger) logger(LogLevel.Verbose, `Dataverse userId ${userId}`, false); 237 | 238 | return response.accessToken; 239 | } else { 240 | throw `Authentication for ${envUrl} was unsuccessful`; 241 | } 242 | } 243 | async function whoAmI(environmentUrl: string, response: AuthenticationResult): Promise { 244 | const envUrl = normalizeEnvUrl(environmentUrl); 245 | const whoAmIResponse = await fetch(`https://${envUrl}/api/data/v9.2/WhoAmI()`, { 246 | method: "GET", 247 | headers: { 248 | "OData-MaxVersion": "4.0", 249 | "OData-Version": "4.0", 250 | Accept: "application/json", 251 | "Content-Type": "application/json; charset=UTF-8", 252 | Authorization: `Bearer ${response.accessToken}`, 253 | }, 254 | }); 255 | if (whoAmIResponse.status !== 200) { 256 | throw `WhoAmI request failed with ${whoAmIResponse.statusText}`; 257 | } 258 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 259 | const whoAmIResponseData = (await whoAmIResponse.json()) as any; 260 | return whoAmIResponseData.UserId as string; 261 | } 262 | --------------------------------------------------------------------------------