Handle grant scope mismatches in login

Detect the Gitea different scope authorize failure as a dedicated auth
error, show the revoke URL and client ID, and retry login once after
manual grant revocation without forcing a second full authentication.

Expand the requested scope set to include read:organization, add the
revoke-grant helper path and setup auto-yes flag in the scaffold,
document the recovery flow, and cover revoke prompting and retry
behavior in forge_auth tests.
This commit is contained in:
FanaticPythoner (Nathan Trudeau)
2026-04-27 16:01:36 -04:00
parent e27c8a2bd6
commit c8b4b0ce9a
6 changed files with 458 additions and 15 deletions

View File

@@ -52,6 +52,42 @@ class AuthError(RuntimeError):
"""Any non-transient OAuth/auth-file error. Always user-visible."""
class ScopeMismatchAuthError(AuthError):
"""Gitea ``server_error`` caused by a grant/scope conflict.
Raised when Gitea refuses an authorize request because a grant
already exists for ``client_id`` under a different scope set
(RFC 6749 §4.1.2.1; ``error_description`` contains "different
scope"). Recovery: revoke the grant under
``<gitea_base_url>/user/settings/applications`` and re-run.
Subclassing ``AuthError`` keeps existing ``except AuthError``
handlers (e.g., the credential helper) unchanged. Callers that
want to drive an interactive revoke-and-retry flow check for
this concrete subclass and read the structured attributes.
"""
def __init__(
self,
message: str,
*,
gitea_base_url: str,
client_id: str,
scopes: str,
) -> None:
super().__init__(message)
self.gitea_base_url = gitea_base_url
self.client_id = client_id
self.scopes = scopes
@property
def revoke_url(self) -> str:
base = self.gitea_base_url.rstrip("/")
if not base:
return "<gitea-base-url>/user/settings/applications"
return f"{base}/user/settings/applications"
# --------------------------------------------------------------------
# CLI output helpers. Tag scheme matches scripts/common.sh:
# cyan=info, green=ok, yellow=warn, red=err. ANSI disabled when stderr
@@ -164,7 +200,7 @@ class ForgeAuthConfig:
gitea_base_url: str
client_id: str
redirect_uri: str
scopes: str = "openid profile email read:user read:repository write:repository"
scopes: str = "openid profile email read:user read:organization read:repository write:repository"
insecure_tls: bool = False # only for dev Gitea with self-signed certs
expected_username: str = "" # from FORGE_GITEA_USERNAME; empty = no check
@@ -308,9 +344,19 @@ def build_authorize_url(
*,
challenge: str,
state: str,
force_login_prompt: bool = True,
) -> str:
"""PKCE authorise URL with ``prompt=login`` (OIDC Core §3.1.2.1) and,
when ``config.expected_username`` is set, ``login_hint``.
"""PKCE authorise URL with optional ``prompt=login`` and ``login_hint``.
``prompt=login`` (OIDC Core §3.1.2.1) is the default: it forces
Gitea to re-authenticate the user even when a session cookie is
already present, which is the right ergonomic for the first attempt
of ``just login``. Setting ``force_login_prompt=False`` drops the
parameter so the second attempt of an auto-retry (after a grant
revocation) reuses the established session cookie and only triggers
the consent screen, halving the browser-side prompts. The post-auth
``expected_username`` check in ``run_login`` still enforces the
wrong-user guard on either path. ``login_hint`` is unaffected.
"""
params: dict[str, str] = {
"client_id": config.client_id,
@@ -320,8 +366,9 @@ def build_authorize_url(
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"prompt": "login",
}
if force_login_prompt:
params["prompt"] = "login"
if config.expected_username:
params["login_hint"] = config.expected_username
return f"{endpoints['authorization_endpoint']}?{urlencode(params)}"
@@ -500,13 +547,16 @@ def _build_authorize_error(
if "different scope" in low or ("scope" in low and "grant" in low):
cid = (client_id or "<unknown-client-id>").strip()
scope_fragment = scopes or "<unknown-scopes>"
return AuthError(
return ScopeMismatchAuthError(
f'Gitea server_error: "{desc or error}".\n'
f" Client ID: {cid}\n"
f" Revoke at: {settings_url} "
f"(\"Authorized OAuth2 Applications\").\n"
f" Requested: {scope_fragment}\n"
" See: docs/oauth-grant-scope-mismatch.md"
" See: docs/oauth-grant-scope-mismatch.md",
gitea_base_url=gitea_base_url,
client_id=cid,
scopes=scope_fragment,
)
if error == "access_denied" or "access_denied" in low or "denied" in low:
@@ -800,12 +850,16 @@ def run_login(
open_browser: bool = True,
force: bool = False,
print_authorize_url: bool = True,
force_login_prompt: bool = True,
) -> AuthFile:
"""Run the full PKCE flow and persist the result.
Idempotent: if the stored file already carries a live Gitea token
and ``force`` is False, skip everything and return the existing
state. The caller is the one deciding when to force a refresh.
``force_login_prompt`` is forwarded to ``build_authorize_url`` and
is set to False by the auto-retry path after a grant revocation so
Gitea reuses the existing browser session.
"""
store = auth_store_path()
existing = AuthFile.read(store)
@@ -818,7 +872,13 @@ def run_login(
nonce = secrets.token_urlsafe(24)
state = sign_state(session_key, nonce)
auth_url = build_authorize_url(config, endpoints, challenge=challenge, state=state)
auth_url = build_authorize_url(
config,
endpoints,
challenge=challenge,
state=state,
force_login_prompt=force_login_prompt,
)
redirect = urlparse(config.redirect_uri)
assert redirect.hostname and redirect.port # validated upstream
@@ -964,11 +1024,84 @@ def run_logout() -> Path | None:
# --------------------------------------------------------------------
def _can_prompt_for_revoke() -> bool:
"""Return True only when the environment is interactive enough to
block on ``input()`` and have the contributor click "Revoke".
Disabling vectors (any one of these returns False):
* ``FORGE_AUTO_REVOKE`` set to ``0``/``no``/``false``: explicit
opt-out for callers that want strict fail-fast behaviour.
* ``FORGE_SETUP_YES=1``: non-interactive auto-yes mode used by
``setup.sh`` and CI; never block on ``input()``.
* stderr or stdin is not a TTY: avoids deadlocks in piped or
backgrounded executions.
"""
val = os.environ.get("FORGE_AUTO_REVOKE", "1").strip().lower()
if val in {"0", "no", "false"}:
return False
if os.environ.get("FORGE_SETUP_YES", "0").strip() == "1":
return False
try:
return sys.stderr.isatty() and sys.stdin.isatty()
except (AttributeError, ValueError):
return False
def _prompt_revoke_and_wait(
exc: ScopeMismatchAuthError, *, open_browser: bool
) -> bool:
"""Drive the manual revoke step. Returns True iff the operator
pressed Enter (i.e., agreed to retry); False on EOF/Ctrl-C.
Side effects: prints the revoke URL and ``client_id`` to stderr;
when ``open_browser`` is True, also calls ``webbrowser.open`` on
the revoke URL (best-effort; failure is silent).
"""
cli_info(
'opening Gitea\'s "Authorized OAuth2 Applications" page so the '
"conflicting grant can be revoked."
)
cli_info(f" URL: {exc.revoke_url}")
cli_info(f" Client ID: {exc.client_id}")
if open_browser:
try:
webbrowser.open(exc.revoke_url, new=2)
except webbrowser.Error:
pass
cli_info(
'after clicking "Revoke" on the row matching the Client ID '
"above, press Enter here to retry login (or Ctrl-C to abort)."
)
try:
input("")
except (EOFError, KeyboardInterrupt):
return False
return True
def _cmd_login(argv: list[str]) -> int:
force = "--force" in argv
no_browser = "--no-browser" in argv
config = ForgeAuthConfig.from_env()
state = run_login(config, open_browser=not no_browser, force=force)
try:
state = run_login(config, open_browser=not no_browser, force=force)
except ScopeMismatchAuthError as exc:
cli_err(str(exc))
if not _can_prompt_for_revoke():
return 1
if not _prompt_revoke_and_wait(exc, open_browser=not no_browser):
return 1
# Single retry. ``force=True`` bypasses the live-token short-
# circuit; ``force_login_prompt=False`` reuses the browser
# session cookie established by the failed first attempt so
# Gitea only shows the consent screen on the retry.
state = run_login(
config,
open_browser=not no_browser,
force=True,
force_login_prompt=False,
)
cli_ok(f"authenticated as: {state.username}")
cli_info(f"auth file: {auth_store_path()}")
return 0

51
scripts/revoke_grant.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# revoke_grant.sh: open Gitea's "Authorized OAuth2 Applications" page so
# the contributor can revoke a stale OAuth grant whose scope set no
# longer matches the unified scope set requested by this scaffold and
# the orchestrator's gateway. See docs/oauth-grant-scope-mismatch.md
# for the full failure mode and recovery procedure.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck disable=SC1091
. "$here/common.sh"
load_env
require_env FORGE_GITEA_URL
require_env FSDGG_CLI_CLIENT_ID
require_cmd python3
base="${FORGE_GITEA_URL%/}"
url="${base}/user/settings/applications"
cid="${FSDGG_CLI_CLIENT_ID}"
cat <<EOF
[revoke-grant] Authorized OAuth2 Applications:
${url}
[revoke-grant] Client ID to revoke:
${cid}
Procedure:
1. The browser opens the URL above.
2. Locate the row whose Client ID matches ${cid}.
3. Press "Revoke".
4. Return here and run 'just login' (or re-run the failed recipe).
EOF
if [ "${FORGE_REVOKE_NO_BROWSER:-0}" = "1" ]; then
info "FORGE_REVOKE_NO_BROWSER=1 set; skipping browser launch."
exit 0
fi
python3 - "$url" <<'PY'
import sys, webbrowser
url = sys.argv[1]
ok = webbrowser.open(url, new=1, autoraise=True)
print(
"[revoke-grant] "
+ ("opened in browser." if ok else "no browser launched; open the URL manually.")
)
PY

View File

@@ -17,24 +17,28 @@ cd "$root"
headless=0
usage() {
cat <<'USAGE'
Usage: just setup [--headless|--no-browser]
Usage: just setup [--headless|--no-browser] [--yes|-y]
Options:
--headless Do not open the browser during login. Prints the
--no-browser authorisation URL to stderr instead; paste it into
any browser that can reach the loopback callback
port (typically via SSH port-forward, see README).
--yes, -y Auto-accept every prompt (session reuse, checkout
reuse) by setting FORGE_SETUP_YES=1 for this run.
Safe only when FORGE_GITEA_USERNAME is already set
in .env. Does not relax the --headless guard.
-h, --help Show this message.
Environment:
FORGE_SETUP_YES=1 Accept every default; do not prompt. Safe only when
FORGE_GITEA_USERNAME is already set in .env.
FORGE_SETUP_YES=1 Same as --yes; honoured even when no flag is given.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--headless|--no-browser) headless=1; shift;;
--yes|-y) export FORGE_SETUP_YES=1; shift;;
-h|--help) usage; exit 0;;
--) shift; break;;
-*) die "unknown option: $1 (try 'just setup --help')";;