feat(cli): add pipefy auth login --device#259
Conversation
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.
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.
adriannoes
left a comment
There was a problem hiding this comment.
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_tokenonly mapsValueErrortoLoginError, whileexchange_coderoutesValidationErrorthrough_format_validation_error. If the IdP returns a floatexpires_in(e.g.300.0), users get the raw pydantic dump instead of the shorter message used elsewhere — worth mirroring theexchange_codehandler 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 .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
|
Re F2: addressed in the same commit (a9e33cb). |
Summary
Implement OAuth 2.0 Device Authorization Grant (RFC 8628) for
pipefy auth login --deviceso 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 newdev(which now includes both prerequisite refactors, #257 + #258).Changes
pipefy_auth/device.pyruns the device-code flow end-to-end. CLI prints the user code + verification URL (plus_completeform 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, honorslow_down/authorization_pending.code_challenge. Sending it on the auth request plus matchingcode_verifieron the token poll is safe on IdPs that don't enforce it.pipefy auth login --deviceis the CLI entry point.--device + --no-browserand--device + --callback-timeout <explicit>both hard-error viatyper.BadParameter.discoveryparses and SSRF-validatesdevice_authorization_endpointfrom the OIDC document.docs/cli/auth.mddocuments the device grant under "Headless / SSH", including the IdP requirement.Built on the foundations from the prior two PRs:
device.pyusesTokenResponse/OAuthErrorResponsefrom #258 and thepipefy_infra.coercehelpers from #257.Test plan
packages/cli/tests/test_auth_device.pycover 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 passeduv run pytest packages/sdk/tests/ packages/cli/tests/: 1188 passed, 30 skippeduv run pytest packages/mcp/tests/: 1320 passed, 16 skipped