Rewrite onboarding prose to a neutral voice

This commit is contained in:
FanaticPythoner (Nathan Trudeau)
2026-04-26 12:26:19 -04:00
parent a591cd21f2
commit e27c8a2bd6
16 changed files with 69 additions and 70 deletions

View File

@@ -23,7 +23,7 @@ FSDGG_CLI_CLIENT_ID="ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5"
# http-scheme loopback: http://127.0.0.1:<port>/<path>, # http-scheme loopback: http://127.0.0.1:<port>/<path>,
# http://[::1]:<port>/<path>, or http://localhost:<port>/<path>. # http://[::1]:<port>/<path>, or http://localhost:<port>/<path>.
# The value must also match the redirect URI registered in the Gitea # The value must also match the redirect URI registered in the Gitea
# OAuth app; change it only if your Gitea app registration was updated # OAuth app; change it only if the Gitea app registration was updated
# in sync. # in sync.
FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback" FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback"

View File

@@ -53,7 +53,7 @@ check-gitea:
@echo "[check-gitea] GET $FORGE_GITEA_URL/api/v1/version" @echo "[check-gitea] GET $FORGE_GITEA_URL/api/v1/version"
@curl -fsS --max-time 10 ${FORGE_INSECURE_TLS:+-k} "$FORGE_GITEA_URL/api/v1/version" \ @curl -fsS --max-time 10 ${FORGE_INSECURE_TLS:+-k} "$FORGE_GITEA_URL/api/v1/version" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print("[check-gitea] Gitea version:", d.get("version","?"))' | python3 -c 'import json,sys; d=json.load(sys.stdin); print("[check-gitea] Gitea version:", d.get("version","?"))'
@echo "[check-gitea] OK -- you can reach Gitea." @echo "[check-gitea] OK: Gitea reachable."
# Browser OAuth2 (PKCE) login. Reuses a live token; runs the flow otherwise. # Browser OAuth2 (PKCE) login. Reuses a live token; runs the flow otherwise.
login: login:
@@ -91,12 +91,12 @@ check-access:
DISPLAY='' WAYLAND_DISPLAY='' \ DISPLAY='' WAYLAND_DISPLAY='' \
git ls-remote "$FORGE_ORCHESTRATOR_REPO_URL" HEAD >/dev/null 2>&1 \ git ls-remote "$FORGE_ORCHESTRATOR_REPO_URL" HEAD >/dev/null 2>&1 \
&& { \ && { \
echo "[check-access] OK -- you can reach the orchestrator."; \ echo "[check-access] OK: orchestrator reachable."; \
} || { \ } || { \
echo "[check-access] FAILED. Likely causes:"; \ echo "[check-access] FAILED. Likely causes:"; \
echo " - you have not run 'just login' yet"; \ echo " - 'just login' has not completed yet"; \
echo " - your refresh token expired; run 'just relogin'"; \ echo " - stored refresh token expired; run 'just relogin'"; \
echo " - your Gitea account is not yet in the org"; \ echo " - Gitea account is not yet in the org"; \
echo " - FORGE_ORCHESTRATOR_REPO_URL in .env is wrong"; \ echo " - FORGE_ORCHESTRATOR_REPO_URL in .env is wrong"; \
exit 1; \ exit 1; \
} }
@@ -141,7 +141,7 @@ next-steps *args:
run-next-steps *args: run-next-steps *args:
@bash scripts/next_steps.sh --run {{args}} @bash scripts/next_steps.sh --run {{args}}
# Remove the credential helper and our fields from client-auth.json. # Remove the credential helper and repo-managed fields from client-auth.json.
uninstall: uninstall:
@bash scripts/uninstall.sh @bash scripts/uninstall.sh

View File

