Skip to content

perps: SCM/Klear auth foundation — KlearClient with session-cookie + MFA login #399

Description

@TexasCoding

Epic: part of #387 · Depends on: #388

Summary

The Self-Clearing-Member "Klear" API (/tmp/perps_scm_openapi.yaml, title Kalshi Self-Clearing Member API) uses a completely different auth model from the rest of the SDK: instead of per-request RSA-PSS signing, it authenticates with email + password (+ optional MFA code) via POST /log_in, which sets a session cookie (Set-Cookie) that must be replayed on every subsequent request. This issue builds the auth foundation only: a standalone KlearClient / AsyncKlearClient, a dedicated KlearConfig, and a new cookie-session auth strategy wired into the transport — plus the single LogIn operation. The Klear data endpoints (/margin/reports, /margin/active_obligation, settlement balance, etc.) are out of scope and ship in a follow-up issue.

This is the third distinct base host + auth surface in the SDK (RSA-PSS for trade-api, and now cookie-session for klear-api). It is labelled breaking only because it adds a new top-level public surface (KlearClient, AsyncKlearClient, KlearConfig, KlearAuth) and a new kalshi/perps/klear/ package; no existing behaviour changes.

Security consideration (call out in review)

  • email, password, and code are secrets. They must never appear in log output, exception messages, or repr(). The transport already avoids interpolating bodies into log lines (it logs only METHOD path); preserve that. LogInRequest must not be logged. Do not add the request body to any logger.debug(...) call.
  • The session cookie is a bearer credential. Store it in the httpx cookie jar; do not echo it into logs or repr().
  • KlearConfig / KlearAuth repr must redact credentials (mirror the spirit of KalshiConfig, which never holds the private key in a logged field).

Endpoints / scope

Method Path operationId Notes
POST /log_in LogIn security: [] (unauthenticated — bootstraps the session). Body LogInRequest. Response LogInResponse + Set-Cookie header. Two-phase: first call with email+password; if required_mfa_method is non-null in the response, re-call with the same credentials plus code.

Base URLs (own host, NOT trade-api): prod https://api.klear.kalshi.com/klear-api/v1, demo https://demo-api.kalshi.co/klear-api/v1. Path component is /klear-api/v1 (note: not /trade-api/v2KlearConfig must validate against its own path, see below).

Security scheme from spec (components.securitySchemes.kalshiSession): type: apiKey, in: cookie, name: session. So the session cookie is literally named session.

Models

New file: kalshi/perps/klear/models/auth.py

LogInRequest (request model — model_config = {"extra": "forbid"})

Spec #/components/schemas/LogInRequest. Required: email, password. Optional: code.

  • email: str — required.
  • password: str — required.
  • code: str | None = None — MFA code; only sent on the second (challenge-response) call.
  • Serialize via model.model_dump(exclude_none=True, by_alias=True, mode="json"). No alias mismatches here (all three wire names match Python names), so no serialization_alias needed. No DollarDecimal / FixedPointCount fields — all three are plain strings.
  • Do not give it a custom __repr__ that leaks fields; if a redacting repr is added, redact password and code.

LogInResponse (response model)

Spec #/components/schemas/LogInResponse. All fields optional per spec (none listed under required).

  • token: str | None = None — session token, present on successful auth.
  • user_id: str | None = None — admin user ID, present on successful auth.
  • access_level: str | None = None.
  • required_mfa_method: str | None = None — when set, MFA is required; caller must re-call /log_in with code. This is the signal the client branches on.
  • No prices/counts/timestamps. No aliases. (Field names map 1:1 to the spec.)

Error model (shared Klear error — confirm whether foundation issue already added it)

Spec #/components/schemas/Error (code, message, details?, service?). The existing _map_error in kalshi/_base_client.py already reads message/details, so the standard exception hierarchy (KalshiAuthError on 401, KalshiRateLimitError on 429) maps cleanly without a new exception type. Do not introduce new exception classes — reuse kalshi.errors. If the foundation issue hasn't already added a Klear Error Pydantic model, add it as kalshi/perps/klear/models/common.py::Error (fields code: str, message: str, details: str | None, service: str | None); otherwise reuse it.

No enums in this issue's owned schemas.

Auth strategy (new) — kalshi/perps/klear/auth.py

