From 6918943efbe8e6750ae9d31df1aa72bc8d187b2a Mon Sep 17 00:00:00 2001 From: Goodyslim <332204418+Goodyslim@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:27:57 +0100 Subject: [PATCH] feat(github): show granted OAuth scopes and warn when repo is missing The dashboard only showed the scopes requested at connect time. Add GitHubClient.get_granted_scopes() (reads the X-OAuth-Scopes header from GET /user) and display the scopes GitHub actually granted, falling back to the scopes captured at connect time. When the granted list is known and omits repo, show a warning banner that private repositories will not load, and expose repo_scope_missing on /github/api/status. Scopes remain informational only and are never used for authorization decisions. Closes #57 --- app/github/routes.py | 44 +++++++++++++-- app/services/github.py | 17 ++++++ app/templates/github/index.html | 14 ++++- tests/test_github_routes.py | 94 +++++++++++++++++++++++++++++++++ tests/test_github_service.py | 23 ++++++++ 5 files changed, 188 insertions(+), 4 deletions(-) diff --git a/app/github/routes.py b/app/github/routes.py index c5aae29..3d55661 100644 --- a/app/github/routes.py +++ b/app/github/routes.py @@ -69,9 +69,39 @@ @bp.route("/") @login_required def index(): - """Dashboard showing the user's GitHub connection status.""" + """Dashboard showing the user's GitHub connection status. + + Displays the scopes GitHub actually *granted* (issue #57), falling back to + the scopes captured at connect time, and warns when the ``repo`` scope is + missing (private repositories will not load). + """ account = GithubAccount.query.filter_by(user_id=current_user.id).first() - return render_template("github/index.html", account=account) + granted_scopes = _granted_scopes(account) + repo_scope_missing = bool(granted_scopes) and "repo" not in granted_scopes + return render_template( + "github/index.html", + account=account, + granted_scopes=granted_scopes, + repo_scope_missing=repo_scope_missing, + ) + + +def _granted_scopes(account: GithubAccount | None) -> list[str]: + """Return the scopes to display for the dashboard. + + Prefers a live ``GET /user`` read (the token's current grants) and falls + back to the scopes stored at connect time. Best-effort: a failed read never + breaks the dashboard, and the value is used for display only — never for an + authorization decision (issue #57). + """ + if account is None: + return [] + stored = [scope.strip() for scope in (account.scopes or "").split(",") if scope.strip()] + try: + live = _client().get_granted_scopes() + except GitHubError: + live = [] + return live or stored @bp.route("/connect") @@ -154,7 +184,13 @@ def callback(): db.session.add(account) account.github_user_id = user["id"] account.github_username = user.get("login", "") - account.scopes = token_data.get("scope", "") + # Prefer the scopes GitHub reports via the X-OAuth-Scopes header, falling + # back to the token-exchange `scope` value. Informational only (issue #57). + try: + granted = client.get_granted_scopes() + except GitHubError: + granted = [] + account.scopes = ",".join(granted) if granted else token_data.get("scope", "") account.token_type = token_data.get("token_type", "bearer") account.set_access_token(token) account.set_refresh_token(token_data.get("refresh_token")) @@ -216,6 +252,8 @@ def status(): } if account is not None: payload["rate_limit"] = _rate_limit_summary() + scopes = [scope.strip() for scope in (account.scopes or "").split(",") if scope.strip()] + payload["repo_scope_missing"] = bool(scopes) and "repo" not in scopes return jsonify(payload) diff --git a/app/services/github.py b/app/services/github.py index 7e17d20..709bfe1 100644 --- a/app/services/github.py +++ b/app/services/github.py @@ -356,6 +356,23 @@ def _get_page( def get_user(self) -> dict: return self._get("/user") + def get_granted_scopes(self) -> list[str]: + """Return the OAuth scopes GitHub actually granted this token (issue #57). + + GitHub reports the granted scopes in the ``X-OAuth-Scopes`` response + header of an authenticated request (the token exchange response also + carries them, but the header reflects the token's *current* grants). + The result is informational only: it is never used to make + authorization decisions, which always depend on the token itself. + """ + response = self.session.get(f"{self.api_url}/user", timeout=self.timeout) + if response.status_code >= 400: + # Re-issue through _request so failures become the typed errors the + # rest of the app expects (auth, rate limit, not found, ...). + self._request("GET", "/user") + raw = response.headers.get("X-OAuth-Scopes", "") + return [scope.strip() for scope in raw.split(",") if scope.strip()] + def get_rate_limit(self) -> dict | None: """Return the caller's core rate-limit budget, or ``None`` if unknown. diff --git a/app/templates/github/index.html b/app/templates/github/index.html index 9080aa3..80605d0 100644 --- a/app/templates/github/index.html +++ b/app/templates/github/index.html @@ -12,7 +12,11 @@
repo scope.
+ Private repositories will not load until you reconnect and grant it.
+ Reconnect GitHub.
+