Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions docs/cli/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,17 @@ The service-account OAuth token URL (Tier 3) and the OIDC issuer URL (Tier 4) ar

| Command | Status | Notes |
|---------|--------|-------|
| `pipefy auth login` | Available | Browser-based PKCE login; persists the session in the OS keychain. |
| `pipefy auth login` | Available | Browser-based PKCE login (default) or device grant with `--device`; persists the session in the OS keychain. |
| `pipefy auth status` | Available | Print which auth source is active, identity, and session expiry. |
| `pipefy auth logout` | Available | Revoke the refresh token at the IdP and clear the stored session. |

#### `pipefy auth login` flags

| Flag | Default | Effect |
|------|---------|--------|
| `--no-browser` | _off_ | Print the authorization URL to stdout instead of trying to launch a browser. |
| `--callback-timeout <s>` | `180.0` | Seconds to wait for the browser callback (minimum 5). |
| `--device` | _off_ | Use the OAuth 2.0 device authorization grant (RFC 8628): prints a user code and verification URL instead of binding a loopback callback. |
| `--no-browser` | _off_ | Print the authorization URL to stdout instead of trying to launch a browser (PKCE flow only). |
| `--callback-timeout <s>` | `180.0` | Seconds to wait for the browser callback (minimum 5; PKCE flow only). |

#### `pipefy auth status` flags

