├── .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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------