├── .gitignore ├── README.md ├── build └── bundle.js ├── dprint.json ├── hints.json ├── media └── demo.svg ├── package.json └── src ├── index.js ├── serve.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | min.js 4 | .cache/ 5 | .parcel* 6 | dist/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | demo 3 |

ms-graph-cli: tiny & elegant cli to authenticate microsoft graph

4 |

5 | 6 | ## Description 7 | 8 | `ms-graph-cli` helps you run through microsoft's 9 | [get access on behalf of a user](https://docs.microsoft.com/en-us/graph/auth-v2-user) easily! Created mainly for helping **onedrive & sharepoint** get the 10 | `access-token` and `refresh-token` to access ms-graph API. 11 | 12 | ## Graph permissons needed 13 | 14 | - onedrive: `Files.Read.All Files.ReadWrite.All offline_access` 15 | - sharepoint: `Sites.Read.All Sites.ReadWrite.All offline_access` 16 | 17 | ## CLI usage 18 | 19 | **!Note**: To automate the redirection process, `ms-graph-cli` needs your app's `redirect_uri` set to `http://localhost:3000`, the port can be changed as long as you have system permission to create a http server on that port 20 | 21 | If you are somehow unable to meet the requirements of `redirect_uri`, please use the **[legacy version][legacy-version]** 22 | 23 | ```bash 24 | # Print generated credentials to stdout 25 | npx @beetcb/ms-graph-cli 26 | 27 | # Save generated credentials to .env file 28 | npx @beetcb/ms-graph-cli -s 29 | 30 | # Specify the display language, support CN \ EN, default is EN 31 | npx @beetcb/ms-graph-cli -l cn 32 | 33 | # Or specify them both 34 | npx @beetcb/ms-graph-cli -s -l cn 35 | ``` 36 | 37 | ## Generated credentials 38 | 39 | **It's a `object`(maybe `.env`-fromatted) contains following key-value pairs:** 40 | 41 | - `access_token`: use it to access ms-graph 42 | - `refresh_token`: use it to refresh the `access_token` 43 | - `redirect_uri`: your application redirect uri 44 | - `client_id`: your application client id 45 | - `client_secret`: your application client secret(this can be ignored when using 46 | public client) 47 | - `auth_endpoint`: api endpoint to request token 48 | - `drive_api`: api endpoint to access your drive resource 49 | - `graph_api`: api endpoint to access ms-graph 50 | - `site_id?`: sharepoint site id 51 | 52 | All fields in the object are your private information, please keep it safe. 53 | 54 | ## TODO 55 | 56 | - [x] Create a local server to catch the redirect `code` 57 | - [x] Better error handling 58 | 59 | [legacy-version]: https://www.npmjs.com/package/@beetcb/ms-graph-cli/v/0.1.0 60 | -------------------------------------------------------------------------------- /build/bundle.js: -------------------------------------------------------------------------------- 1 | require('esbuild').buildSync({ 2 | entryPoints: ['src/index.js'], 3 | outfile: 'dist/index.js', 4 | bundle: true, 5 | platform: 'node', 6 | format: 'cjs', 7 | external: Object.keys(require('../package.json').dependencies), 8 | }) 9 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://dprint.dev/schemas/v0.json", 3 | "projectType": "openSource", 4 | "indentWidth": 2, 5 | "lineWidth": 80, 6 | "incremental": true, 7 | "typescript": { 8 | "semiColons": "asi", 9 | "quoteStyle": "preferSingle" 10 | }, 11 | "json": {}, 12 | "markdown": {}, 13 | "includes": [ 14 | "**/*.{ts,tsx,js,jsx,cjs,mjs,json,md}" 15 | ], 16 | "excludes": [ 17 | "**/node_modules", 18 | "**/*-lock.json", 19 | "**/lib", 20 | "**/docs" 21 | ], 22 | "plugins": [ 23 | "https://plugins.dprint.dev/typescript-0.46.0.wasm", 24 | "https://plugins.dprint.dev/json-0.11.0.wasm", 25 | "https://plugins.dprint.dev/markdown-0.7.1.wasm" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /hints.json: -------------------------------------------------------------------------------- 1 | { 2 | "step_init": [ 3 | [ 4 | "account_type", 5 | { 6 | "prompt_type": "select", 7 | "prompt_text": { 8 | "en": [ 9 | "Please select your OneDrive or SharePoint account type:", 10 | "Global", 11 | "Operated by 21Vianet in China" 12 | ], 13 | "cn": [ 14 | "请选择你的 OneDrive 或 SharePoint 账户类型:", 15 | "国际版", 16 | "世纪互联版" 17 | ] 18 | } 19 | } 20 | ], 21 | [ 22 | "deploy_type", 23 | { 24 | "prompt_type": "select", 25 | "prompt_text": { 26 | "en": [ 27 | "Please select your deploy type (OneDrive or SharePoint):", 28 | "OneDrive", 29 | "SharePoint" 30 | ], 31 | "cn": [ 32 | "请选择你的部署类型:", 33 | "OneDrive", 34 | "SharePoint" 35 | ] 36 | } 37 | } 38 | ], 39 | [ 40 | "client_id", 41 | { 42 | "prompt_type": "text", 43 | "prompt_text": { 44 | "en": [ 45 | "Enter your client_id:" 46 | ], 47 | "cn": [ 48 | "请提供你的 client_id:" 49 | ] 50 | } 51 | } 52 | ], 53 | [ 54 | "client_secret", 55 | { 56 | "prompt_type": "password", 57 | "prompt_text": { 58 | "en": [ 59 | "Enter your client_secret:" 60 | ], 61 | "cn": [ 62 | "请提供你的 client_secret:" 63 | ] 64 | } 65 | } 66 | ], 67 | [ 68 | "redirect_uri", 69 | { 70 | "prompt_type": "text", 71 | "prompt_text": { 72 | "en": [ 73 | "Enter your redirect_uri ([Default] http://localhost:3000):" 74 | ], 75 | "cn": [ 76 | "请提供你的 redirect_uri ([默认] http://localhost:3000):" 77 | ] 78 | }, 79 | "initial": "http://localhost:3000" 80 | } 81 | ] 82 | ], 83 | "step_sharepoint_need_site_id": [ 84 | [ 85 | "need_site_id", 86 | { 87 | "prompt_type": "select", 88 | "prompt_text": { 89 | "en": [ 90 | "Do you want to get SharePoint SiteId ?", 91 | "YES", 92 | "NO" 93 | ], 94 | "cn": [ 95 | "是否获取 SharePoint SiteId ?", 96 | "是", 97 | "否" 98 | ] 99 | } 100 | } 101 | ] 102 | ], 103 | "step_sharepoint_site_id": [ 104 | [ 105 | "host_name", 106 | { 107 | "prompt_type": "text", 108 | "prompt_text": { 109 | "en": [ 110 | "To get the SharePoint SiteID, You must specify:\n1. SharePoint site host (e.g., cent.sharepoint.com)" 111 | ], 112 | "cn": [ 113 | "为获取 SharePoint Site,你需要提供如下两个参数:\n1. SharePoint site host (比如:cent.sharepoint.com)" 114 | ] 115 | } 116 | } 117 | ], 118 | [ 119 | "site_path", 120 | { 121 | "prompt_type": "text", 122 | "prompt_text": { 123 | "en": [ 124 | "SharePoint sites path (e.g., /sites/centUser)" 125 | ], 126 | "cn": [ 127 | "SharePoint sites path (比如:/sites/centUser)" 128 | ] 129 | } 130 | } 131 | ] 132 | ] 133 | } 134 | -------------------------------------------------------------------------------- /media/demo.svg: -------------------------------------------------------------------------------- 1 | ms-graph-clionmaster[!]is📦v0.3.0viav16.0.0npxms-graph-cli?PleaseselectyourOneDriveorSharePointaccounttype:-Usearrow-keys.Returntosubmit.PleaseselectyourOneDriveorSharePointaccounttype:Operatedby21VianetinChinaPleaseselectyourdeploytype(OneDriveorSharePoint):OneDriveEnteryourclient_id:1Enteryourclient_secret:*Enteryourredirect_uri([Default]http://localhost:3000):http://localhost:3000Acquiretokenfailed!Unauthorizedms-graph-clionmaster[!]is📦v0.3.0viav16.0.0took7sexitexitGlobalOperatedby21VianetinChinaGlobalOperatedby21VianetinChina?Pleaseselectyourdeploytype(OneDriveorSharePoint):-Usearrow-keys.Returntosubmit.OneDriveSharePoint?Enteryourclient_id:?Enteryourclient_id:1?Enteryourclient_secret:?Enteryourclient_secret:*?Enteryourredirect_uri([Default]http://localhost:3000):http://localhost:3000eexexi -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beetcb/ms-graph-cli", 3 | "version": "0.3.1", 4 | "description": "elegant cli to authenticate microsoft graph", 5 | "bin": "./dist/index.js", 6 | "files": [ 7 | "dist/" 8 | ], 9 | "scripts": { 10 | "prebuild": "dprint fmt", 11 | "build": "node build/bundle.js", 12 | "pub": "npm publish --access=public" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/beetcb/ms-graph-cli.git" 17 | }, 18 | "keywords": [ 19 | "microsoft", 20 | "graph", 21 | "cli", 22 | "auth", 23 | "auth-cli" 24 | ], 25 | "author": "beetcb", 26 | "license": "ISC", 27 | "bugs": { 28 | "url": "https://github.com/beetcb/ms-graph-cli/issues" 29 | }, 30 | "homepage": "https://github.com/beetcb/ms-graph-cli#readme", 31 | "dependencies": { 32 | "fastify": "^3.18.0", 33 | "node-fetch": "^2.6.1", 34 | "open": "^7.4.2", 35 | "prompts": "^2.4.1" 36 | }, 37 | "devDependencies": { 38 | "esbuild": "^0.12.22" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { writeFileSync } from 'fs' 3 | import { EOL } from 'os' 4 | import { resolve } from 'path' 5 | 6 | import fetch from 'node-fetch' 7 | import open from 'open' 8 | import prompts from 'prompts' 9 | 10 | import json from '../hints.json' 11 | import serve from './serve' 12 | import { delTmpKeys, someUndefinedOrEmptyString } from './utils' 13 | 14 | /** 15 | * Don't wanna introduce typescript because json produces a dynamic type 16 | * @typedef {typeof json} StepsWithHint 17 | * @typedef {StepsWithHint[keyof StepsWithHint]} Hints 18 | * @typedef {'en' | 'cn'} Lang 19 | */ 20 | 21 | /** 22 | * @type {StepsWithHint} 23 | */ 24 | const steps = json 25 | 26 | const headers = { 27 | 'content-type': 'application/x-www-form-urlencoded', 28 | } 29 | 30 | /** 31 | * Prompt and acquire code, returns credentials 32 | * @param {Lang} lang 33 | */ 34 | async function init(lang) { 35 | const res = await getPromptWithHints(steps.step_init, lang) 36 | const { client_id, client_secret, deploy_type, account_type, redirect_uri } = 37 | res 38 | 39 | if ( 40 | !someUndefinedOrEmptyString( 41 | client_id, 42 | client_secret, 43 | deploy_type, 44 | account_type, 45 | redirect_uri, 46 | ) 47 | ) { 48 | const auth_endpoint = `${ 49 | [ 50 | 'https://login.microsoftonline.com', 51 | 'https://login.partner.microsoftonline.cn', 52 | ][account_type] 53 | }/common/oauth2/v2.0` 54 | 55 | await open( 56 | `${auth_endpoint}/authorize?${ 57 | new URLSearchParams({ 58 | client_id, 59 | scope: deploy_type 60 | ? 'Sites.Read.All Sites.ReadWrite.All offline_access' // SharePoint 61 | : 'Files.Read.All Files.ReadWrite.All offline_access', // OneDrive 62 | response_type: 'code', 63 | }).toString() 64 | }&redirect_uri=${redirect_uri}`, 65 | ) 66 | 67 | const code = await serve(redirect_uri).catch(() => 68 | console.error('\u274c Acquire authorization_code failed!') 69 | ) 70 | 71 | const credentials = { 72 | account_type, 73 | deploy_type, 74 | code, 75 | client_id, 76 | client_secret, 77 | redirect_uri, 78 | auth_endpoint, 79 | } 80 | return credentials 81 | } 82 | } 83 | 84 | // Acquire token with credentials 85 | async function acquireToken(credentials) { 86 | const { code, client_id, client_secret, auth_endpoint, redirect_uri } = 87 | credentials 88 | 89 | if ( 90 | !someUndefinedOrEmptyString( 91 | code, 92 | client_id, 93 | client_secret, 94 | auth_endpoint, 95 | redirect_uri, 96 | ) 97 | ) { 98 | const res = await fetch(`${auth_endpoint}/token`, { 99 | method: 'POST', 100 | body: `${ 101 | new URLSearchParams({ 102 | grant_type: 'authorization_code', 103 | code, 104 | client_id, 105 | client_secret, 106 | }).toString() 107 | }&redirect_uri=${redirect_uri}`, 108 | headers, 109 | }) 110 | if (res.ok) { 111 | const data = await res.json() 112 | const { refresh_token, access_token } = data 113 | return { refresh_token, access_token } 114 | } else { 115 | console.error('\u274c Acquire token failed! ' + res.statusText) 116 | } 117 | } 118 | } 119 | 120 | async function addDriveAPI(credentials, token, lang) { 121 | const { account_type, deploy_type } = credentials 122 | const { access_token } = token 123 | if (!someUndefinedOrEmptyString(account_type, deploy_type, access_token)) { 124 | const graphAPI = [ 125 | 'https://graph.microsoft.com/v1.0', 126 | 'https://microsoftgraph.chinacloudapi.cn/v1.0', 127 | ][account_type] 128 | 129 | if (deploy_type === 1) { 130 | // SharePoint 131 | let res = await getPromptWithHints( 132 | steps.step_sharepoint_need_site_id, 133 | lang, 134 | ) 135 | 136 | if (res.need_site_id === 0) { 137 | res = await getPromptWithHints(steps.step_sharepoint_site_id, lang) 138 | 139 | console.log('Grab site-id from ms-graph') 140 | res = await fetch( 141 | `${graphAPI}/sites/${res.host_name}:${res.site_path}?$select=id`, 142 | { 143 | headers: { 144 | Authorization: `bearer ${access_token}`, 145 | }, 146 | }, 147 | ) 148 | 149 | if (res.ok) { 150 | data = await res.json() 151 | credentials.drive_api = `${graphAPI}/sites/${data.id}/drive` 152 | credentials.site_id = data.id 153 | } 154 | } 155 | } else { 156 | // Onedrive 157 | credentials.drive_api = `${graphAPI}/me/drive` 158 | } 159 | credentials.graph_api = graphAPI 160 | return credentials 161 | } 162 | } 163 | 164 | /** 165 | * @param {Hints} hints 166 | * @param {Lang} lang 167 | */ 168 | async function getPromptWithHints(hints, lang) { 169 | const promptsWithHint = hints.map((h) => { 170 | const [name, messages] = h 171 | const { 172 | prompt_type: type, 173 | prompt_text: { 174 | [lang]: [message, ...choices], 175 | }, 176 | initial, 177 | } = messages 178 | const p = { 179 | type, 180 | name, 181 | message, 182 | choices, 183 | initial, 184 | } 185 | 186 | return p 187 | }) 188 | 189 | return prompts(promptsWithHint) 190 | } 191 | 192 | ;(async () => { 193 | // Command line arguments parser 194 | const argumets = process.argv.slice(2) 195 | // Default: won't save, lang is EN 196 | let [isSave, lang, isLangSpecified] = [0, 'en', 0] 197 | 198 | argumets.forEach((e) => { 199 | switch (e) { 200 | case '-h': 201 | case '--help': { 202 | console.log(` 203 | Usage: ms-graph-cli [options] 204 | Options: 205 | -h, --help show this help message 206 | -s, --save save generated .env file in cwd 207 | -l, --lang change the prompt's language, valid values are: 'en', 'cn' 208 | `) 209 | process.exit(1) 210 | } 211 | case '-s': 212 | case '--save': { 213 | isSave = 1 214 | break 215 | } 216 | case '-l': 217 | case '--lang': { 218 | isLangSpecified = 1 219 | break 220 | } 221 | case 'en': 222 | case 'cn': { 223 | if (e === 'cn' && isLangSpecified) lang = 'cn' 224 | } 225 | } 226 | }) 227 | 228 | let token, 229 | result, 230 | credentials = await init(lang) 231 | if (credentials) { 232 | token = await acquireToken(credentials) 233 | if (token) { 234 | credentials = await addDriveAPI(credentials, token, lang) 235 | if (credentials) { 236 | delTmpKeys(credentials, ['code', 'account_type', 'deploy_type']) 237 | result = { ...credentials, ...token } 238 | } 239 | } 240 | } 241 | 242 | if (result && isSave) { 243 | writeFileSync( 244 | resolve('./.env'), 245 | Object.keys(result).reduce((env, e) => { 246 | return `${env}${e} = ${result[e]}${EOL}` 247 | }, ''), 248 | ) 249 | console.warn( 250 | lang 251 | ? '生成的验证信息已保存到 ./.env , enjoy! 🎉' 252 | : 'Saved generated credentials to ./.env , enjoy! 🎉', 253 | ) 254 | } else if (result) { 255 | console.log(result) 256 | } 257 | process.exit(1) 258 | })() 259 | -------------------------------------------------------------------------------- /src/serve.js: -------------------------------------------------------------------------------- 1 | import fastify from 'fastify' 2 | 3 | const server = fastify() 4 | 5 | export default async (url) => 6 | new Promise(async (resolve, reject) => { 7 | server.get('/', (request, reply) => { 8 | const code = request.query.code 9 | if (code) { 10 | reply 11 | .type('text/html') 12 | .send( 13 | ``, 14 | ) 15 | resolve(code) 16 | } 17 | reject() 18 | }) 19 | await server.listen(parseInt(url) || 3000) 20 | }) 21 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function delTmpKeys(credentials, keys) { 2 | keys.forEach((key) => Reflect.deleteProperty(credentials, key)) 3 | } 4 | 5 | export function someUndefinedOrEmptyString(...args) { 6 | return args.some((k) => k === undefined || k === '') 7 | } 8 | --------------------------------------------------------------------------------