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

@@ -571,12 +571,12 @@ class HeadlessGuidanceTests(unittest.TestCase):
def test_ssh_branch_when_SSH_CONNECTION_set(self) -> None:
out = self._capture({"SSH_CONNECTION": "1.2.3.4 22 5.6.7.8 22", "USER": "alice"})
self.assertIn("running inside an SSH session", out)
self.assertIn("SSH session detected", out)
def test_non_ssh_branch_when_no_ssh_env(self) -> None:
out = self._capture()
self.assertIn("if this machine is remote", out)
self.assertNotIn("running inside an SSH session", out)
self.assertIn("Remote-host case", out)
self.assertNotIn("SSH session detected", out)
class BuildAuthorizeErrorTests(unittest.TestCase):
@@ -584,7 +584,7 @@ class BuildAuthorizeErrorTests(unittest.TestCase):
BASE = "https://gitea.example.com"
CID = "ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5"
SCOPES = "openid profile email read:user read:repository write:repository"
SCOPES = "openid profile email read:user read:organization read:repository write:repository"
def _exc_different_scope(self, **overrides: str) -> fa.AuthError:
kw: dict[str, object] = {
@@ -724,6 +724,256 @@ class AuthorizeUrlIsHeadlessInvariantTests(unittest.TestCase):
params = parse_qs(urlparse(url).query)
self.assertEqual(params["scope"], [self.cfg.scopes])
def test_default_url_carries_prompt_login(self) -> None:
from urllib.parse import parse_qs, urlparse
url = fa.build_authorize_url(
self.cfg, self.endpoints, challenge="c", state="s"
)
params = parse_qs(urlparse(url).query)
self.assertEqual(params["prompt"], ["login"])
def test_force_login_prompt_false_drops_prompt_param(self) -> None:
from urllib.parse import parse_qs, urlparse
url = fa.build_authorize_url(
self.cfg,
self.endpoints,
challenge="c",
state="s",
force_login_prompt=False,
)
params = parse_qs(urlparse(url).query)
self.assertNotIn("prompt", params)
# login_hint must still ride along; the retry path keeps it so
# Gitea can pre-fill the username field if a fresh login screen
# ever does appear (e.g., expired session cookie).
self.assertEqual(params["login_hint"], [self.cfg.expected_username])
class ScopeMismatchAuthErrorTests(unittest.TestCase):
"""Contract tests for the ``ScopeMismatchAuthError`` subclass.
The "different scope" branch of ``_build_authorize_error`` must
return a ``ScopeMismatchAuthError`` so callers can drive a
revoke-and-retry recovery flow instead of swallowing the error.
The class is also a subclass of ``AuthError`` so existing
``except AuthError`` handlers (e.g., the credential helper) keep
working unchanged.
"""
BASE = "https://gitea.example.com"
CID = "ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5"
SCOPES = "openid profile email read:user read:organization read:repository write:repository"
def _build(self, **overrides):
kw = {
"error": "server_error",
"error_description": "a grant exists with different scope",
"gitea_base_url": self.BASE,
"client_id": self.CID,
"scopes": self.SCOPES,
}
kw.update(overrides)
return fa._build_authorize_error(
str(kw["error"]),
str(kw["error_description"]) if kw["error_description"] else None,
str(kw["gitea_base_url"]),
client_id=str(kw["client_id"]),
scopes=str(kw["scopes"]),
)
def test_different_scope_returns_subclass(self) -> None:
self.assertIsInstance(self._build(), fa.ScopeMismatchAuthError)
def test_subclass_inherits_from_autherror(self) -> None:
self.assertTrue(issubclass(fa.ScopeMismatchAuthError, fa.AuthError))
def test_attributes_carry_diagnostic_fields(self) -> None:
exc = self._build()
self.assertEqual(exc.gitea_base_url, self.BASE)
self.assertEqual(exc.client_id, self.CID)
self.assertEqual(exc.scopes, self.SCOPES)
def test_revoke_url_with_base(self) -> None:
exc = self._build()
self.assertEqual(
exc.revoke_url,
f"{self.BASE}/user/settings/applications",
)
def test_revoke_url_strips_trailing_slash(self) -> None:
exc = self._build(gitea_base_url=self.BASE + "/")
self.assertEqual(
exc.revoke_url,
f"{self.BASE}/user/settings/applications",
)
def test_revoke_url_without_base_uses_placeholder(self) -> None:
exc = self._build(gitea_base_url="")
self.assertEqual(
exc.revoke_url,
"<gitea-base-url>/user/settings/applications",
)
def test_access_denied_branch_is_plain_autherror(self) -> None:
# Negative case: only the scope-conflict branch upgrades to
# the subclass; access_denied stays a generic AuthError.
exc = fa._build_authorize_error(
"access_denied", "denied by user", self.BASE,
)
self.assertNotIsInstance(exc, fa.ScopeMismatchAuthError)
self.assertIsInstance(exc, fa.AuthError)
class CanPromptForRevokeTests(unittest.TestCase):
"""``_can_prompt_for_revoke`` gates the interactive retry path.
Returns False whenever the contributor cannot reasonably be asked
to press Enter: explicit opt-out via ``FORGE_AUTO_REVOKE``,
non-interactive mode via ``FORGE_SETUP_YES``, or non-TTY stdio.
"""
def setUp(self) -> None:
self._env_keys = ("FORGE_AUTO_REVOKE", "FORGE_SETUP_YES")
self._env_backup = {k: os.environ.get(k) for k in self._env_keys}
for k in self._env_keys:
os.environ.pop(k, None)
def tearDown(self) -> None:
for k, v in self._env_backup.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def _run(self, *, stderr_tty: bool = True, stdin_tty: bool = True) -> bool:
with mock.patch.object(sys.stderr, "isatty", lambda: stderr_tty), \
mock.patch.object(sys.stdin, "isatty", lambda: stdin_tty):
return fa._can_prompt_for_revoke()
def test_default_with_both_ttys_returns_true(self) -> None:
self.assertTrue(self._run())
def test_force_auto_revoke_off_disables(self) -> None:
for v in ("0", "no", "false", "FALSE", "No"):
with self.subTest(value=v):
os.environ["FORGE_AUTO_REVOKE"] = v
self.assertFalse(self._run())
os.environ.pop("FORGE_AUTO_REVOKE", None)
def test_setup_yes_disables(self) -> None:
os.environ["FORGE_SETUP_YES"] = "1"
self.assertFalse(self._run())
def test_no_stderr_tty_disables(self) -> None:
self.assertFalse(self._run(stderr_tty=False))
def test_no_stdin_tty_disables(self) -> None:
self.assertFalse(self._run(stdin_tty=False))
class CmdLoginRetryOnScopeMismatchTests(unittest.TestCase):
"""``_cmd_login`` auto-retries once after ``ScopeMismatchAuthError``
when the prompt path is enabled; otherwise exits 1 immediately.
The retry must call ``run_login`` with ``force=True`` and
``force_login_prompt=False`` so the cached live-token short-circuit
cannot mask a stale grant and Gitea can reuse the existing browser
session cookie (only consent screen on retry).
"""
def setUp(self) -> None:
# Capture stderr to keep cli_err/cli_ok/cli_info from polluting
# the test runner output for the entire class.
stderr_patch = mock.patch.object(sys, "stderr", new_callable=io.StringIO)
stderr_patch.start()
self.addCleanup(stderr_patch.stop)
self.fake_cfg = fa.ForgeAuthConfig(
gitea_base_url="https://gitea.example.com",
client_id="ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5",
redirect_uri="http://127.0.0.1:38111/callback",
)
self.fake_state = mock.Mock(username="alice")
self.scope_exc = fa.ScopeMismatchAuthError(
"boom",
gitea_base_url=self.fake_cfg.gitea_base_url,
client_id=self.fake_cfg.client_id,
scopes=self.fake_cfg.scopes,
)
def _common_patches(self, *, run_login_side_effect):
return [
mock.patch.object(
fa.ForgeAuthConfig, "from_env", return_value=self.fake_cfg
),
mock.patch.object(
fa, "run_login", side_effect=run_login_side_effect
),
mock.patch.object(
fa, "auth_store_path", return_value=Path("/tmp/dummy.json")
),
]
def _start(self, patches):
for p in patches:
p.start()
self.addCleanup(lambda: [p.stop() for p in patches])
def test_retries_when_prompt_allowed(self) -> None:
patches = self._common_patches(
run_login_side_effect=[self.scope_exc, self.fake_state]
) + [
mock.patch.object(fa, "_can_prompt_for_revoke", return_value=True),
mock.patch.object(fa, "_prompt_revoke_and_wait", return_value=True),
]
self._start(patches)
rc = fa._cmd_login([])
self.assertEqual(rc, 0)
self.assertEqual(fa.run_login.call_count, 2)
_, kwargs = fa.run_login.call_args_list[1]
self.assertTrue(kwargs.get("force"))
# The retry must drop ``prompt=login`` so Gitea reuses the
# browser session cookie established by the failed first call.
self.assertIs(kwargs.get("force_login_prompt"), False)
def test_does_not_retry_when_prompt_disabled(self) -> None:
prompt_mock = mock.Mock(return_value=True)
patches = self._common_patches(
run_login_side_effect=[self.scope_exc]
) + [
mock.patch.object(fa, "_can_prompt_for_revoke", return_value=False),
mock.patch.object(fa, "_prompt_revoke_and_wait", new=prompt_mock),
]
self._start(patches)
rc = fa._cmd_login([])
self.assertEqual(rc, 1)
self.assertEqual(fa.run_login.call_count, 1)
prompt_mock.assert_not_called()
def test_does_not_retry_when_user_aborts(self) -> None:
patches = self._common_patches(
run_login_side_effect=[self.scope_exc]
) + [
mock.patch.object(fa, "_can_prompt_for_revoke", return_value=True),
mock.patch.object(fa, "_prompt_revoke_and_wait", return_value=False),
]
self._start(patches)
rc = fa._cmd_login([])
self.assertEqual(rc, 1)
self.assertEqual(fa.run_login.call_count, 1)
def test_unrelated_autherror_propagates(self) -> None:
patches = self._common_patches(
run_login_side_effect=[fa.AuthError("unrelated")]
) + [
mock.patch.object(fa, "_can_prompt_for_revoke", return_value=True),
mock.patch.object(fa, "_prompt_revoke_and_wait", return_value=True),
]
self._start(patches)
with self.assertRaises(fa.AuthError):
fa._cmd_login([])
if __name__ == "__main__":
unittest.main()