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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user