├── .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 | [![NPM](https://nodei.co/npm/ghauth.svg)](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 | 139 | 140 | 141 | 142 | 143 |
Rod VaggGitHub/rvaggTwitter/@rvagg
Jeppe Nejsum MadsenGitHub/jeppenejsumTwitter/@nejsum
Max OgdenGitHub/maxogdenTwitter/@maxogden
Bret ComnesGitHub/bcomnesTwitter/@bcomnes
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 | --------------------------------------------------------------------------------