client-attestation-sdk 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ """Agent-side handling of a policy enforcement point's AUTHORIZATION CHALLENGES.
2
+
3
+ When a PEP will not let a call proceed as presented, it does not simply deny — it says what would
4
+ make the call permissible: an authenticated user, a specific authorisation for THIS instruction, a
5
+ verified credential, a consent, a lodged intent. This package is the client half of that
6
+ conversation:
7
+
8
+ * :func:`parse_challenge` — turn whatever the PEP said (REST status + ``WWW-Authenticate`` + body,
9
+ a JSON-RPC error, an MCP tool result) into one typed :class:`Challenge`. Pure; vector-pinned across
10
+ language ports.
11
+ * :class:`CibaClient` — the OpenID CIBA client: raise a decoupled push to the human's authenticator
12
+ and poll for the answer at the AS's cadence (the pacing rules are the part everyone gets wrong).
13
+ * :class:`CibaResolver` / :class:`WebConsentResolver` — the ceremony state machines: dedupe, channel
14
+ choice, push, settle, terminal record, and verification that a resumed call matches what the human
15
+ approved. Deployment edges (directories, device registries) are injected via :class:`Hooks`.
16
+
17
+ Vocabulary is fixed in :mod:`.challenge` (challenge types) and :mod:`.resolver` (statuses,
18
+ channels, assurance rungs the resolver may claim).
19
+ """
20
+ from . import challenge as _challenge
21
+ from . import ciba as _ciba
22
+ from . import resolver as _resolver
23
+ from .challenge import (
24
+ AUTHN, CHALLENGE_TYPES, CONSENT, IDENTITY_PROOFING, INTENT, RESOURCE_AUTHORISATION,
25
+ Challenge, parse_challenge, parse_www_authenticate,
26
+ )
27
+ from .ciba import (
28
+ APPROVED, CIBA_GRANT, DENIED, EXPIRED, PENDING,
29
+ CibaClient, CibaError, CibaRequest, ClientAuth, PrivateKeyJwtAuth,
30
+ private_key_jwt_assertion, urllib_http,
31
+ )
32
+ from .resolver import (
33
+ ASSURANCE_DEVICE_APPROVED, ASSURANCE_WEB_CONSENT, CHANNEL_DEVICE, CHANNEL_LOCAL, CHANNEL_WEB,
34
+ TERMINAL, Ceremony, CeremonyStore, ChallengeResolver, CibaResolver, Hooks, MemoryStore,
35
+ Pending, PollResult, ProofingResolver, Resolved, Unavailable, WebConsentResolver,
36
+ SUBJECT_KEY, new_code, new_handle, transaction_hash,
37
+ )
38
+
39
+ __all__ = [
40
+ # challenge
41
+ "Challenge", "parse_challenge", "parse_www_authenticate", "CHALLENGE_TYPES",
42
+ "AUTHN", "RESOURCE_AUTHORISATION", "CONSENT", "IDENTITY_PROOFING", "INTENT",
43
+ # ciba
44
+ "CibaClient", "CibaError", "CibaRequest", "ClientAuth", "PrivateKeyJwtAuth",
45
+ "private_key_jwt_assertion", "urllib_http", "CIBA_GRANT",
46
+ "PENDING", "APPROVED", "DENIED", "EXPIRED",
47
+ # resolver
48
+ "ChallengeResolver", "CibaResolver", "WebConsentResolver", "ProofingResolver", "Hooks",
49
+ "Ceremony", "CeremonyStore", "MemoryStore",
50
+ "Pending", "Resolved", "Unavailable", "PollResult",
51
+ "transaction_hash", "new_code", "new_handle", "SUBJECT_KEY", "TERMINAL",
52
+ "CHANNEL_DEVICE", "CHANNEL_WEB", "CHANNEL_LOCAL",
53
+ "ASSURANCE_DEVICE_APPROVED", "ASSURANCE_WEB_CONSENT",
54
+ ]
55
+ __version__ = "0.2.0"
@@ -0,0 +1,265 @@
1
+ """Normalise a policy enforcement point's authorization CHALLENGE into one typed object.
2
+
3
+ A PEP that will not let a call proceed as presented can say so in more than one way, and the ways
4
+ do not agree with each other:
5
+
6
+ * **REST**: an HTTP status + an RFC 6750 ``WWW-Authenticate: Bearer error="…"`` header + a JSON body
7
+ (``{"error": "insufficient_scope", "scope": "…", "reason": "…", "pep": "…"}``).
8
+ * **JSON-RPC** (an MCP edge): HTTP 200 carrying ``{"error": {"code": -32401, "message": "…",
9
+ "data": {"authz_challenge": {…}}}}`` — the ``data`` member is the structured form; older PEPs put
10
+ the same facts only in the prose ``message`` (``"insufficient_scope scope=… :: reason"``).
11
+ * **Tool result**: an MCP server that already translated the gateway's answer into a tool payload
12
+ (``{"authorized": false, "insufficient_scope": true, "scope_required": "…"}``).
13
+
14
+ The status code alone cannot classify a challenge — this demo's PEPs answer 401 both for "no user
15
+ at all" and for "user present, scope missing", and a plain deny is 403 with no challenge in it. So the
16
+ first job of an agent-side SDK is *not* to resolve the challenge; it is to say, deterministically,
17
+ which one it is. That is what :func:`parse_challenge` does. It is pure (no I/O, no clock) so it can
18
+ be pinned by shared vectors across every language port.
19
+
20
+ The five challenge types are the demo's authorisation taxonomy — the same words the resource-authz
21
+ directory records use — so a challenge, the ceremony that resolves it, and the record it leaves
22
+ behind all agree on the term:
23
+
24
+ ====================== ====================================================== =====================
25
+ type what the PEP is asking for distinguishing field
26
+ ====================== ====================================================== =====================
27
+ ``authn`` an authenticated end user (there is none) ``acr_values``
28
+ ``resource_authorisation`` a specific, fine-grained authorisation (RFC 9470) ``scope``
29
+ ``consent`` a coarse-grained agreement (reserved — no PEP signal) —
30
+ ``identity_proofing`` a verified-credential presentation (e.g. an mDL) ``doctype``
31
+ ``intent`` a lodged intent / Grant Management grant (reserved) ``grant_id``
32
+ ====================== ====================================================== =====================
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import re
38
+ from dataclasses import asdict, dataclass, field
39
+ from typing import Any, Mapping, Optional
40
+
41
+ # ---- challenge types --------------------------------------------------------------------------
42
+ # String values match the resource-authorisation directory's AUTHZ_TYPE_* constants so records and
43
+ # challenges use one vocabulary. Do not rename without renaming the records.
44
+ AUTHN = "authn"
45
+ RESOURCE_AUTHORISATION = "resource_authorisation"
46
+ CONSENT = "consent"
47
+ IDENTITY_PROOFING = "identity_proofing"
48
+ INTENT = "intent"
49
+
50
+ CHALLENGE_TYPES = (AUTHN, RESOURCE_AUTHORISATION, CONSENT, IDENTITY_PROOFING, INTENT)
51
+
52
+ # ---- wire vocabulary ---------------------------------------------------------------------------
53
+ # RFC 6750 / RFC 9470 error codes and this demo's PEP-specific ones, mapped to a challenge type.
54
+ _ERROR_TO_TYPE = {
55
+ # RFC 9470 §3: the RS wants a stronger / fresher user authentication.
56
+ "insufficient_user_authentication": AUTHN,
57
+ # Demo PEPs (Kong authzen-pdp, coaz-pep): no X-User-Token at all.
58
+ "login_required": AUTHN,
59
+ # RFC 6750 §3.1: the token lacks a scope the RS needs. Here: a specific authorisation.
60
+ "insufficient_scope": RESOURCE_AUTHORISATION,
61
+ # Demo PEPs: the customer has no verified identity-proofing record.
62
+ "identity_verification_required": IDENTITY_PROOFING,
63
+ # Reserved — no PEP emits these today; recognised so a future PEP needs no client change.
64
+ "consent_required": CONSENT,
65
+ "intent_required": INTENT,
66
+ }
67
+
68
+ # The legacy JSON-RPC prose forms a coaz-pep emitted before the structured `data` member existed.
69
+ # "insufficient_scope scope=<scope> :: <reason>"
70
+ # "identity_verification_required doctype=<doctype> :: <reason>"
71
+ _PROSE_RE = re.compile(
72
+ r"^\s*(?P<error>insufficient_scope|identity_verification_required|login_required)"
73
+ r"(?:\s+(?P<key>scope|doctype|acr_values)=(?P<value>\S+))?"
74
+ r"(?:\s*::\s*(?P<reason>.*))?\s*$", re.DOTALL)
75
+
76
+ # RFC 6750 §3 auth-param grammar, permissively: key="value" or key=token, comma/space separated.
77
+ _AUTH_PARAM_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_\-]*)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))')
78
+
79
+
80
+ @dataclass
81
+ class Challenge:
82
+ """One normalised authorization challenge.
83
+
84
+ ``type`` is one of :data:`CHALLENGE_TYPES`. Exactly the fields that carry meaning for that type
85
+ are set (``scope`` for a resource authorisation, ``doctype`` for identity proofing, ``acr_values``
86
+ for authn); the rest are ``None``. ``error`` is the wire-level error code the PEP used; ``reason``
87
+ is the policy's human-readable explanation; ``pep`` names which enforcement point answered
88
+ (an audit convenience, not a protocol field). ``raw`` keeps the untouched source for callers
89
+ that need something this model does not carry.
90
+ """
91
+ type: str
92
+ error: str
93
+ scope: Optional[str] = None
94
+ doctype: Optional[str] = None
95
+ acr_values: Optional[str] = None
96
+ grant_id: Optional[str] = None
97
+ reason: Optional[str] = None
98
+ pep: Optional[str] = None
99
+ source: str = "rest" # "rest" | "jsonrpc" | "tool_result"
100
+ raw: Any = field(default=None, repr=False, compare=False)
101
+
102
+ def to_dict(self) -> dict:
103
+ d = asdict(self)
104
+ d.pop("raw", None)
105
+ return {k: v for k, v in d.items() if v is not None}
106
+
107
+
108
+ def parse_www_authenticate(value: Optional[str]) -> dict:
109
+ """Parse an RFC 6750 ``WWW-Authenticate: Bearer …`` header value into its auth-params.
110
+
111
+ Returns ``{}`` for a missing header or a non-Bearer scheme. Values are unquoted; backslash
112
+ escapes inside quoted strings are resolved. Keys are lower-cased.
113
+ """
114
+ if not value:
115
+ return {}
116
+ parts = value.strip().split(None, 1)
117
+ if not parts or parts[0].lower() != "bearer":
118
+ return {}
119
+ if len(parts) == 1:
120
+ return {}
121
+ out: dict = {}
122
+ for m in _AUTH_PARAM_RE.finditer(parts[1]):
123
+ key = m.group(1).lower()
124
+ quoted, bare = m.group(2), m.group(3)
125
+ val = re.sub(r"\\(.)", r"\1", quoted) if quoted is not None else bare
126
+ out[key] = val
127
+ return out
128
+
129
+
130
+ def _from_error(error: Optional[str], fields: Mapping[str, Any], *, source: str, raw: Any) -> Optional[Challenge]:
131
+ """Build a Challenge from a wire error code plus whichever fields came with it."""
132
+ if not error:
133
+ return None
134
+ ctype = _ERROR_TO_TYPE.get(str(error).lower())
135
+ if not ctype:
136
+ return None
137
+ return Challenge(
138
+ type=ctype, error=str(error).lower(),
139
+ scope=_s(fields.get("scope")),
140
+ doctype=_s(fields.get("doctype")),
141
+ acr_values=_s(fields.get("acr_values")),
142
+ grant_id=_s(fields.get("grant_id")),
143
+ reason=_s(fields.get("reason") or fields.get("error_description")),
144
+ pep=_s(fields.get("pep")),
145
+ source=source, raw=raw)
146
+
147
+
148
+ def _s(v: Any) -> Optional[str]:
149
+ if v is None:
150
+ return None
151
+ if isinstance(v, (list, tuple)):
152
+ v = " ".join(str(x) for x in v)
153
+ v = str(v).strip()
154
+ return v or None
155
+
156
+
157
+ def _lower_headers(headers: Optional[Mapping[str, str]]) -> dict:
158
+ return {str(k).lower(): v for k, v in (headers or {}).items()}
159
+
160
+
161
+ def _json_body(body: Any) -> Any:
162
+ if body is None:
163
+ return None
164
+ if isinstance(body, (bytes, bytearray)):
165
+ body = body.decode("utf-8", "replace")
166
+ if isinstance(body, str):
167
+ s = body.strip()
168
+ if not s:
169
+ return None
170
+ try:
171
+ return json.loads(s)
172
+ except ValueError:
173
+ return s
174
+ return body
175
+
176
+
177
+ def parse_challenge(status: Optional[int] = None,
178
+ headers: Optional[Mapping[str, str]] = None,
179
+ body: Any = None) -> Optional[Challenge]:
180
+ """Classify a PEP response. Returns a :class:`Challenge`, or ``None`` if the response is not a
181
+ challenge (a permit, a hard deny, a transport error, or something this parser does not know).
182
+
183
+ Accepts any of the three wire forms described in the module docstring. Precedence when a
184
+ response carries more than one signal:
185
+
186
+ 1. a JSON-RPC ``error.data.authz_challenge`` object (the structured form wins);
187
+ 2. a JSON body ``error`` code (REST) — the body is what carries ``scope``/``doctype``/``reason``;
188
+ 3. the ``WWW-Authenticate`` header's ``error`` auth-param;
189
+ 4. a JSON-RPC prose ``error.message`` in the legacy ``"<error> <key>=<value> :: <reason>"`` form;
190
+ 5. an MCP tool-result dict (``insufficient_scope`` / ``identity_proofing_required`` flags).
191
+
192
+ A 403 with no challenge fields is a hard deny → ``None``. A 401 whose body/header carries no
193
+ recognisable error is an ordinary "invalid token" → ``None`` (nothing an authorization ceremony
194
+ can fix). ``status`` is advisory only; the parser never classifies on status alone.
195
+ """
196
+ hdrs = _lower_headers(headers)
197
+ data = _json_body(body)
198
+ raw = {"status": status, "headers": dict(hdrs), "body": data}
199
+
200
+ # ---- JSON-RPC envelope ----------------------------------------------------------------------
201
+ if isinstance(data, dict) and isinstance(data.get("error"), dict) and "code" in data["error"]:
202
+ err = data["error"]
203
+ # 1) structured
204
+ d = err.get("data")
205
+ if isinstance(d, dict):
206
+ ac = d.get("authz_challenge") if isinstance(d.get("authz_challenge"), dict) else (
207
+ d if d.get("type") in CHALLENGE_TYPES else None)
208
+ if ac:
209
+ ctype = ac.get("type")
210
+ if ctype in CHALLENGE_TYPES:
211
+ return Challenge(
212
+ type=ctype, error=_s(ac.get("error")) or _default_error(ctype),
213
+ scope=_s(ac.get("scope")), doctype=_s(ac.get("doctype")),
214
+ acr_values=_s(ac.get("acr_values")), grant_id=_s(ac.get("grant_id")),
215
+ reason=_s(ac.get("reason")), pep=_s(ac.get("pep")),
216
+ source="jsonrpc", raw=raw)
217
+ # 4) legacy prose
218
+ m = _PROSE_RE.match(str(err.get("message") or ""))
219
+ if m:
220
+ fields: dict = {}
221
+ if m.group("key"):
222
+ fields[m.group("key")] = m.group("value")
223
+ if m.group("reason"):
224
+ fields["reason"] = m.group("reason").strip()
225
+ return _from_error(m.group("error"), fields, source="jsonrpc", raw=raw)
226
+ return None
227
+
228
+ # ---- REST: JSON body error code, then WWW-Authenticate --------------------------------------
229
+ www = parse_www_authenticate(hdrs.get("www-authenticate"))
230
+ if isinstance(data, dict) and data.get("error"):
231
+ merged = dict(www)
232
+ merged.update({k: v for k, v in data.items() if v is not None})
233
+ ch = _from_error(data.get("error"), merged, source="rest", raw=raw)
234
+ if ch:
235
+ return ch
236
+ if www.get("error"):
237
+ ch = _from_error(www.get("error"), www, source="rest", raw=raw)
238
+ if ch:
239
+ return ch
240
+
241
+ # ---- MCP tool-result dict --------------------------------------------------------------------
242
+ if isinstance(data, dict) and data.get("authorized") is False:
243
+ if data.get("insufficient_scope"):
244
+ return Challenge(type=RESOURCE_AUTHORISATION, error="insufficient_scope",
245
+ scope=_s(data.get("scope_required") or data.get("scope")),
246
+ reason=_s(data.get("policy_reason") or data.get("reason")),
247
+ pep=_s(data.get("pep")), source="tool_result", raw=raw)
248
+ if data.get("identity_proofing_required"):
249
+ return Challenge(type=IDENTITY_PROOFING, error="identity_verification_required",
250
+ doctype=_s(data.get("doctype")),
251
+ reason=_s(data.get("policy_reason") or data.get("reason")),
252
+ pep=_s(data.get("pep")), source="tool_result", raw=raw)
253
+ if data.get("login_required") or data.get("error") == "login_required":
254
+ return Challenge(type=AUTHN, error="login_required",
255
+ acr_values=_s(data.get("acr_values")),
256
+ reason=_s(data.get("policy_reason") or data.get("reason")),
257
+ pep=_s(data.get("pep")), source="tool_result", raw=raw)
258
+ return None
259
+
260
+
261
+ def _default_error(ctype: str) -> str:
262
+ for err, t in _ERROR_TO_TYPE.items():
263
+ if t == ctype:
264
+ return err
265
+ return ctype
@@ -0,0 +1,346 @@
1
+ """OpenID Connect CIBA (Client Initiated Backchannel Authentication) — the CLIENT side.
2
+
3
+ An agent that hits an authorization challenge and holds a paired-device channel for the human it
4
+ acts for can raise the ceremony itself: ``POST /as/bc-auth.ciba`` (a push to the human's
5
+ authenticator, decoupled from wherever the agent runs) then poll ``/as/token.oauth2`` with
6
+ ``grant_type=urn:openid:params:grant-type:ciba`` until the human approves, declines, or the request
7
+ expires. This module is that client, and nothing more: it knows the wire protocol and the AS's
8
+ pacing rules; it does not know what the approval *means* (see :mod:`.resolver`).
9
+
10
+ Two lessons are encoded here that were expensive to learn against PingFederate and must survive
11
+ every port:
12
+
13
+ 1. **The poll cadence is the AS's, not yours.** ``bc-auth`` returns ``interval``. Poll faster and
14
+ PF escalates ``authorization_pending`` → ``slow_down`` → ``invalid_request`` ("please respect the
15
+ polling interval") → **it invalidates the ``auth_req_id``** (``invalid_grant``), which a naive
16
+ client reports as "expired on device" — every ceremony dying ~10s in, before any human could
17
+ possibly have tapped. :meth:`CibaClient.poll` is therefore *throttled*: at most one token-endpoint
18
+ call per interval (calls in between return the cached outcome), +5s on ``slow_down`` (CIBA Core
19
+ §11), and a hard back-off on PF's interval-violation warning.
20
+
21
+ 2. **``private_key_jwt`` audience.** PF validates the client assertion's ``aud`` against its own
22
+ issuer / token endpoint, which in a proxied deployment is the INTERNAL address, not the public
23
+ host you posted to. :func:`private_key_jwt_assertion` sends an ``aud`` array carrying the endpoint
24
+ you called *and* the AS's internal issuer + token endpoint, or PF answers 400 ``invalid_client``.
25
+
26
+ Client authentication is pluggable (:class:`ClientAuth`). ``private_key_jwt`` ships here because that
27
+ is what the deployed CIBA clients use today; an attestation-based authenticator (this SDK's core
28
+ capability) slots in behind the same interface once the AS accepts it on the backchannel endpoint.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import ssl
34
+ import threading
35
+ import time
36
+ import urllib.parse
37
+ import uuid
38
+ import warnings
39
+ from dataclasses import dataclass, field
40
+ from typing import Callable, Optional, Protocol
41
+
42
+ import jwt
43
+
44
+ from client_attestation_sdk import _http
45
+ from client_attestation_sdk.errors import TransportError
46
+
47
+ # ---- outcomes -----------------------------------------------------------------------------------
48
+ PENDING = "pending" # authorization_pending / slow_down — keep waiting
49
+ APPROVED = "approved" # 200 from the token endpoint — the human approved
50
+ DENIED = "denied" # access_denied
51
+ EXPIRED = "expired" # expired_token, or PF's invalid_grant after it killed the request
52
+
53
+ CIBA_GRANT = "urn:openid:params:grant-type:ciba"
54
+ _JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
55
+
56
+
57
+ class CibaError(Exception):
58
+ """A backchannel request could not be raised (transport failure or a non-200 from bc-auth). The
59
+ default transport (:func:`urllib_http`) also raises it for a network failure, a timeout, a redirect
60
+ (never followed) or an oversized response."""
61
+
62
+ def __init__(self, message: str, *, status: Optional[int] = None, body: Optional[str] = None):
63
+ super().__init__(message)
64
+ self.status = status
65
+ self.body = body
66
+
67
+
68
+ # ---- transport ----------------------------------------------------------------------------------
69
+ # (method, url, form-fields, headers) -> (status, body-text). Injectable so a caller can supply
70
+ # its own HTTP stack (httpx, a test double, a recorder). The default is stdlib urllib, matching
71
+ # the rest of this SDK.
72
+ HttpFn = Callable[[str, str, dict, dict], "tuple[int, str]"]
73
+
74
+
75
+ def urllib_http(*, timeout: float = 30.0, dangerously_disable_tls_verification: bool = False,
76
+ insecure_tls: Optional[bool] = None) -> HttpFn:
77
+ """Default transport.
78
+
79
+ ``dangerously_disable_tls_verification`` skips certificate AND hostname verification, so anyone
80
+ on the path can impersonate the AS and read every client assertion and token. Only for an AS
81
+ reached on an internal self-signed address in development, never for a public one.
82
+
83
+ ``insecure_tls`` is the old name for the same switch. It still works for one release and emits
84
+ a :class:`DeprecationWarning`; it will be removed.
85
+ """
86
+ if insecure_tls is not None:
87
+ warnings.warn("urllib_http(insecure_tls=...) is deprecated; use "
88
+ "dangerously_disable_tls_verification=... instead", DeprecationWarning, stacklevel=2)
89
+ dangerously_disable_tls_verification = dangerously_disable_tls_verification or bool(insecure_tls)
90
+ ctx = None
91
+ if dangerously_disable_tls_verification:
92
+ ctx = ssl.create_default_context()
93
+ ctx.check_hostname = False
94
+ ctx.verify_mode = ssl.CERT_NONE
95
+
96
+ def _do(method: str, url: str, form: dict, headers: dict) -> "tuple[int, str]":
97
+ data = urllib.parse.urlencode(form).encode("utf-8") if form is not None else None
98
+ all_headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
99
+ all_headers.update(headers or {})
100
+ try:
101
+ resp = _http.request(method, url, headers=all_headers, body=data, timeout=timeout, context=ctx)
102
+ except TransportError as exc:
103
+ raise CibaError(str(exc), status=exc.status, body=exc.body) from exc
104
+ # Non-2xx is returned, not raised: the poll reads the OAuth error body (authorization_pending etc).
105
+ return resp.status, resp.text()
106
+ return _do
107
+
108
+
109
+ # ---- client authentication -------------------------------------------------------------------
110
+ class ClientAuth(Protocol):
111
+ """Adds the client's credential to a form POST bound for ``endpoint``."""
112
+
113
+ def apply(self, endpoint: str, form: dict) -> None: ... # noqa: E704
114
+
115
+
116
+ def private_key_jwt_assertion(*, client_id: str, endpoint: str, private_key_pem: str, kid: str,
117
+ extra_audiences: "tuple[str, ...]" = (), ttl: int = 120,
118
+ algorithm: str = "ES256") -> str:
119
+ """Mint an RFC 7523 client-assertion JWT.
120
+
121
+ ``aud`` is an ARRAY: the endpoint being called plus ``extra_audiences`` — the AS's internal issuer
122
+ and token endpoint when the AS validates the audience against an address other than the one you
123
+ reached it on (PingFederate behind a proxy does exactly this; omit them and PF 400s
124
+ ``invalid_client``).
125
+ """
126
+ now = int(time.time())
127
+ aud = [endpoint, *[a for a in extra_audiences if a and a != endpoint]]
128
+ return jwt.encode({"iss": client_id, "sub": client_id, "aud": aud if len(aud) > 1 else aud[0],
129
+ "jti": uuid.uuid4().hex, "iat": now, "exp": now + ttl},
130
+ private_key_pem, algorithm=algorithm, headers={"kid": kid})
131
+
132
+
133
+ @dataclass
134
+ class PrivateKeyJwtAuth:
135
+ """``private_key_jwt`` client authentication (RFC 7523 §2.2)."""
136
+ client_id: str
137
+ private_key_pem: str = field(repr=False) # never in repr/logs
138
+ kid: str
139
+ # The AS's internal issuer + token endpoint, when they differ from the public URL you call.
140
+ extra_audiences: "tuple[str, ...]" = ()
141
+ algorithm: str = "ES256"
142
+
143
+ def apply(self, endpoint: str, form: dict) -> None:
144
+ form["client_assertion_type"] = _JWT_BEARER
145
+ form["client_assertion"] = private_key_jwt_assertion(
146
+ client_id=self.client_id, endpoint=endpoint, private_key_pem=self.private_key_pem,
147
+ kid=self.kid, extra_audiences=self.extra_audiences, algorithm=self.algorithm)
148
+
149
+
150
+ # ---- request / poll state -----------------------------------------------------------------------
151
+ @dataclass
152
+ class CibaRequest:
153
+ """One backchannel authentication request as the AS accepted it, plus the poll throttle state.
154
+
155
+ This object IS the poll state — pass the same instance to every :meth:`CibaClient.poll`. If it is
156
+ persisted and rebuilt (a restart mid-ceremony), seed ``last_poll_at`` with the current time on
157
+ rebuild: the throttle state was lost, and one wasted interval is cheaper than a killed grant.
158
+ """
159
+ auth_req_id: str = field(repr=False) # bearer for the token poll: keep out of repr/logs
160
+ interval: int # seconds; PF's answer, honoured and mutated by poll()
161
+ expires_in: int # seconds from ``requested_at``
162
+ requested_at: float = field(default_factory=time.time)
163
+ last_poll_at: float = 0.0
164
+ last_outcome: str = PENDING
165
+ last_error: str = ""
166
+ access_token: Optional[str] = field(default=None, repr=False) # set on APPROVED, if the caller wants it
167
+ token_response: Optional[dict] = field(default=None, repr=False)
168
+
169
+ @property
170
+ def deadline(self) -> float:
171
+ return self.requested_at + max(0, self.expires_in)
172
+
173
+ def expired_by_clock(self, now: Optional[float] = None) -> bool:
174
+ return self.expires_in > 0 and (now or time.time()) >= self.deadline
175
+
176
+ def to_dict(self) -> dict:
177
+ return {"auth_req_id": self.auth_req_id, "interval": self.interval,
178
+ "expires_in": self.expires_in, "requested_at": self.requested_at,
179
+ "last_outcome": self.last_outcome}
180
+
181
+ @classmethod
182
+ def rehydrate(cls, d: dict) -> "CibaRequest":
183
+ """Rebuild from :meth:`to_dict` output. Seeds the throttle so the first poll waits."""
184
+ r = cls(auth_req_id=str(d.get("auth_req_id") or ""),
185
+ interval=max(1, int(d.get("interval") or 3)),
186
+ expires_in=int(d.get("expires_in") or 0),
187
+ requested_at=float(d.get("requested_at") or time.time()))
188
+ r.last_outcome = str(d.get("last_outcome") or PENDING)
189
+ r.last_poll_at = time.time()
190
+ return r
191
+
192
+
193
+ class CibaClient:
194
+ """Raise and poll CIBA requests against one authorization server.
195
+
196
+ :param bc_auth_endpoint: e.g. ``https://as.example/as/bc-auth.ciba``
197
+ :param token_endpoint: e.g. ``https://as.example/as/token.oauth2``
198
+ :param client_id: the CIBA client's id (also the ``iss`` of a signed request object)
199
+ :param client_auth: how this client authenticates to the AS
200
+ :param request_signing_key_pem: when set, ``bc-auth`` carries a SIGNED request object (JWS,
201
+ ``request=`` parameter) — what a FAPI-CIBA client sends. When ``None`` the parameters go flat.
202
+ :param request_signing_kid: the JWS ``kid`` for the signed request
203
+ :param issuer: the AS issuer — the ``aud`` of the signed request object
204
+ :param http: transport (see :func:`urllib_http`)
205
+ """
206
+
207
+ def __init__(self, *, bc_auth_endpoint: str, token_endpoint: str, client_id: str,
208
+ client_auth: ClientAuth, issuer: str,
209
+ request_signing_key_pem: Optional[str] = None,
210
+ request_signing_kid: Optional[str] = None,
211
+ request_signing_alg: str = "ES256",
212
+ http: Optional[HttpFn] = None,
213
+ clock: Callable[[], float] = time.time):
214
+ self.bc_auth_endpoint = bc_auth_endpoint
215
+ self.token_endpoint = token_endpoint
216
+ self.client_id = client_id
217
+ self.client_auth = client_auth
218
+ self.issuer = issuer
219
+ self.request_signing_key_pem = request_signing_key_pem
220
+ self.request_signing_kid = request_signing_kid
221
+ self.request_signing_alg = request_signing_alg
222
+ self.http = http or urllib_http()
223
+ self.clock = clock
224
+ self._lock = threading.Lock()
225
+
226
+ # ---- bc-auth --------------------------------------------------------------------------------
227
+ def request(self, *, login_hint: str, binding_message: Optional[str] = None,
228
+ scope: str = "openid", authorization_details: Optional[list] = None,
229
+ extra: Optional[dict] = None, request_ttl: int = 300) -> CibaRequest:
230
+ """``POST bc-auth``. Returns the accepted request (with the AS's ``interval`` and
231
+ ``expires_in``) or raises :class:`CibaError`.
232
+
233
+ ``binding_message`` is what the human sees on the device — keep it SHORT (PingOne MFA renders
234
+ ≤20 chars) and charset-safe; carry the full instruction out of band, keyed by it.
235
+ ``authorization_details`` (RFC 9396) is sent FLAT, never inside the signed request object —
236
+ on PingFederate the RAR pipeline runs on flat delivery but drops the details when they ride
237
+ in the signed ``request`` JWT (a confirmed product defect).
238
+ """
239
+ now = int(self.clock())
240
+ claims = {"scope": scope, "login_hint": login_hint}
241
+ if binding_message:
242
+ claims["binding_message"] = binding_message
243
+ if extra:
244
+ claims.update(extra)
245
+ form: dict = {}
246
+ if self.request_signing_key_pem:
247
+ req_obj = {"iss": self.client_id, "aud": self.issuer, "jti": uuid.uuid4().hex,
248
+ "iat": now, "exp": now + request_ttl, "nbf": now, **claims}
249
+ form["request"] = jwt.encode(req_obj, self.request_signing_key_pem,
250
+ algorithm=self.request_signing_alg,
251
+ headers={"kid": self.request_signing_kid} if self.request_signing_kid else None)
252
+ else:
253
+ form.update(claims)
254
+ if authorization_details is not None:
255
+ form["authorization_details"] = json.dumps(authorization_details, separators=(",", ":"))
256
+ self.client_auth.apply(self.bc_auth_endpoint, form)
257
+
258
+ status, text = self.http("POST", self.bc_auth_endpoint, form, {})
259
+ if status != 200:
260
+ raise CibaError(f"bc-auth {status}: {text[:300]}", status=status, body=text)
261
+ try:
262
+ body = json.loads(text) if text else {}
263
+ except ValueError:
264
+ raise CibaError(f"bc-auth returned non-JSON: {text[:200]}", status=status, body=text)
265
+ if not isinstance(body, dict):
266
+ raise CibaError(f"bc-auth returned JSON that is not an object: {text[:200]}",
267
+ status=status, body=text)
268
+ try:
269
+ interval = max(1, int(body.get("interval") or 3))
270
+ except (TypeError, ValueError):
271
+ interval = 3
272
+ try:
273
+ expires_in = max(0, int(body.get("expires_in") or 0))
274
+ except (TypeError, ValueError):
275
+ expires_in = 0
276
+ auth_req_id = str(body.get("auth_req_id") or "")
277
+ if not auth_req_id:
278
+ raise CibaError("bc-auth returned no auth_req_id", status=status, body=text)
279
+ return CibaRequest(auth_req_id=auth_req_id, interval=interval, expires_in=expires_in,
280
+ requested_at=float(self.clock()))
281
+
282
+ # ---- token poll -----------------------------------------------------------------------------
283
+ def poll(self, req: CibaRequest) -> str:
284
+ """Ask the AS whether the human has answered. Returns one of :data:`PENDING`,
285
+ :data:`APPROVED`, :data:`DENIED`, :data:`EXPIRED`.
286
+
287
+ THROTTLED: if fewer than ``req.interval`` seconds have passed since the last real call, the
288
+ cached outcome is returned and the AS is NOT contacted. Callers may therefore poll as often as
289
+ they like (a browser every 2s, a concierge every 3s, a bridge every second) — the AS sees at
290
+ most one request per interval.
291
+ """
292
+ with self._lock:
293
+ now = self.clock()
294
+ if req.last_outcome in (APPROVED, DENIED, EXPIRED):
295
+ return req.last_outcome
296
+ if not req.auth_req_id:
297
+ return PENDING
298
+ if req.expired_by_clock(now):
299
+ req.last_outcome = EXPIRED
300
+ req.last_error = "expires_in elapsed (client clock)"
301
+ return EXPIRED
302
+ if now - req.last_poll_at < req.interval + 0.5:
303
+ return req.last_outcome
304
+ req.last_poll_at = now
305
+
306
+ form = {"grant_type": CIBA_GRANT, "auth_req_id": req.auth_req_id}
307
+ self.client_auth.apply(self.token_endpoint, form)
308
+ try:
309
+ status, text = self.http("POST", self.token_endpoint, form, {})
310
+ except Exception as exc: # noqa: BLE001 — a transport blip is not a verdict
311
+ req.last_error = f"transport: {exc}"
312
+ return req.last_outcome
313
+ outcome = PENDING
314
+ if status == 200:
315
+ outcome = APPROVED
316
+ try:
317
+ tr = json.loads(text) if text else {}
318
+ except ValueError:
319
+ tr = {}
320
+ req.token_response = tr
321
+ req.access_token = tr.get("access_token") if isinstance(tr, dict) else None
322
+ else:
323
+ err, desc = "", ""
324
+ try:
325
+ j = json.loads(text) if text else {}
326
+ err, desc = str(j.get("error") or ""), str(j.get("error_description") or "")
327
+ except ValueError:
328
+ err = text[:80]
329
+ req.last_error = f"{status} {err} {desc}".strip()
330
+ if err == "slow_down":
331
+ # CIBA Core §11: the client MUST add 5s to its interval.
332
+ req.interval += 5
333
+ elif err == "access_denied":
334
+ outcome = DENIED
335
+ elif err in ("expired_token", "invalid_grant"):
336
+ # invalid_grant is what PF says AFTER it has killed the request for interval
337
+ # violations — indistinguishable from expiry to the client, so report it as one.
338
+ outcome = EXPIRED
339
+ elif err == "invalid_request" and "polling interval" in desc:
340
+ # PF's last warning before it invalidates the grant. Back off hard.
341
+ req.interval += 5
342
+ elif err == "authorization_pending":
343
+ pass
344
+ # anything else: stay pending; last_error carries the detail for logs
345
+ req.last_outcome = outcome
346
+ return outcome
File without changes