Skip to content

refactor(auth): consolidate OAuth response parsing#258

Merged
gbrlcustodio merged 2 commits into
devfrom
refactor/auth-responses
May 31, 2026
Merged

refactor(auth): consolidate OAuth response parsing#258
gbrlcustodio merged 2 commits into
devfrom
refactor/auth-responses

Conversation

@gbrlcustodio

@gbrlcustodio gbrlcustodio commented May 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Give the OAuth wire shapes typed domain objects instead of raw dicts threaded through call sites, and parse them through Pydantic v2 models rather than hand-rolled _required_str / optional_* validation. Centralizes RFC 6749 / RFC 8628 parsing in one place and matches the codebase pattern used everywhere else for untrusted wire data (SDK input models, MCP tool inputs, every Settings layer).

Why split out

Carved out of #154 as the second foundational layer. Depended on #257 (infra coerce helpers), which has landed. The device login strategy (PR #154, to be re-pointed) sits on top of this.

Changes

Typed OAuth wrappers (01e1cd9)

  • New pipefy_auth.responses module:
    • TokenResponse (RFC 6749 §5.1) with factory from_payload(dict).
    • OAuthErrorResponse (RFC 6749 §5.2 / RFC 8628 §3.5) with factory from_response(httpx.Response) and .render(prefix, fallback) for safe user-facing messages.
  • StoredSession bundles a TokenResponse. On-disk JSON keeps the legacy flat shape via destructure-on-write / rebuild-on-load, so existing keychain entries continue to load without re-login.
  • refresh.py drops the bespoke _refresh_error_from for OAuthErrorResponse.from_response. _refresh_and_store wraps malformed payloads as RefreshError.
  • CLI auth login / auth status / auth logout access token fields via session.token.<field> instead of session.<field>.

Pydantic v2 models (88a3252)

  • TokenResponse, OAuthErrorResponse, StoredSession are now BaseModel subclasses. Required strings use StrictStr + Field(min_length=1); lifetime fields use StrictInt | None so a bool cannot slip in as 1. Unknown fields are ignored on the wire-facing models (Keycloak adds not-before-policy, session_state) and forbidden on StoredSession (corruption signal: we own both writer and reader).
  • StoredSession keeps the legacy on-disk shape via @model_validator(mode="before") _accept_flat_blob (re-nests destructured token fields back under token) plus @model_serializer(mode="wrap") _to_flat_blob (always emits the flat shape). _session_to_blob / _session_from_blob / _required_str are deleted; store_session / load_session collapse to model_dump_json / model_validate_json.
  • flow.py:exchange_code and refresh.py:_refresh_and_store get ValidationError branches that render via a shared _format_validation_error helper, mirroring the MCP-tool message shape (pipefy_mcp.tools.portal_tool_helpers.portal_element_validation_error). Pydantic's verbose default str(exc) with doc URLs never reaches end users.
  • Optional string fields (scope, id_token, error, error_description) route through _strip_or_none, which drops non-strings to None. Load-bearing for the inline call sites that invoke OAuthErrorResponse.from_response(response).render(...) without a try: a malformed {"error": 0} body falls through to the fallback rather than crash the error-rendering path.

Test plan

  • uv run pytest packages/auth/tests/: 152 passed
  • uv run pytest packages/cli/tests/: 303 passed, 13 skipped
  • uv run pytest packages/mcp/tests/: passes
  • Cross-package suite: 2764 passed, 46 skipped
  • Smoke test against the file keychain backend (PIPEFY_KEYCHAIN_BACKEND=file, sandboxed XDG_CONFIG_HOME): legacy flat blob loads, nested-shape blob loads, fresh write produces flat on-disk shape, OAuthErrorResponse.from_response({"error": 0}) renders the fallback without raising, pipefy auth status --json reads auth_source: "stored-session" end-to-end.

@gbrlcustodio gbrlcustodio force-pushed the refactor/infra-coerce branch from 5c8db27 to 638baa3 Compare May 30, 2026 20:08
@gbrlcustodio gbrlcustodio force-pushed the refactor/auth-responses branch from f04f589 to 325d6f1 Compare May 30, 2026 20:08
@gbrlcustodio gbrlcustodio force-pushed the refactor/infra-coerce branch from 638baa3 to d9c5402 Compare May 30, 2026 21:33
@gbrlcustodio gbrlcustodio force-pushed the refactor/auth-responses branch from 325d6f1 to 285500e Compare May 30, 2026 21:35
@gbrlcustodio gbrlcustodio force-pushed the refactor/infra-coerce branch from d9c5402 to 53fa1a9 Compare May 30, 2026 22:09
@gbrlcustodio gbrlcustodio force-pushed the refactor/auth-responses branch 2 times, most recently from bb19834 to b3ab7c0 Compare May 30, 2026 22:12
@gbrlcustodio gbrlcustodio self-assigned this May 30, 2026
@gbrlcustodio gbrlcustodio deleted the branch dev May 30, 2026 22:13
@gbrlcustodio gbrlcustodio reopened this May 30, 2026
@gbrlcustodio gbrlcustodio changed the base branch from refactor/infra-coerce to dev May 30, 2026 22:14
Give the OAuth wire shapes typed domain objects instead of raw dicts
threaded through call sites, eliminating three near-duplicate
`_format_*_error` free functions and centralizing the RFC 6749 /
RFC 8628 parsing in one place.

