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.
- authz_challenge/__init__.py +55 -0
- authz_challenge/challenge.py +265 -0
- authz_challenge/ciba.py +346 -0
- authz_challenge/py.typed +0 -0
- authz_challenge/resolver.py +1030 -0
- client_attestation_sdk/__init__.py +83 -0
- client_attestation_sdk/_http.py +148 -0
- client_attestation_sdk/builders.py +244 -0
- client_attestation_sdk/credential.py +38 -0
- client_attestation_sdk/errors.py +20 -0
- client_attestation_sdk/evidence.py +208 -0
- client_attestation_sdk/issuance.py +109 -0
- client_attestation_sdk/keys.py +100 -0
- client_attestation_sdk/py.typed +0 -0
- client_attestation_sdk/signer.py +137 -0
- client_attestation_sdk/spiffe.py +144 -0
- client_attestation_sdk/svid_source.py +75 -0
- client_attestation_sdk/token_source.py +536 -0
- client_attestation_sdk-0.2.0.dist-info/METADATA +157 -0
- client_attestation_sdk-0.2.0.dist-info/RECORD +32 -0
- client_attestation_sdk-0.2.0.dist-info/WHEEL +5 -0
- client_attestation_sdk-0.2.0.dist-info/licenses/LICENSE +202 -0
- client_attestation_sdk-0.2.0.dist-info/top_level.txt +3 -0
- token_validator/__init__.py +22 -0
- token_validator/config.py +52 -0
- token_validator/discovery.py +19 -0
- token_validator/errors.py +18 -0
- token_validator/jwks.py +122 -0
- token_validator/py.typed +0 -0
- token_validator/resource.py +133 -0
- token_validator/result.py +35 -0
- token_validator/validator.py +158 -0
|
@@ -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
|
authz_challenge/ciba.py
ADDED
|
@@ -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
|
authz_challenge/py.typed
ADDED
|
File without changes
|