@@ -55,7 +55,7 @@ edit is `FORGE_GITEA_USERNAME`. Defaults:
**`FSDGG_CLI_REDIRECT_URI` is not the Gitea URL.** The Gitea server is **`FSDGG_CLI_REDIRECT_URI` is not the Gitea URL.** The Gitea server is
at `FORGE_GITEA_URL` (remote, HTTPS). The redirect URI is the local at `FORGE_GITEA_URL` (remote, HTTPS). The redirect URI is the local
loopback HTTP listener the CLI binds on *your* machine so Gitea can loopback HTTP listener the CLI binds on the local machine so Gitea can
hand back the OAuth authorisation code; OAuth 2.0 for Native Apps hand back the OAuth authorisation code; OAuth 2.0 for Native Apps
(RFC 8252 §7.3) prohibits any non-loopback / non-HTTP scheme here, (RFC 8252 §7.3) prohibits any non-loopback / non-HTTP scheme here,
and no public CA will issue a cert for `127.0.0.1` so HTTPS on and no public CA will issue a cert for `127.0.0.1` so HTTPS on
@@ -217,7 +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. |
| Want a clean slate | `just uninstall`. | | Reset local state | `just uninstall`. |
## Security properties ## Security properties

View File

@@ -63,7 +63,7 @@ repo_root() {
} }
# Load $repo/.env into the environment. # Load $repo/.env into the environment.
# Existing environment values take precedence (matches just's dotenv-load). # Existing environment values take precedence (matches dotenv-load in just).
load_env() { load_env() {
local root env line key rest local root env line key rest
root="$(repo_root)" root="$(repo_root)"
@@ -111,6 +111,6 @@ require_cmd() {
require_env() { require_env() {
local name="$1" local name="$1"
if [ -z "${!name:-}" ]; then if [ -z "${!name:-}" ]; then
die "environment variable '$name' is unset. Run 'just init-env' and fill .env." die "environment variable '$name' is unset. Run 'just init-env'. Fill .env."
fi fi
} }

View File

@@ -110,7 +110,7 @@ if command -v xdg-open >/dev/null 2>&1 || command -v open >/dev/null 2>&1; then
printf ' %s %-12s -> (detected)\n' "$(_fc_tag "$_FC_GREEN" ok)" 'web browser' printf ' %s %-12s -> (detected)\n' "$(_fc_tag "$_FC_GREEN" ok)" 'web browser'
else else
printf ' %s %-12s -> no xdg-open/open on PATH.\n' "$(_fc_tag "$_FC_YELLOW" warn)" 'web browser' printf ' %s %-12s -> no xdg-open/open on PATH.\n' "$(_fc_tag "$_FC_YELLOW" warn)" 'web browser'
printf ' if this is a headless machine, run: just login-headless\n' printf ' headless mode: just login-headless\n'
case "$pm" in case "$pm" in
apt) printf ' otherwise install: sudo apt-get install -y xdg-utils\n' ;; apt) printf ' otherwise install: sudo apt-get install -y xdg-utils\n' ;;
dnf) printf ' otherwise install: sudo dnf install -y xdg-utils\n' ;; dnf) printf ' otherwise install: sudo dnf install -y xdg-utils\n' ;;
@@ -214,7 +214,7 @@ if [ "$missing" -gt 0 ]; then
printf ' %s\n' "$c" printf ' %s\n' "$c"
done done
printf '\n' printf '\n'
warn 'then re-run: just doctor' warn 're-run: just doctor'
exit 1 exit 1
fi fi

View File