This is the core of the issue. The existing SyncTransport / AsyncTransport (kalshi/_base_client.py) inject auth by calling auth.sign_request(method, sign_path) and merging the returned KALSHI-ACCESS-* headers per attempt. Cookie-session auth does NOT produce per-request headers; it relies on httpx's cookie jar. Design:

  • New class KlearAuth (sync) / shared with async — a lightweight session holder, not a signer. It holds the session cookie value once log_in succeeds, and exposes whether it is authenticated. Unlike KalshiAuth it has no RSA key, no sign_request.
  • The transport must learn to carry cookies. The cleanest, lowest-blast-radius approach (confirm during impl, run gitnexus_impact on SyncTransport.__init__ / AsyncTransport.__init__ first): let the httpx Client/AsyncClient own the cookie jar. The login response's Set-Cookie is auto-captured by httpx when the same client instance is reused, and auto-replayed on subsequent requests. So KlearClient should:
    1. Construct its own httpx.Client(base_url=..., cookies=httpx.Cookies()) (or reuse the existing transport with auth=None so no KALSHI-ACCESS-* headers are ever signed/sent).
    2. Call POST /log_in (unauthenticated — security: []), let httpx capture the session cookie.
    3. If required_mfa_method is set, re-call with code.
  • Do NOT reuse the RSA-PSS signing path. KlearClient constructs its transport with auth=None so the per-attempt auth.sign_request(...) branch is skipped (self._auth is falsy → auth_headers = {}). The session cookie travels on the httpx client's jar, not via extra_headers. Verify that passing auth=None to SyncTransport/AsyncTransport already yields a no-auth request (it does — see _base_client.py lines 303 / 534).
  • Retry policy is inherited unchanged: POST /log_in is never retried (POST), and the transport already enforces that. Good — login must not be replayed.

KlearConfigkalshi/perps/klear/config.py

Mirror kalshi/config.py::KalshiConfig (frozen dataclass) but:

  • PRODUCTION_KLEAR_URL = "https://api.klear.kalshi.com/klear-api/v1", DEMO_KLEAR_URL = "https://demo-api.kalshi.co/klear-api/v1".
  • Add the new hosts to a Klear-specific _KNOWN_HOSTS (api.klear.kalshi.com, demo-api.kalshi.co); keep the allow_unknown_host escape hatch.
  • Path validation must enforce /klear-api/v1 (NOT /trade-api/v2 — copy the __post_init__ path check and change the expected component).
  • No ws_base_url (Klear has no WS surface) — drop the WS fields and the split-environment check.
  • Keep timeout / retry / extra_headers fields. The extra_headers KALSHI-ACCESS-* rejection from KalshiConfig is harmless to keep but irrelevant here.
  • .production() / .demo() classmethods.

KlearClient / AsyncKlearClientkalshi/perps/klear/client.py and async_client.py

Standalone classes (NOT a namespace on KalshiClient and NOT on PerpsClient). Public surface mirrors KalshiClient's lifecycle (__init__, close, context-manager, is_authenticated). Hold the transport + a KlearAuth session holder.

Resource methods

New file: kalshi/perps/klear/resources/auth.py (or fold log_in directly onto the client — pick the one that matches PerpsClient from the foundation issue; prefer a resource for contract-map symmetry).

Inside the resource class, annotate any list returns with builtins.list[T] (the list builtin is shadowed). No pagination here (/log_in returns a single object, no cursor) so no list_all() / AsyncIterator.

Sync (AuthResource(SyncResource)):

def log_in(self, *, email: str, password: str, code: str | None = None) -> LogInResponse: ...

Async (AsyncAuthResource(AsyncResource)):

async def log_in(self, *, email: str, password: str, code: str | None = None) -> LogInResponse: ...

