Skip to content

feat(cli): add pipefy auth login --device#259

Open
gbrlcustodio wants to merge 3 commits into
devfrom
feat/138-auth-login-device
Open

feat(cli): add pipefy auth login --device#259
gbrlcustodio wants to merge 3 commits into
devfrom
feat/138-auth-login-device

Conversation

@gbrlcustodio

Copy link
Copy Markdown
Collaborator

Summary

Implement OAuth 2.0 Device Authorization Grant (RFC 8628) for pipefy auth login --device so headless flows (CI runners, SSH-only boxes, dev containers without a system browser) can sign in. Closes #138.

Context

Replaces #154, which was auto-closed when its base branch (refactor/auth-responses) was deleted on merge of #258. No content change since then: the device-login commit was rebased onto the new dev (which now includes both prerequisite refactors, #257 + #258).

Changes

  • New pipefy_auth/device.py runs the device-code flow end-to-end. CLI prints the user code + verification URL (plus _complete form when the IdP advertises it), then polls per RFC 8628 §3.5: clamp non-positive intervals to 5s, sleep only remaining TTL before each request, honor slow_down / authorization_pending.
  • PKCE bound to the device grant. Realms that mandate PKCE on the OIDC client (observed: piporacle staging) reject the device-auth request without code_challenge. Sending it on the auth request plus matching code_verifier on the token poll is safe on IdPs that don't enforce it.
  • pipefy auth login --device is the CLI entry point. --device + --no-browser and --device + --callback-timeout <explicit> both hard-error via typer.BadParameter.
  • discovery parses and SSRF-validates device_authorization_endpoint from the OIDC document.
  • docs/cli/auth.md documents the device grant under "Headless / SSH", including the IdP requirement.

Built on the foundations from the prior two PRs: device.py uses TokenResponse / OAuthErrorResponse from #258 and the pipefy_infra.coerce helpers from #257.

Test plan

  • 27 new tests in packages/cli/tests/test_auth_device.py cover the polling state machine, TTL-aware sleep, RFC interval clamp, slow-down handling, PKCE binding, conflicting-flag CLI errors.
  • uv run pytest packages/auth/tests/ packages/infra/tests/: 206 passed
  • uv run pytest packages/sdk/tests/ packages/cli/tests/: 1188 passed, 30 skipped
  • uv run pytest packages/mcp/tests/: 1320 passed, 16 skipped

Implement OAuth 2.0 Device Authorization Grant (RFC 8628) for headless
sign-in flows where the loopback callback dance isn't workable: CI
runners, SSH-only boxes, dev containers without a system browser.

* `pipefy_auth.device` runs the device-code flow end-to-end. The CLI
  prints the user code and verification URL (plus the `_complete` form
  when the IdP advertises it), then polls the token endpoint per
  RFC 8628 §3.5: clamp non-positive intervals to 5s, sleep only the
  remaining TTL before each request, and honor `slow_down` /
  `authorization_pending`.

* PKCE is bound to the device grant. Realms that mandate PKCE on the
  OIDC client (observed: piporacle staging) reject the device-auth
  request without a `code_challenge`; sending it on the authorization
  request and the matching `code_verifier` on the token poll is safe
  on IdPs that don't enforce it.

* `pipefy auth login --device` is the CLI entry point. `--device`
  combined with `--no-browser` or an explicit `--callback-timeout`
  hard-errors via `typer.BadParameter` — the device flow has no
  browser-callback dance to suppress and the deadline is governed by
  the IdP's `expires_in`. The `_DEFAULT_CALLBACK_TIMEOUT_S` constant
  exists so the explicit-default check compares by value.

* `discovery` parses and SSRF-validates `device_authorization_endpoint`
  from the OIDC document; the docstring covers the device path
  alongside the browser dance and token exchange.

* `docs/cli/auth.md` documents the device grant under
  "Headless / SSH", including the IdP requirement
  (`device_authorization_endpoint` in OIDC discovery).

Built on the foundations from the prior two PRs: device.py uses the
shared `TokenResponse` / `OAuthErrorResponse` wrappers from
``pipefy_auth.responses`` and the permissive `pipefy_infra.coerce`
helpers throughout.

Test surface: 27 tests covering the device polling state machine,
TTL-aware sleep, RFC-mandated interval clamp, slow-down handling,
PKCE binding, and the conflicting-flag CLI errors.
@gbrlcustodio gbrlcustodio self-assigned this May 31, 2026
@gbrlcustodio gbrlcustodio added this to the User auth milestone May 31, 2026
Linux CI runs under FORCE_COLOR=1, where Rich/Typer splits ``--no-browser`` /
``--callback-timeout`` with bold ANSI escapes between hyphens, breaking the
literal substring match. Mirror the same _ANSI_ESCAPE_RE pattern already used
in test_table_record_commands.py.
@gbrlcustodio gbrlcustodio requested a review from adriannoes June 1, 2026 23:05

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Nice work on the device grant — the polling behavior (immediate first poll, TTL-aware sleep, slow_down, PKCE on device auth for Keycloak) is well thought out, and the test suite plus docs/cli/auth.md updates give good confidence. I checked out d46f20e locally: ruff clean, 172 tests green in packages/auth/tests/ + test_auth_device.py. CI is green on your branch as well.

This is merge-ready for real Pipefy issuers; one small hardening item inline on expires_in/interval parsing.

Also noted

  • F2: On a 200 token response, poll_device_token only maps ValueError to LoginError, while exchange_code routes ValidationError through _format_validation_error. If the IdP returns a float expires_in (e.g. 300.0), users get the raw pydantic dump instead of the shorter message used elsewhere — worth mirroring the exchange_code handler when you have a moment.

Using this review with AI

A fuller write-up (local repro notes, deferred items) lives in .cursor/dev-planning/code-review/pr-259-auth-login-device.md on our fork for agents fixing by finding ID.

Verification

uv run pytest packages/auth/tests/ packages/cli/tests/test_auth_device.py -m "not integration" -q
uv run ruff check .

Comment thread packages/auth/src/pipefy_auth/device.py Outdated
Move DeviceAuthorization from a frozen dataclass in device.py to a
Pydantic BaseModel in responses.py, matching the TokenResponse /
OAuthErrorResponse pattern established in #258.

- StrictInt on expires_in and interval (rejects bool, str, float)
- _OptionalStr (BeforeValidator strip-or-none) on verification_uri_complete
- field_validator clamps non-positive interval to the RFC 8628 §3.5 default
- device.py routes both from_payload failures through
  _format_validation_error for consistent LoginError shape with flow.py
@gbrlcustodio

Copy link
Copy Markdown
Collaborator Author

Re F2: addressed in the same commit (a9e33cb). poll_device_token now catches ValidationError from TokenResponse.from_payload and routes it through _format_validation_error, matching the exchange_code handler in flow.py.

Base automatically changed from dev to main June 2, 2026 14:39
@gbrlcustodio gbrlcustodio changed the base branch from main to dev June 2, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants