From e2a290e30eba07aa16c12d2af4413186c9ea2751 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Tue, 20 Jan 2026 13:40:58 +0000 Subject: [PATCH 1/3] add InvalidTokenError and validate GitHub token in authentication process --- lib50/_errors.py | 8 +++++++- lib50/authentication.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/lib50/_errors.py b/lib50/_errors.py index 03ef1a7..0c45705 100644 --- a/lib50/_errors.py +++ b/lib50/_errors.py @@ -11,7 +11,8 @@ "MissingToolError", "TimeoutError", "ConnectionError", - "RejectedHonestyPromptError" + "RejectedHonestyPromptError", + "InvalidTokenError" ] @@ -110,4 +111,9 @@ class InvalidSignatureError(Error): class RejectedHonestyPromptError(Error): """A ``lib50.Error`` signalling the honesty prompt was rejected by the user.""" + pass + + +class InvalidTokenError(Error): + """A ``lib50.Error`` signalling that the GitHub token is invalid or expired.""" pass \ No newline at end of file diff --git a/lib50/authentication.py b/lib50/authentication.py index d97a550..0fe2d01 100644 --- a/lib50/authentication.py +++ b/lib50/authentication.py @@ -4,6 +4,7 @@ import os import pexpect import re +import requests import sys import termcolor import termios @@ -13,7 +14,7 @@ from . import _ from . import _api as api -from ._errors import ConnectionError, InvalidBranchError, RejectedHonestyPromptError +from ._errors import ConnectionError, InvalidBranchError, InvalidTokenError, RejectedHonestyPromptError __all__ = ["User", "authenticate", "logout"] @@ -249,6 +250,16 @@ def _authenticate_https(org, repo=None): print(termcolor.colored(prompt, color="yellow", attrs=["bold"])) logout() sys.exit(1) + + # Validate that the token is actually working + try: + _validate_github_token(password) + except InvalidTokenError: + msg = _("There seems to be an issue authenticating with your GitHub token."\ + " Please visit https://cs50.dev/restart to restart your codespace and try again.") + print(termcolor.colored(msg, color="yellow", attrs=["bold"])) + logout() + sys.exit(1) # Otherwise, get credentials from cache if possible if username is None or password is None: @@ -331,6 +342,30 @@ def _show_gh_changes_warning(): _show_gh_changes_warning.showed = True +def _validate_github_token(token): + """Validate a GitHub token by making an authenticated request to the GitHub API.""" + try: + response = requests.get( + "https://api.github.com/user", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28" + }, + timeout=10 + ) + + if response.status_code in (401, 403): + raise InvalidTokenError() + elif not response.ok: + raise ConnectionError(f"Could not validate GitHub token. Received status code: {response.status_code}") + + except requests.exceptions.Timeout: + raise ConnectionError("Connection to GitHub timed out while validating token.") + except requests.exceptions.RequestException as e: + raise ConnectionError(f"Could not connect to GitHub to validate token: {e}") + + def _prompt_username(prompt="Username: "): """Prompt the user for username.""" try: From 79f3ab7f1ba69e5ca8fbf9c7afec00ee2cdeddb0 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Tue, 20 Jan 2026 13:50:10 +0000 Subject: [PATCH 2/3] bump version to 3.2.1 in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 78c5370..e9c35c3 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,6 @@ python_requires=">= 3.10", packages=["lib50"], url="https://github.com/cs50/lib50", - version="3.2.0", + version="3.2.1", include_package_data=True ) From c016a0873dda82f564192a6bc261c84c80c52328 Mon Sep 17 00:00:00 2001 From: Rongxin Liu Date: Tue, 20 Jan 2026 13:58:44 +0000 Subject: [PATCH 3/3] implement GitHub token validation tests and handle connection errors in authentication --- lib50/authentication.py | 3 +++ tests/api_tests.py | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/lib50/authentication.py b/lib50/authentication.py index 0fe2d01..6b5ff4b 100644 --- a/lib50/authentication.py +++ b/lib50/authentication.py @@ -260,6 +260,9 @@ def _authenticate_https(org, repo=None): print(termcolor.colored(msg, color="yellow", attrs=["bold"])) logout() sys.exit(1) + except ConnectionError: + # If we can't reach GitHub to validate, proceed anyway and let it fail later if needed + pass # Otherwise, get credentials from cache if possible if username is None or password is None: diff --git a/tests/api_tests.py b/tests/api_tests.py index e6dc85a..f72f9f1 100644 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -1,4 +1,5 @@ import unittest +from unittest import mock import os import sys import contextlib @@ -11,9 +12,11 @@ import time import termcolor import pexpect +import requests import lib50._api import lib50.authentication +import lib50._errors class TestGit(unittest.TestCase): def setUp(self): @@ -236,6 +239,54 @@ def resolve_backspaces(str): self.assertEqual(resolve_backspaces(f.getvalue()).count("*"), 2) +class TestValidateGitHubToken(unittest.TestCase): + def test_valid_token(self): + """Test that a valid token (200 response) does not raise an exception.""" + with mock.patch("lib50.authentication.requests.get") as mock_get: + mock_get.return_value.status_code = 200 + mock_get.return_value.ok = True + # Should not raise + lib50.authentication._validate_github_token("valid_token") + + def test_invalid_token_401(self): + """Test that a 401 response raises InvalidTokenError.""" + with mock.patch("lib50.authentication.requests.get") as mock_get: + mock_get.return_value.status_code = 401 + mock_get.return_value.ok = False + with self.assertRaises(lib50._errors.InvalidTokenError): + lib50.authentication._validate_github_token("invalid_token") + + def test_forbidden_token_403(self): + """Test that a 403 response raises InvalidTokenError.""" + with mock.patch("lib50.authentication.requests.get") as mock_get: + mock_get.return_value.status_code = 403 + mock_get.return_value.ok = False + with self.assertRaises(lib50._errors.InvalidTokenError): + lib50.authentication._validate_github_token("forbidden_token") + + def test_other_http_error(self): + """Test that other HTTP errors (e.g., 500) raise ConnectionError.""" + with mock.patch("lib50.authentication.requests.get") as mock_get: + mock_get.return_value.status_code = 500 + mock_get.return_value.ok = False + with self.assertRaises(lib50._errors.ConnectionError): + lib50.authentication._validate_github_token("some_token") + + def test_timeout(self): + """Test that a timeout raises ConnectionError.""" + with mock.patch("lib50.authentication.requests.get") as mock_get: + mock_get.side_effect = requests.exceptions.Timeout() + with self.assertRaises(lib50._errors.ConnectionError): + lib50.authentication._validate_github_token("some_token") + + def test_request_exception(self): + """Test that a request exception raises ConnectionError.""" + with mock.patch("lib50.authentication.requests.get") as mock_get: + mock_get.side_effect = requests.exceptions.RequestException("Network error") + with self.assertRaises(lib50._errors.ConnectionError): + lib50.authentication._validate_github_token("some_token") + + class TestGetLocalSlugs(unittest.TestCase): def setUp(self): self.old_path = lib50.get_local_path()