Behaviour:

  • Build LogInRequest(email=..., password=..., code=...), serialize via req.model_dump(exclude_none=True, by_alias=True, mode="json"), POST to /log_in.
  • Parse LogInResponse. On success the httpx client jar now holds the session cookie automatically; update KlearAuth so is_authenticated flips true.
  • Return the LogInResponse so the caller can inspect required_mfa_method and re-call with code. (Do NOT auto-loop on MFA — the SDK can't conjure the OOB code; return it and let the caller drive the second call.)

Convenience (optional, only if trivial — otherwise drop per Simplicity First): a KlearClient.login(email, password, code=None) that delegates to auth.log_in and stores session state.

Contract-test wiring

NOTE — harness change owned by the foundation dependency: the contract harness currently hard-codes SPEC_FILE = specs/openapi.yaml (tests/test_contracts.py:54) and METHOD_ENDPOINT_MAP is parametrized against that single spec. Perps uses its own spec. The foundation issue must (a) commit /tmp/perps_scm_openapi.yaml to specs/perps_scm_openapi.yaml (and /tmp/perps_openapi.yamlspecs/perps_openapi.yaml), and (b) parameterize the drift tests to resolve each MethodEndpointEntry against the correct spec file (e.g. add a spec_file discriminator or a second map). This issue assumes that infrastructure exists; if it does not yet, it is blocked on foundation.

Add to METHOD_ENDPOINT_MAP (tests/_contract_support.py), tagged to the Klear SCM spec:

MethodEndpointEntry(
    sdk_method="kalshi.perps.klear.resources.auth.AuthResource.log_in",
    http_method="POST",
    path_template="/log_in",
    request_body_schema="#/components/schemas/LogInRequest",
)

(The async sibling AsyncAuthResource.log_in is derived automatically — do NOT add it.)

Add to BODY_MODEL_MAP (tests/test_contracts.py):

"#/components/schemas/LogInRequest": "kalshi.perps.klear.models.auth.LogInRequest",

EXCLUSIONS (tests/_contract_support.py): none expected — LogInRequest's three fields (email, password, code) map 1:1 to the spec with no rename/wire-normalization. If the body-drift test flags code because the SDK marks it optional while leaving it in the signature, that is correct (spec lists only email/password as required) and needs no exclusion. Add an Exclusion(reason=..., kind="...") only if a real drift surfaces during impl, with a concrete reason string.

Tests

New file tests/perps/klear/test_auth.py, using respx.mock. Reuse conftest.py fixtures where applicable (note: the RSA-key fixtures are NOT needed here — Klear has no signing — but the config/base-URL helpers are). Cover per public method:

  • log_in happy path (no MFA): mock POST /log_in → 200 with {token, user_id, access_level} and a Set-Cookie: session=... header. Assert LogInResponse parses, required_mfa_method is None, and the session cookie lands in the client's httpx jar (assert it is replayed on a subsequent dummy request).
  • log_in MFA challenge then success: first mock returns 200 with required_mfa_method set and no token; assert the SDK returns it without raising and does NOT auto-retry. Second call with code=... returns 200 with token + Set-Cookie; assert success and cookie capture.
  • Error path — 401: mock POST /log_in → 401 with Error body ({code, message}); assert it maps to KalshiAuthError and that the body (email/password) is not present in the exception message.
  • Error path — 429: mock → 429; assert KalshiRateLimitError, and assert POST is not retried (respx route called exactly once).
  • Edge — request body shape: assert the serialized body omits code when None (exclude_none=True) and includes it when provided; assert LogInRequest rejects an unknown field (extra="forbid"ValidationError).
  • Security — no credential leak: capture log records during log_in and assert password / code / the cookie value never appear in any emitted log line or in repr(config) / repr(client).
  • Async parity: mirror happy-path + 401 for AsyncKlearClient.auth.log_in.
  • Config: KlearConfig rejects a base_url lacking /klear-api/v1; .production() / .demo() resolve the right hosts; unknown host rejected unless allow_unknown_host=True.

Acceptance criteria

  • kalshi/perps/klear/config.py (KlearConfig), kalshi/perps/klear/auth.py (KlearAuth session holder), kalshi/perps/klear/client.py (KlearClient), async_client.py (AsyncKlearClient) implemented.
  • kalshi/perps/klear/models/auth.py with LogInRequest (extra="forbid") and LogInResponse; Klear Error model present (here or from foundation).
  • kalshi/perps/klear/resources/auth.py with sync AuthResource.log_in + async AsyncAuthResource.log_in; MFA is returned to the caller, not auto-looped; POST /log_in not retried.
  • Session cookie (session) captured from Set-Cookie and auto-replayed on subsequent requests via the httpx jar; RSA-PSS signing path is NOT used (auth=None on the transport).
  • KlearClient, AsyncKlearClient, KlearConfig, KlearAuth, LogInRequest, LogInResponse exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py.
  • No credential leakage: password/code/cookie never logged or in repr; verified by a test.
  • Contract map updated: METHOD_ENDPOINT_MAP entry (with request_body_schema) + BODY_MODEL_MAP entry added; resolved against specs/perps_scm_openapi.yaml; drift tests green.
  • uv run mypy kalshi/ strict clean (builtins.list[T] inside resource classes).
  • uv run ruff check . clean.
  • uv run pytest tests/ -v green, including TestRequestParamDrift, TestRequestBodyDrift, and the new tests/perps/klear/test_auth.py.

Dependencies

  • perps: foundation — must vendor specs/perps_scm_openapi.yaml (+ specs/perps_openapi.yaml), scaffold the kalshi/perps/ package + public-export plumbing, and parameterize the contract-test harness (SPEC_FILE / METHOD_ENDPOINT_MAP) to load the per-area perps spec instead of the single hard-coded specs/openapi.yaml.

Metadata

Metadata

Assignees

No one assigned

    Labels

    breakingBackwards-incompatible changeenhancementNew feature or requestinfraInfrastructure/toolingperpsPerps / margin (perpetual futures) API

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions