From f4117990b29b8f50ad3c57c86c5af1f9700c1b9c Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Sun, 26 Apr 2026 13:42:14 +0200 Subject: [PATCH] Add --token-from-gh authentication option --- CHANGES.rst | 5 +++ README.rst | 7 ++-- github_backup/github_backup.py | 48 +++++++++++++++++++++++-- tests/test_auth.py | 65 ++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/test_auth.py diff --git a/CHANGES.rst b/CHANGES.rst index 6041b9e..364bd3d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Unreleased +---------- +- Add ``--token-from-gh`` to read authentication from ``gh auth token``. + + 0.61.5 (2026-02-18) ------------------- ------------------------ diff --git a/README.rst b/README.rst index cd7be1f..030f260 100644 --- a/README.rst +++ b/README.rst @@ -36,8 +36,8 @@ Show the CLI help output:: CLI Help output:: - github-backup [-h] [-t TOKEN_CLASSIC] [-f TOKEN_FINE] [-q] [--as-app] - [-o OUTPUT_DIRECTORY] [-l LOG_LEVEL] [-i] + github-backup [-h] [-t TOKEN_CLASSIC] [-f TOKEN_FINE] [--token-from-gh] + [-q] [--as-app] [-o OUTPUT_DIRECTORY] [-l LOG_LEVEL] [-i] [--incremental-by-files] [--starred] [--all-starred] [--starred-skip-size-over MB] [--watched] [--followers] [--following] [--all] @@ -71,6 +71,7 @@ CLI Help output:: -f, --token-fine TOKEN_FINE fine-grained personal access token (github_pat_....), or path to token (file://...) + --token-from-gh read token from GitHub CLI (gh auth token) -q, --quiet supress log messages less severe than warning, e.g. info --as-app authenticate as github app instead of as a user. @@ -171,6 +172,8 @@ The positional argument ``USER`` specifies the user or organization account you **Classic tokens** (``-t TOKEN``) are `slightly less secure `_ as they provide very coarse-grained permissions. +If you already authenticate with the `GitHub CLI `_, you can use ``--token-from-gh`` to read the token with ``gh auth token`` instead of passing a token directly. This avoids placing the token in shell history or process arguments. When ``--github-host`` is set, the token is read with ``gh auth token --hostname HOST``. + Fine Tokens ~~~~~~~~~~~ diff --git a/github_backup/github_backup.py b/github_backup/github_backup.py index 4d5394e..fd2fd99 100644 --- a/github_backup/github_backup.py +++ b/github_backup/github_backup.py @@ -167,6 +167,12 @@ def parse_args(args=None): dest="token_fine", help="fine-grained personal access token (github_pat_....), or path to token (file://...)", ) # noqa + parser.add_argument( + "--token-from-gh", + action="store_true", + dest="token_from_gh", + help="read token from GitHub CLI (gh auth token)", + ) parser.add_argument( "-q", "--quiet", @@ -537,8 +543,14 @@ def get_auth(args, encode=True, for_git_cli=False): raise Exception( "Fine-grained token supplied does not look like a GitHub PAT" ) - elif args.token_classic: - if args.token_classic.startswith(FILE_URI_PREFIX): + elif args.token_classic or args.token_from_gh: + if args.token_from_gh: + if args.as_app: + raise Exception( + "--token-from-gh cannot be used with --as-app; provide the app token with --token instead" + ) + args.token_classic = read_token_from_gh_cli(args) + elif args.token_classic.startswith(FILE_URI_PREFIX): args.token_classic = read_file_contents(args.token_classic) if not args.as_app: @@ -580,6 +592,38 @@ def read_file_contents(file_uri): return open(file_uri[len(FILE_URI_PREFIX) :], "rt").readline().strip() +def read_token_from_gh_cli(args): + cached_token = getattr(args, "_token_from_gh_value", None) + if cached_token: + return cached_token + + command = ["gh", "auth", "token"] + if args.github_host: + command.extend(["--hostname", get_github_host(args)]) + + try: + token = subprocess.check_output(command, stderr=subprocess.PIPE).decode( + "utf-8" + ).strip() + except FileNotFoundError: + raise Exception( + "Unable to read token from GitHub CLI: 'gh' executable not found" + ) + except subprocess.CalledProcessError as e: + stderr = e.stderr.decode("utf-8", errors="replace").strip() + if stderr: + raise Exception( + "Unable to read token from GitHub CLI: {0}".format(stderr) + ) + raise Exception("Unable to read token from GitHub CLI") + + if not token: + raise Exception("Unable to read token from GitHub CLI: token was empty") + + args._token_from_gh_value = token + return token + + def get_github_repo_url(args, repository): if repository.get("is_gist"): if args.prefer_ssh: diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..504c822 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,65 @@ +"""Tests for authentication helpers.""" + +from unittest.mock import patch + +import pytest + +from github_backup import github_backup + + +def test_token_from_gh_flag_parses(): + args = github_backup.parse_args(["--token-from-gh", "testuser"]) + assert args.token_from_gh is True + + +def test_get_auth_reads_token_from_gh_cli(create_args): + args = create_args(token_from_gh=True) + + with patch( + "github_backup.github_backup.subprocess.check_output", + return_value=b"gho_test_token\n", + ) as mock_check_output: + auth = github_backup.get_auth(args, encode=False) + + assert auth == "gho_test_token:x-oauth-basic" + mock_check_output.assert_called_once_with( + ["gh", "auth", "token"], stderr=github_backup.subprocess.PIPE + ) + + +def test_get_auth_reads_token_from_gh_cli_for_enterprise_host(create_args): + args = create_args(token_from_gh=True, github_host="ghe.example.com") + + with patch( + "github_backup.github_backup.subprocess.check_output", + return_value=b"gho_enterprise_token\n", + ) as mock_check_output: + auth = github_backup.get_auth(args, encode=False) + + assert auth == "gho_enterprise_token:x-oauth-basic" + mock_check_output.assert_called_once_with( + ["gh", "auth", "token", "--hostname", "ghe.example.com"], + stderr=github_backup.subprocess.PIPE, + ) + + +def test_token_from_gh_is_cached(create_args): + args = create_args(token_from_gh=True) + + with patch( + "github_backup.github_backup.subprocess.check_output", + return_value=b"gho_cached_token\n", + ) as mock_check_output: + assert github_backup.get_auth(args, encode=False) == "gho_cached_token:x-oauth-basic" + assert github_backup.get_auth(args, encode=False) == "gho_cached_token:x-oauth-basic" + + mock_check_output.assert_called_once() + + +def test_token_from_gh_rejects_as_app(create_args): + args = create_args(token_from_gh=True, as_app=True) + + with pytest.raises(Exception) as exc_info: + github_backup.get_auth(args, encode=False) + + assert "--token-from-gh cannot be used with --as-app" in str(exc_info.value)