Expand Down Expand Up @@ -184,16 +185,16 @@ Production: `https://signin.pipefy.com/realms/pipefy` (the `PIPEFY_AUTH_URL` def

## Headless / SSH

The PKCE flow needs a loopback HTTP server on `127.0.0.1:<ephemeral>` and a browser that can reach it. SSH sessions and headless boxes break both assumptions.
The default PKCE flow needs a loopback HTTP server on `127.0.0.1:<ephemeral>` and a browser that can reach it. SSH sessions and headless boxes break both assumptions.

Options today:
**Device login:** run `pipefy auth login --device` when your IdP advertises `device_authorization_endpoint` in OIDC discovery. The CLI prints a **user code** and **verification URL** (and a one-shot link when the IdP provides `verification_uri_complete`). Open the URL on any machine with a browser, enter the code, and the CLI polls until tokens arrive — no loopback listener is required.

1. **Run `pipefy auth login` on your laptop**, then copy `.env` plus the relevant secrets over. The keychain entry itself doesn't transfer between machines.
Other options:

1. **Run `pipefy auth login` on your laptop** (PKCE), then copy `.env` plus the relevant secrets over. The keychain entry itself doesn't transfer between machines.
2. **Use a service account** (`PIPEFY_SERVICE_ACCOUNT_*`) on the headless box — this is the canonical answer for CI and servers.
3. **Static bearer** via `PIPEFY_TOKEN` for short-lived debugging.

Forthcoming: an OAuth 2.0 Device Authorization Grant (`pipefy auth login --device`) that swaps the loopback callback for a code you paste into a browser elsewhere. Tracked in issue #138.

---

## Troubleshooting
Expand Down
3 changes: 3 additions & 0 deletions packages/auth/src/pipefy_auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
RefreshableBearerAuth,
StaticBearerAuth,
)
from pipefy_auth.device import DeviceAuthorization, run_device_login
from pipefy_auth.discovery import (
DiscoveryPolicy,
ProviderMetadata,
Expand Down Expand Up @@ -60,6 +61,7 @@
"AuthSettings",
"CallableBearerAuth",
"DEFAULT_AUTH_CLIENT_ID",
"DeviceAuthorization",
"DiscoveryPolicy",
"LoginError",
"LoginResult",
Expand Down Expand Up @@ -91,6 +93,7 @@
"refresh_access_token",
"resolve_pipefy_auth",
"revoke_session",
"run_device_login",
"run_login",
"store_session",
"tier_for",
Expand Down
212 changes: 212 additions & 0 deletions packages/auth/src/pipefy_auth/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""Orchestrate the OAuth 2.0 Device Authorization Grant login (RFC 8628)."""

from __future__ import annotations

import time
from typing import Callable

import httpx
from pydantic import ValidationError

from pipefy_auth import _http
from pipefy_auth.discovery import (
DiscoveryPolicy,
ProviderMetadata,
fetch_provider_metadata,
)
from pipefy_auth.flow import LoginError, LoginResult
from pipefy_auth.pkce import challenge_from_verifier, generate_verifier
from pipefy_auth.responses import (
DeviceAuthorization,
OAuthErrorResponse,
TokenResponse,
_format_validation_error,
)

_DEFAULT_SCOPES = ("openid", "profile", "email", "offline_access")
_DEVICE_AUTH_TIMEOUT_S = 30.0
_SLOW_DOWN_INCREMENT_S = 5
_DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"

_EXPIRED_TOKEN_MESSAGE = (
"Sign-in didn't complete in time. Re-run `pipefy auth login --device` to try again."
)
_ACCESS_DENIED_MESSAGE = "Sign-in cancelled by the user."


def request_device_authorization(
*,
metadata: ProviderMetadata,
client_id: str,
code_challenge: str,
scopes: tuple[str, ...] = _DEFAULT_SCOPES,
client: httpx.Client,
) -> DeviceAuthorization:
"""POST to the issuer's device authorization endpoint and parse the response.

PKCE (``code_challenge`` + ``code_challenge_method=S256``) is sent on every
request: RFC 8628 doesn't require it, but Keycloak applies a client's
"PKCE Code Challenge Method" setting uniformly across grant types, so a
realm that mandates PKCE on the authorization-code path also rejects
PKCE-less device-auth requests with ``invalid_request: Missing parameter:
code_challenge_method``. IdPs that don't enforce PKCE accept the extra
parameters and validate the verifier without complaint.
"""
if metadata.device_authorization_endpoint is None:
raise LoginError(
"OIDC discovery did not advertise a device_authorization_endpoint. "
"This issuer may not support device login."
)

try:
response = client.post(
metadata.device_authorization_endpoint,
data={
"client_id": client_id,
"scope": " ".join(scopes),
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
)
except httpx.HTTPError as exc:
raise LoginError(f"Device authorization request failed: {exc}") from exc

if response.status_code != 200:
raise LoginError(
OAuthErrorResponse.from_response(response).render(
fallback=f"Device authorization endpoint returned HTTP {response.status_code}",
prefix="Device authorization failed",
)
)

try:
payload = response.json()
except ValueError as exc:
raise LoginError(
f"Device authorization endpoint returned non-JSON response: {exc}"
) from exc
if not isinstance(payload, dict):
raise LoginError(
"Device authorization endpoint returned a non-object JSON payload."
)

try:
return DeviceAuthorization.from_payload(payload)
except ValidationError as exc:
raise LoginError(_format_validation_error(exc)) from exc


def poll_device_token(
*,
metadata: ProviderMetadata,
client_id: str,
code_verifier: str,
device_auth: DeviceAuthorization,
client: httpx.Client,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> TokenResponse:
"""Poll the token endpoint until authorization completes or the device code expires."""
deadline = monotonic() + device_auth.expires_in
interval = device_auth.interval

while True:
if monotonic() >= deadline:
raise LoginError(_EXPIRED_TOKEN_MESSAGE)

try:
response = client.post(
metadata.token_endpoint,
data={
"grant_type": _DEVICE_GRANT_TYPE,
"device_code": device_auth.device_code,
"client_id": client_id,
"code_verifier": code_verifier,
},
)
except httpx.HTTPError as exc:
raise LoginError(f"Device token poll request failed: {exc}") from exc

if response.status_code == 200:
try:
payload = response.json()
except ValueError as exc:
raise LoginError(
f"Token endpoint returned non-JSON response: {exc}"
) from exc
if not isinstance(payload, dict):
raise LoginError("Token endpoint returned a non-object JSON payload.")
try:
return TokenResponse.from_payload(payload)
except ValidationError as exc:
raise LoginError(_format_validation_error(exc)) from exc

err = OAuthErrorResponse.from_response(response)
if err.error == "expired_token":
raise LoginError(_EXPIRED_TOKEN_MESSAGE)
if err.error == "access_denied":
raise LoginError(_ACCESS_DENIED_MESSAGE)
if err.error == "slow_down":
interval += _SLOW_DOWN_INCREMENT_S
elif err.error != "authorization_pending":
raise LoginError(
err.render(
fallback=f"Token endpoint returned HTTP {response.status_code}",
prefix="Device token poll failed",
)
)

wait_s = min(interval, deadline - monotonic())
if wait_s > 0:
sleep(wait_s)


def run_device_login(
*,
issuer_url: str,
client_id: str,
scopes: tuple[str, ...] = _DEFAULT_SCOPES,
on_device_info: Callable[[DeviceAuthorization], None] | None = None,
http_client: httpx.Client | None = None,
discovery_policy: DiscoveryPolicy = DiscoveryPolicy(),
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> LoginResult:
"""Run the full device authorization grant. Returns tokens; does **not** persist them."""
with _http.http_client(http_client, timeout=_DEVICE_AUTH_TIMEOUT_S) as http:
try:
metadata = fetch_provider_metadata(
issuer_url, policy=discovery_policy, client=http
)
except ValueError as exc:
raise LoginError(str(exc)) from exc

verifier = generate_verifier()
device_auth = request_device_authorization(
metadata=metadata,
client_id=client_id,
code_challenge=challenge_from_verifier(verifier),
scopes=scopes,
client=http,
)
if on_device_info is not None:
on_device_info(device_auth)

token = poll_device_token(
metadata=metadata,
client_id=client_id,
code_verifier=verifier,
device_auth=device_auth,
client=http,
sleep=sleep,
monotonic=monotonic,
)
return LoginResult(issuer=metadata.issuer, token=token)


__all__ = [
"DeviceAuthorization",
"poll_device_token",
"request_device_authorization",
"run_device_login",
]
19 changes: 15 additions & 4 deletions packages/auth/src/pipefy_auth/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class ProviderMetadata:
authorization_endpoint: str
token_endpoint: str
end_session_endpoint: str | None = None
device_authorization_endpoint: str | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -59,10 +60,12 @@ def fetch_provider_metadata(
"""Fetch and parse the issuer's OIDC discovery document.

Validates that ``metadata.issuer`` matches ``issuer_url`` (per OIDC
Discovery 1.0 §4.3) and that the returned ``authorization_endpoint`` and
``token_endpoint`` aren't pointing at internal hosts or non-HTTPS URLs —
a tampered or misconfigured discovery doc must not be allowed to redirect
the browser dance or token exchange to an attacker-controlled target.
Discovery 1.0 §4.3) and that the returned ``authorization_endpoint``,
``token_endpoint``, and (when present) ``device_authorization_endpoint``
aren't pointing at internal hosts or non-HTTPS URLs — a tampered or
misconfigured discovery doc must not be allowed to redirect the browser
dance, device authorization, or token exchange to an attacker-controlled
target.

Raises:
ValueError: When the discovery document is unreachable, malformed,
Expand Down Expand Up @@ -111,12 +114,19 @@ def fetch_provider_metadata(
token_endpoint = str(data["token_endpoint"])
end_session_endpoint = optional_str(data.get("end_session_endpoint"))

raw_device = data.get("device_authorization_endpoint")
device_authorization_endpoint = str(raw_device) if raw_device else None

endpoints: list[tuple[str, str]] = [
("authorization_endpoint", authorization_endpoint),
("token_endpoint", token_endpoint),
]
if end_session_endpoint is not None:
endpoints.append(("end_session_endpoint", end_session_endpoint))
if device_authorization_endpoint is not None:
endpoints.append(
("device_authorization_endpoint", device_authorization_endpoint)
)
for field, value in endpoints:
try:
security.validate_https_url(
Expand All @@ -130,6 +140,7 @@ def fetch_provider_metadata(
authorization_endpoint=authorization_endpoint,
token_endpoint=token_endpoint,
end_session_endpoint=end_session_endpoint,
device_authorization_endpoint=device_authorization_endpoint,
)


Expand Down
Loading