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

@@ -71,6 +71,10 @@ logout:
relogin: relogin:
@bash scripts/forge_login.sh --force @bash scripts/forge_login.sh --force
# Open Gitea's "Authorized OAuth2 Applications" page to revoke a stale grant. Resolves the "different scope" failure mode (see docs/oauth-grant-scope-mismatch.md).
revoke-grant:
@bash scripts/revoke_grant.sh
# Force a token refresh (normally automatic inside the credential helper). # Force a token refresh (normally automatic inside the credential helper).
refresh: refresh:
@python3 scripts/forge_auth.py refresh --force @python3 scripts/forge_auth.py refresh --force

View File

@@ -217,6 +217,7 @@ with a browser to populate a valid refresh token before running
| Git prompts for a password on pull/push | Refresh token expired. Run `just relogin`. | | Git prompts for a password on pull/push | Refresh token expired. Run `just relogin`. |
| `just status` shows `live: False` | Run `just refresh`; also happens automatically on the next git op. | | `just status` shows `live: False` | Run `just refresh`; also happens automatically on the next git op. |
| `just clone-orchestrator` prints `already cloned` | Intended; idempotent. | | `just clone-orchestrator` prints `already cloned` | Intended; idempotent. |
| `just login` exits with `Gitea server_error: "a grant exists with different scope"` | Run `just revoke-grant` (opens `<FORGE_GITEA_URL>/user/settings/applications` and prints the matching `FSDGG_CLI_CLIENT_ID`). Revoke the matching app, then re-run `just login`. Required only once after a scope-set change. Full reference: `docs/oauth-grant-scope-mismatch.md`. |
| Reset local state | `just uninstall`. | | Reset local state | `just uninstall`. |
## Security properties ## Security properties

View File

