fix devpi gateway auth

This commit is contained in:
Nathan Trudeau
2026-05-03 07:25:31 -04:00
parent c5bcd24e06
commit 6dca4aceb9
3 changed files with 28 additions and 19 deletions

View File

@@ -12,11 +12,12 @@ On every ``get`` the helper:
1. Reads the auth file (same path the gateway uses: 1. Reads the auth file (same path the gateway uses:
``~/.forge-stack-devpi-gateway-gitea/client-auth.json`` by default, ``~/.forge-stack-devpi-gateway-gitea/client-auth.json`` by default,
or whatever ``FSDGG_AUTH_STORE_PATH`` / ``FSDGG_RUNTIME_DIR`` point or whatever ``FSDGG_AUTH_STORE_PATH`` / ``FSDGG_RUNTIME_DIR`` point
at). If the file is missing, the helper passes git's input through at). If the file is missing, the helper emits no output so the next
unchanged so the normal prompt chain continues. helper in Git's chain can answer without receiving echoed fields from
this helper.
2. Checks that the git request host matches the host recorded in 2. Checks that the git request host matches the host recorded in
``_forge_gitea_base_url`` (or ``FORGE_GITEA_URL``). If not, passes ``_forge_gitea_base_url`` (or ``FORGE_GITEA_URL``). If not, emits no
through. This prevents OAuth token disclosure to unrelated output. This prevents OAuth token disclosure to unrelated
hosts even if git mis-scopes its lookup. hosts even if git mis-scopes its lookup.
3. If ``gitea_access_token`` is live, emits 3. If ``gitea_access_token`` is live, emits
``username=<stored-user>`` and ``password=<gitea_access_token>``. ``username=<stored-user>`` and ``password=<gitea_access_token>``.
@@ -29,7 +30,7 @@ Security notes
-------------- --------------
* The helper never writes to stdout except the credential key=value * The helper never writes to stdout except the credential key=value
block. Logs go to stderr. block. Logs go to stderr.
* On OAuth refresh failure the helper exits **non-zero** rather than silently * On OAuth refresh failure the helper emits no credentials rather than
returning stale credentials. returning stale credentials.
""" """
from __future__ import annotations from __future__ import annotations
@@ -82,6 +83,10 @@ def emit(fields: dict[str, str]) -> None:
sys.stdout.write(f"{key}={value}\n") sys.stdout.write(f"{key}={value}\n")
def emit_no_credentials() -> None:
return
# -------------------------------------------------------------------- # --------------------------------------------------------------------
# Host matching # Host matching
# -------------------------------------------------------------------- # --------------------------------------------------------------------
@@ -150,10 +155,10 @@ def _request_matches(
def cmd_get(fields: dict[str, str]) -> int: def cmd_get(fields: dict[str, str]) -> int:
configured = _configured_host() configured = _configured_host()
if configured is None: if configured is None:
emit(fields) # pass-through: helper scope is unknown emit_no_credentials()
return 0 return 0
if not _request_matches(fields, configured): if not _request_matches(fields, configured):
emit(fields) # pass-through: request targets a different host emit_no_credentials()
return 0 return 0
state = forge_auth.AuthFile.read(forge_auth.auth_store_path()) state = forge_auth.AuthFile.read(forge_auth.auth_store_path())
@@ -172,7 +177,7 @@ def cmd_get(fields: dict[str, str]) -> int:
"FSDGG_CLI_CLIENT_ID are not set in the environment; " "FSDGG_CLI_CLIENT_ID are not set in the environment; "
"cannot refresh. Run 'just login' to re-authenticate." "cannot refresh. Run 'just login' to re-authenticate."
) )
emit(fields) emit_no_credentials()
return 0 return 0
try: try:
refreshed = forge_auth.run_refresh(config, must_refresh=True) refreshed = forge_auth.run_refresh(config, must_refresh=True)
@@ -181,7 +186,7 @@ def cmd_get(fields: dict[str, str]) -> int:
f"token refresh failed: {exc}. " f"token refresh failed: {exc}. "
f"Run 'just login' to re-authenticate." f"Run 'just login' to re-authenticate."
) )
emit(fields) emit_no_credentials()
return 0 return 0
_emit_credentials(fields, refreshed) _emit_credentials(fields, refreshed)
return 0 return 0
@@ -190,7 +195,7 @@ def cmd_get(fields: dict[str, str]) -> int:
def _emit_credentials(fields: dict[str, str], state: forge_auth.AuthFile) -> None: def _emit_credentials(fields: dict[str, str], state: forge_auth.AuthFile) -> None:
token = state.gitea_access_token token = state.gitea_access_token
if not token: if not token:
emit(fields) emit_no_credentials()
return return
out = dict(fields) out = dict(fields)
out["username"] = state.username or fields.get("username") or "oauth" out["username"] = state.username or fields.get("username") or "oauth"

