├── .github
├── dependabot.yml
└── workflows
│ └── test-and-release.yml
├── .gitignore
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── example.js
├── ghauth.js
└── package.json
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: 'github-actions'
4 | directory: '/'
5 | schedule:
6 | interval: 'daily'
7 | commit-message:
8 | prefix: 'chore'
9 | include: 'scope'
10 | - package-ecosystem: 'npm'
11 | directory: '/'
12 | schedule:
13 | interval: 'daily'
14 | commit-message:
15 | prefix: 'chore'
16 | include: 'scope'
17 |
--------------------------------------------------------------------------------
/.github/workflows/test-and-release.yml:
--------------------------------------------------------------------------------
1 | name: Test & Maybe Release
2 | on: [push, pull_request]
3 | jobs:
4 | test:
5 | strategy:
6 | fail-fast: false
7 | matrix:
8 | node: [18.x, 20.x, lts/*]
9 | os: [macos-latest, ubuntu-latest, windows-latest]
10 | runs-on: ${{ matrix.os }}
11 | steps:
12 | - name: Checkout Repository
13 | uses: actions/checkout@v4.2.2
14 | - name: Use Node.js ${{ matrix.node }}
15 | uses: actions/setup-node@v4.4.0
16 | with:
17 | node-version: ${{ matrix.node }}
18 | - name: Install Dependencies
19 | run: |
20 | npm install --no-progress
21 | - name: Run tests
22 | run: |
23 | npm test
24 | release:
25 | name: Release
26 | needs: test
27 | runs-on: ubuntu-latest
28 | if: github.event_name == 'push' && github.ref == 'refs/heads/master'
29 | steps:
30 | - name: Checkout
31 | uses: actions/checkout@v4.2.2
32 | with:
33 | fetch-depth: 0
34 | - name: Setup Node.js
35 | uses: actions/setup-node@v4.4.0
36 | with:
37 | node-version: lts/*
38 | - name: Install dependencies
39 | run: |
40 | npm install --no-progress --no-package-lock --no-save
41 | - name: Build
42 | run: |
43 | npm run build
44 | - name: Install plugins
45 | run: |
46 | npm install \
47 | @semantic-release/commit-analyzer \
48 | conventional-changelog-conventionalcommits \
49 | @semantic-release/release-notes-generator \
50 | @semantic-release/npm \
51 | @semantic-release/github \
52 | @semantic-release/git \
53 | @semantic-release/changelog \
54 | --no-progress --no-package-lock --no-save
55 | - name: Release
56 | env:
57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
59 | run: npx semantic-release
60 |
61 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [6.0.13](https://github.com/rvagg/ghauth/compare/v6.0.12...v6.0.13) (2025-04-15)
2 |
3 | ### Trivial Changes
4 |
5 | * **deps:** bump actions/setup-node from 4.3.0 to 4.4.0 ([7739438](https://github.com/rvagg/ghauth/commit/773943882172658cc3bd35b97ebd44d83be2a3ba))
6 |
7 | ## [6.0.12](https://github.com/rvagg/ghauth/compare/v6.0.11...v6.0.12) (2025-03-18)
8 |
9 | ### Trivial Changes
10 |
11 | * **deps:** bump actions/setup-node from 4.2.0 to 4.3.0 ([4f4514f](https://github.com/rvagg/ghauth/commit/4f4514ff12545a2920075d26be7bb22bb2d46d15))
12 |
13 | ## [6.0.11](https://github.com/rvagg/ghauth/compare/v6.0.10...v6.0.11) (2025-01-28)
14 |
15 | ### Trivial Changes
16 |
17 | * **deps:** bump actions/setup-node from 4.1.0 to 4.2.0 ([139761a](https://github.com/rvagg/ghauth/commit/139761a4e70479727568823d6385bfa1c6da4798))
18 |
19 | ## [6.0.10](https://github.com/rvagg/ghauth/compare/v6.0.9...v6.0.10) (2024-10-25)
20 |
21 | ### Trivial Changes
22 |
23 | * **deps:** bump actions/setup-node from 4.0.4 to 4.1.0 ([fa150fe](https://github.com/rvagg/ghauth/commit/fa150fe06f51c6cdc8552d657b633678fb04fe9f))
24 |
25 | ## [6.0.9](https://github.com/rvagg/ghauth/compare/v6.0.8...v6.0.9) (2024-10-24)
26 |
27 | ### Trivial Changes
28 |
29 | * **deps:** bump actions/checkout from 4.2.1 to 4.2.2 ([78642f5](https://github.com/rvagg/ghauth/commit/78642f52b07934f85900333a66bac8b3bb6ff729))
30 |
31 | ## [6.0.8](https://github.com/rvagg/ghauth/compare/v6.0.7...v6.0.8) (2024-10-08)
32 |
33 | ### Trivial Changes
34 |
35 | * **deps:** bump actions/checkout from 4.2.0 to 4.2.1 ([951c167](https://github.com/rvagg/ghauth/commit/951c167790536c9f867bbd7d6b43928b90d8181d))
36 |
37 | ## [6.0.7](https://github.com/rvagg/ghauth/compare/v6.0.6...v6.0.7) (2024-09-26)
38 |
39 | ### Trivial Changes
40 |
41 | * **deps:** bump actions/checkout from 4.1.7 to 4.2.0 ([01e5dd7](https://github.com/rvagg/ghauth/commit/01e5dd78374ad9d9fdaa77754f71f6d91e6194cd))
42 | * **deps:** bump actions/setup-node from 4.0.3 to 4.0.4 ([916a7a8](https://github.com/rvagg/ghauth/commit/916a7a8e1060208102c187f2597327daa06831d0))
43 |
44 | ## [6.0.6](https://github.com/rvagg/ghauth/compare/v6.0.5...v6.0.6) (2024-07-10)
45 |
46 | ### Trivial Changes
47 |
48 | * **deps:** bump actions/setup-node from 4.0.1 to 4.0.3 ([74bf512](https://github.com/rvagg/ghauth/commit/74bf512f94fa1ab97428d03831a731da13d5505a))
49 |
50 | ## [6.0.5](https://github.com/rvagg/ghauth/compare/v6.0.4...v6.0.5) (2024-06-13)
51 |
52 | ### Trivial Changes
53 |
54 | * **deps:** bump actions/checkout from 4.1.4 to 4.1.5 ([16a6f0a](https://github.com/rvagg/ghauth/commit/16a6f0aadcd8e01a2b19b381a00f173fd3e7a902))
55 | * **deps:** bump actions/checkout from 4.1.5 to 4.1.6 ([183ac16](https://github.com/rvagg/ghauth/commit/183ac161d2db4b097b16700483787fc6f3b70515))
56 | * **deps:** bump actions/checkout from 4.1.6 to 4.1.7 ([000acd5](https://github.com/rvagg/ghauth/commit/000acd52f12eac6973c787bec21fd3f46e538e15))
57 |
58 | ## [6.0.4](https://github.com/rvagg/ghauth/compare/v6.0.3...v6.0.4) (2024-04-29)
59 |
60 |
61 | ### Trivial Changes
62 |
63 | * **deps:** bump actions/checkout from 4.1.3 to 4.1.4 ([e221dbc](https://github.com/rvagg/ghauth/commit/e221dbc58c90e3f2fa536a37f5c231144019d314))
64 |
65 | ## [6.0.3](https://github.com/rvagg/ghauth/compare/v6.0.2...v6.0.3) (2024-04-19)
66 |
67 |
68 | ### Trivial Changes
69 |
70 | * **deps:** bump actions/checkout from 4.1.2 to 4.1.3 ([99e0af6](https://github.com/rvagg/ghauth/commit/99e0af6caa21c9b5e10edd22ec964c889cf3cf7d))
71 |
72 | ## [6.0.2](https://github.com/rvagg/ghauth/compare/v6.0.1...v6.0.2) (2024-03-13)
73 |
74 |
75 | ### Trivial Changes
76 |
77 | * **deps:** bump actions/checkout from 4.1.1 to 4.1.2 ([86a12f7](https://github.com/rvagg/ghauth/commit/86a12f71e6429475bec57971e7af82b1728a33d7))
78 |
79 | ## [6.0.1](https://github.com/rvagg/ghauth/compare/v6.0.0...v6.0.1) (2024-01-01)
80 |
81 |
82 | ### Trivial Changes
83 |
84 | * **deps:** bump actions/setup-node from 4.0.0 to 4.0.1 ([753fd0c](https://github.com/rvagg/ghauth/commit/753fd0cadc1fb0a8b7e0c827017d63d82154a836))
85 |
86 | ## [6.0.0](https://github.com/rvagg/ghauth/compare/v5.0.2...v6.0.0) (2023-12-08)
87 |
88 |
89 | ### ⚠ BREAKING CHANGES
90 |
91 | * Node.js 18+ is required
92 |
93 | ### Bug Fixes
94 |
95 | * replace `node-fetch` with built-in `fetch` ([#31](https://github.com/rvagg/ghauth/issues/31)) ([a39c686](https://github.com/rvagg/ghauth/commit/a39c686a2c95d81f7f7b54c9d226f3916922af88))
96 |
97 | ## [5.0.2](https://github.com/rvagg/ghauth/compare/v5.0.1...v5.0.2) (2023-12-08)
98 |
99 |
100 | ### Bug Fixes
101 |
102 | * add github actions, auto release & dependabot ([66a73ac](https://github.com/rvagg/ghauth/commit/66a73aceb69bc2b53fcc2f69637ac3a0de9af998))
103 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # ghauth is an OPEN Open Source Project
2 |
3 | -----------------------------------------
4 |
5 | ## What?
6 |
7 | Individuals making significant and valuable contributions are given commit-access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project.
8 |
9 | ## Rules
10 |
11 | There are a few basic ground-rules for contributors:
12 |
13 | 1. **No `--force` pushes** or modifying the Git history in any way.
14 | 1. **Non-master branches** ought to be used for ongoing work.
15 | 1. **External API changes and significant modifications** ought to be subject to an **internal pull-request** to solicit feedback from other contributors.
16 | 1. Internal pull-requests to solicit feedback are *encouraged* for any other non-trivial contribution but left to the discretion of the contributor.
17 | 1. Contributors should attempt to adhere to the prevailing code-style.
18 |
19 | ## Releases
20 |
21 | Declaring formal releases remains the prerogative of the project maintainer.
22 |
23 | ## Changes to this arrangement
24 |
25 | This is an experiment and feedback is welcome! This document may also be subject to pull-requests or changes by contributors where you believe you have something valuable to add or change.
26 |
27 | -----------------------------------------
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2014, Rod Vagg (the "Original Author")
2 | All rights reserved.
3 |
4 | MIT +no-false-attribs License
5 |
6 | Permission is hereby granted, free of charge, to any person
7 | obtaining a copy of this software and associated documentation
8 | files (the "Software"), to deal in the Software without
9 | restriction, including without limitation the rights to use,
10 | copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the
12 | Software is furnished to do so, subject to the following
13 | conditions:
14 |
15 | The above copyright notice and this permission notice shall be
16 | included in all copies or substantial portions of the Software.
17 |
18 | Distributions of all or part of the Software intended to be used
19 | by the recipients as they would use the unmodified Software,
20 | containing modifications that substantially alter, remove, or
21 | disable functionality of the Software, outside of the documented
22 | configuration mechanisms provided by the Software, shall be
23 | modified such that the Original Author's bug reporting email
24 | addresses and urls are either replaced with the contact information
25 | of the parties responsible for the changes, or removed entirely.
26 |
27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
28 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
29 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
30 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
31 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
32 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
33 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
34 | OTHER DEALINGS IN THE SOFTWARE.
35 |
36 |
37 | Except where noted, this license applies to any and all software
38 | programs and associated documentation files created by the
39 | Original Author, when distributed with the Software.
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ghauth
2 |
3 | **Create and load persistent GitHub authentication tokens for command-line apps**
4 |
5 | [](https://nodei.co/npm/ghauth/)
6 |
7 | **Important**
8 |
9 | Github deprecated their basic username/password auth api and have [scheduled to sunset it November 13, 2020](https://developer.github.com/changes/2020-02-14-deprecating-oauth-auth-endpoint/). `ghauth` v5.0.0+ supports the new [device auth flow](https://docs.github.com/en/developers/apps/authorizing-oauth-apps#device-flow) but requires some implementation changes and application registration with Github. Review the new API docs and see [Setup](#setup) for a simple upgrade guide between v4 and v5.
10 |
11 | ## Example usage
12 |
13 | ```js
14 | const ghauth = require('ghauth')
15 | const authOptions = {
16 | // provide a clientId from a Github oAuth application registration
17 | clientId: '123456',
18 |
19 | // awesome.json within the user's config directory will store the token
20 | configName: 'awesome',
21 |
22 | // (optional) whatever GitHub auth scopes you require
23 | scopes: [ 'user' ],
24 |
25 | // (optional)
26 | userAgent: 'My Awesome App'
27 | }
28 |
29 | const authData = await ghauth(authOptions)
30 | console.log(authData)
31 |
32 | // can also be run with a callback as:
33 | //
34 | // ghauth(authOptions, function (err, authData) {
35 | // console.log(authData)
36 | // })
37 |
38 | ```
39 |
40 | Will run something like this:
41 |
42 | ```console
43 | $ node awesome.js
44 | Authorize with GitHub by opening this URL in a browser:
45 |
46 | https://github.com/login/device
47 |
48 | and enter the following User Code:
49 | (or press ⏎ to enter a personal access token)
50 |
51 | ✔ Device flow complete. Manage at https://github.com/settings/connections/applications/123456
52 | ✔ Authorized for rvagg
53 | Wrote access token to "~/.config/awesome/config.json"
54 | {
55 | token: '24d5dee258c64aef38a66c0c5eca459c379901c2',
56 | user: 'rvagg'
57 | }
58 | ```
59 |
60 | Because the token is persisted, the next time you run it there will be no prompts:
61 |
62 | ```console
63 | $ node awesome.js
64 |
65 | { user: 'rvagg',
66 | token: '24d5dee258c64aef38a66c0c5eca459c379901c2' }
67 | ```
68 |
69 | When `authUrl` is configured for a Github enterprise endpoint, it will look more like this:
70 |
71 | ```console
72 | $ node awesome.js
73 |
74 | GitHub username: rvagg
75 | GitHub password: ✔✔✔✔✔✔✔✔✔✔✔✔
76 | GitHub OTP (optional): 669684
77 |
78 | { user: 'rvagg',
79 | token: '24d5dee258c64aef38a66c0c5eca459c379901c2' }
80 | ```
81 |
82 | ## API
83 |
84 | ghauth(options, callback)
85 |
86 | The options
argument can have the following properties:
87 |
88 | * `clientId` (String, required unless `noDeviceFlow` is `true`): the clientId of your oAuth application on Github. See [setup](#setup) below for more info on creating a Github oAuth application.
89 | * `configName` (String, required unless `noSave` is `true`): the name of the config you are creating, this is required for saving a `.json` file into the users config directory with the token created. Note that the **config directory is determined by [application-config](https://github.com/LinusU/node-application-config) and is OS-specific.**
90 | * `noSave` (Boolean, optional): if you don't want to persist the token to disk, set this to `true` but be aware that you will still be creating a saved token on GitHub that will need cleaning up if you are not persisting the token.
91 | * `authUrl` (String, optional): defaults to `null` since public Github no longer supports basic auth. Setting `authUrl` will allow you to perform basic authentication with a Github Enterprise instance. This setting is ignored if the `host` of the url is `api.github.com` or `github.com`.
92 | * `promptName` (String, optional): defaults to `'GitHub Enterprise'`, change this if you are prompting for GHE credentials. Not used for public GH authentication.
93 | * `scopes` (Array, optional): defaults to `[]`, consult the GitHub [scopes](https://developer.github.com/v3/oauth/#scopes) documentation to see what you may need for your application.
94 | * `note` (String, optional): defaults to `'Node.js command-line app with ghauth'`, override if you want to save a custom note with the GitHub token (user-visible). Only used with GHE basic authentication.
95 | * `userAgent` (String, optional): defaults to `'Magic Node.js application that does magic things with ghauth'`, only used for requests to GitHub, override if you have a good reason to do so.
96 | * `passwordReplaceChar` (String, optional): defaults to `'✔'`, the character echoed when the user inputs their password. Can be set to `''` to silence the output.
97 | * `noDeviceFlow` (Boolean, optional): disable the Device Flow authentication method. This will prompt users for a personal access token immediately if no existing configuration is found. Only applies when `authUrl` is not used.
98 |
99 | The callback
will be called with either an `Error` object describing what went wrong, or a `data` object as the second argument if the auth creation (or cache read) was successful. The shape of the second argument is `{ user:String, token:String }`.
100 |
101 | ## Setup
102 |
103 | Github requires a `clientId` from a Github oAuth Application in order to complete oAuth device flow authentication.
104 |
105 | 1. Register an "oAuth Application" with Github:
106 | - [Personal Account oAuth apps page](https://github.com/settings/developers)
107 | - `https://github.com/organizations/${org_name}/settings/applications`: Organization oAuth settings page.
108 | 2. Provide an application name, homepage URL and callback URL. You can make these two URLs the same, since your app will not be using a callback URL with the device flow.
109 | 3. Go to your oAuth application's settings page and take note of the "Client ID" (this will get passed as `clientId` to `ghauth`). You can ignore the "Client Secret" value. It is not used.
110 |
111 | The `clientId` is registered by the developer of the tool or CLI, and is baked into the code of your program. Users do not need to set this up, onyl the publisher of the app.
112 |
113 | - [Device flow docs](https://docs.github.com/en/developers/apps/authorizing-oauth-apps#device-flow)
114 |
115 | ### v4 to v5 Upgrade guide
116 |
117 | - A `options.clientId` is required to use device flow. Set up an oAuth application to get a `clientId`.
118 | - the `options.authUrl` now only applies to GitHub enterprise authentication which still only supports basic auth. Only pass this if you intend for GitHub Enterpise authentication.
119 | - `options.note` is only used for GHE basic auth now. Your oAuth application details serve the purpose of token note.
120 | - `options.noDeviceFlow` is available to skip the device flow if you are unable to create a `clientId` for some reason, and wish to skip to the personal access token input prompt immediately.
121 |
122 | ## Contributing
123 |
124 | ghauth is an **OPEN Open Source Project**. This means that:
125 |
126 | > Individuals making significant and valuable contributions are given commit-access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project.
127 |
128 | See the [CONTRIBUTING.md](https://github.com/rvagg/ghauth/blob/master/CONTRIBUTING.md) file for more details.
129 |
130 | ### A note about tests
131 |
132 | ... there are no proper tests yet unfortunately. If you would like to contribute some that would be very helpful! We need to mock the GitHub API to properly test the functionality. Otherwise, testing of this library is done by its use downstream.
133 |
134 | ### Contributors
135 |
136 | ghauth is made possible by the excellent work of the following contributors:
137 |
138 |
144 |
145 | License & copyright
146 | -----------------------
147 |
148 | Copyright (c) 2014 ghauth contributors (listed above).
149 |
150 | ghauth is licensed under the MIT license. All rights not explicitly granted in the MIT license are reserved. See the included LICENSE.md file for more details.
151 |
--------------------------------------------------------------------------------
/example.js:
--------------------------------------------------------------------------------
1 | const ghauth = require('./')
2 | const authOptions = {
3 | // provide a clientId from a Github oAuth application registration
4 | clientId: '123456',
5 |
6 | // ~/.config/awesome.json will store the token
7 | configName: 'awesome',
8 |
9 | // (optional) whatever GitHub auth scopes you require
10 | scopes: ['user'],
11 |
12 | // (optional)
13 | userAgent: 'My Awesome App'
14 | }
15 |
16 | ghauth(authOptions, function (err, authData) {
17 | if (err) throw err
18 | console.log(authData)
19 | })
20 |
--------------------------------------------------------------------------------
/ghauth.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { promisify } = require('util')
4 | const read = promisify(require('read'))
5 | const appCfg = require('application-config')
6 | const querystring = require('querystring')
7 | const ora = require('ora')
8 |
9 | const defaultUA = 'Magic Node.js application that does magic things with ghauth'
10 | const defaultScopes = []
11 | const defaultPasswordReplaceChar = '\u2714'
12 |
13 | // split a string at roughly `len` characters, being careful of word boundaries
14 | function newlineify (len, str) {
15 | let s = ''
16 | let l = 0
17 | const sa = str.split(' ')
18 |
19 | while (sa.length) {
20 | if (l + sa[0].length > len) {
21 | s += '\n'
22 | l = 0
23 | } else {
24 | s += ' '
25 | }
26 | s += sa[0]
27 | l += sa[0].length
28 | sa.splice(0, 1)
29 | }
30 |
31 | return s
32 | }
33 |
34 | function sleep (s) {
35 | const ms = s * 1000
36 | return new Promise(resolve => setTimeout(resolve, ms))
37 | }
38 |
39 | function basicAuthHeader (user, pass) {
40 | return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`
41 | }
42 |
43 | // prompt the user for credentials
44 | async function deviceFlowPrompt (options) {
45 | const scopes = options.scopes || defaultScopes
46 | const passwordReplaceChar = options.passwordReplaceChar || defaultPasswordReplaceChar
47 | const deviceCodeUrl = 'https://github.com/login/device/code'
48 | const fallbackDeviceAuthUrl = 'https://github.com/login/device'
49 | const accessTokenUrl = 'https://github.com/login/oauth/access_token'
50 | const oauthAppsBaseUrl = 'https://github.com/settings/connections/applications'
51 | const userEndpointUrl = 'https://api.github.com/user'
52 | const patUrl = 'https://github.com/settings/tokens'
53 |
54 | const defaultReqOptions = {
55 | headers: {
56 | 'User-Agent': options.userAgent || defaultUA,
57 | Accept: 'application/json'
58 | },
59 | method: 'post'
60 | }
61 |
62 | // get token data from device flow, or interrupt to try PAT flow
63 | const deviceFlowSpinner = ora()
64 | let endDeviceFlow = false // race status indicator for deviceFlowInterrupt and deviceFlow
65 | let interruptHandlerRef // listener reference for deviceFlowInterrupt
66 | let tokenData
67 |
68 | if (!options.noDeviceFlow) {
69 | tokenData = await Promise.race([deviceFlow(), deviceFlowInterrupt()])
70 | process.stdin.off('keypress', interruptHandlerRef) // disable keypress listener when race finishes
71 |
72 | // try the PAT flow if interrupted
73 | if (tokenData === false) {
74 | deviceFlowSpinner.warn('Device flow canceled.')
75 | tokenData = await patFlow()
76 | }
77 | } else {
78 | console.log('Personal access token auth for Github.')
79 | tokenData = await patFlow()
80 | }
81 |
82 | return tokenData
83 |
84 | // prompt for a personal access token with simple validation
85 | async function patFlow () {
86 | let patMsg = `Enter a 40 character personal access token generated at ${patUrl} ` +
87 | (scopes.length ? `with the following scopes: ${scopes.join(', ')}` : '(no scopes necessary)') + '\n' +
88 | 'PAT: '
89 | patMsg = newlineify(80, patMsg)
90 | const pat = await read({ prompt: patMsg, silent: true, replace: passwordReplaceChar })
91 | if (!pat) throw new TypeError('Empty personal access token received.')
92 | if (pat.length !== 40) throw new TypeError('Personal access tokens must be 40 characters long')
93 | const tokenData = { token: pat }
94 |
95 | return supplementUserData(tokenData)
96 | }
97 |
98 | // cancel deviceFlow if user presses enter``
99 | function deviceFlowInterrupt () {
100 | return new Promise((resolve, reject) => {
101 | process.stdin.on('keypress', keyPressHandler)
102 |
103 | interruptHandlerRef = keyPressHandler
104 | function keyPressHandler (letter, key) {
105 | if (key.name === 'return') {
106 | endDeviceFlow = true
107 | resolve(false)
108 | }
109 | }
110 | })
111 | }
112 |
113 | // create a device flow session and return tokenData
114 | async function deviceFlow () {
115 | let currentInterval
116 | let currentDeviceCode
117 | let currentUserCode
118 | let verificationUri
119 |
120 | await initializeNewDeviceFlow()
121 |
122 | const authPrompt = ' Authorize with Github by opening this URL in a browser:' +
123 | '\n' +
124 | '\n' +
125 | ` ${verificationUri}` +
126 | '\n' +
127 | '\n' +
128 | ' and enter the following User Code:\n' +
129 | ' (or press ⏎ to enter a personal access token)\n'
130 |
131 | console.log(authPrompt)
132 |
133 | deviceFlowSpinner.start(`User Code: ${currentUserCode}`)
134 |
135 | const accessToken = await pollAccessToken()
136 | if (accessToken === false) return false // interrupted, don't return anything
137 |
138 | const tokenData = { token: accessToken.access_token, scope: accessToken.scope }
139 | deviceFlowSpinner.succeed(`Device flow complete. Manage at ${oauthAppsBaseUrl}/${options.clientId}`)
140 |
141 | return supplementUserData(tokenData)
142 |
143 | async function initializeNewDeviceFlow () {
144 | const deviceCode = await requestDeviceCode()
145 |
146 | if (deviceCode.error) {
147 | let error
148 | switch (deviceCode.error) {
149 | case 'Not Found': {
150 | error = new Error('Not found: is the clientId correct?')
151 | break
152 | }
153 | case 'unauthorized_client': {
154 | error = new Error(`${deviceCode.error_description} Did you enable 'Device authorization flow' for your oAuth application?`)
155 | break
156 | }
157 | default: {
158 | error = new Error(deviceCode.error_description || deviceCode.error)
159 | break
160 | }
161 | }
162 | error.data = deviceCode
163 | throw error
164 | }
165 |
166 | if (!(deviceCode.device_code || deviceCode.user_code)) {
167 | const error = new Error('No device code from GitHub!')
168 | error.data = deviceCode
169 | throw error
170 | }
171 |
172 | currentInterval = deviceCode.interval || 5
173 | verificationUri = deviceCode.verification_uri || fallbackDeviceAuthUrl
174 | currentDeviceCode = deviceCode.device_code
175 | currentUserCode = deviceCode.user_code
176 | }
177 |
178 | async function pollAccessToken () {
179 | let endDeviceFlowDetected
180 |
181 | while (!endDeviceFlowDetected) {
182 | await sleep(currentInterval)
183 | const data = await requestAccessToken(currentDeviceCode)
184 |
185 | if (data.access_token) return data
186 | if (data.error === 'authorization_pending') continue
187 | if (data.error === 'slow_down') currentInterval = data.interval
188 | if (data.error === 'expired_token') {
189 | deviceFlowSpinner.text('User Code: Updating...')
190 | await initializeNewDeviceFlow()
191 | deviceFlowSpinner.text(`User Code: ${currentUserCode}`)
192 | }
193 | if (data.error === 'unsupported_grant_type') throw new Error(data.error_description || 'Incorrect grant type.')
194 | if (data.error === 'incorrect_client_credentials') throw new Error(data.error_description || 'Incorrect clientId.')
195 | if (data.error === 'incorrect_device_code') throw new Error(data.error_description || 'Incorrect device code.')
196 | if (data.error === 'access_denied') throw new Error(data.error_description || 'The authorized user canceled the access request.')
197 | endDeviceFlowDetected = endDeviceFlow // update inner interrupt scope
198 | }
199 |
200 | // interrupted
201 | return false
202 | }
203 | }
204 |
205 | function requestAccessToken (deviceCode) {
206 | const query = {
207 | client_id: options.clientId,
208 | device_code: deviceCode,
209 | grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
210 | }
211 |
212 | return fetch(`${accessTokenUrl}?${querystring.stringify(query)}`, defaultReqOptions).then(req => req.json())
213 | }
214 |
215 | function requestDeviceCode () {
216 | const query = {
217 | client_id: options.clientId
218 | }
219 | if (scopes.length) query.scope = scopes.join(' ')
220 |
221 | return fetch(`${deviceCodeUrl}?${querystring.stringify(query)}`, defaultReqOptions).then(req => req.json())
222 | }
223 |
224 | function requestUser (token) {
225 | const reqOptions = {
226 | headers: {
227 | 'User-Agent': options.userAgent || defaultUA,
228 | Accept: 'application/vnd.github.v3+json',
229 | Authorization: `token ${token}`
230 | },
231 | method: 'get'
232 | }
233 |
234 | return fetch(userEndpointUrl, reqOptions).then(req => req.json())
235 | }
236 |
237 | async function supplementUserData (tokenData) {
238 | // Get user login info
239 | const userSpinner = ora().start('Retrieving user...')
240 | try {
241 | const user = await requestUser(tokenData.token)
242 | if (!user || !user.login) {
243 | userSpinner.fail('Failed to retrieve user info.')
244 | } else {
245 | userSpinner.succeed(`Authorized for ${user.login}`)
246 | }
247 | tokenData.user = user.login
248 | } catch (e) {
249 | userSpinner.fail(`Failed to retrieve user info: ${e.message}`)
250 | }
251 |
252 | return tokenData
253 | }
254 | }
255 |
256 | // prompt the user for credentials
257 | async function enterprisePrompt (options) {
258 | const defaultNote = 'Node.js command-line app with ghauth'
259 | const promptName = options.promptName || 'Github Enterprise'
260 | const accessTokenUrl = options.accessTokenUrl
261 | const scopes = options.scopes || defaultScopes
262 | const usernamePrompt = options.usernamePrompt || `Your ${promptName} username:`
263 | const tokenQuestionPrompt = options.tokenQuestionPrompt || 'This appears to be a personal access token, is that correct? [y/n] '
264 | const passwordReplaceChar = options.passwordReplaceChar || defaultPasswordReplaceChar
265 | const authUrl = options.authUrl || 'https://api.github.com/authorizations'
266 | let passwordPrompt = options.passwordPrompt
267 |
268 | if (!passwordPrompt) {
269 | let patMsg = `You may either enter your ${promptName} password or use a 40 character personal access token generated at ${accessTokenUrl} ` +
270 | (scopes.length ? `with the following scopes: ${scopes.join(', ')}` : '(no scopes necessary)')
271 | patMsg = newlineify(80, patMsg)
272 | passwordPrompt = `${patMsg}\nYour ${promptName} password:`
273 | }
274 |
275 | // username
276 |
277 | const user = await read({ prompt: usernamePrompt })
278 | if (user === '') {
279 | return
280 | }
281 |
282 | // password || token
283 |
284 | const pass = await read({ prompt: passwordPrompt, silent: true, replace: passwordReplaceChar })
285 |
286 | if (pass.length === 40) {
287 | // might be a token?
288 | do {
289 | const yorn = await read({ prompt: tokenQuestionPrompt })
290 |
291 | if (yorn.toLowerCase() === 'y') {
292 | // a token, apparently we have everything
293 | return { user, token: pass }
294 | }
295 |
296 | if (yorn.toLowerCase() === 'n') {
297 | break
298 | }
299 | } while (true)
300 | }
301 |
302 | // username + password
303 | // check for 2FA, this may trigger an SMS if the user set it up that way
304 | const otpReqOptions = {
305 | headers: {
306 | 'User-Agent': options.userAgent || defaultUA,
307 | Authorization: basicAuthHeader(user, pass)
308 | },
309 | method: 'POST'
310 | }
311 |
312 | const response = await fetch(authUrl, otpReqOptions)
313 | const otpHeader = response.headers.get('x-github-otp')
314 | response.arrayBuffer() // exaust response body
315 |
316 | let otp
317 | if (otpHeader && otpHeader.indexOf('required') > -1) {
318 | otp = await read({ prompt: 'Your GitHub OTP/2FA Code (required):' })
319 | }
320 |
321 | const currentDate = new Date().toJSON()
322 | const patReqOptions = {
323 | headers: {
324 | 'User-Agent': options.userAgent || defaultUA,
325 | 'Content-type': 'application/json',
326 | Authorization: basicAuthHeader(user, pass)
327 | },
328 | method: 'POST',
329 | body: JSON.stringify({
330 | scopes,
331 | note: `${(options.note || defaultNote)} (${currentDate})`
332 | })
333 | }
334 | if (otp) patReqOptions.headers['X-GitHub-OTP'] = otp
335 |
336 | const data = await fetch(authUrl, patReqOptions).then(res => res.json())
337 |
338 | if (data.message) {
339 | const error = new Error(data.message)
340 | error.data = data
341 | throw error
342 | }
343 |
344 | if (!data.token) {
345 | throw new Error('No token from GitHub!')
346 | }
347 |
348 | return { user, token: data.token, scope: scopes.join(' ') }
349 | }
350 |
351 | function isEnterprise (authUrl) {
352 | if (!authUrl) return false
353 | const parsedAuthUrl = new URL(authUrl)
354 | if (parsedAuthUrl.host === 'github.com') return false
355 | if (parsedAuthUrl.host === 'api.github.com') return false
356 | return true
357 | }
358 |
359 | async function auth (options) {
360 | if (typeof options !== 'object') {
361 | throw new TypeError('ghauth requires an options argument')
362 | }
363 |
364 | let config
365 |
366 | if (!options.noSave) {
367 | if (typeof options.configName !== 'string') {
368 | throw new TypeError('ghauth requires an options.configName property')
369 | }
370 |
371 | config = appCfg(options.configName)
372 | const authData = await config.read()
373 | if (authData && authData.user && authData.token) {
374 | // we had it saved in a config file
375 | return authData
376 | }
377 | }
378 |
379 | let tokenData
380 | if (!isEnterprise(options.authUrl)) {
381 | if (typeof options.clientId !== 'string' && !options.noDeviceFlow) {
382 | throw new TypeError('ghauth requires an options.clientId property')
383 | }
384 |
385 | tokenData = await deviceFlowPrompt(options) // prompt the user for data
386 | } else {
387 | tokenData = await enterprisePrompt(options) // prompt the user for data
388 | }
389 | if (!(tokenData || tokenData.token || tokenData.user)) throw new Error('Authentication error: token or user not generated')
390 |
391 | if (options.noSave) {
392 | return tokenData
393 | }
394 |
395 | process.umask(0o077)
396 | await config.write(tokenData)
397 |
398 | process.stdout.write(`Wrote access token to "${config.filePath}"\n`)
399 |
400 | return tokenData
401 | }
402 |
403 | module.exports = function ghauth (options, callback) {
404 | if (typeof callback !== 'function') {
405 | return auth(options) // promise, it can be awaited
406 | }
407 |
408 | auth(options).then((data) => callback(null, data)).catch(callback)
409 | }
410 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ghauth",
3 | "version": "6.0.13",
4 | "description": "Create and load persistent GitHub authentication tokens for command-line apps",
5 | "main": "ghauth.js",
6 | "scripts": {
7 | "lint": "standard *.js",
8 | "test": "npm run lint && echo 'no tests to run'",
9 | "build": "true"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "https://github.com/rvagg/ghauth.git"
14 | },
15 | "homepage": "https://github.com/rvagg/ghauth",
16 | "authors": [
17 | "Rod Vagg (https://github.com/rvagg)",
18 | "Jeppe Nejsum Madsen (https://github.com/jeppenejsum)",
19 | "Max Ogden (https://github.com/maxogden)"
20 | ],
21 | "keywords": [
22 | "github",
23 | "auth",
24 | "frozenpizza"
25 | ],
26 | "license": "MIT",
27 | "dependencies": {
28 | "application-config": "^2.0.0",
29 | "ora": "^4.0.5",
30 | "read": "^1.0.7"
31 | },
32 | "devDependencies": {
33 | "standard": "^17.1.0"
34 | },
35 | "release": {
36 | "branches": [
37 | "master"
38 | ],
39 | "plugins": [
40 | [
41 | "@semantic-release/commit-analyzer",
42 | {
43 | "preset": "conventionalcommits",
44 | "releaseRules": [
45 | {
46 | "breaking": true,
47 | "release": "major"
48 | },
49 | {
50 | "revert": true,
51 | "release": "patch"
52 | },
53 | {
54 | "type": "feat",
55 | "release": "minor"
56 | },
57 | {
58 | "type": "fix",
59 | "release": "patch"
60 | },
61 | {
62 | "type": "chore",
63 | "release": "patch"
64 | },
65 | {
66 | "type": "docs",
67 | "release": "patch"
68 | },
69 | {
70 | "type": "test",
71 | "release": "patch"
72 | },
73 | {
74 | "scope": "no-release",
75 | "release": false
76 | }
77 | ]
78 | }
79 | ],
80 | [
81 | "@semantic-release/release-notes-generator",
82 | {
83 | "preset": "conventionalcommits",
84 | "presetConfig": {
85 | "types": [
86 | {
87 | "type": "feat",
88 | "section": "Features"
89 | },
90 | {
91 | "type": "fix",
92 | "section": "Bug Fixes"
93 | },
94 | {
95 | "type": "chore",
96 | "section": "Trivial Changes"
97 | },
98 | {
99 | "type": "docs",
100 | "section": "Trivial Changes"
101 | },
102 | {
103 | "type": "test",
104 | "section": "Tests"
105 | }
106 | ]
107 | }
108 | }
109 | ],
110 | "@semantic-release/changelog",
111 | "@semantic-release/npm",
112 | "@semantic-release/github",
113 | "@semantic-release/git"
114 | ]
115 | }
116 | }
117 |
--------------------------------------------------------------------------------