@@ -52,6 +52,42 @@ class AuthError(RuntimeError):
"""Any non-transient OAuth/auth-file error. Always user-visible.""" """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: # CLI output helpers. Tag scheme matches scripts/common.sh:
# cyan=info, green=ok, yellow=warn, red=err. ANSI disabled when stderr # cyan=info, green=ok, yellow=warn, red=err. ANSI disabled when stderr
@@ -164,7 +200,7 @@ class ForgeAuthConfig:
gitea_base_url: str gitea_base_url: str
client_id: str client_id: str
redirect_uri: 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 insecure_tls: bool = False # only for dev Gitea with self-signed certs
expected_username: str = "" # from FORGE_GITEA_USERNAME; empty = no check expected_username: str = "" # from FORGE_GITEA_USERNAME; empty = no check
@@ -308,9 +344,19 @@ def build_authorize_url(
*, *,
challenge: str, challenge: str,
state: str, state: str,
force_login_prompt: bool = True,
) -> str: ) -> str:
"""PKCE authorise URL with ``prompt=login`` (OIDC Core §3.1.2.1) and, """PKCE authorise URL with optional ``prompt=login`` and ``login_hint``.
when ``config.expected_username`` is set, ``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] = { params: dict[str, str] = {
"client_id": config.client_id, "client_id": config.client_id,
@@ -320,8 +366,9 @@ def build_authorize_url(
"state": state, "state": state,
"code_challenge": challenge, "code_challenge": challenge,
"code_challenge_method": "S256", "code_challenge_method": "S256",
"prompt": "login",
} }
if force_login_prompt:
params["prompt"] = "login"
if config.expected_username: if config.expected_username:
params["login_hint"] = config.expected_username params["login_hint"] = config.expected_username
return f"{endpoints['authorization_endpoint']}?{urlencode(params)}" 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): if "different scope" in low or ("scope" in low and "grant" in low):
cid = (client_id or "<unknown-client-id>").strip() cid = (client_id or "<unknown-client-id>").strip()
scope_fragment = scopes or "<unknown-scopes>" scope_fragment = scopes or "<unknown-scopes>"
return AuthError( return ScopeMismatchAuthError(
f'Gitea server_error: "{desc or error}".\n' f'Gitea server_error: "{desc or error}".\n'
f" Client ID: {cid}\n" f" Client ID: {cid}\n"
f" Revoke at: {settings_url} " f" Revoke at: {settings_url} "
f"(\"Authorized OAuth2 Applications\").\n" f"(\"Authorized OAuth2 Applications\").\n"
f" Requested: {scope_fragment}\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: if error == "access_denied" or "access_denied" in low or "denied" in low:
@@ -800,12 +850,16 @@ def run_login(
open_browser: bool = True, open_browser: bool = True,
force: bool = False, force: bool = False,
print_authorize_url: bool = True, print_authorize_url: bool = True,
force_login_prompt: bool = True,
) -> AuthFile: ) -> AuthFile:
"""Run the full PKCE flow and persist the result. """Run the full PKCE flow and persist the result.
Idempotent: if the stored file already carries a live Gitea token Idempotent: if the stored file already carries a live Gitea token
and ``force`` is False, skip everything and return the existing and ``force`` is False, skip everything and return the existing
state. The caller is the one deciding when to force a refresh. 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() store = auth_store_path()
existing = AuthFile.read(store) existing = AuthFile.read(store)
@@ -818,7 +872,13 @@ def run_login(
nonce = secrets.token_urlsafe(24) nonce = secrets.token_urlsafe(24)
state = sign_state(session_key, nonce) 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) redirect = urlparse(config.redirect_uri)
assert redirect.hostname and redirect.port # validated upstream 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: def _cmd_login(argv: list[str]) -> int:
force = "--force" in argv force = "--force" in argv
no_browser = "--no-browser" in argv no_browser = "--no-browser" in argv
config = ForgeAuthConfig.from_env() 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_ok(f"authenticated as: {state.username}")
cli_info(f"auth file: {auth_store_path()}") cli_info(f"auth file: {auth_store_path()}")
return 0 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 headless=0
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
Usage: just setup [--headless|--no-browser] Usage: just setup [--headless|--no-browser] [--yes|-y]
Options: Options:
--headless Do not open the browser during login. Prints the --headless Do not open the browser during login. Prints the
--no-browser authorisation URL to stderr instead; paste it into --no-browser authorisation URL to stderr instead; paste it into
any browser that can reach the loopback callback any browser that can reach the loopback callback
port (typically via SSH port-forward, see README). 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. -h, --help Show this message.
Environment: Environment:
FORGE_SETUP_YES=1 Accept every default; do not prompt. Safe only when FORGE_SETUP_YES=1 Same as --yes; honoured even when no flag is given.
FORGE_GITEA_USERNAME is already set in .env.
USAGE USAGE
} }
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--headless|--no-browser) headless=1; shift;; --headless|--no-browser) headless=1; shift;;
--yes|-y) export FORGE_SETUP_YES=1; shift;;
-h|--help) usage; exit 0;; -h|--help) usage; exit 0;;
--) shift; break;; --) shift; break;;
-*) die "unknown option: $1 (try 'just setup --help')";; -*) die "unknown option: $1 (try 'just setup --help')";;

View File

@@ -571,12 +571,12 @@ class HeadlessGuidanceTests(unittest.TestCase):
def test_ssh_branch_when_SSH_CONNECTION_set(self) -> None: 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"}) 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: def test_non_ssh_branch_when_no_ssh_env(self) -> None:
out = self._capture() out = self._capture()
self.assertIn("if this machine is remote", out) self.assertIn("Remote-host case", out)
self.assertNotIn("running inside an SSH session", out) self.assertNotIn("SSH session detected", out)
class BuildAuthorizeErrorTests(unittest.TestCase): class BuildAuthorizeErrorTests(unittest.TestCase):
@@ -584,7 +584,7 @@ class BuildAuthorizeErrorTests(unittest.TestCase):
BASE = "https://gitea.example.com" BASE = "https://gitea.example.com"
CID = "ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5" 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: def _exc_different_scope(self, **overrides: str) -> fa.AuthError:
kw: dict[str, object] = { kw: dict[str, object] = {
@@ -724,6 +724,256 @@ class AuthorizeUrlIsHeadlessInvariantTests(unittest.TestCase):
params = parse_qs(urlparse(url).query) params = parse_qs(urlparse(url).query)
self.assertEqual(params["scope"], [self.cfg.scopes]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()