10. Build the Dev GitHub Tool
The separate scripts helped you inspect each OAuth step. Now you'll combine the same pieces into one command-line tool: open the browser, wait for GitHub's callback, validate state, exchange the code with PKCE, call the API, and display useful account information.
Read the final script in passes
The complete script is longer than the earlier examples. Read it in five passes:
- Configuration: load client ID, client secret, redirect URI, and scope.
- PKCE helpers: create the verifier and challenge.
- OAuth flow: open the browser, catch the callback, validate state, exchange code.
- GitHub API helpers: send bearer-token requests and handle errors.
- Display: print the authenticated profile.
The complete tool
Save this as dev_github_tool.py. This first complete version keeps the scope narrow with read:user. Repository features can be added after the scope discussion, with a deliberate permission upgrade.
Unlike the split scripts, this version never writes the access token to disk. It holds the token in memory for the run, uses it for the GitHub API call, and exits cleanly.
import base64
import hashlib
import os
import secrets
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlencode, urlparse
import requests
from dotenv import load_dotenv
load_dotenv()
CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI")
SCOPE = "read:user"
def callback_server_address():
parsed = urlparse(REDIRECT_URI)
host = parsed.hostname or "localhost"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return host, port
def create_code_challenge(verifier):
digest = hashlib.sha256(verifier.encode("ascii")).digest()
encoded = base64.urlsafe_b64encode(digest).decode("ascii")
return encoded.rstrip("=")
def mask_token(token):
if not token or len(token) < 12:
return "[hidden]"
return f"{token[:4]}...{token[-4:]}"
def get_access_token():
state = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(64)
code_challenge = create_code_challenge(code_verifier)
params = {
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
auth_url = "https://github.com/login/oauth/authorize?" + urlencode(params)
result = {}
class CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
result["code"] = query.get("code", [None])[0]
result["state"] = query.get("state", [None])[0]
result["error"] = query.get("error", [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h2>Return to the terminal.</h2></body></html>"
)
def log_message(self, format, *args):
pass
server = HTTPServer(callback_server_address(), CallbackHandler)
print(f"Waiting for callback on {REDIRECT_URI}")
print("Opening GitHub authorization in your browser...")
print(auth_url)
webbrowser.open(auth_url)
server.handle_request()
if result.get("error"):
raise RuntimeError(f"GitHub authorization failed: {result['error']}")
if result.get("state") != state:
raise RuntimeError("State mismatch. Aborting before token exchange.")
if not result.get("code"):
raise RuntimeError("No authorization code received.")
response = requests.post(
"https://github.com/login/oauth/access_token",
data={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"redirect_uri": REDIRECT_URI,
"code": result["code"],
"code_verifier": code_verifier,
},
headers={"Accept": "application/json"},
timeout=10,
)
response.raise_for_status()
token_data = response.json()
if "error" in token_data:
raise RuntimeError(token_data.get("error_description", token_data["error"]))
return token_data["access_token"]
def github_get(url, token):
response = requests.get(
url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
},
timeout=10,
)
response.raise_for_status()
return response.json()
def main():
if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI:
raise SystemExit("Set GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_REDIRECT_URI in .env")
token = get_access_token()
print(f"Connected with token: {mask_token(token)}")
user = github_get("https://api.github.com/user", token)
print()
print(f"Authenticated as: {user['login']}")
print(f"Name: {user.get('name') or 'Not set'}")
print(f"Public repos: {user['public_repos']}")
print("Done. Your GitHub password was never used or shared.")
if __name__ == "__main__":
main()
Unlike the split scripts, this version never writes the token to disk. It holds the access token in memory for the duration of the run and exits clean. That is closer to how a hosted application would treat a session token, and it makes the difference between learning-time persistence and production storage visible.
Adding repository features
By default, this tool only asks for read:user. Adding private repository, issue, or pull request features means first deciding which GitHub permission model fits the real app. For a learning OAuth App, that may mean requesting a repository scope. For a production GitHub integration, it may mean using a GitHub App instead.
If you deliberately upgrade the learning tool to inspect repositories, add the scope intentionally:
SCOPE = "read:user public_repo" # Public repository inspection
# Use "repo" only when the feature genuinely needs private repository access.
Then add repository display helpers. GitHub's issues endpoint includes pull requests, so filter those out when showing issues:
def show_repositories(token):
repos = github_get(
"https://api.github.com/user/repos?sort=updated&per_page=5",
token,
)
print("\nYour repositories")
for repo in repos:
visibility = "private" if repo["private"] else "public"
language = repo.get("language") or "Not specified"
print(f"- {repo['full_name']} ({visibility}, {language})")
def show_open_issues(token, repo_full_name):
items = github_get(
f"https://api.github.com/repos/{repo_full_name}/issues?state=open&per_page=20",
token,
)
issues = [item for item in items if "pull_request" not in item][:5]
if not issues:
print("No open issues.")
return
for issue in issues:
print(f"#{issue['number']} {issue['title']}")
def show_recent_pull_requests(token, repo_full_name):
pulls = github_get(
f"https://api.github.com/repos/{repo_full_name}/pulls?state=open&per_page=5",
token,
)
if not pulls:
print("No open pull requests.")
return
for pr in pulls:
print(f"#{pr['number']} {pr['title']}")
To use those helpers, call them from main() after the profile request:
show_repositories(token)
show_open_issues(token, "OWNER/REPO")
show_recent_pull_requests(token, "OWNER/REPO")
That keeps the original payoff of the Dev GitHub Tool available: profile, repositories, issues, and pull requests. The difference is that the reader now sees the permission upgrade as part of the design, not something hidden in the first authorization URL.
That is the central OAuth habit: make permissions visible as design decisions.