View File

@@ -177,16 +177,16 @@ class CmdGetTests(unittest.TestCase):
self.assertEqual(parsed["password"], "LIVETOKEN") self.assertEqual(parsed["password"], "LIVETOKEN")
self.assertEqual(parsed["host"], "g.example:8443") self.assertEqual(parsed["host"], "g.example:8443")
def test_no_store_passes_through(self) -> None: def test_no_store_emits_no_credentials(self) -> None:
rc, out, _ = self._run( rc, out, _ = self._run(
store_payload=None, store_payload=None,
stdin_text="protocol=https\nhost=g.example:8443\n\n", stdin_text="protocol=https\nhost=g.example:8443\n\n",
) )
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
self.assertEqual(out, "")
self.assertNotIn("password=", out) self.assertNotIn("password=", out)
self.assertIn("host=g.example:8443", out)
def test_non_matching_host_passes_through(self) -> None: def test_non_matching_host_emits_no_credentials(self) -> None:
payload = { payload = {
"username": "alice", "username": "alice",
"gitea_access_token": "LIVETOKEN", "gitea_access_token": "LIVETOKEN",
@@ -198,10 +198,10 @@ class CmdGetTests(unittest.TestCase):
stdin_text="protocol=https\nhost=github.com\n\n", stdin_text="protocol=https\nhost=github.com\n\n",
) )
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
self.assertEqual(out, "")
self.assertNotIn("password=", out) self.assertNotIn("password=", out)
self.assertIn("host=github.com", out)
def test_match_but_no_token_passes_through(self) -> None: def test_match_but_no_token_emits_no_credentials(self) -> None:
payload = { payload = {
"username": "alice", "username": "alice",
"gitea_access_token": "", "gitea_access_token": "",
@@ -212,6 +212,7 @@ class CmdGetTests(unittest.TestCase):
stdin_text="protocol=https\nhost=g.example:8443\n\n", stdin_text="protocol=https\nhost=g.example:8443\n\n",
) )
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
self.assertEqual(out, "")
self.assertNotIn("password=", out) self.assertNotIn("password=", out)
# --- expired token + refresh ------------------------------------- # --- expired token + refresh -------------------------------------
@@ -263,6 +264,7 @@ class CmdGetTests(unittest.TestCase):
stdin_text="protocol=https\nhost=g.example:8443\n\n", stdin_text="protocol=https\nhost=g.example:8443\n\n",
) )
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
self.assertEqual(out, "")
self.assertNotIn("password=", out) self.assertNotIn("password=", out)
self.assertIn("token refresh failed", err) self.assertIn("token refresh failed", err)
self.assertIn("just login", err) self.assertIn("just login", err)

View File

@@ -24,13 +24,15 @@ assert() {
assert 'setup.sh parses as valid bash' bash -n "$root/scripts/setup.sh" assert 'setup.sh parses as valid bash' bash -n "$root/scripts/setup.sh"
help_out="$(bash "$root/scripts/setup.sh" --help 2>&1)" help_file="$(mktemp)"
bash "$root/scripts/setup.sh" --help >"$help_file" 2>&1
assert '--help prints the Usage header' \ assert '--help prints the Usage header' \
bash -c "printf '%s' \"$help_out\" | grep -q '^Usage: just setup'" grep -q '^Usage: just setup' "$help_file"
assert '--help documents --headless' \ assert '--help documents --headless' \
bash -c "printf '%s' \"$help_out\" | grep -q -- '--headless'" grep -q -- '--headless' "$help_file"
assert '--help documents FORGE_SETUP_YES' \ assert '--help documents FORGE_SETUP_YES' \
bash -c "printf '%s' \"$help_out\" | grep -q 'FORGE_SETUP_YES'" grep -q 'FORGE_SETUP_YES' "$help_file"
rm -f "$help_file"
set +e set +e
bash "$root/scripts/setup.sh" --not-a-flag >/dev/null 2>"$here/.bad.err" bash "$root/scripts/setup.sh" --not-a-flag >/dev/null 2>"$here/.bad.err"