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
8 changes: 7 additions & 1 deletion lib50/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"MissingToolError",
"TimeoutError",
"ConnectionError",
"RejectedHonestyPromptError"
"RejectedHonestyPromptError",
"InvalidTokenError"
]


Expand Down Expand Up @@ -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
40 changes: 39 additions & 1 deletion lib50/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import pexpect
import re
import requests
import sys
import termcolor
import termios
Expand All @@ -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"]

Expand Down Expand Up @@ -249,6 +250,19 @@ 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)
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:
Expand Down Expand Up @@ -331,6 +345,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:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
51 changes: 51 additions & 0 deletions tests/api_tests.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import unittest
from unittest import mock
import os
import sys
import contextlib
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand Down