From 9267fc1e91d0e333c6c4a4a77da4da78f1f740a5 Mon Sep 17 00:00:00 2001 From: Richard Pacheco Date: Fri, 17 Jul 2026 09:10:04 -0300 Subject: [PATCH] fix(cli): don't render a non-expiring refresh token as expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pipefy auth status` computed the refresh-token expiry as `obtained_at + refresh_expires_in`. Keycloak sends `refresh_expires_in: 0` to advertise a non-expiring refresh token, but `_iso_expiry` reads `0` as a 0-second TTL and places expiry at the login instant — so the durable credential was always shown as `expired` (and `refresh_expires_at` was a past timestamp in `--json`). Eager refresh resets `obtained_at` to now on every rotation, so it stays perpetually "expired". Normalize the sentinel where the refresh token is read (`refresh_expires_in or None`), leaving `_iso_expiry` generic so a genuine 0-second access-token TTL still reads as expired. Purely a status-readout change; the refresh decision (`_is_stale`) never consults this value — it's only carried forward verbatim on rotation. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/cli/auth.md | 2 +- packages/cli/src/pipefy_cli/commands/auth.py | 6 ++++-- packages/cli/tests/test_auth_status.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/cli/auth.md b/docs/cli/auth.md index d5380c7c..9fe06bc0 100644 --- a/docs/cli/auth.md +++ b/docs/cli/auth.md @@ -139,7 +139,7 @@ The command answers four diagnostic questions: am I signed in, as whom, via whic "issuer": "https://signin.pipefy.com/realms/pipefy", "state": "active", // or "refresh-expired" | "needs-login" | "n/a" "access_expires_at": "2026-05-20T22:14:03Z", // ISO 8601, null for static-bearer - "refresh_expires_at": "2026-06-19T18:02:00Z", // ISO 8601, null when not stored-session + "refresh_expires_at": "2026-06-19T18:02:00Z", // ISO 8601; null when not a stored session, or when the IdP advertises no refresh-token expiry (refresh_expires_in: 0) "token_rejected": false, // true only when the identity `me` query returned 401 "keychain_backend": "Keyring", // null for non-stored-session sources "masking_env_vars": [] // env vars masking a stored session, if any diff --git a/packages/cli/src/pipefy_cli/commands/auth.py b/packages/cli/src/pipefy_cli/commands/auth.py index 8dccf341..8cfa1757 100644 --- a/packages/cli/src/pipefy_cli/commands/auth.py +++ b/packages/cli/src/pipefy_cli/commands/auth.py @@ -358,8 +358,10 @@ def _populate_stored_session(report: AuthStatusReport, oidc: OidcClient) -> None report.access_expires_at = _iso_expiry( stale.obtained_at, stale.token.expires_in ) + # refresh_expires_in: 0 is Keycloak's "no advertised expiry" sentinel, + # not a 0-second TTL — treat as no expiry (else it renders "expired"). report.refresh_expires_at = _iso_expiry( - stale.obtained_at, stale.token.refresh_expires_in + stale.obtained_at, stale.token.refresh_expires_in or None ) raise _StatusExit( report=report, @@ -382,7 +384,7 @@ def _populate_stored_session(report: AuthStatusReport, oidc: OidcClient) -> None fresh_session.obtained_at, fresh_session.token.expires_in ) report.refresh_expires_at = _iso_expiry( - fresh_session.obtained_at, fresh_session.token.refresh_expires_in + fresh_session.obtained_at, fresh_session.token.refresh_expires_in or None ) diff --git a/packages/cli/tests/test_auth_status.py b/packages/cli/tests/test_auth_status.py index 9a8869b9..807bb89f 100644 --- a/packages/cli/tests/test_auth_status.py +++ b/packages/cli/tests/test_auth_status.py @@ -121,6 +121,24 @@ def test_status_stored_session_text_output( assert "Expires:" in result.stdout +def test_status_non_expiring_refresh_token_not_reported_expired( + clean_pipefy_env, saved_cwd, monkeypatch, runner, fake_keyring +): + """Keycloak advertises a non-expiring refresh token as ``refresh_expires_in: 0``. + That must not be read as a 0-second TTL and rendered as already ``expired``.""" + _set_auth_env(monkeypatch) + _seed_session(monkeypatch, refresh_expires_in=0) + session = storage.load_session(issuer=_ISSUER, client_id=_CLIENT_ID) + client = _mock_client_with_me() + with _patch_fresh_session(session), _patch_command_client(client): + json_result = _invoke_status(runner, ["--json"]) + text_result = _invoke_status(runner) + + assert json_result.exit_code == 0, json_result.stdout + assert json.loads(json_result.stdout)["refresh_expires_at"] is None + assert "Refresh token: expired" not in text_result.stdout + + # --------------------------------------------------------------------------- # # Scenario 2: stored-session, eager refresh runs (we mock the result as fresh) # # --------------------------------------------------------------------------- #