Skip to content

Commit 043de9e

Browse files
committed
Add --token-from-gh authentication option
1 parent 4c1f21a commit 043de9e

4 files changed

Lines changed: 127 additions & 8 deletions

File tree

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
Changelog
22
=========
33

4+
Unreleased
5+
----------
6+
- Add ``--token-from-gh`` to read authentication from ``gh auth token``.
7+
8+
49
0.61.5 (2026-02-18)
510
-------------------
611
------------------------

README.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ Show the CLI help output::
3636

3737
CLI Help output::
3838

39-
github-backup [-h] [-t TOKEN_CLASSIC] [-f TOKEN_FINE] [-q] [--as-app]
40-
[-o OUTPUT_DIRECTORY] [-l LOG_LEVEL] [-i]
39+
github-backup [-h] [-t TOKEN_CLASSIC] [-f TOKEN_FINE] [--token-from-gh]
40+
[-q] [--as-app] [-o OUTPUT_DIRECTORY] [-l LOG_LEVEL] [-i]
4141
[--incremental-by-files]
4242
[--starred] [--all-starred] [--starred-skip-size-over MB]
4343
[--watched] [--followers] [--following] [--all]
@@ -71,6 +71,7 @@ CLI Help output::
7171
-f, --token-fine TOKEN_FINE
7272
fine-grained personal access token (github_pat_....),
7373
or path to token (file://...)
74+
--token-from-gh read token from GitHub CLI (gh auth token)
7475
-q, --quiet supress log messages less severe than warning, e.g.
7576
info
7677
--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
171172

172173
**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.
173174

175+
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``.
176+
174177

175178
Fine Tokens
176179
~~~~~~~~~~~

github_backup/github_backup.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ def parse_args(args=None):
167167
dest="token_fine",
168168
help="fine-grained personal access token (github_pat_....), or path to token (file://...)",
169169
) # noqa
170+
parser.add_argument(
171+
"--token-from-gh",
172+
action="store_true",
173+
dest="token_from_gh",
174+
help="read token from GitHub CLI (gh auth token)",
175+
)
170176
parser.add_argument(
171177
"-q",
172178
"--quiet",
@@ -537,17 +543,25 @@ def get_auth(args, encode=True, for_git_cli=False):
537543
raise Exception(
538544
"Fine-grained token supplied does not look like a GitHub PAT"
539545
)
540-
elif args.token_classic:
541-
if args.token_classic.startswith(FILE_URI_PREFIX):
542-
args.token_classic = read_file_contents(args.token_classic)
546+
elif args.token_classic or args.token_from_gh:
547+
token = args.token_classic
548+
if args.token_from_gh:
549+
if args.as_app:
550+
raise Exception(
551+
"--token-from-gh cannot be used with --as-app; provide the app token with --token instead"
552+
)
553+
token = read_token_from_gh_cli(args)
554+
elif token.startswith(FILE_URI_PREFIX):
555+
token = read_file_contents(token)
556+
args.token_classic = token
543557

544558
if not args.as_app:
545-
auth = args.token_classic + ":" + "x-oauth-basic"
559+
auth = token + ":" + "x-oauth-basic"
546560
else:
547561
if not for_git_cli:
548-
auth = args.token_classic
562+
auth = token
549563
else:
550-
auth = "x-access-token:" + args.token_classic
564+
auth = "x-access-token:" + token
551565

552566
if not auth:
553567
return None
@@ -580,6 +594,38 @@ def read_file_contents(file_uri):
580594
return open(file_uri[len(FILE_URI_PREFIX) :], "rt").readline().strip()
581595

582596

597+
def read_token_from_gh_cli(args):
598+
cached_token = getattr(args, "_token_from_gh_value", None)
599+
if cached_token:
600+
return cached_token
601+
602+
command = ["gh", "auth", "token"]
603+
if args.github_host:
604+
command.extend(["--hostname", get_github_host(args)])
605+
606+
try:
607+
token = subprocess.check_output(command, stderr=subprocess.PIPE).decode(
608+
"utf-8"
609+
).strip()
610+
except FileNotFoundError:
611+
raise Exception(
612+
"Unable to read token from GitHub CLI: 'gh' executable not found"
613+
)
614+
except subprocess.CalledProcessError as e:
615+
stderr = e.stderr.decode("utf-8", errors="replace").strip()
616+
if stderr:
617+
raise Exception(
618+
"Unable to read token from GitHub CLI: {0}".format(stderr)
619+
)
620+
raise Exception("Unable to read token from GitHub CLI")
621+
622+
if not token:
623+
raise Exception("Unable to read token from GitHub CLI: token was empty")
624+
625+
args._token_from_gh_value = token
626+
return token
627+
628+
583629
def get_github_repo_url(args, repository):
584630
if repository.get("is_gist"):
585631
if args.prefer_ssh:

tests/test_auth.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Tests for authentication helpers."""
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from github_backup import github_backup
8+
9+
10+
def test_token_from_gh_flag_parses():
11+
args = github_backup.parse_args(["--token-from-gh", "testuser"])
12+
assert args.token_from_gh is True
13+
14+
15+
def test_get_auth_reads_token_from_gh_cli(create_args):
16+
args = create_args(token_from_gh=True)
17+
18+
with patch(
19+
"github_backup.github_backup.subprocess.check_output",
20+
return_value=b"gho_test_token\n",
21+
) as mock_check_output:
22+
auth = github_backup.get_auth(args, encode=False)
23+
24+
assert auth == "gho_test_token:x-oauth-basic"
25+
mock_check_output.assert_called_once_with(
26+
["gh", "auth", "token"], stderr=github_backup.subprocess.PIPE
27+
)
28+
29+
30+
def test_get_auth_reads_token_from_gh_cli_for_enterprise_host(create_args):
31+
args = create_args(token_from_gh=True, github_host="ghe.example.com")
32+
33+
with patch(
34+
"github_backup.github_backup.subprocess.check_output",
35+
return_value=b"gho_enterprise_token\n",
36+
) as mock_check_output:
37+
auth = github_backup.get_auth(args, encode=False)
38+
39+
assert auth == "gho_enterprise_token:x-oauth-basic"
40+
mock_check_output.assert_called_once_with(
41+
["gh", "auth", "token", "--hostname", "ghe.example.com"],
42+
stderr=github_backup.subprocess.PIPE,
43+
)
44+
45+
46+
def test_token_from_gh_is_cached(create_args):
47+
args = create_args(token_from_gh=True)
48+
49+
with patch(
50+
"github_backup.github_backup.subprocess.check_output",
51+
return_value=b"gho_cached_token\n",
52+
) as mock_check_output:
53+
assert github_backup.get_auth(args, encode=False) == "gho_cached_token:x-oauth-basic"
54+
assert github_backup.get_auth(args, encode=False) == "gho_cached_token:x-oauth-basic"
55+
56+
mock_check_output.assert_called_once()
57+
58+
59+
def test_token_from_gh_rejects_as_app(create_args):
60+
args = create_args(token_from_gh=True, as_app=True)
61+
62+
with pytest.raises(Exception) as exc_info:
63+
github_backup.get_auth(args, encode=False)
64+
65+
assert "--token-from-gh cannot be used with --as-app" in str(exc_info.value)

0 commit comments

Comments
 (0)