* New `responses` module exports `TokenResponse` (RFC 6749 §5.1) and
  `OAuthErrorResponse` (§5.2 / RFC 8628 §3.5). `TokenResponse` replaces
  `dict[str, object]` across `exchange_code` and `store_session`;
  `OAuthErrorResponse` replaces the duplicated error-parsing helpers in
  `flow.py` and `refresh.py`. Both factories use the shared
  `pipefy_infra.coerce` helpers internally.

* `StoredSession` now bundles a `TokenResponse` so the keychain blob
  reads from one source of truth. On-disk JSON keeps the legacy flat
  shape via destructure-on-write / rebuild-on-load, so existing keychain
  entries continue to load without forcing a re-login.

* `refresh._refresh_error_from` collapses to
  `OAuthErrorResponse.from_response(response)`, and `_refresh_and_store`
  wraps the `TokenResponse.from_payload` `ValueError` as `RefreshError`
  so a malformed refresh body surfaces with the right exception class.

* CLI `auth login` / `auth status` / `auth logout` access token fields
  via `session.token.<field>` (attribute) instead of `session.<field>`
  (flat). The post-store `ValueError` catch drops out because malformed
  payloads now fail earlier in `TokenResponse.from_payload`.

Test surface: refresh, resolver, storage, and CLI auth tests migrated
to the bundled `TokenResponse` shape. Auth + CLI + MCP suites pass.
@gbrlcustodio gbrlcustodio force-pushed the refactor/auth-responses branch from b3ab7c0 to 01e1cd9 Compare May 30, 2026 22:15
Replace the hand-rolled `_required_str` / `optional_*` validation in the
three frozen dataclasses with `BaseModel` subclasses, aligning with the
codebase's established pattern for parsing untrusted wire data (SDK
input models, MCP tool inputs, every `Settings` layer).

* `TokenResponse`, `OAuthErrorResponse`, `StoredSession` are now
  `BaseModel` with `ConfigDict(extra="ignore", frozen=True)` (or
  `extra="forbid"` for `StoredSession`, since both writer and reader
  live in this repo and an unknown outer key signals corruption).
  Required strings use `StrictStr` + `Field(min_length=1)`; lifetime
  fields use `StrictInt | None` so a bool can't slip in as `1`.

* `StoredSession` preserves the legacy on-disk shape via
  `@model_validator(mode="before") _accept_flat_blob` (re-nests
  destructured token fields back under `token` before validation) plus
  `@model_serializer(mode="wrap") _to_flat_blob` (always emits the flat
  shape). Existing keychain entries continue to load without forcing a
  re-login. `_session_to_blob` / `_session_from_blob` / `_required_str`
  are deleted; `store_session` / `load_session` collapse to
  `model_dump_json` / `model_validate_json`.

* `flow.py:exchange_code` and `refresh.py:_refresh_and_store` gain
  `ValidationError` branches that render via a shared
  `_format_validation_error` helper, mirroring the MCP house style
  (`pipefy_mcp.tools.portal_tool_helpers.portal_element_validation_error`).
  Pydantic's verbose default `str(exc)` with doc URLs never reaches
  end users.

* `_strip_or_none` for optional string fields (`scope`, `id_token`,
  `error`, `error_description`) drops non-strings to `None` rather
  than rejecting them. Load-bearing for the inline call sites at
  `flow.py:96` and `refresh.py:_refresh_error_from`, which invoke
  `OAuthErrorResponse.from_response(response).render(...)` without a
  try: a malformed `{"error": 0}` body must fall through to the
  fallback rather than raise `ValidationError` into an
  error-rendering path.

Test surface: new `test_responses.py` covers missing/empty/bool/null
required fields, `_strip_or_none` behavior on optional strings,
`from_response` over non-JSON / non-dict / numeric-error bodies, the
three `render()` branches, and the `_format_validation_error` output
shape. `test_storage_backend.py` adds legacy-flat + nested-shape
round-trips, corrupt JSON, missing required, and forbid-extras.
Auth (152) + CLI (303) + cross-package (2764 total) suites pass.
@gbrlcustodio gbrlcustodio merged commit 1c666e4 into dev May 31, 2026
2 checks passed
@gbrlcustodio gbrlcustodio deleted the refactor/auth-responses branch May 31, 2026 23:29
gbrlcustodio added a commit that referenced this pull request Jun 1, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant