diff --git a/README.md b/README.md index 57566f5..1d2dbfc 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,15 @@ with AuthorizerClient( print("access_token:", token.access_token) ``` +> **Note (Authorizer >= v2.4.0):** MFA is on by default, so `login`/`signup` withhold the access token and answer `"Proceed to mfa setup"` with `should_show_totp_screen=True`. Either walk the user through setup (`totp_mfa_setup`, `email_otp_mfa_setup`, …) or skip it. The MFA session is identified by a cookie, so **`skip_mfa_setup` must be called on the same client instance** that did the login/signup: +> +> ```python +> token = client.login(LoginRequest(email="user@example.com", password="Abc@123")) +> if not token.access_token: # MFA offer — same client keeps the MFA session cookie +> token = client.skip_mfa_setup(SkipMfaSetupRequest(email="user@example.com")) +> print("access_token:", token.access_token) +> ``` + > **Note (Authorizer >= v2.3.0):** the server's CSRF guard requires an `Origin` header on state-changing requests. The client sends the Authorizer server's own origin by default, which always passes. If your instance restricts `ALLOWED_ORIGINS`, pass your app's origin instead via `extra_headers`: `{"Origin": "https://your-app.com"}`. ## gRPC transport diff --git a/src/authorizer/_core.py b/src/authorizer/_core.py index ea100f0..3541905 100644 --- a/src/authorizer/_core.py +++ b/src/authorizer/_core.py @@ -2,6 +2,7 @@ from __future__ import annotations +import http.cookiejar as _cookiejar import json as _json from dataclasses import dataclass, field from typing import Any @@ -30,6 +31,42 @@ class ClientConfig: grpc_endpoint: str = "" +class _LoopbackCookieJar(_cookiejar.CookieJar): + """Cookie jar that keeps loopback cookies usable, the way browsers do. + + Server >= 2.4.0 has MFA on by default: signup/login withhold the access token + and start an MFA session identified ONLY by the ``mfa_session`` cookie, so + :meth:`~authorizer.client.AuthorizerClient.skip_mfa_setup` and the + ``*_mfa_setup`` calls depend on that cookie going back out. Two + :mod:`http.cookiejar` rules silently drop it against a local server: + + * ``Secure`` cookies are never sent to an ``http://`` URL, but the server sets + ``Secure`` by default (``--app-cookie-secure``) even when served over http; + * ``eff_request_host`` derives ``localhost.local`` for a dotless host, which + never domain-matches the ``Domain=localhost`` cookie the server sets. + + Browsers (and hence the login UI) send the cookie in both cases: W3C secure + contexts treat loopback as a trustworthy origin. The fix normalises the stored + cookie rather than installing a :class:`~http.cookiejar.CookiePolicy` because + httpx rebuilds the outgoing jar with the default policy on every request + (``BaseClient._merge_cookies``), which discards any custom policy. Non-loopback + cookies are untouched. + """ + + def set_cookie(self, cookie: Any) -> None: + host = (cookie.domain or "").lstrip(".").lower() + if host in ("localhost", "127.0.0.1", "::1") or host.endswith(".localhost"): + cookie.secure = False + if "." not in host: + cookie.domain = f".{host}.local" + super().set_cookie(cookie) + + +def new_cookie_jar() -> _cookiejar.CookieJar: + """Cookie jar for the SDK's httpx client (see :class:`_LoopbackCookieJar`).""" + return _LoopbackCookieJar() + + @dataclass class RequestSpec: method: str diff --git a/src/authorizer/_grpc_transport.py b/src/authorizer/_grpc_transport.py index 852c90e..6a49212 100644 --- a/src/authorizer/_grpc_transport.py +++ b/src/authorizer/_grpc_transport.py @@ -6,6 +6,7 @@ from __future__ import annotations +from http.cookies import SimpleCookie from typing import Any from urllib.parse import urlparse @@ -62,6 +63,46 @@ def grpc_metadata(config: ClientConfig, per_call: dict[str, str] | None) -> list return list(md.items()) +# -- cookies over gRPC ------------------------------------------------------- # +# gRPC has no cookie concept, so the server serialises response cookies as +# ``set-cookie`` metadata entries and reads them back from a ``cookie`` entry +# (internal/grpcsrv/transport/grpc_metadata.go). Without this round trip the MFA +# session opened by signup/login — which is identified ONLY by the mfa_session +# cookie — is lost, and skip_mfa_setup / *_mfa_setup can never redeem the +# withheld access token. httpx does this for graphql/rest via its cookie jar; +# this is the gRPC equivalent, kept per-client so cookies never leak across +# clients. + + +def apply_cookies( + metadata: list[tuple[str, str]], cookies: dict[str, str] +) -> list[tuple[str, str]]: + """Append the stored cookies as a single ``cookie`` metadata entry.""" + if not cookies: + return metadata + return [*metadata, ("cookie", "; ".join(f"{k}={v}" for k, v in cookies.items()))] + + +def store_cookies(initial_metadata: Any, cookies: dict[str, str]) -> None: + """Record ``set-cookie`` response metadata into *cookies* (in place).""" + for key, value in initial_metadata or (): + if key.lower() != "set-cookie": + continue + parsed: Any = SimpleCookie() + parsed.load(value) + for name, morsel in parsed.items(): + try: + # A zero/negative Max-Age is the server deleting the cookie + # (logout, DeleteMfaSession) — drop it rather than replay it. + expired = int(morsel["max-age"]) <= 0 + except (TypeError, ValueError): + expired = False + if expired: + cookies.pop(name, None) + else: + cookies[name] = morsel.value + + def make_channel(authorizer_url: str, grpc_endpoint: str = "") -> Any: """Create a synchronous gRPC channel.""" grpc = _require_grpc() @@ -107,16 +148,22 @@ def grpc_call( data: dict[str, Any] | None, metadata: list[tuple[str, str]], admin: bool, + cookies: dict[str, str] | None = None, ) -> dict[str, Any] | None: """Invoke a unary gRPC method on a synchronous channel and return a dict.""" grpc = _require_grpc() stub_cls, pb2, _ = _resolve(spec, admin) stub = stub_cls(channel) request = build_message(getattr(pb2, spec.grpc_request), data or {}) + if cookies is None: + cookies = {} try: - response = getattr(stub, spec.grpc_method)(request, metadata=metadata) + response, call = getattr(stub, spec.grpc_method).with_call( + request, metadata=apply_cookies(metadata, cookies) + ) except grpc.RpcError as e: # pragma: no cover - exercised live raise AuthorizerError(_rpc_message(e)) from e + store_cookies(call.initial_metadata(), cookies) return unwrap_field(message_to_dict(response), spec.grpc_response_unwrap) @@ -126,16 +173,21 @@ async def grpc_acall( data: dict[str, Any] | None, metadata: list[tuple[str, str]], admin: bool, + cookies: dict[str, str] | None = None, ) -> dict[str, Any] | None: """Invoke a unary gRPC method on a grpc.aio channel and return a dict.""" grpc = _require_grpc() stub_cls, pb2, _ = _resolve(spec, admin) stub = stub_cls(channel) request = build_message(getattr(pb2, spec.grpc_request), data or {}) + if cookies is None: + cookies = {} + call = getattr(stub, spec.grpc_method)(request, metadata=apply_cookies(metadata, cookies)) try: - response = await getattr(stub, spec.grpc_method)(request, metadata=metadata) + response = await call except grpc.RpcError as e: # pragma: no cover - exercised live raise AuthorizerError(_rpc_message(e)) from e + store_cookies(await call.initial_metadata(), cookies) return unwrap_field(message_to_dict(response), spec.grpc_response_unwrap) diff --git a/src/authorizer/admin_client.py b/src/authorizer/admin_client.py index 1fc8eb7..efdd96f 100644 --- a/src/authorizer/admin_client.py +++ b/src/authorizer/admin_client.py @@ -18,6 +18,7 @@ PROTOCOLS, ClientConfig, RequestSpec, + new_cookie_jar, parse_graphql_response, parse_rest, prepare_http, @@ -55,8 +56,10 @@ def __init__( admin_secret=admin_secret, grpc_endpoint=grpc_endpoint.strip(), ) - self._http = httpx.Client() + self._http = httpx.Client(cookies=new_cookie_jar()) self._channel: Any = None + # gRPC has no cookie jar; see _grpc_transport.store_cookies. + self._grpc_cookies: dict[str, str] = {} # -- lifecycle -------------------------------------------------------- # def close(self) -> None: @@ -101,7 +104,7 @@ def _invoke( self._config.authorizer_url, self._config.grpc_endpoint ) md = g.grpc_metadata(self._config, headers) - return g.grpc_call(self._channel, spec, data, md, self._ADMIN) + return g.grpc_call(self._channel, spec, data, md, self._ADMIN, self._grpc_cookies) req, kind, unwrap = prepare_http(self._config, spec, data, headers) res = self._send(req) if kind == "rest": diff --git a/src/authorizer/async_admin_client.py b/src/authorizer/async_admin_client.py index f7c65f6..fc363d8 100644 --- a/src/authorizer/async_admin_client.py +++ b/src/authorizer/async_admin_client.py @@ -13,6 +13,7 @@ PROTOCOLS, ClientConfig, RequestSpec, + new_cookie_jar, parse_graphql_response, parse_rest, prepare_http, @@ -50,8 +51,10 @@ def __init__( admin_secret=admin_secret, grpc_endpoint=grpc_endpoint.strip(), ) - self._http = httpx.AsyncClient() + self._http = httpx.AsyncClient(cookies=new_cookie_jar()) self._channel: Any = None + # gRPC has no cookie jar; see _grpc_transport.store_cookies. + self._grpc_cookies: dict[str, str] = {} # -- lifecycle -------------------------------------------------------- # async def aclose(self) -> None: @@ -98,7 +101,9 @@ async def _invoke( self._config.authorizer_url, self._config.grpc_endpoint ) md = g.grpc_metadata(self._config, headers) - return await g.grpc_acall(self._channel, spec, data, md, self._ADMIN) + return await g.grpc_acall( + self._channel, spec, data, md, self._ADMIN, self._grpc_cookies + ) req, kind, unwrap = prepare_http(self._config, spec, data, headers) res = await self._send(req) if kind == "rest": diff --git a/src/authorizer/async_client.py b/src/authorizer/async_client.py index b6c1017..c840db2 100644 --- a/src/authorizer/async_client.py +++ b/src/authorizer/async_client.py @@ -17,6 +17,7 @@ build_headers, build_oauth_request, build_token_body, + new_cookie_jar, parse_graphql_data, parse_graphql_response, parse_oauth_response, @@ -56,8 +57,10 @@ def __init__( protocol=protocol, grpc_endpoint=grpc_endpoint.strip(), ) - self._http = httpx.AsyncClient() + self._http = httpx.AsyncClient(cookies=new_cookie_jar()) self._channel: Any = None + # gRPC has no cookie jar; see _grpc_transport.store_cookies. + self._grpc_cookies: dict[str, str] = {} # -- lifecycle -------------------------------------------------------- # async def aclose(self) -> None: @@ -125,7 +128,9 @@ async def _invoke( self._config.authorizer_url, self._config.grpc_endpoint ) md = g.grpc_metadata(self._config, headers) - return await g.grpc_acall(self._channel, spec, data, md, self._ADMIN) + return await g.grpc_acall( + self._channel, spec, data, md, self._ADMIN, self._grpc_cookies + ) req, kind, unwrap = prepare_http(self._config, spec, data, headers) res = await self._send(req) if kind == "rest": diff --git a/src/authorizer/client.py b/src/authorizer/client.py index 9803ab9..6b86b2a 100644 --- a/src/authorizer/client.py +++ b/src/authorizer/client.py @@ -17,6 +17,7 @@ build_headers, build_oauth_request, build_token_body, + new_cookie_jar, parse_graphql_data, parse_graphql_response, parse_oauth_response, @@ -56,8 +57,10 @@ def __init__( protocol=protocol, grpc_endpoint=grpc_endpoint.strip(), ) - self._http = httpx.Client() + self._http = httpx.Client(cookies=new_cookie_jar()) self._channel: Any = None + # gRPC has no cookie jar; see _grpc_transport.store_cookies. + self._grpc_cookies: dict[str, str] = {} # -- lifecycle -------------------------------------------------------- # def close(self) -> None: @@ -125,7 +128,7 @@ def _invoke( self._config.authorizer_url, self._config.grpc_endpoint ) md = g.grpc_metadata(self._config, headers) - return g.grpc_call(self._channel, spec, data, md, self._ADMIN) + return g.grpc_call(self._channel, spec, data, md, self._ADMIN, self._grpc_cookies) req, kind, unwrap = prepare_http(self._config, spec, data, headers) res = self._send(req) if kind == "rest": diff --git a/tests/integration/test_live.py b/tests/integration/test_live.py index 9084269..b67ab72 100644 --- a/tests/integration/test_live.py +++ b/tests/integration/test_live.py @@ -119,6 +119,37 @@ def admin(protocol: str) -> AuthorizerAdminClient: c.close() +def _complete_mfa(client: AuthorizerClient, auth: t.AuthToken, email: str) -> t.AuthToken: + """Redeem the access token withheld by the MFA offer. + + Since server 2.4.0 MFA is on by default: signup/login answer "Proceed to mfa + setup" with no access token and open an MFA session identified by a cookie. + ``skip_mfa_setup`` on the SAME client (so the cookie is replayed) hands the + token over. Older servers return the token directly, hence the guard. + """ + if auth.access_token: + return auth + return client.skip_mfa_setup(t.SkipMfaSetupRequest(email=email)) + + +async def _acomplete_mfa( + client: AsyncAuthorizerClient, auth: t.AuthToken, email: str +) -> t.AuthToken: + """Async mirror of :func:`_complete_mfa`.""" + if auth.access_token: + return auth + return await client.skip_mfa_setup(t.SkipMfaSetupRequest(email=email)) + + +def _signup_authed(client: AuthorizerClient, prefix: str) -> tuple[str, t.AuthToken]: + """Sign a fresh user up over *client*'s protocol and clear the MFA offer.""" + email = f"{_unique(prefix)}@example.com" + auth = client.signup( + t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD) + ) + return email, _complete_mfa(client, auth, email) + + def _signup(client: AuthorizerClient) -> tuple[t.AuthToken, dict[str, str], str]: """Signup a fresh user over graphql and return (auth, bearer_header, session_cookie). @@ -128,13 +159,7 @@ def _signup(client: AuthorizerClient) -> tuple[t.AuthToken, dict[str, str], str] """ gql = AuthorizerClient(CLIENT_ID, URL, protocol="graphql") try: - auth = gql.signup( - t.SignUpRequest( - email=f"{_unique('py-live')}@example.com", - password=PASSWORD, - confirm_password=PASSWORD, - ) - ) + _, auth = _signup_authed(gql, "py-live") cookie = gql._http.cookies.get("cookie_session") or "" finally: gql.close() @@ -152,10 +177,7 @@ def test_meta(client: AuthorizerClient) -> None: def test_signup_and_profile(client: AuthorizerClient) -> None: - email = f"{_unique('py-live')}@example.com" - auth = client.signup( - t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD) - ) + email, auth = _signup_authed(client, "py-live") assert auth.access_token assert auth.user is not None and auth.user.email == email headers = {"Authorization": f"Bearer {auth.access_token}"} @@ -211,9 +233,10 @@ def test_check_and_list_permissions(client: AuthorizerClient, fga_seed: None) -> # --------------------------------------------------------------------------- # def test_login(client: AuthorizerClient) -> None: # Sign up a fresh user (over the same protocol), then log in with it. - email = f"{_unique('py-login')}@example.com" - client.signup(t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD)) - auth = client.login(t.LoginRequest(email=email, password=PASSWORD)) + email, _ = _signup_authed(client, "py-login") + auth = _complete_mfa( + client, client.login(t.LoginRequest(email=email, password=PASSWORD)), email + ) assert auth.access_token assert auth.user is not None and auth.user.email == email @@ -221,10 +244,7 @@ def test_login(client: AuthorizerClient) -> None: def test_update_profile(client: AuthorizerClient) -> None: # update_profile is authenticated: over grpc the bearer is sent as metadata # (#636 interceptor); over http it is the Authorization header. - email = f"{_unique('py-upd')}@example.com" - auth = client.signup( - t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD) - ) + _, auth = _signup_authed(client, "py-upd") headers = {"Authorization": f"Bearer {auth.access_token}"} res = client.update_profile(t.UpdateProfileRequest(given_name="Updated"), headers=headers) assert res is not None # GenericResponse over all protocols @@ -265,6 +285,7 @@ async def test_async_signup_and_profile(protocol: str) -> None: auth = await c.signup( t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD) ) + auth = await _acomplete_mfa(c, auth, email) assert auth.user is not None and auth.user.email == email prof = await c.get_profile( headers={"Authorization": f"Bearer {auth.access_token}"} @@ -281,7 +302,10 @@ async def test_async_login_and_update_profile(protocol: str) -> None: auth = await c.signup( t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD) ) - logged_in = await c.login(t.LoginRequest(email=email, password=PASSWORD)) + auth = await _acomplete_mfa(c, auth, email) + logged_in = await _acomplete_mfa( + c, await c.login(t.LoginRequest(email=email, password=PASSWORD)), email + ) assert logged_in.access_token headers = {"Authorization": f"Bearer {auth.access_token}"} res = await c.update_profile( @@ -527,10 +551,7 @@ def test_admin_org_member_lifecycle( admin: AuthorizerAdminClient, client: AuthorizerClient, protocol: str ) -> None: org = admin.create_organization(t.CreateOrganizationRequest(name=_unique("org-mem"))) - email = f"{_unique('member')}@example.com" - signup = client.signup( - t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD) - ) + email, signup = _signup_authed(client, "member") user_id = signup.user.id try: member = admin.add_org_member( @@ -676,10 +697,7 @@ def test_webauthn_options_and_credentials( Only the option-issuing and listing halves are exercised: completing a ceremony needs a real authenticator to sign the challenge. """ - email = f"{_unique('passkey')}@example.com" - signup = client.signup( - t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD) - ) + email, signup = _signup_authed(client, "passkey") bearer = {"Authorization": f"Bearer {signup.access_token}"} try: opts = client.webauthn_registration_options(headers=bearer) diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 0000000..c576b3a --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,77 @@ +# tests/test_cookies.py +import respx +from httpx import Response + +from authorizer import types as t +from authorizer.client import AuthorizerClient + +# What the server sends on an MFA offer: Secure (--app-cookie-secure defaults to +# true) and Domain-scoped to the host, even when served over plain http. +MFA_COOKIE = "mfa_session=sess-1; Path=/; Domain={host}; Max-Age=179; HttpOnly; Secure" + +OFFER = {"data": {"signup": {"message": "Proceed to mfa setup", "access_token": None}}} +SKIPPED = {"data": {"skip_mfa_setup": {"message": "MFA setup skipped", "access_token": "tok"}}} + + +def _run(url, host): + """Signup (MFA offer + Set-Cookie) then skip_mfa_setup; return the sent Cookie header.""" + with respx.mock: + route = respx.post(f"{url}/graphql") + route.side_effect = [ + Response(200, json=OFFER, headers={"set-cookie": MFA_COOKIE.format(host=host)}), + Response(200, json=SKIPPED), + ] + client = AuthorizerClient("cid", url) + try: + offer = client.signup( + t.SignUpRequest(email="a@b.com", password="p", confirm_password="p") + ) + assert offer.access_token is None + token = client.skip_mfa_setup(t.SkipMfaSetupRequest(email="a@b.com")) + finally: + client.close() + return route.calls[1].request.headers.get("cookie"), token + + +def test_mfa_session_cookie_is_returned_over_http_localhost(): + # Regression: a Secure, Domain=localhost cookie is dropped by http.cookiejar + # (Secure vs http, and dotless host -> "localhost.local"), so skip_mfa_setup + # could never redeem the withheld token against a local server. + cookie, token = _run("http://localhost:8380", "localhost") + assert cookie == "mfa_session=sess-1" + assert token.access_token == "tok" + + +def test_mfa_session_cookie_is_returned_over_https(): + cookie, token = _run("https://auth.example.com", "auth.example.com") + assert cookie == "mfa_session=sess-1" + assert token.access_token == "tok" + + +def test_secure_cookie_is_not_downgraded_for_non_loopback_hosts(): + # Only loopback gets the browser-style relaxation; a Secure cookie from a + # real host must still never be sent over plain http. + cookie, _ = _run("http://auth.example.com", "auth.example.com") + assert cookie is None + + +def test_grpc_cookies_round_trip(): + # gRPC has no cookie jar: the server serialises cookies as `set-cookie` + # response metadata and reads them back from a `cookie` entry. + from authorizer._grpc_transport import apply_cookies, store_cookies + + jar: dict[str, str] = {} + assert apply_cookies([("x", "1")], jar) == [("x", "1")] + + store_cookies( + [ + ("content-type", "application/grpc"), + ("set-cookie", MFA_COOKIE.format(host="localhost")), + ], + jar, + ) + assert apply_cookies([("x", "1")], jar) == [("x", "1"), ("cookie", "mfa_session=sess-1")] + + # Max-Age<=0 is the server deleting the cookie (logout / DeleteMfaSession). + store_cookies([("set-cookie", "mfa_session=; Path=/; Max-Age=-1")], jar) + assert jar == {} diff --git a/tests/test_protocol_selection.py b/tests/test_protocol_selection.py index 178b882..21ee9e9 100644 --- a/tests/test_protocol_selection.py +++ b/tests/test_protocol_selection.py @@ -100,7 +100,7 @@ def close(self) -> None: channel = FakeChannel() monkeypatch.setattr(g, "make_channel", lambda url, endpoint="": channel) - def fake_call(ch, spec, data, metadata, admin): # type: ignore[no-untyped-def] + def fake_call(ch, spec, data, metadata, admin, cookies): # type: ignore[no-untyped-def] captured.update(channel=ch, method=spec.grpc_method, data=data, admin=admin) return {"access_token": "tok"}