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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/authorizer/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions src/authorizer/_grpc_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

from http.cookies import SimpleCookie
from typing import Any
from urllib.parse import urlparse

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)


Expand All @@ -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)


Expand Down
7 changes: 5 additions & 2 deletions src/authorizer/admin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
PROTOCOLS,
ClientConfig,
RequestSpec,
new_cookie_jar,
parse_graphql_response,
parse_rest,
prepare_http,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down
9 changes: 7 additions & 2 deletions src/authorizer/async_admin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
PROTOCOLS,
ClientConfig,
RequestSpec,
new_cookie_jar,
parse_graphql_response,
parse_rest,
prepare_http,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down
9 changes: 7 additions & 2 deletions src/authorizer/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
build_headers,
build_oauth_request,
build_token_body,
new_cookie_jar,
parse_graphql_data,
parse_graphql_response,
parse_oauth_response,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down
7 changes: 5 additions & 2 deletions src/authorizer/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
build_headers,
build_oauth_request,
build_token_body,
new_cookie_jar,
parse_graphql_data,
parse_graphql_response,
parse_oauth_response,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down
Loading
Loading