Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Changelog
=========

Unreleased
----------
- Add ``--token-from-gh`` to read authentication from ``gh auth token``.


0.61.5 (2026-02-18)
-------------------
------------------------
Expand Down
7 changes: 5 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -171,6 +172,8 @@ The positional argument ``USER`` specifies the user or organization account you

**Classic tokens** (``-t TOKEN``) are `slightly less secure <https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#personal-access-tokens-classic>`_ as they provide very coarse-grained permissions.

If you already authenticate with the `GitHub CLI <https://cli.github.com/>`_, 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
~~~~~~~~~~~
Expand Down
48 changes: 46 additions & 2 deletions github_backup/github_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -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)