@@ -282,11 +282,12 @@ def _http_post_form(
def discover_endpoints(config: ForgeAuthConfig) -> dict[str, str]: def discover_endpoints(config: ForgeAuthConfig) -> dict[str, str]:
"""Call Gitea's ``/.well-known/openid-configuration`` and return the """Call Gitea's ``/.well-known/openid-configuration`` and return the
three endpoints we care about, validated against the issuer. three required endpoints, validated against the issuer.
""" """
url = f"{config.gitea_base_url}/.well-known/openid-configuration" url = f"{config.gitea_base_url}/.well-known/openid-configuration"
payload = _http_get_json(url, insecure_tls=config.insecure_tls) payload = _http_get_json(url, insecure_tls=config.insecure_tls)
issuer = payload.get("issuer") issuer = payload.get("issuer")
if not isinstance(issuer, str) or issuer.rstrip("/") != config.gitea_base_url.rstrip("/"): if not isinstance(issuer, str) or issuer.rstrip("/") != config.gitea_base_url.rstrip("/"):
raise AuthError( raise AuthError(
f"OIDC discovery issuer {issuer!r} does not match " f"OIDC discovery issuer {issuer!r} does not match "
@@ -401,7 +402,7 @@ class _CallbackHandler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write( self.wfile.write(
b"<html><body><h1>Login failed</h1><p>You can close this window.</p></body></html>" b"<html><body><h1>Login failed</h1><p>Window close is safe.</p></body></html>"
) )
server.result_queue.put( server.result_queue.put(
_build_authorize_error( _build_authorize_error(
@@ -426,7 +427,7 @@ class _CallbackHandler(BaseHTTPRequestHandler):
self.wfile.write( self.wfile.write(
b"<html><body style='font-family:sans-serif'>" b"<html><body style='font-family:sans-serif'>"
b"<h1>Login complete.</h1>" b"<h1>Login complete.</h1>"
b"<p>You can close this window and return to your terminal.</p>" b"<p>Window close is safe. Terminal focus can resume.</p>"
b"</body></html>" b"</body></html>"
) )
server.result_queue.put((code, state)) server.result_queue.put((code, state))
@@ -471,7 +472,7 @@ def _build_authorize_error(
gitea_base_url : str gitea_base_url : str
Base URL of the Gitea server. Used to build the user-settings Base URL of the Gitea server. Used to build the user-settings
URL in the remediation message. Empty string yields a URL in the remediation message. Empty string yields a
``<your-gitea-url>`` placeholder. ``<gitea-base-url>`` placeholder.
client_id : str, optional client_id : str, optional
OAuth client id requesting the authorization. Surfaced in the OAuth client id requesting the authorization. Surfaced in the
"different scope" message to disambiguate the grant row in "different scope" message to disambiguate the grant row in
@@ -493,7 +494,7 @@ def _build_authorize_error(
settings_url = ( settings_url = (
f"{base}/user/settings/applications" f"{base}/user/settings/applications"
if base if base
else "<your-gitea-url>/user/settings/applications" else "<gitea-base-url>/user/settings/applications"
) )
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):
@@ -554,8 +555,8 @@ def wait_for_callback(
except OSError as exc: except OSError as exc:
raise AuthError( raise AuthError(
f"cannot bind loopback server on {host}:{port}: {exc}. " f"cannot bind loopback server on {host}:{port}: {exc}. "
f"Is another 'just login' still running? " f"Another 'just login' instance may still be running. "
f"If port {port} is held by an unrelated process, override " f"If port {port} is held by an unrelated process, set "
f"FSDGG_CLI_REDIRECT_URI in .env (note: the port must match " f"FSDGG_CLI_REDIRECT_URI in .env (note: the port must match "
f"the OAuth app registered in Gitea)." f"the OAuth app registered in Gitea)."
) from exc ) from exc
@@ -566,7 +567,7 @@ def wait_for_callback(
except Exception as exc: except Exception as exc:
raise AuthError( raise AuthError(
f"timed out after {int(timeout_seconds)}s waiting for OAuth " f"timed out after {int(timeout_seconds)}s waiting for OAuth "
f"callback. Did you complete the browser login?" f"callback. Browser login completion was not detected."
) from exc ) from exc
finally: finally:
server.server_close() server.server_close()
@@ -763,18 +764,17 @@ def _print_headless_guidance(auth_url: str, redirect) -> None:
] ]
if in_ssh: if in_ssh:
lines += [ lines += [
f"{info_tag} this process is running inside an SSH session. From the", f"{info_tag} SSH session detected. From the browser-side machine run:",
" machine where you will open the browser, run:",
"", "",
f" ssh -L {cb_port}:127.0.0.1:{cb_port} {user}@{hostname}", f" ssh -L {cb_port}:127.0.0.1:{cb_port} {user}@{hostname}",
"", "",
" and paste the URL above into THAT machine's browser.", " Paste the URL above into that machine's browser.",
"", "",
] ]
else: else:
lines += [ lines += [
f"{info_tag} if this machine is remote, from the machine with the", f"{info_tag} Remote-host case: from the browser-side machine run",
" browser run (before pasting the URL):", " the following command before pasting the URL:",
"", "",
f" ssh -L {cb_port}:127.0.0.1:{cb_port} {user}@{hostname}", f" ssh -L {cb_port}:127.0.0.1:{cb_port} {user}@{hostname}",
"", "",
@@ -825,8 +825,8 @@ def run_login(
if print_authorize_url: if print_authorize_url:
if open_browser: if open_browser:
cli_info( cli_info(
f"open this URL in your browser if it does not open " f"authorization URL (fallback when automatic browser open fails):\n"
f"automatically:\n {auth_url}" f" {auth_url}"
) )
else: else:
_print_headless_guidance(auth_url, redirect) _print_headless_guidance(auth_url, redirect)
@@ -882,7 +882,7 @@ def run_login(
f"'{config.expected_username}'. The stored auth file has " f"'{config.expected_username}'. The stored auth file has "
"NOT been updated.\n" "NOT been updated.\n"
" To fix:\n" " To fix:\n"
f" 1. Sign out of Gitea in your browser: {logout_url}\n" f" 1. Sign out of Gitea in the browser: {logout_url}\n"
f" 2. Sign back in as '{config.expected_username}'\n" f" 2. Sign back in as '{config.expected_username}'\n"
" 3. Re-run 'just login'" " 3. Re-run 'just login'"
) )
@@ -905,8 +905,7 @@ def run_refresh(config: ForgeAuthConfig, *, must_refresh: bool = False) -> AuthF
If ``must_refresh`` is False and the access token is still live, If ``must_refresh`` is False and the access token is still live,
this is a no-op. If refresh fails, raises ``AuthError`` (the this is a no-op. If refresh fails, raises ``AuthError`` (the
credential helper surfaces this to git as "fall through to prompt", credential helper surfaces this to git as "fall through to prompt",
and the CLI ``just refresh`` surfaces it with an instruction to and the CLI ``just refresh`` surfaces the failure to stderr).
re-run ``just login``).
""" """
store = auth_store_path() store = auth_store_path()
existing = AuthFile.read(store) existing = AuthFile.read(store)
@@ -934,7 +933,7 @@ def run_refresh(config: ForgeAuthConfig, *, must_refresh: bool = False) -> AuthF
def run_logout() -> Path | None: def run_logout() -> Path | None:
"""Remove every codevalet-managed field from the auth file. """Remove every codevalet-managed field from the auth file.
If the file only ever held our fields, delete it entirely. If the If the file only ever held repo-managed fields, delete it entirely. If the
gateway has already populated its own fields (access_token etc.), gateway has already populated its own fields (access_token etc.),
wipe only the Gitea + welcome-repo keys and leave the rest. wipe only the Gitea + welcome-repo keys and leave the rest.
""" """

View File

@@ -24,6 +24,6 @@ python3 "$here/forge_auth.py" login "$@"
"$here/install-git-credential-helper.sh" "$here/install-git-credential-helper.sh"
ok "login complete" ok "login complete"
note "git operations against your Gitea server now authenticate silently." note "git operations against FORGE_GITEA_URL now authenticate without prompts."
note "Test with:" note "Verification command:"
note " git ls-remote \"\$FORGE_ORCHESTRATOR_REPO_URL\"" note " git ls-remote \"\$FORGE_ORCHESTRATOR_REPO_URL\""

View File

@@ -16,7 +16,7 @@ On every ``get`` the helper:
unchanged so the normal prompt chain continues. unchanged so the normal prompt chain continues.
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, passes
through: this ensures we never hand OAuth tokens to unrelated through: 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 +29,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 we exit **non-zero** rather than silently * On OAuth refresh failure the process exits **non-zero** rather than silently
returning stale credentials. returning stale credentials.
""" """
from __future__ import annotations from __future__ import annotations
@@ -93,7 +93,7 @@ def _configured_host() -> tuple[str, str, int | None] | None:
Priority: Priority:
1. The ``_forge_gitea_base_url`` field inside the stored auth 1. The ``_forge_gitea_base_url`` field inside the stored auth
file: that is the host we actually authenticated against. file: the authenticated host.
2. The ``FORGE_GITEA_URL`` env var (pre-login override). 2. The ``FORGE_GITEA_URL`` env var (pre-login override).
Returns None if neither is set; the helper then passes through. Returns None if neither is set; the helper then passes through.
""" """
@@ -150,7 +150,7 @@ 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: we don't know who we answer for emit(fields) # pass-through: helper scope is undefined
return 0 return 0
if not _request_matches(fields, configured): if not _request_matches(fields, configured):
emit(fields) # pass-through: request is for a different host emit(fields) # pass-through: request is for a different host

View File

@@ -37,11 +37,11 @@ python3 -c 'import sys; compile(open(sys.argv[1]).read(), sys.argv[1], "exec")'
key="credential.${FORGE_GITEA_URL}.helper" key="credential.${FORGE_GITEA_URL}.helper"
use_path_key="credential.${FORGE_GITEA_URL}.useHttpPath" use_path_key="credential.${FORGE_GITEA_URL}.useHttpPath"
# Wipe any previous helpers we may have set so we do not stack them. # Wipe previously configured helpers to avoid stacking entries.
git config --global --unset-all "$key" 2>/dev/null || true git config --global --unset-all "$key" 2>/dev/null || true
git config --global --unset-all "$use_path_key" 2>/dev/null || true git config --global --unset-all "$use_path_key" 2>/dev/null || true
# Username is derived from the OAuth token at runtime by the helper; we # Username is derived from the OAuth token at runtime by the helper; do
# deliberately do NOT set credential.<URL>.username here, to avoid # deliberately do NOT set credential.<URL>.username here, to avoid
# pinning an old login. The helper emits username=<stored> on every # pinning an old login. The helper emits username=<stored> on every
# `get`, which git respects. # `get`, which git respects.

View File

@@ -66,7 +66,7 @@ orchestrator="$workspace_root/$repo_name"
if [ ! -d "$orchestrator/.git" ]; then if [ ! -d "$orchestrator/.git" ]; then
err "orchestrator checkout not found at $orchestrator" err "orchestrator checkout not found at $orchestrator"
note "run 'just clone-orchestrator' (or 'just setup') first." note "required command: just clone-orchestrator"
exit 1 exit 1
fi fi
@@ -119,9 +119,9 @@ if [ "$MODE" != "run" ]; then
if [ "${#forward_args[@]}" -gt 0 ]; then if [ "${#forward_args[@]}" -gt 0 ]; then
fwd_suffix=" ${forward_args[*]}" fwd_suffix=" ${forward_args[*]}"
fi fi
note "Execute via:" note "Command:"
note " just run-next-steps${fwd_suffix}" note " just run-next-steps${fwd_suffix}"
note "Execute inside the orchestrator:" note "Orchestrator command:"
note " cd $orchestrator && just contributor-setup${fwd_suffix}" note " cd $orchestrator && just contributor-setup${fwd_suffix}"
exit 0 exit 0
fi fi

View File

@@ -84,7 +84,7 @@ fi
load_env load_env
if [ -z "${FORGE_GITEA_USERNAME:-}" ]; then if [ -z "${FORGE_GITEA_USERNAME:-}" ]; then
note "FORGE_GITEA_USERNAME is blank. Enter your Gitea username exactly as it appears in your profile URL." note "FORGE_GITEA_USERNAME is blank. Enter the Gitea username from the profile URL."
new_user="$(prompt_line "Gitea username")" new_user="$(prompt_line "Gitea username")"
if [ -z "$new_user" ]; then if [ -z "$new_user" ]; then
die "no username entered: aborting. Edit .env by hand and re-run 'just setup'." die "no username entered: aborting. Edit .env by hand and re-run 'just setup'."
@@ -180,7 +180,7 @@ if [ "$run_login" = "1" ]; then
forbid any interaction. Choose one: forbid any interaction. Choose one:
1. Drop FORGE_SETUP_YES and re-run 'just setup --headless' 1. Drop FORGE_SETUP_YES and re-run 'just setup --headless'
(setup will print the authorisation URL and wait (setup will print the authorisation URL and wait
while you paste it into a browser). while the URL is pasted into a browser).
2. Run 'just login' once on a machine with a browser 2. Run 'just login' once on a machine with a browser
to populate the stored refresh token, then re-run to populate the stored refresh token, then re-run
'just setup --headless' from the headless host. 'just setup --headless' from the headless host.
@@ -202,7 +202,7 @@ GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=Never \
VSCODE_GIT_ASKPASS_EXTRA_ARGS="" VSCODE_GIT_IPC_HANDLE="" \ VSCODE_GIT_ASKPASS_EXTRA_ARGS="" VSCODE_GIT_IPC_HANDLE="" \
DISPLAY="" WAYLAND_DISPLAY="" \ DISPLAY="" WAYLAND_DISPLAY="" \
git ls-remote "$FORGE_ORCHESTRATOR_REPO_URL" HEAD >/dev/null 2>&1 \ git ls-remote "$FORGE_ORCHESTRATOR_REPO_URL" HEAD >/dev/null 2>&1 \
|| die "git ls-remote failed. Your account may not yet be in the org, or FORGE_ORCHESTRATOR_REPO_URL is wrong." || die "git ls-remote failed. Account membership may be missing, or FORGE_ORCHESTRATOR_REPO_URL is wrong."
ok "silent clone access confirmed" ok "silent clone access confirmed"
step "6/7 cloning the orchestrator (just clone-orchestrator)" step "6/7 cloning the orchestrator (just clone-orchestrator)"

View File

@@ -10,7 +10,7 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
load_env load_env
# 1. Log out at the OAuth layer (clears our managed fields from # 1. Log out at the OAuth layer (clears repo-managed fields from
# client-auth.json and keeps any fields owned by the orchestrator # client-auth.json and keeps any fields owned by the orchestrator
# gateway untouched). # gateway untouched).
if [ -f "$here/forge_auth.py" ]; then if [ -f "$here/forge_auth.py" ]; then
@@ -37,4 +37,4 @@ done
info 'uninstall complete.' info 'uninstall complete.'
info "note: $FORGE_AUTH_FILE is left in place if the orchestrator wrote it;" info "note: $FORGE_AUTH_FILE is left in place if the orchestrator wrote it;"
info ' delete it manually if you want a completely clean state.' info ' delete it manually for a fully clean state.'

View File

@@ -13,7 +13,7 @@ bash tests/test_doctor.sh
## Inventory ## Inventory
- **`test_forge_auth.py`** `scripts/forge_auth.py` unit tests: PKCE - **`test_forge_auth.py`**: `scripts/forge_auth.py` unit tests: PKCE
pair generation, HMAC state signing + CSRF rejection, pair generation, HMAC state signing + CSRF rejection,
`ForgeAuthConfig.from_env` validation (loopback-only redirect, `ForgeAuthConfig.from_env` validation (loopback-only redirect,
missing port, missing env vars, `FORGE_GITEA_USERNAME` missing port, missing env vars, `FORGE_GITEA_USERNAME`
@@ -22,19 +22,19 @@ bash tests/test_doctor.sh
read/write/merge/`has_live_gitea_token`, `auth_store_path` read/write/merge/`has_live_gitea_token`, `auth_store_path`
precedence, `run_logout`, `main()` dispatcher. precedence, `run_logout`, `main()` dispatcher.
- **`test_git_credential_forge.py`** `scripts/git-credential-forge.py` - **`test_git_credential_forge.py`**: `scripts/git-credential-forge.py`
unit tests: credential protocol I/O, host/scheme/port matching, unit tests: credential protocol I/O, host/scheme/port matching,
live-token fast-path, pass-through for missing store or non-matching live-token fast-path, pass-through for missing store or non-matching
host, expired-token refresh, refresh-failure handling, `store`/`erase` host, expired-token refresh, refresh-failure handling, `store`/`erase`
no-ops, `main()` dispatcher. no-ops, `main()` dispatcher.
- **`test_forge_auth_integration.py`** end-to-end Python integration - **`test_forge_auth_integration.py`**: end-to-end Python integration
tests against `tests/mock_oidc_server.py`: full PKCE flow, tests against `tests/mock_oidc_server.py`: full PKCE flow,
gateway-required schema on disk, idempotent re-login, refresh token gateway-required schema on disk, idempotent re-login, refresh token
rotation with server-side revocation, logout preserving rotation with server-side revocation, logout preserving
gateway-bearer fields. gateway-bearer fields.
- **`test_forge_auth_integration.sh`** shell end-to-end: drives - **`test_forge_auth_integration.sh`**: shell end-to-end: drives
`forge_auth.py login` against the mock server, installs the `forge_auth.py login` against the mock server, installs the
credential helper into a sandboxed `$HOME`, and exercises credential helper into a sandboxed `$HOME`, and exercises
`git credential fill`. Covers URL matching, `github.com` `git credential fill`. Covers URL matching, `github.com`
@@ -43,17 +43,17 @@ bash tests/test_doctor.sh
logout URL surfaced, authorise URL carries `prompt=login` + logout URL surfaced, authorise URL carries `prompt=login` +
`login_hint`). `login_hint`).
- **`test_setup_args.sh`** `scripts/setup.sh` coverage: argument - **`test_setup_args.sh`**: `scripts/setup.sh` coverage: argument
parsing, `--help`, `--headless` wiring to `forge_login.sh parsing, `--help`, `--headless` wiring to `forge_login.sh
--no-browser`, the `--headless + FORGE_SETUP_YES=1` hang guard, --no-browser`, the `--headless + FORGE_SETUP_YES=1` hang guard,
live-token reuse, silent-refresh rescue, `prompt_choice` non-tty live-token reuse, silent-refresh rescue, `prompt_choice` non-tty
stdout isolation. stdout isolation.
- **`test_doctor.sh`** `scripts/doctor.sh`: miss-path under a - **`test_doctor.sh`**: `scripts/doctor.sh`: miss-path under a
sandboxed PATH, asserts every `[MISS]` line is followed by a `fix:` sandboxed PATH, asserts every `[MISS]` line is followed by a `fix:`
line. line.
- **`mock_oidc_server.py`** test fixture implementing - **`mock_oidc_server.py`**: test fixture implementing
`/.well-known/openid-configuration`, `/login/oauth/authorize`, `/.well-known/openid-configuration`, `/login/oauth/authorize`,
`/login/oauth/access_token`, `/login/oauth/userinfo`. PKCE `/login/oauth/access_token`, `/login/oauth/userinfo`. PKCE
verification on `authorization_code`; rotation + revocation on verification on `authorization_code`; rotation + revocation on

View File

@@ -15,8 +15,8 @@ out="$(mktemp)"
trap 'rm -f "$out"' EXIT trap 'rm -f "$out"' EXIT
# Strip everything that could satisfy the checks we want to fail. # Strip everything that could satisfy the checks we want to fail.
# /usr/bin/python3 is 3.10.x on Ubuntu 22.04; that's fine: we want to # /usr/bin/python3 is 3.10.x on Ubuntu 22.04; this case exercises the
# prove the python>=3.11 miss branch renders its fix+alt lines. # python>=3.11 miss branch and its fix+alt lines.
if env -i HOME="$HOME" PATH="/usr/bin:/bin" bash "$repo/scripts/doctor.sh" >"$out" 2>&1; then if env -i HOME="$HOME" PATH="/usr/bin:/bin" bash "$repo/scripts/doctor.sh" >"$out" 2>&1; then
echo "FAIL: doctor.sh exited 0 despite missing prerequisites" echo "FAIL: doctor.sh exited 0 despite missing prerequisites"
cat "$out" cat "$out"

View File

@@ -335,7 +335,7 @@ class AuthFileTests(unittest.TestCase):
def test_merge_login_preserves_gateway_bearer(self) -> None: def test_merge_login_preserves_gateway_bearer(self) -> None:
# Simulates the case where the orchestrator already ran # Simulates the case where the orchestrator already ran
# `auth login` and populated the gateway bearer. We must not # `auth login` and populated the gateway bearer. The code must not
# overwrite those fields. # overwrite those fields.
f = fa.AuthFile(raw={ f = fa.AuthFile(raw={
"username": "old-alice", "username": "old-alice",
@@ -396,8 +396,8 @@ class AuthFileTests(unittest.TestCase):
self.assertEqual(roundtrip["username"], "u") self.assertEqual(roundtrip["username"], "u")
def test_write_preserves_unknown_keys(self) -> None: def test_write_preserves_unknown_keys(self) -> None:
# Forward-compat: the gateway might add new fields we don't # Forward-compat: the gateway might add new fields unknown to the
# know about. Writing must preserve them verbatim. # current schema. Writing must preserve them verbatim.
raw = {"username": "u", "future_field": {"x": 1}, "access_token": "A"} raw = {"username": "u", "future_field": {"x": 1}, "access_token": "A"}
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
p = Path(d) / "a.json" p = Path(d) / "a.json"
@@ -644,7 +644,7 @@ class BuildAuthorizeErrorTests(unittest.TestCase):
def test_different_scope_without_base_url_uses_placeholder(self) -> None: def test_different_scope_without_base_url_uses_placeholder(self) -> None:
self.assertIn( self.assertIn(
"<your-gitea-url>/user/settings/applications", "<gitea-base-url>/user/settings/applications",
str(self._exc_different_scope(gitea_base_url="")), str(self._exc_different_scope(gitea_base_url="")),
) )

View File

@@ -4,7 +4,7 @@ Covers the full PKCE login flow (authorize → callback → token
exchange → userinfo → persist), transparent refresh, logout, and exchange → userinfo → persist), transparent refresh, logout, and
the idempotent "already authenticated" short-circuit. the idempotent "already authenticated" short-circuit.
No real network calls. No browser required: we simulate the No real network calls. No browser required: the test simulates the
browser by doing an HTTP GET to the authorize endpoint; the mock browser by doing an HTTP GET to the authorize endpoint; the mock
server 302-redirects to the loopback callback, which server 302-redirects to the loopback callback, which
`forge_auth.run_login` is already listening on. `forge_auth.run_login` is already listening on.
@@ -44,14 +44,14 @@ def _free_loopback_port() -> int:
class _MockBrowser: class _MockBrowser:
"""Drive the authorize endpoint on a worker thread. """Drive the authorize endpoint on a worker thread.
We wait a fraction of a second for `run_login` to bind its The worker waits a fraction of a second for `run_login` to bind its
loopback callback server, then GET the authorize URL. The mock loopback callback server, then GET the authorize URL. The mock
server redirects us to the callback; following the redirect server redirects to the callback; following the redirect
causes `run_login`'s callback handler to fire, and the auth flow causes `run_login`'s callback handler to fire, and the auth flow
completes. completes.
urllib's default opener follows redirects automatically, which is urllib's default opener follows redirects automatically, which is
exactly what we want here: one GET, one automatic redirect, done. the required behavior here: one GET, one automatic redirect, done.
""" """
def __init__(self, authorize_url: str, delay_seconds: float = 0.2) -> None: def __init__(self, authorize_url: str, delay_seconds: float = 0.2) -> None:
@@ -111,14 +111,14 @@ class ForgeAuthIntegrationTests(unittest.TestCase):
# Helpers # Helpers
# ----------------------------------------------------------------- # -----------------------------------------------------------------
def _login(self) -> fa.AuthFile: def _login(self) -> fa.AuthFile:
"""Run run_login() with an auto-browser that does the GET for us.""" """Run run_login() with an auto-browser issuing the authorize GET."""
with mock.patch.dict(os.environ, self.env, clear=True): with mock.patch.dict(os.environ, self.env, clear=True):
config = fa.ForgeAuthConfig.from_env() config = fa.ForgeAuthConfig.from_env()
# We need to start the mock "browser" AFTER run_login # The mock browser starts AFTER run_login
# prints the authorize URL but BEFORE it blocks on the # prints the authorize URL but BEFORE it blocks on the
# loopback server. Since run_login prints then blocks # loopback server. Since run_login prints then blocks
# synchronously, we can intercept webbrowser.open to # synchronously, the code intercepts webbrowser.open to
# kick off the GET at exactly the right moment. # kick off the GET at exactly the right moment.
browser_holder: dict[str, _MockBrowser] = {} browser_holder: dict[str, _MockBrowser] = {}
@@ -240,8 +240,8 @@ class ForgeAuthIntegrationTests(unittest.TestCase):
def test_callback_state_csrf_mismatch_raises(self) -> None: def test_callback_state_csrf_mismatch_raises(self) -> None:
"""A tampered state on the callback must raise. """A tampered state on the callback must raise.
We cannot easily tamper with the real PKCE flow end-to-end, The real PKCE flow is not easily tampered end-to-end here,
so we exercise verify_state directly: the `run_login` path so the test exercises verify_state directly: the `run_login` path
wires it straight through. wires it straight through.
""" """
key = b"\x01" * 32 key = b"\x01" * 32