Skip to content

fix: persist MFA session cookie so skip_mfa_setup works - #11

Open
lakhansamani wants to merge 1 commit into
mainfrom
fix/mfa-session-cookie-persistence
Open

fix: persist MFA session cookie so skip_mfa_setup works#11
lakhansamani wants to merge 1 commit into
mainfrom
fix/mfa-session-cookie-persistence

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

The bug

Since Authorizer 2.4.0 MFA is on by default. signup/login now withhold the access token and answer:

message = "Proceed to mfa setup", access_token = None, should_show_totp_screen = True

The token is redeemed by calling skip_mfa_setup, and the MFA session it belongs to is identified only by the mfa_session cookie. The SDK never sent that cookie back, so skip_mfa_setup (and totp_mfa_setup / email_otp_mfa_setup / sms_otp_mfa_setup / lock_mfa / webauthn_registration_verify, which use the same session) always failed:

authorizer.exceptions.AuthorizerError: invalid session

User-visible impact: against a 2.4.0+ server, every Python SDK user is locked out after signup or login. There is no access token in the response and no way to obtain one — the SDK is unusable for its primary purpose.

The response parsing was fine (AuthToken already carries message, should_show_totp_screen, the should_offer_*_mfa_setup flags), and httpx.Client does keep a cookie jar. The failure was in getting the cookie back out, in two independent places.

1. http/localhost (graphql + rest)

The server sets the cookie Secure (--app-cookie-secure defaults to true) with Domain=localhost, even over plain http. Two http.cookiejar rules then drop it:

  • Secure cookies are never sent to an http:// URL;
  • eff_request_host rewrites a dotless host to localhost.local, which never domain-matches Domain=localhost.

Browsers send the cookie in both cases — W3C secure contexts treat loopback as a trustworthy origin — and so does curl. Only the Python SDK dropped it, which is why every local dev setup and every CI run against a local server broke.

The fix normalises the stored cookie for loopback origins instead of installing a CookiePolicy, because httpx rebuilds the outgoing jar with the default policy on every request (BaseClient._merge_cookies does Cookies(self.cookies)), silently discarding any custom policy. Non-loopback cookies are untouched: a Secure cookie from a real host is still never sent over plain http (covered by a test).

2. gRPC — no cookie handling at all

_grpc_transport ignored response cookies entirely. gRPC has no cookie concept, so the server serialises them as set-cookie response metadata and reads them back from a cookie metadata entry (internal/grpcsrv/transport/grpc_metadata.go). Neither half was implemented, so the whole cookie-bound surface was dead over protocol="grpc". Cookies are now captured and replayed per client instance.

Verification

Against a real server (2.4.0, sqlite, default --app-cookie-secure), signup -> MFA offer -> skip_mfa_setup -> get_profile:

[graphql] signup -> 'Proceed to mfa setup' token=None totp_screen=True
[graphql] skip_mfa_setup -> 'MFA setup skipped' token=True
[graphql] profile -> py-graphql-2cb25b42@example.com
[rest]    skip_mfa_setup -> 'MFA setup skipped' token=True
[grpc]    cookies captured: ['mfa_session', 'mfa_session_domain']
[grpc]    skip_mfa_setup -> 'MFA setup skipped' token=True
[async]   skip_mfa_setup -> 'MFA setup skipped' token=True

All four clients (sync/async, public/admin) fixed.

Test suite, same server, graphql,rest,grpc:

before after
unit (-m "not live") 117 passed 121 passed
live integration 36 failed, 48 passed 84 passed

The 36 live failures were pre-existing (identical on main): the suite asserted the pre-2.4.0 contract that signup returns a token. It now completes the MFA offer via skip_mfa_setup, which also exercises the fix end to end on every protocol.

ruff and mypy --strict clean.

Notes

  • tests/test_cookies.py is the regression test; the localhost case fails without the fix (verified) and covers gRPC metadata round-tripping plus the "don't downgrade Secure for real hosts" guard.
  • README documents the MFA offer and that skip_mfa_setup must be called on the same client instance that did the login/signup.

Server >= 2.4.0 has MFA on by default: signup/login withhold the access
token and open an MFA session identified ONLY by the mfa_session cookie.
skip_mfa_setup (and the *_mfa_setup calls) could not redeem that token.

Two independent cookie failures:

* http/localhost: the server sets the cookie Secure (--app-cookie-secure
  defaults to true) and Domain=localhost. http.cookiejar refuses Secure
  cookies on http:// and rewrites a dotless host to "localhost.local",
  which never matches Domain=localhost, so the cookie was stored but
  never sent. Browsers send it (loopback is a trustworthy origin), and
  so does curl. Normalise loopback cookies in the jar rather than
  installing a CookiePolicy: httpx rebuilds the outgoing jar with the
  default policy on every request (BaseClient._merge_cookies).
* grpc: the transport had no cookie handling at all. The server encodes
  cookies as set-cookie response metadata and reads them back from a
  cookie entry; capture and replay them per client.

Live suite is MFA-aware now; it was asserting the pre-2.4.0 contract
that signup returns a token (36 of 84 failing against 2.4.0).
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.

1 participant