spanforge 1.0.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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
|
@@ -0,0 +1,2114 @@
|
|
|
1
|
+
"""spanforge.sdk.identity — SpanForge sf-identity client.
|
|
2
|
+
|
|
3
|
+
Implements the full sf-identity API surface for Phase 1 of the SpanForge
|
|
4
|
+
roadmap. All operations run locally in-process (zero external dependencies)
|
|
5
|
+
when ``config.endpoint`` is empty or when the remote service is unreachable
|
|
6
|
+
and ``config.local_fallback_enabled`` is ``True``.
|
|
7
|
+
|
|
8
|
+
Local-mode feature parity
|
|
9
|
+
--------------------------
|
|
10
|
+
* API key lifecycle: issue, rotate, revoke.
|
|
11
|
+
* Session JWT issuance (HS256 via stdlib :mod:`hmac` + :mod:`hashlib`).
|
|
12
|
+
* Magic-link issuance and single-use exchange.
|
|
13
|
+
* TOTP enrolment and verification (RFC 6238, SHA-1, 6 digits, 30 s period).
|
|
14
|
+
* TOTP backup codes (8 x 8-char alphanumeric, single-use).
|
|
15
|
+
* Per-key IP allowlist enforcement.
|
|
16
|
+
* Per-key sliding-window rate limiting.
|
|
17
|
+
* Brute-force lockout (5 consecutive failures -> 15 min lockout).
|
|
18
|
+
* JWKS endpoint stub.
|
|
19
|
+
|
|
20
|
+
Security requirements
|
|
21
|
+
---------------------
|
|
22
|
+
* All secret comparisons use :func:`hmac.compare_digest`.
|
|
23
|
+
* ``SecretStr`` values are never logged or included in exception messages.
|
|
24
|
+
* TOTP backup codes are stored as SHA-256 hashes only; plaintext is never
|
|
25
|
+
retained after enrolment.
|
|
26
|
+
* JWT tokens use HS256 in local mode (stdlib only). RS256 is used when a
|
|
27
|
+
remote sf-identity service is configured (requires the optional
|
|
28
|
+
``cryptography`` extra: ``pip install spanforge[identity]``).
|
|
29
|
+
* ``secrets`` module is used for all token/key generation.
|
|
30
|
+
|
|
31
|
+
Notes:
|
|
32
|
+
-----
|
|
33
|
+
All in-memory state (keys, sessions, links, TOTP) is **per-instance**.
|
|
34
|
+
State is not shared between instances and is not persisted. For production
|
|
35
|
+
use, configure a remote sf-identity service endpoint.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import base64
|
|
41
|
+
import hashlib
|
|
42
|
+
import hmac as _hmac
|
|
43
|
+
import ipaddress
|
|
44
|
+
import json
|
|
45
|
+
import logging
|
|
46
|
+
import os
|
|
47
|
+
import secrets
|
|
48
|
+
import struct
|
|
49
|
+
import threading
|
|
50
|
+
import time
|
|
51
|
+
import uuid
|
|
52
|
+
from datetime import datetime, timezone
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
from spanforge.sdk._base import (
|
|
56
|
+
SFClientConfig,
|
|
57
|
+
SFServiceClient,
|
|
58
|
+
_SlidingWindowRateLimiter,
|
|
59
|
+
)
|
|
60
|
+
from spanforge.sdk._exceptions import (
|
|
61
|
+
SFAuthError,
|
|
62
|
+
SFBruteForceLockedError,
|
|
63
|
+
SFIPDeniedError,
|
|
64
|
+
SFMFARequiredError,
|
|
65
|
+
SFQuotaExceededError,
|
|
66
|
+
SFScopeError,
|
|
67
|
+
SFTokenInvalidError,
|
|
68
|
+
)
|
|
69
|
+
from spanforge.sdk._types import (
|
|
70
|
+
APIKeyBundle,
|
|
71
|
+
JWTClaims,
|
|
72
|
+
KeyFormat,
|
|
73
|
+
MagicLinkResult,
|
|
74
|
+
OIDCAuthRequest,
|
|
75
|
+
OIDCTokenResult,
|
|
76
|
+
QuotaTier,
|
|
77
|
+
RateLimitInfo,
|
|
78
|
+
SCIMGroup,
|
|
79
|
+
SCIMListResponse,
|
|
80
|
+
SCIMUser,
|
|
81
|
+
SecretStr,
|
|
82
|
+
SSOSession,
|
|
83
|
+
TokenIntrospectionResult,
|
|
84
|
+
TOTPEnrollResult,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
__all__ = ["SFIdentityClient"]
|
|
88
|
+
|
|
89
|
+
_log = logging.getLogger(__name__)
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Constants
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
96
|
+
_API_KEY_RANDOM_LEN = 48 # chars of base62 after the prefix
|
|
97
|
+
_MAGIC_LINK_TTL_SECONDS = 15 * 60 # 15 minutes
|
|
98
|
+
_SESSION_TTL_SECONDS = 7 * 24 * 3600 # 7 days
|
|
99
|
+
_BRUTE_FORCE_MAX_FAILURES = 5
|
|
100
|
+
_BRUTE_FORCE_LOCKOUT_SECONDS = 15 * 60 # 15 minutes
|
|
101
|
+
_TOTP_MAX_FAILURES = 5
|
|
102
|
+
_TOTP_LOCKOUT_SECONDS = 15 * 60 # 15 minutes
|
|
103
|
+
_TOTP_WINDOW = 1 # ± 1 time-step (30 s) drift tolerance
|
|
104
|
+
_TOTP_PERIOD = 30 # seconds per time-step
|
|
105
|
+
_BACKUP_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # excludes I, O, 0, 1
|
|
106
|
+
_BACKUP_CODE_LEN = 8
|
|
107
|
+
_BACKUP_CODE_COUNT = 8
|
|
108
|
+
_FALLBACK_SIGNING_KEY = "spanforge-local-dev-signing-key-v1"
|
|
109
|
+
_FALLBACK_MAGIC_SECRET = "spanforge-local-dev-magic-secret-v1" # nosec B105 -- dev-only fallback; overridden via SPANFORGE_MAGIC_SECRET in production
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Pure-stdlib JWT helpers (HS256)
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_JWT_SEGMENTS: int = 3
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _b64url_encode(data: bytes) -> str:
|
|
121
|
+
"""Base64url-encode *data* without padding."""
|
|
122
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _b64url_decode(s: str) -> bytes:
|
|
126
|
+
"""Base64url-decode *s*, tolerating missing padding."""
|
|
127
|
+
padding = "=" * ((-len(s)) % 4)
|
|
128
|
+
return base64.urlsafe_b64decode(s + padding)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_HEADER_B64 = _b64url_encode(b'{"alg":"HS256","typ":"JWT"}')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _issue_hs256_jwt(payload: dict[str, Any], secret: bytes) -> str:
|
|
135
|
+
"""Sign *payload* as a HS256 JWT.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
payload: Claims dict. Must include ``"exp"`` (Unix timestamp).
|
|
139
|
+
secret: Signing key bytes.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Compact serialised JWT string.
|
|
143
|
+
"""
|
|
144
|
+
header = _HEADER_B64
|
|
145
|
+
body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
|
146
|
+
signing_input = f"{header}.{body}".encode()
|
|
147
|
+
sig = _hmac.new(secret, signing_input, hashlib.sha256).digest()
|
|
148
|
+
return f"{header}.{body}.{_b64url_encode(sig)}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _verify_hs256_jwt(token: str, secret: bytes) -> dict[str, Any]:
|
|
152
|
+
"""Verify and decode a HS256 JWT.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
token: Compact serialised JWT string.
|
|
156
|
+
secret: Signing key bytes.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Decoded claims dict.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
:exc:`~spanforge.sdk._exceptions.SFTokenInvalidError`: On any
|
|
163
|
+
validation failure (malformed, bad signature, or expired).
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
parts = token.split(".")
|
|
167
|
+
if len(parts) != _JWT_SEGMENTS:
|
|
168
|
+
raise SFTokenInvalidError("JWT has wrong number of segments")
|
|
169
|
+
|
|
170
|
+
header_b64, payload_b64, sig_b64 = parts
|
|
171
|
+
signing_input = f"{header_b64}.{payload_b64}".encode()
|
|
172
|
+
expected_sig = _hmac.new(secret, signing_input, hashlib.sha256).digest()
|
|
173
|
+
provided_sig = _b64url_decode(sig_b64)
|
|
174
|
+
|
|
175
|
+
if not _hmac.compare_digest(expected_sig, provided_sig):
|
|
176
|
+
raise SFTokenInvalidError("JWT signature verification failed")
|
|
177
|
+
|
|
178
|
+
claims: dict[str, Any] = json.loads(_b64url_decode(payload_b64))
|
|
179
|
+
|
|
180
|
+
exp = claims.get("exp")
|
|
181
|
+
if exp is not None and int(time.time()) > exp:
|
|
182
|
+
raise SFTokenInvalidError("JWT has expired")
|
|
183
|
+
|
|
184
|
+
return claims
|
|
185
|
+
|
|
186
|
+
except SFTokenInvalidError:
|
|
187
|
+
raise
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
raise SFTokenInvalidError(f"JWT could not be decoded: {type(exc).__name__}") from exc
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# TOTP helpers (RFC 6238)
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _compute_totp(secret_b32: str, timestamp: float | None = None) -> str:
|
|
198
|
+
"""Compute a 6-digit TOTP code (RFC 6238, SHA-1, 30 s period).
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
secret_b32: Base32-encoded TOTP secret.
|
|
202
|
+
timestamp: Unix timestamp override (uses :func:`time.time` if omitted).
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Zero-padded 6-digit string, e.g. ``"042917"``.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
ValueError: If *secret_b32* is not valid base32.
|
|
209
|
+
"""
|
|
210
|
+
if timestamp is None:
|
|
211
|
+
timestamp = time.time()
|
|
212
|
+
counter = int(timestamp) // _TOTP_PERIOD
|
|
213
|
+
key = base64.b32decode(secret_b32.upper())
|
|
214
|
+
msg = struct.pack(">Q", counter)
|
|
215
|
+
mac_digest = _hmac.new(key, msg, hashlib.sha1).digest()
|
|
216
|
+
offset = mac_digest[-1] & 0x0F
|
|
217
|
+
code_int = struct.unpack(">I", mac_digest[offset : offset + 4])[0] & 0x7FFFFFFF
|
|
218
|
+
return str(code_int % 1_000_000).zfill(6)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Key generation helpers
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _random_base62(length: int) -> str:
|
|
227
|
+
"""Generate a cryptographically random base62 string of *length* chars."""
|
|
228
|
+
return "".join(secrets.choice(_BASE62) for _ in range(length))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _generate_api_key(test_mode: bool = False) -> str:
|
|
232
|
+
"""Generate a SpanForge API key in ``sf_(live|test)_<48 base62>`` format."""
|
|
233
|
+
env = "test" if test_mode else "live"
|
|
234
|
+
return f"sf_{env}_{_random_base62(_API_KEY_RANDOM_LEN)}"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _generate_key_id() -> str:
|
|
238
|
+
"""Generate a short opaque key identifier."""
|
|
239
|
+
return "key_" + secrets.token_hex(10)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _today_midnight_utc() -> float:
|
|
243
|
+
"""Return the Unix timestamp of midnight UTC for today."""
|
|
244
|
+
now = datetime.now(timezone.utc)
|
|
245
|
+
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
246
|
+
return midnight.timestamp()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# SFIdentityClient
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class SFIdentityClient(SFServiceClient):
|
|
255
|
+
"""SpanForge sf-identity service client.
|
|
256
|
+
|
|
257
|
+
Manages API key lifecycle, session tokens, TOTP MFA, magic-link
|
|
258
|
+
authentication, IP allowlists, and rate limiting.
|
|
259
|
+
|
|
260
|
+
In **local mode** (``config.endpoint == ""``), all operations execute
|
|
261
|
+
entirely in-process with no network calls. State is stored in memory
|
|
262
|
+
and is not persisted.
|
|
263
|
+
|
|
264
|
+
In **remote mode** (``config.endpoint`` set), operations are proxied to
|
|
265
|
+
the configured sf-identity service over HTTPS with retry and circuit
|
|
266
|
+
breaker protection.
|
|
267
|
+
|
|
268
|
+
Thread safety:
|
|
269
|
+
All mutable state uses :class:`threading.Lock`.
|
|
270
|
+
|
|
271
|
+
Example::
|
|
272
|
+
|
|
273
|
+
from spanforge.sdk._base import SFClientConfig
|
|
274
|
+
from spanforge.sdk.identity import SFIdentityClient
|
|
275
|
+
|
|
276
|
+
config = SFClientConfig() # local mode
|
|
277
|
+
identity = SFIdentityClient(config)
|
|
278
|
+
|
|
279
|
+
bundle = identity.issue_api_key(scopes=["sf_audit"])
|
|
280
|
+
print(bundle.key_id) # key_abc...
|
|
281
|
+
print(bundle.api_key) # <SecretStr:***>
|
|
282
|
+
print(bundle.api_key.get_secret_value()) # sf_live_...
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(self, config: SFClientConfig | None = None) -> None:
|
|
286
|
+
if config is None:
|
|
287
|
+
config = SFClientConfig.from_env()
|
|
288
|
+
super().__init__(config, "identity")
|
|
289
|
+
|
|
290
|
+
# Signing key: from config > env > fallback (dev-only)
|
|
291
|
+
self._signing_key: str = (
|
|
292
|
+
config.signing_key
|
|
293
|
+
or os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
294
|
+
or _FALLBACK_SIGNING_KEY
|
|
295
|
+
)
|
|
296
|
+
self._magic_secret: str = (
|
|
297
|
+
config.magic_secret
|
|
298
|
+
or os.environ.get("SPANFORGE_MAGIC_SECRET", "")
|
|
299
|
+
or _FALLBACK_MAGIC_SECRET
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# In-memory state (local mode)
|
|
303
|
+
self._lock = threading.Lock()
|
|
304
|
+
self._keys: dict[str, dict[str, Any]] = {} # api_key_value -> record
|
|
305
|
+
self._keys_by_id: dict[str, dict[str, Any]] = {} # key_id -> same record
|
|
306
|
+
self._revoked_jtis: set[str] = set()
|
|
307
|
+
self._magic_links: dict[str, dict[str, Any]] = {} # link_id -> record
|
|
308
|
+
self._totp_data: dict[str, dict[str, Any]] = {} # key_id -> totp record
|
|
309
|
+
self._brute_force: dict[str, dict[str, Any]] = {} # identifier -> brute-force record
|
|
310
|
+
self._rate_limiter = _SlidingWindowRateLimiter(limit=600, window_seconds=60.0)
|
|
311
|
+
# ID-031: MFA enforcement policies (project_id -> mfa_required)
|
|
312
|
+
self._mfa_policies: dict[str, bool] = {}
|
|
313
|
+
# ID-051/052: Quota tier tracking
|
|
314
|
+
self._key_tiers: dict[str, str] = {} # key_id -> QuotaTier name
|
|
315
|
+
self._daily_counts: dict[str, list[float]] = {} # key_id -> [utc timestamps]
|
|
316
|
+
# ID-041: SCIM 2.0 in-memory stores
|
|
317
|
+
self._scim_users: dict[str, dict[str, Any]] = {} # user_id -> record
|
|
318
|
+
self._scim_users_by_name: dict[str, str] = {} # user_name -> user_id
|
|
319
|
+
self._scim_groups: dict[str, dict[str, Any]] = {} # group_id -> record
|
|
320
|
+
# ID-042: OIDC pending auth requests (state -> record)
|
|
321
|
+
self._oidc_states: dict[str, dict[str, Any]] = {}
|
|
322
|
+
# ID-043: SSO session delegation (idp_session_id -> sso_session record)
|
|
323
|
+
self._sso_sessions: dict[str, dict[str, Any]] = {} # session_id -> record
|
|
324
|
+
self._sso_by_idp: dict[str, str] = {} # idp_session_id -> session_id
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------
|
|
327
|
+
# ID-003: Token refresh hook override
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def _on_token_near_expiry(self, seconds_remaining: int) -> None:
|
|
331
|
+
"""Override: attempt inline token refresh when expiry is near.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
seconds_remaining: Seconds until expiry per ``X-SF-Token-Expires`` header.
|
|
335
|
+
"""
|
|
336
|
+
_log.debug("Auth token expiring in %ds; attempting inline refresh", seconds_remaining)
|
|
337
|
+
try:
|
|
338
|
+
self.refresh_token()
|
|
339
|
+
except SFAuthError as exc:
|
|
340
|
+
if not self._config.local_fallback_enabled:
|
|
341
|
+
raise
|
|
342
|
+
_log.warning("Inline token refresh failed: %s", exc)
|
|
343
|
+
|
|
344
|
+
def refresh_token(self) -> str:
|
|
345
|
+
"""Refresh the session JWT.
|
|
346
|
+
|
|
347
|
+
In remote mode: ``POST /v1/tokens/refresh`` with the configured API key.
|
|
348
|
+
In local mode: issues a new session JWT for the configured key (no-op
|
|
349
|
+
equivalent when the key is still valid).
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
New JWT string.
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If no valid key is
|
|
356
|
+
available.
|
|
357
|
+
"""
|
|
358
|
+
if not self._is_local_mode():
|
|
359
|
+
resp = self._request("POST", "/v1/tokens/refresh")
|
|
360
|
+
return str(resp.get("jwt", ""))
|
|
361
|
+
|
|
362
|
+
api_key = self._config.api_key.get_secret_value()
|
|
363
|
+
if not api_key:
|
|
364
|
+
raise SFAuthError("No API key configured for token refresh")
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
KeyFormat.validate(api_key)
|
|
368
|
+
return self.create_session(api_key)
|
|
369
|
+
except (SFAuthError, Exception) as exc:
|
|
370
|
+
raise SFAuthError("Token refresh failed: no valid session available") from exc
|
|
371
|
+
|
|
372
|
+
# ------------------------------------------------------------------
|
|
373
|
+
# 4.2 API Key Lifecycle
|
|
374
|
+
# ------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def issue_api_key(
|
|
377
|
+
self,
|
|
378
|
+
*,
|
|
379
|
+
scopes: list[str] | None = None,
|
|
380
|
+
project_id: str = "",
|
|
381
|
+
expires_in_days: int = 365,
|
|
382
|
+
ip_allowlist: list[str] | None = None,
|
|
383
|
+
test_mode: bool = False,
|
|
384
|
+
) -> APIKeyBundle:
|
|
385
|
+
"""Issue a new SpanForge API key with an embedded session JWT.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
scopes: Permission scopes (e.g. ``["sf_pii", "sf_audit"]``).
|
|
389
|
+
``None`` or empty = unrestricted.
|
|
390
|
+
project_id: Project scope for the key. Defaults to the config
|
|
391
|
+
project_id.
|
|
392
|
+
expires_in_days: Session JWT TTL. Default: 365 days.
|
|
393
|
+
ip_allowlist: CIDR strings restricting which IPs may use this key.
|
|
394
|
+
Empty = unrestricted.
|
|
395
|
+
test_mode: If ``True``, issues a ``sf_test_`` prefixed key.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
:class:`~spanforge.sdk._types.APIKeyBundle` with the raw key value
|
|
399
|
+
(write-once; display to user once only).
|
|
400
|
+
"""
|
|
401
|
+
if not self._is_local_mode():
|
|
402
|
+
resp = self._request(
|
|
403
|
+
"POST",
|
|
404
|
+
"/v1/keys",
|
|
405
|
+
{
|
|
406
|
+
"scopes": scopes or [],
|
|
407
|
+
"project_id": project_id or self._config.project_id,
|
|
408
|
+
"expires_in_days": expires_in_days,
|
|
409
|
+
"ip_allowlist": ip_allowlist or [],
|
|
410
|
+
"test_mode": test_mode,
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
return self._bundle_from_response(resp)
|
|
414
|
+
|
|
415
|
+
return self._local_issue_api_key(
|
|
416
|
+
scopes=scopes or [],
|
|
417
|
+
project_id=project_id or self._config.project_id,
|
|
418
|
+
expires_in_days=expires_in_days,
|
|
419
|
+
ip_allowlist=ip_allowlist or [],
|
|
420
|
+
test_mode=test_mode,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def _local_issue_api_key(
|
|
424
|
+
self,
|
|
425
|
+
*,
|
|
426
|
+
scopes: list[str],
|
|
427
|
+
project_id: str,
|
|
428
|
+
expires_in_days: int,
|
|
429
|
+
ip_allowlist: list[str],
|
|
430
|
+
test_mode: bool,
|
|
431
|
+
) -> APIKeyBundle:
|
|
432
|
+
key_value = _generate_api_key(test_mode=test_mode)
|
|
433
|
+
key_id = _generate_key_id()
|
|
434
|
+
now = int(time.time())
|
|
435
|
+
exp = now + expires_in_days * 86_400
|
|
436
|
+
jti = str(uuid.uuid4())
|
|
437
|
+
|
|
438
|
+
record: dict[str, Any] = {
|
|
439
|
+
"key_id": key_id,
|
|
440
|
+
"key_value": key_value,
|
|
441
|
+
"scopes": scopes,
|
|
442
|
+
"project_id": project_id,
|
|
443
|
+
"ip_allowlist": ip_allowlist,
|
|
444
|
+
"created_at": now,
|
|
445
|
+
"expires_at": exp,
|
|
446
|
+
"revoked": False,
|
|
447
|
+
"rotated_to": None,
|
|
448
|
+
}
|
|
449
|
+
payload = {
|
|
450
|
+
"iss": "spanforge",
|
|
451
|
+
"sub": key_id,
|
|
452
|
+
"aud": project_id or "default",
|
|
453
|
+
"iat": now,
|
|
454
|
+
"exp": exp,
|
|
455
|
+
"jti": jti,
|
|
456
|
+
"scopes": scopes,
|
|
457
|
+
}
|
|
458
|
+
jwt = _issue_hs256_jwt(payload, self._signing_key.encode())
|
|
459
|
+
|
|
460
|
+
with self._lock:
|
|
461
|
+
self._keys[key_value] = record
|
|
462
|
+
self._keys_by_id[key_id] = record
|
|
463
|
+
|
|
464
|
+
return APIKeyBundle(
|
|
465
|
+
api_key=SecretStr(key_value),
|
|
466
|
+
key_id=key_id,
|
|
467
|
+
jwt=jwt,
|
|
468
|
+
expires_at=datetime.fromtimestamp(exp, tz=timezone.utc),
|
|
469
|
+
scopes=scopes,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def issue_magic_link(self, email: str) -> MagicLinkResult:
|
|
473
|
+
"""Issue a one-time magic-link token for *email*.
|
|
474
|
+
|
|
475
|
+
The link expires in 15 minutes and can be exchanged exactly once via
|
|
476
|
+
:meth:`exchange_magic_link`.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
email: Recipient email address (not validated here; validated by
|
|
480
|
+
the caller / form layer).
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
:class:`~spanforge.sdk._types.MagicLinkResult` with ``link_id``
|
|
484
|
+
and expiry.
|
|
485
|
+
"""
|
|
486
|
+
if not self._is_local_mode():
|
|
487
|
+
resp = self._request("POST", "/v1/magic-links", {"email": email})
|
|
488
|
+
return MagicLinkResult(
|
|
489
|
+
link_id=resp["link_id"],
|
|
490
|
+
expires_at=datetime.fromisoformat(resp["expires_at"]),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
return self._local_issue_magic_link(email)
|
|
494
|
+
|
|
495
|
+
def _local_issue_magic_link(self, email: str) -> MagicLinkResult:
|
|
496
|
+
nonce = secrets.token_urlsafe(32)
|
|
497
|
+
expiry = int(time.time()) + _MAGIC_LINK_TTL_SECONDS
|
|
498
|
+
sig_input = f"{email}:{nonce}:{expiry}".encode()
|
|
499
|
+
mac = _hmac.new(self._magic_secret.encode(), sig_input, hashlib.sha256).hexdigest()
|
|
500
|
+
token = f"{nonce}.{expiry}.{mac}"
|
|
501
|
+
link_id = secrets.token_urlsafe(16)
|
|
502
|
+
|
|
503
|
+
with self._lock:
|
|
504
|
+
self._magic_links[link_id] = {
|
|
505
|
+
"email": email,
|
|
506
|
+
"token": token,
|
|
507
|
+
"expiry": expiry,
|
|
508
|
+
"used": False,
|
|
509
|
+
}
|
|
510
|
+
return MagicLinkResult(
|
|
511
|
+
link_id=link_id,
|
|
512
|
+
expires_at=datetime.fromtimestamp(expiry, tz=timezone.utc),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def exchange_magic_link(
|
|
516
|
+
self,
|
|
517
|
+
token: str,
|
|
518
|
+
*,
|
|
519
|
+
link_id: str,
|
|
520
|
+
otp: str | None = None,
|
|
521
|
+
mfa_challenge: str | None = None,
|
|
522
|
+
) -> APIKeyBundle:
|
|
523
|
+
"""Exchange a magic-link token for an API key bundle.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
token: The token portion of the magic link (from the URL).
|
|
527
|
+
link_id: The ``link_id`` returned by :meth:`issue_magic_link`.
|
|
528
|
+
otp: TOTP OTP (required if the account has TOTP enrolled and
|
|
529
|
+
``mfa_challenge`` is present).
|
|
530
|
+
mfa_challenge: Challenge ID from a prior
|
|
531
|
+
:exc:`~spanforge.sdk._exceptions.SFMFARequiredError`.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
A new :class:`~spanforge.sdk._types.APIKeyBundle`.
|
|
535
|
+
|
|
536
|
+
Raises:
|
|
537
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If the token is
|
|
538
|
+
invalid, expired, or has already been used.
|
|
539
|
+
:exc:`~spanforge.sdk._exceptions.SFMFARequiredError`: If TOTP is
|
|
540
|
+
required but ``otp`` was not provided.
|
|
541
|
+
"""
|
|
542
|
+
if not self._is_local_mode():
|
|
543
|
+
resp = self._request(
|
|
544
|
+
"POST",
|
|
545
|
+
"/v1/magic-links/exchange",
|
|
546
|
+
{"token": token, "link_id": link_id, "otp": otp},
|
|
547
|
+
)
|
|
548
|
+
return self._bundle_from_response(resp)
|
|
549
|
+
|
|
550
|
+
return self._local_exchange_magic_link(token, link_id=link_id, otp=otp)
|
|
551
|
+
|
|
552
|
+
def _local_exchange_magic_link(
|
|
553
|
+
self, token: str, *, link_id: str, otp: str | None
|
|
554
|
+
) -> APIKeyBundle:
|
|
555
|
+
with self._lock:
|
|
556
|
+
record = self._magic_links.get(link_id)
|
|
557
|
+
|
|
558
|
+
if record is None:
|
|
559
|
+
raise SFAuthError("Magic link not found or already consumed")
|
|
560
|
+
|
|
561
|
+
if record["used"]:
|
|
562
|
+
raise SFAuthError("Magic link has already been used")
|
|
563
|
+
|
|
564
|
+
now_ts = int(time.time())
|
|
565
|
+
if now_ts > record["expiry"]:
|
|
566
|
+
raise SFAuthError("Magic link has expired")
|
|
567
|
+
|
|
568
|
+
# Verify HMAC of the token
|
|
569
|
+
email = record["email"]
|
|
570
|
+
expiry = record["expiry"]
|
|
571
|
+
sig_input = f"{email}:{token.split('.')[0]}:{expiry}".encode()
|
|
572
|
+
expected_mac = _hmac.new(self._magic_secret.encode(), sig_input, hashlib.sha256).hexdigest()
|
|
573
|
+
provided_mac = token.split(".")[-1] if "." in token else ""
|
|
574
|
+
if not _hmac.compare_digest(expected_mac, provided_mac):
|
|
575
|
+
raise SFAuthError("Magic link token is invalid")
|
|
576
|
+
|
|
577
|
+
with self._lock:
|
|
578
|
+
record["used"] = True
|
|
579
|
+
|
|
580
|
+
# ID-031: Enforce MFA policy for the project
|
|
581
|
+
project_id = self._config.project_id
|
|
582
|
+
with self._lock:
|
|
583
|
+
mfa_required = self._mfa_policies.get(project_id, False)
|
|
584
|
+
|
|
585
|
+
if mfa_required and otp is None:
|
|
586
|
+
challenge_id = secrets.token_urlsafe(16)
|
|
587
|
+
raise SFMFARequiredError(challenge_id=challenge_id)
|
|
588
|
+
|
|
589
|
+
# Issue a key for the authenticated email
|
|
590
|
+
return self._local_issue_api_key(
|
|
591
|
+
scopes=["magic_link"],
|
|
592
|
+
project_id=self._config.project_id,
|
|
593
|
+
expires_in_days=1,
|
|
594
|
+
ip_allowlist=[],
|
|
595
|
+
test_mode=False,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
def rotate_key(self, key_id: str) -> APIKeyBundle:
|
|
599
|
+
"""Rotate a key, revoking the old one and issuing a new bundle.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
key_id: The ``key_id`` of the key to rotate.
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
A fresh :class:`~spanforge.sdk._types.APIKeyBundle`.
|
|
606
|
+
|
|
607
|
+
Raises:
|
|
608
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If *key_id* is
|
|
609
|
+
unknown.
|
|
610
|
+
"""
|
|
611
|
+
if not self._is_local_mode():
|
|
612
|
+
resp = self._request("POST", f"/v1/keys/{key_id}/rotate")
|
|
613
|
+
return self._bundle_from_response(resp)
|
|
614
|
+
|
|
615
|
+
with self._lock:
|
|
616
|
+
old_record = self._keys_by_id.get(key_id)
|
|
617
|
+
|
|
618
|
+
if old_record is None:
|
|
619
|
+
raise SFAuthError(f"Key not found: key_id={key_id!r}")
|
|
620
|
+
|
|
621
|
+
# Issue a new key with the same scopes / project
|
|
622
|
+
new_bundle = self._local_issue_api_key(
|
|
623
|
+
scopes=old_record["scopes"],
|
|
624
|
+
project_id=old_record["project_id"],
|
|
625
|
+
expires_in_days=365,
|
|
626
|
+
ip_allowlist=old_record["ip_allowlist"],
|
|
627
|
+
test_mode=old_record["key_value"].startswith("sf_test_"),
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
# Revoke old key (after issuing new one to avoid gap)
|
|
631
|
+
with self._lock:
|
|
632
|
+
old_record["revoked"] = True
|
|
633
|
+
old_record["rotated_to"] = new_bundle.key_id
|
|
634
|
+
|
|
635
|
+
return new_bundle
|
|
636
|
+
|
|
637
|
+
def revoke_key(self, key_id: str) -> None:
|
|
638
|
+
"""Immediately revoke a key.
|
|
639
|
+
|
|
640
|
+
All sessions created from this key continue to work until their JWT
|
|
641
|
+
expiry. Use :meth:`verify_token` which checks the revocation flag
|
|
642
|
+
before creating new sessions.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
key_id: The ``key_id`` of the key to revoke.
|
|
646
|
+
|
|
647
|
+
Raises:
|
|
648
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If *key_id* is
|
|
649
|
+
unknown.
|
|
650
|
+
"""
|
|
651
|
+
if not self._is_local_mode():
|
|
652
|
+
self._request("DELETE", f"/v1/keys/{key_id}")
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
with self._lock:
|
|
656
|
+
record = self._keys_by_id.get(key_id)
|
|
657
|
+
if record is None:
|
|
658
|
+
raise SFAuthError(f"Key not found: key_id={key_id!r}")
|
|
659
|
+
record["revoked"] = True
|
|
660
|
+
|
|
661
|
+
# ------------------------------------------------------------------
|
|
662
|
+
# 4.3 Session Management
|
|
663
|
+
# ------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
def create_session(self, api_key: str) -> str:
|
|
666
|
+
"""Issue a session JWT for a valid API key.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
api_key: The raw API key value (``sf_live_...`` or ``sf_test_...``).
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
A compact HS256 JWT string.
|
|
673
|
+
|
|
674
|
+
Raises:
|
|
675
|
+
:exc:`~spanforge.sdk._exceptions.SFKeyFormatError`: If the key
|
|
676
|
+
format is invalid.
|
|
677
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If the key is
|
|
678
|
+
unknown, revoked, or has expired.
|
|
679
|
+
:exc:`~spanforge.sdk._exceptions.SFIPDeniedError`: If the
|
|
680
|
+
key has an IP allowlist and the check fails.
|
|
681
|
+
"""
|
|
682
|
+
KeyFormat.validate(api_key)
|
|
683
|
+
|
|
684
|
+
if not self._is_local_mode():
|
|
685
|
+
resp = self._request("POST", "/v1/sessions", {"api_key": api_key})
|
|
686
|
+
return str(resp["jwt"])
|
|
687
|
+
|
|
688
|
+
with self._lock:
|
|
689
|
+
record = self._keys.get(api_key)
|
|
690
|
+
|
|
691
|
+
if record is None:
|
|
692
|
+
raise SFAuthError("Unknown API key")
|
|
693
|
+
|
|
694
|
+
if record["revoked"]:
|
|
695
|
+
raise SFAuthError("API key has been revoked")
|
|
696
|
+
|
|
697
|
+
now_ts = int(time.time())
|
|
698
|
+
if now_ts > record["expires_at"]:
|
|
699
|
+
raise SFAuthError("API key has expired")
|
|
700
|
+
|
|
701
|
+
# Issue a short-lived session JWT
|
|
702
|
+
exp = now_ts + _SESSION_TTL_SECONDS
|
|
703
|
+
jti = str(uuid.uuid4())
|
|
704
|
+
payload = {
|
|
705
|
+
"iss": "spanforge",
|
|
706
|
+
"sub": record["key_id"],
|
|
707
|
+
"aud": record["project_id"] or "default",
|
|
708
|
+
"iat": now_ts,
|
|
709
|
+
"exp": exp,
|
|
710
|
+
"jti": jti,
|
|
711
|
+
"scopes": record["scopes"],
|
|
712
|
+
}
|
|
713
|
+
return _issue_hs256_jwt(payload, self._signing_key.encode())
|
|
714
|
+
|
|
715
|
+
def verify_token(self, jwt: str) -> JWTClaims:
|
|
716
|
+
"""Validate a JWT and return its claims.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
jwt: Compact serialised JWT string.
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
:class:`~spanforge.sdk._types.JWTClaims`.
|
|
723
|
+
|
|
724
|
+
Raises:
|
|
725
|
+
:exc:`~spanforge.sdk._exceptions.SFTokenInvalidError`: On any
|
|
726
|
+
validation failure (bad signature, expired, or revoked).
|
|
727
|
+
"""
|
|
728
|
+
if not self._is_local_mode():
|
|
729
|
+
resp = self._request("POST", "/v1/tokens/verify", {"token": jwt})
|
|
730
|
+
return self._claims_from_response(resp)
|
|
731
|
+
|
|
732
|
+
claims = _verify_hs256_jwt(jwt, self._signing_key.encode())
|
|
733
|
+
|
|
734
|
+
jti = claims.get("jti", "")
|
|
735
|
+
with self._lock:
|
|
736
|
+
revoked = jti in self._revoked_jtis
|
|
737
|
+
if revoked:
|
|
738
|
+
raise SFTokenInvalidError("Token has been revoked")
|
|
739
|
+
|
|
740
|
+
exp = claims.get("exp", 0)
|
|
741
|
+
iat = claims.get("iat", 0)
|
|
742
|
+
|
|
743
|
+
return JWTClaims(
|
|
744
|
+
subject=claims.get("sub", ""),
|
|
745
|
+
scopes=claims.get("scopes", []),
|
|
746
|
+
project_id=claims.get("aud", ""),
|
|
747
|
+
expires_at=datetime.fromtimestamp(exp, tz=timezone.utc),
|
|
748
|
+
issued_at=datetime.fromtimestamp(iat, tz=timezone.utc),
|
|
749
|
+
jti=jti,
|
|
750
|
+
issuer=claims.get("iss", "spanforge"),
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
def introspect(self, token: str) -> TokenIntrospectionResult:
|
|
754
|
+
"""RFC 7662 token introspection.
|
|
755
|
+
|
|
756
|
+
Returns an ``active=False`` result for invalid tokens instead of
|
|
757
|
+
raising an exception, to ease integration with OAuth 2.0 resource
|
|
758
|
+
servers.
|
|
759
|
+
|
|
760
|
+
Args:
|
|
761
|
+
token: Compact serialised JWT string.
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
:class:`~spanforge.sdk._types.TokenIntrospectionResult`.
|
|
765
|
+
"""
|
|
766
|
+
if not self._is_local_mode():
|
|
767
|
+
resp = self._request("POST", "/v1/tokens/introspect", {"token": token})
|
|
768
|
+
return TokenIntrospectionResult(
|
|
769
|
+
active=resp.get("active", False),
|
|
770
|
+
scope=resp.get("scope", ""),
|
|
771
|
+
exp=resp.get("exp"),
|
|
772
|
+
sub=resp.get("sub", ""),
|
|
773
|
+
client_id=resp.get("client_id", ""),
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
try:
|
|
777
|
+
claims_obj = self.verify_token(token)
|
|
778
|
+
return TokenIntrospectionResult(
|
|
779
|
+
active=True,
|
|
780
|
+
scope=" ".join(claims_obj.scopes),
|
|
781
|
+
exp=int(claims_obj.expires_at.timestamp()),
|
|
782
|
+
sub=claims_obj.subject,
|
|
783
|
+
client_id=claims_obj.project_id,
|
|
784
|
+
)
|
|
785
|
+
except (SFTokenInvalidError, SFAuthError):
|
|
786
|
+
return TokenIntrospectionResult(active=False)
|
|
787
|
+
|
|
788
|
+
# ------------------------------------------------------------------
|
|
789
|
+
# 4.4 MFA — TOTP
|
|
790
|
+
# ------------------------------------------------------------------
|
|
791
|
+
|
|
792
|
+
def enroll_totp(self, key_id: str) -> TOTPEnrollResult:
|
|
793
|
+
"""Enrol a TOTP authenticator for *key_id*.
|
|
794
|
+
|
|
795
|
+
Generates a 160-bit (20-byte) TOTP secret and 8 single-use backup
|
|
796
|
+
codes. Backup codes are stored as SHA-256 hashes.
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
key_id: The ``key_id`` of the key to associate with TOTP.
|
|
800
|
+
|
|
801
|
+
Returns:
|
|
802
|
+
:class:`~spanforge.sdk._types.TOTPEnrollResult` with the raw
|
|
803
|
+
secret, QR URI, and backup codes. **Display to user once only.**
|
|
804
|
+
|
|
805
|
+
Raises:
|
|
806
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If *key_id* is
|
|
807
|
+
unknown.
|
|
808
|
+
"""
|
|
809
|
+
if not self._is_local_mode():
|
|
810
|
+
resp = self._request("POST", f"/v1/keys/{key_id}/totp/enroll")
|
|
811
|
+
return TOTPEnrollResult(
|
|
812
|
+
secret_base32=SecretStr(resp["secret"]),
|
|
813
|
+
qr_uri=resp["qr_uri"],
|
|
814
|
+
backup_codes=resp["backup_codes"],
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
with self._lock:
|
|
818
|
+
if key_id not in self._keys_by_id:
|
|
819
|
+
raise SFAuthError(f"Key not found: key_id={key_id!r}")
|
|
820
|
+
|
|
821
|
+
raw_secret = secrets.token_bytes(20)
|
|
822
|
+
secret_b32 = base64.b32encode(raw_secret).decode()
|
|
823
|
+
backup_codes = [
|
|
824
|
+
"".join(secrets.choice(_BACKUP_CODE_ALPHABET) for _ in range(_BACKUP_CODE_LEN))
|
|
825
|
+
for _ in range(_BACKUP_CODE_COUNT)
|
|
826
|
+
]
|
|
827
|
+
backup_hashes = [hashlib.sha256(c.encode()).hexdigest() for c in backup_codes]
|
|
828
|
+
qr_uri = (
|
|
829
|
+
f"otpauth://totp/SpanForge:{key_id}"
|
|
830
|
+
f"?secret={secret_b32}&issuer=SpanForge"
|
|
831
|
+
f"&algorithm=SHA1&digits=6&period={_TOTP_PERIOD}"
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
with self._lock:
|
|
835
|
+
self._totp_data[key_id] = {
|
|
836
|
+
"secret": secret_b32,
|
|
837
|
+
"backup_hashes": backup_hashes,
|
|
838
|
+
"used_backup_hashes": set(),
|
|
839
|
+
"totp_fail_count": 0,
|
|
840
|
+
"totp_locked_until": 0.0,
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
return TOTPEnrollResult(
|
|
844
|
+
secret_base32=SecretStr(secret_b32),
|
|
845
|
+
qr_uri=qr_uri,
|
|
846
|
+
backup_codes=backup_codes,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
def verify_totp(
|
|
850
|
+
self,
|
|
851
|
+
key_id: str,
|
|
852
|
+
otp: str,
|
|
853
|
+
*,
|
|
854
|
+
timestamp: float | None = None,
|
|
855
|
+
) -> bool:
|
|
856
|
+
"""Verify a TOTP code for *key_id*.
|
|
857
|
+
|
|
858
|
+
Allows ±1 time-step (±30 s) drift tolerance. Five consecutive
|
|
859
|
+
failures trigger a 15-minute lockout (raising
|
|
860
|
+
:exc:`~spanforge.sdk._exceptions.SFBruteForceLockedError`).
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
key_id: The ``key_id`` to verify against.
|
|
864
|
+
otp: 6-digit TOTP code string.
|
|
865
|
+
timestamp: Unix timestamp override for testing.
|
|
866
|
+
|
|
867
|
+
Returns:
|
|
868
|
+
``True`` if the OTP is valid.
|
|
869
|
+
|
|
870
|
+
Raises:
|
|
871
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If *key_id* has
|
|
872
|
+
no TOTP enrolled.
|
|
873
|
+
:exc:`~spanforge.sdk._exceptions.SFBruteForceLockedError`: If
|
|
874
|
+
the account is locked.
|
|
875
|
+
"""
|
|
876
|
+
if not self._is_local_mode():
|
|
877
|
+
resp = self._request("POST", f"/v1/keys/{key_id}/totp/verify", {"otp": otp})
|
|
878
|
+
return bool(resp.get("valid"))
|
|
879
|
+
|
|
880
|
+
with self._lock:
|
|
881
|
+
totp_record = self._totp_data.get(key_id)
|
|
882
|
+
|
|
883
|
+
if totp_record is None:
|
|
884
|
+
raise SFAuthError(f"TOTP not enrolled for key_id={key_id!r}")
|
|
885
|
+
|
|
886
|
+
now_ts = time.time() if timestamp is None else timestamp
|
|
887
|
+
|
|
888
|
+
with self._lock:
|
|
889
|
+
locked_until = totp_record["totp_locked_until"]
|
|
890
|
+
if now_ts < locked_until:
|
|
891
|
+
unlock_at = datetime.fromtimestamp(locked_until, tz=timezone.utc).isoformat()
|
|
892
|
+
raise SFBruteForceLockedError(unlock_at=unlock_at, resource=f"totp:{key_id}")
|
|
893
|
+
|
|
894
|
+
secret = totp_record["secret"]
|
|
895
|
+
for step_offset in range(-_TOTP_WINDOW, _TOTP_WINDOW + 1):
|
|
896
|
+
candidate = _compute_totp(secret, now_ts + step_offset * _TOTP_PERIOD)
|
|
897
|
+
if _hmac.compare_digest(candidate, otp.strip()):
|
|
898
|
+
with self._lock:
|
|
899
|
+
totp_record["totp_fail_count"] = 0
|
|
900
|
+
return True
|
|
901
|
+
|
|
902
|
+
with self._lock:
|
|
903
|
+
totp_record["totp_fail_count"] += 1
|
|
904
|
+
if totp_record["totp_fail_count"] >= _TOTP_MAX_FAILURES:
|
|
905
|
+
totp_record["totp_locked_until"] = now_ts + _TOTP_LOCKOUT_SECONDS
|
|
906
|
+
unlock_at = datetime.fromtimestamp(
|
|
907
|
+
totp_record["totp_locked_until"], tz=timezone.utc
|
|
908
|
+
).isoformat()
|
|
909
|
+
raise SFBruteForceLockedError(unlock_at=unlock_at, resource=f"totp:{key_id}")
|
|
910
|
+
|
|
911
|
+
return False
|
|
912
|
+
|
|
913
|
+
def verify_backup_code(self, key_id: str, code: str) -> bool:
|
|
914
|
+
"""Verify and consume a single-use TOTP backup code.
|
|
915
|
+
|
|
916
|
+
Args:
|
|
917
|
+
key_id: The ``key_id`` to verify against.
|
|
918
|
+
code: 8-character backup code (case-insensitive).
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
``True`` if the code is valid (and marks it consumed).
|
|
922
|
+
|
|
923
|
+
Raises:
|
|
924
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If *key_id* has
|
|
925
|
+
no TOTP enrolled.
|
|
926
|
+
"""
|
|
927
|
+
if not self._is_local_mode():
|
|
928
|
+
resp = self._request(
|
|
929
|
+
"POST",
|
|
930
|
+
f"/v1/keys/{key_id}/totp/backup",
|
|
931
|
+
{"code": code},
|
|
932
|
+
)
|
|
933
|
+
return bool(resp.get("valid"))
|
|
934
|
+
|
|
935
|
+
with self._lock:
|
|
936
|
+
totp_record = self._totp_data.get(key_id)
|
|
937
|
+
|
|
938
|
+
if totp_record is None:
|
|
939
|
+
raise SFAuthError(f"TOTP not enrolled for key_id={key_id!r}")
|
|
940
|
+
|
|
941
|
+
code_hash = hashlib.sha256(code.upper().encode()).hexdigest()
|
|
942
|
+
|
|
943
|
+
with self._lock:
|
|
944
|
+
if code_hash in totp_record["used_backup_hashes"]:
|
|
945
|
+
return False # replay attack — code already consumed
|
|
946
|
+
for stored_hash in totp_record["backup_hashes"]:
|
|
947
|
+
if _hmac.compare_digest(stored_hash, code_hash):
|
|
948
|
+
totp_record["used_backup_hashes"].add(code_hash)
|
|
949
|
+
return True
|
|
950
|
+
|
|
951
|
+
return False
|
|
952
|
+
|
|
953
|
+
# ------------------------------------------------------------------
|
|
954
|
+
# 4.5 SSO — SAML 2.0, SCIM 2.0, OIDC, Session Delegation
|
|
955
|
+
# ------------------------------------------------------------------
|
|
956
|
+
|
|
957
|
+
def saml_metadata(self) -> str:
|
|
958
|
+
"""Return SAML 2.0 SP metadata XML (ID-040).
|
|
959
|
+
|
|
960
|
+
In remote mode, fetches ``GET /v1/sso/saml/metadata`` from the service.
|
|
961
|
+
In local mode, returns a well-formed SP metadata stub suitable for
|
|
962
|
+
testing and development against Okta, Azure AD, or Google Workspace.
|
|
963
|
+
|
|
964
|
+
Returns:
|
|
965
|
+
SP metadata XML string (``application/samlmetadata+xml``).
|
|
966
|
+
"""
|
|
967
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
968
|
+
import urllib.request as _req
|
|
969
|
+
|
|
970
|
+
url = f"{self._config.endpoint.rstrip('/')}/v1/sso/saml/metadata"
|
|
971
|
+
with _req.urlopen(url) as resp: # nosec B310
|
|
972
|
+
return str(resp.read().decode())
|
|
973
|
+
|
|
974
|
+
acs_url = "http://localhost:7464/v1/sso/saml/acs"
|
|
975
|
+
entity_id = "spanforge-local"
|
|
976
|
+
return (
|
|
977
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
978
|
+
'<md:EntityDescriptor'
|
|
979
|
+
' xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"'
|
|
980
|
+
f' entityID="{entity_id}">'
|
|
981
|
+
"<md:SPSSODescriptor"
|
|
982
|
+
' AuthnRequestsSigned="false"'
|
|
983
|
+
' WantAssertionsSigned="true"'
|
|
984
|
+
' protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">'
|
|
985
|
+
"<md:NameIDFormat>"
|
|
986
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
|
987
|
+
"</md:NameIDFormat>"
|
|
988
|
+
f'<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"'
|
|
989
|
+
f' Location="{acs_url}" index="1"/>'
|
|
990
|
+
"</md:SPSSODescriptor>"
|
|
991
|
+
"</md:EntityDescriptor>"
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
def saml_acs(self, saml_response: str) -> dict[str, Any]:
|
|
995
|
+
"""Process a SAML ACS POST and return a SpanForge session JWT (ID-040).
|
|
996
|
+
|
|
997
|
+
In remote mode, delegates to ``POST /v1/sso/saml/acs`` on the service.
|
|
998
|
+
|
|
999
|
+
In local mode, base64-decodes *saml_response*, extracts the
|
|
1000
|
+
``NameID`` (email / subject) via a lightweight XML parse, and issues
|
|
1001
|
+
a SpanForge session JWT. This is suitable for integration testing
|
|
1002
|
+
with a local or mock IdP.
|
|
1003
|
+
|
|
1004
|
+
Args:
|
|
1005
|
+
saml_response: Base64-encoded SAMLResponse XML from the IdP.
|
|
1006
|
+
|
|
1007
|
+
Returns:
|
|
1008
|
+
``{"session_jwt": str, "subject": str, "email": str,
|
|
1009
|
+
"expires_in": int}``
|
|
1010
|
+
|
|
1011
|
+
Raises:
|
|
1012
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If the
|
|
1013
|
+
SAMLResponse cannot be decoded or is missing required fields.
|
|
1014
|
+
"""
|
|
1015
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1016
|
+
return self._request(
|
|
1017
|
+
"POST",
|
|
1018
|
+
"/v1/sso/saml/acs",
|
|
1019
|
+
{"SAMLResponse": saml_response},
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
try:
|
|
1023
|
+
xml_bytes = base64.b64decode(saml_response)
|
|
1024
|
+
except Exception as exc:
|
|
1025
|
+
raise SFAuthError("Invalid SAMLResponse: base64 decode failed") from exc
|
|
1026
|
+
|
|
1027
|
+
import xml.etree.ElementTree as ET # stdlib — safe for untrusted XML via defusedxml if present # nosec B405
|
|
1028
|
+
|
|
1029
|
+
try:
|
|
1030
|
+
root = ET.fromstring(xml_bytes.decode("utf-8")) # nosec B314
|
|
1031
|
+
except ET.ParseError as exc:
|
|
1032
|
+
raise SFAuthError("Invalid SAMLResponse: XML parse error") from exc
|
|
1033
|
+
|
|
1034
|
+
# Extract NameID — search namespace-agnostic
|
|
1035
|
+
name_id: str | None = None
|
|
1036
|
+
for elem in root.iter():
|
|
1037
|
+
if elem.tag.endswith("}NameID") or elem.tag == "NameID":
|
|
1038
|
+
name_id = (elem.text or "").strip()
|
|
1039
|
+
break
|
|
1040
|
+
|
|
1041
|
+
if not name_id:
|
|
1042
|
+
raise SFAuthError("Invalid SAMLResponse: NameID element not found")
|
|
1043
|
+
|
|
1044
|
+
now = time.time()
|
|
1045
|
+
payload = {
|
|
1046
|
+
"sub": name_id,
|
|
1047
|
+
"email": name_id,
|
|
1048
|
+
"iat": int(now),
|
|
1049
|
+
"exp": int(now + _SESSION_TTL_SECONDS),
|
|
1050
|
+
"jti": str(uuid.uuid4()),
|
|
1051
|
+
"sso": "saml",
|
|
1052
|
+
}
|
|
1053
|
+
jwt = _issue_hs256_jwt(payload, self._signing_key.encode())
|
|
1054
|
+
return {
|
|
1055
|
+
"session_jwt": jwt,
|
|
1056
|
+
"subject": name_id,
|
|
1057
|
+
"email": name_id,
|
|
1058
|
+
"expires_in": _SESSION_TTL_SECONDS,
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
# ------------------------------------------------------------------
|
|
1062
|
+
# ID-041: SCIM 2.0 User provisioning
|
|
1063
|
+
# ------------------------------------------------------------------
|
|
1064
|
+
|
|
1065
|
+
def scim_list_users(
|
|
1066
|
+
self,
|
|
1067
|
+
*,
|
|
1068
|
+
filter_str: str | None = None,
|
|
1069
|
+
start_index: int = 1,
|
|
1070
|
+
count: int = 100,
|
|
1071
|
+
) -> SCIMListResponse:
|
|
1072
|
+
"""Return a paginated list of SCIM users (RFC 7644).
|
|
1073
|
+
|
|
1074
|
+
Args:
|
|
1075
|
+
filter_str: Optional SCIM filter expression, e.g.
|
|
1076
|
+
``"userName eq 'alice@example.com'"``.
|
|
1077
|
+
Supported operators: ``eq``.
|
|
1078
|
+
start_index: 1-based starting index (default: 1).
|
|
1079
|
+
count: Maximum results per page (default: 100).
|
|
1080
|
+
|
|
1081
|
+
Returns:
|
|
1082
|
+
:class:`~spanforge.sdk._types.SCIMListResponse`.
|
|
1083
|
+
"""
|
|
1084
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1085
|
+
params = f"?startIndex={start_index}&count={count}"
|
|
1086
|
+
if filter_str:
|
|
1087
|
+
import urllib.parse
|
|
1088
|
+
|
|
1089
|
+
params += f"&filter={urllib.parse.quote(filter_str)}"
|
|
1090
|
+
resp = self._request("GET", f"/scim/v2/Users{params}")
|
|
1091
|
+
resources = [self._scim_user_from_dict(r) for r in resp.get("Resources", [])]
|
|
1092
|
+
return SCIMListResponse(
|
|
1093
|
+
total_results=resp.get("totalResults", len(resources)),
|
|
1094
|
+
start_index=resp.get("startIndex", 1),
|
|
1095
|
+
items_per_page=resp.get("itemsPerPage", len(resources)),
|
|
1096
|
+
resources=resources,
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
with self._lock:
|
|
1100
|
+
users = list(self._scim_users.values())
|
|
1101
|
+
|
|
1102
|
+
# Apply eq filter if provided
|
|
1103
|
+
if filter_str:
|
|
1104
|
+
users = self._scim_filter_users(users, filter_str)
|
|
1105
|
+
|
|
1106
|
+
total = len(users)
|
|
1107
|
+
page = users[start_index - 1 : start_index - 1 + count]
|
|
1108
|
+
return SCIMListResponse(
|
|
1109
|
+
total_results=total,
|
|
1110
|
+
start_index=start_index,
|
|
1111
|
+
items_per_page=len(page),
|
|
1112
|
+
resources=[self._scim_user_from_dict(u) for u in page],
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
def scim_create_user(self, user_data: dict[str, Any]) -> SCIMUser:
|
|
1116
|
+
"""Provision a new SCIM user (RFC 7644 POST /scim/v2/Users).
|
|
1117
|
+
|
|
1118
|
+
Args:
|
|
1119
|
+
user_data: SCIM User schema dict with at minimum
|
|
1120
|
+
``userName``. ``name.formatted`` or
|
|
1121
|
+
``displayName`` used for :attr:`SCIMUser.display_name`.
|
|
1122
|
+
|
|
1123
|
+
Returns:
|
|
1124
|
+
:class:`~spanforge.sdk._types.SCIMUser` with
|
|
1125
|
+
SpanForge-assigned ``id``.
|
|
1126
|
+
|
|
1127
|
+
Raises:
|
|
1128
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If ``userName``
|
|
1129
|
+
is missing or already taken.
|
|
1130
|
+
"""
|
|
1131
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1132
|
+
resp = self._request("POST", "/scim/v2/Users", user_data)
|
|
1133
|
+
return self._scim_user_from_dict(resp)
|
|
1134
|
+
|
|
1135
|
+
user_name = user_data.get("userName", "").strip()
|
|
1136
|
+
if not user_name:
|
|
1137
|
+
raise SFAuthError("SCIM create user: userName is required")
|
|
1138
|
+
|
|
1139
|
+
with self._lock:
|
|
1140
|
+
if user_name in self._scim_users_by_name:
|
|
1141
|
+
raise SFAuthError(f"SCIM create user: userName already exists: {user_name!r}")
|
|
1142
|
+
|
|
1143
|
+
user_id = f"scim-user-{str(uuid.uuid4())[:8]}"
|
|
1144
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
1145
|
+
emails = user_data.get("emails", [])
|
|
1146
|
+
email = ""
|
|
1147
|
+
if emails and isinstance(emails, list):
|
|
1148
|
+
email = emails[0].get("value", "")
|
|
1149
|
+
elif isinstance(user_data.get("email"), str):
|
|
1150
|
+
email = user_data["email"]
|
|
1151
|
+
|
|
1152
|
+
display_name = (
|
|
1153
|
+
user_data.get("displayName")
|
|
1154
|
+
or (user_data.get("name") or {}).get("formatted", "")
|
|
1155
|
+
or user_name
|
|
1156
|
+
)
|
|
1157
|
+
record: dict[str, Any] = {
|
|
1158
|
+
"id": user_id,
|
|
1159
|
+
"user_name": user_name,
|
|
1160
|
+
"display_name": display_name,
|
|
1161
|
+
"active": user_data.get("active", True),
|
|
1162
|
+
"email": email,
|
|
1163
|
+
"groups": [],
|
|
1164
|
+
"external_id": user_data.get("externalId"),
|
|
1165
|
+
"meta": {
|
|
1166
|
+
"resourceType": "User",
|
|
1167
|
+
"created": now_iso,
|
|
1168
|
+
"lastModified": now_iso,
|
|
1169
|
+
"location": f"/scim/v2/Users/{user_id}",
|
|
1170
|
+
},
|
|
1171
|
+
}
|
|
1172
|
+
self._scim_users[user_id] = record
|
|
1173
|
+
self._scim_users_by_name[user_name] = user_id
|
|
1174
|
+
|
|
1175
|
+
return self._scim_user_from_dict(record)
|
|
1176
|
+
|
|
1177
|
+
def scim_get_user(self, user_id: str) -> SCIMUser:
|
|
1178
|
+
"""Fetch a SCIM user by id (RFC 7644 GET /scim/v2/Users/{id}).
|
|
1179
|
+
|
|
1180
|
+
Args:
|
|
1181
|
+
user_id: SpanForge user id as returned by :meth:`scim_create_user`.
|
|
1182
|
+
|
|
1183
|
+
Returns:
|
|
1184
|
+
:class:`~spanforge.sdk._types.SCIMUser`.
|
|
1185
|
+
|
|
1186
|
+
Raises:
|
|
1187
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If the user does
|
|
1188
|
+
not exist.
|
|
1189
|
+
"""
|
|
1190
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1191
|
+
resp = self._request("GET", f"/scim/v2/Users/{user_id}")
|
|
1192
|
+
return self._scim_user_from_dict(resp)
|
|
1193
|
+
|
|
1194
|
+
with self._lock:
|
|
1195
|
+
record = self._scim_users.get(user_id)
|
|
1196
|
+
if record is None:
|
|
1197
|
+
raise SFAuthError(f"SCIM user not found: {user_id!r}")
|
|
1198
|
+
return self._scim_user_from_dict(record)
|
|
1199
|
+
|
|
1200
|
+
def scim_patch_user(self, user_id: str, patch_ops: list[dict[str, Any]]) -> SCIMUser:
|
|
1201
|
+
"""Apply PATCH operations to a SCIM user (RFC 7644 PATCH /scim/v2/Users/{id}).
|
|
1202
|
+
|
|
1203
|
+
Supported ``op`` values: ``replace``, ``add``, ``remove``.
|
|
1204
|
+
Recognised ``path`` values: ``active``, ``displayName``,
|
|
1205
|
+
``emails``, ``name.formatted``.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
user_id: SpanForge user id.
|
|
1209
|
+
patch_ops: List of SCIM patch operation dicts, each with
|
|
1210
|
+
``op``, ``path``, and optionally ``value``.
|
|
1211
|
+
|
|
1212
|
+
Returns:
|
|
1213
|
+
Updated :class:`~spanforge.sdk._types.SCIMUser`.
|
|
1214
|
+
|
|
1215
|
+
Raises:
|
|
1216
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If the user does
|
|
1217
|
+
not exist.
|
|
1218
|
+
"""
|
|
1219
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1220
|
+
resp = self._request("PATCH", f"/scim/v2/Users/{user_id}", {"Operations": patch_ops})
|
|
1221
|
+
return self._scim_user_from_dict(resp)
|
|
1222
|
+
|
|
1223
|
+
with self._lock:
|
|
1224
|
+
record = self._scim_users.get(user_id)
|
|
1225
|
+
if record is None:
|
|
1226
|
+
raise SFAuthError(f"SCIM user not found: {user_id!r}")
|
|
1227
|
+
|
|
1228
|
+
for op in patch_ops:
|
|
1229
|
+
path = op.get("path", "")
|
|
1230
|
+
value = op.get("value")
|
|
1231
|
+
operation = op.get("op", "").lower()
|
|
1232
|
+
if path == "active" and operation in ("replace", "add"):
|
|
1233
|
+
record["active"] = bool(value)
|
|
1234
|
+
elif (path in ("displayName", "display_name") and operation in ("replace", "add")) or (path == "name.formatted" and operation in ("replace", "add")):
|
|
1235
|
+
record["display_name"] = str(value)
|
|
1236
|
+
elif path == "emails" and operation in ("replace", "add"):
|
|
1237
|
+
if isinstance(value, list) and value:
|
|
1238
|
+
record["email"] = value[0].get("value", "")
|
|
1239
|
+
elif isinstance(value, str):
|
|
1240
|
+
record["email"] = value
|
|
1241
|
+
|
|
1242
|
+
record["meta"]["lastModified"] = datetime.now(timezone.utc).isoformat()
|
|
1243
|
+
|
|
1244
|
+
return self._scim_user_from_dict(record)
|
|
1245
|
+
|
|
1246
|
+
def scim_delete_user(self, user_id: str) -> None:
|
|
1247
|
+
"""Delete a SCIM user (RFC 7644 DELETE /scim/v2/Users/{id}).
|
|
1248
|
+
|
|
1249
|
+
Args:
|
|
1250
|
+
user_id: SpanForge user id.
|
|
1251
|
+
|
|
1252
|
+
Raises:
|
|
1253
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If the user does
|
|
1254
|
+
not exist.
|
|
1255
|
+
"""
|
|
1256
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1257
|
+
self._request("DELETE", f"/scim/v2/Users/{user_id}")
|
|
1258
|
+
return
|
|
1259
|
+
|
|
1260
|
+
with self._lock:
|
|
1261
|
+
record = self._scim_users.pop(user_id, None)
|
|
1262
|
+
if record is None:
|
|
1263
|
+
raise SFAuthError(f"SCIM user not found: {user_id!r}")
|
|
1264
|
+
self._scim_users_by_name.pop(record["user_name"], None)
|
|
1265
|
+
|
|
1266
|
+
# ------------------------------------------------------------------
|
|
1267
|
+
# ID-041: SCIM 2.0 Group provisioning
|
|
1268
|
+
# ------------------------------------------------------------------
|
|
1269
|
+
|
|
1270
|
+
def scim_list_groups(
|
|
1271
|
+
self,
|
|
1272
|
+
*,
|
|
1273
|
+
start_index: int = 1,
|
|
1274
|
+
count: int = 100,
|
|
1275
|
+
) -> SCIMListResponse:
|
|
1276
|
+
"""Return a paginated list of SCIM groups (RFC 7644).
|
|
1277
|
+
|
|
1278
|
+
Args:
|
|
1279
|
+
start_index: 1-based starting index.
|
|
1280
|
+
count: Maximum results per page.
|
|
1281
|
+
|
|
1282
|
+
Returns:
|
|
1283
|
+
:class:`~spanforge.sdk._types.SCIMListResponse`.
|
|
1284
|
+
"""
|
|
1285
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1286
|
+
resp = self._request("GET", f"/scim/v2/Groups?startIndex={start_index}&count={count}")
|
|
1287
|
+
resources = [self._scim_group_from_dict(g) for g in resp.get("Resources", [])]
|
|
1288
|
+
return SCIMListResponse(
|
|
1289
|
+
total_results=resp.get("totalResults", len(resources)),
|
|
1290
|
+
start_index=resp.get("startIndex", 1),
|
|
1291
|
+
items_per_page=resp.get("itemsPerPage", len(resources)),
|
|
1292
|
+
resources=resources,
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
with self._lock:
|
|
1296
|
+
groups = list(self._scim_groups.values())
|
|
1297
|
+
|
|
1298
|
+
total = len(groups)
|
|
1299
|
+
page = groups[start_index - 1 : start_index - 1 + count]
|
|
1300
|
+
return SCIMListResponse(
|
|
1301
|
+
total_results=total,
|
|
1302
|
+
start_index=start_index,
|
|
1303
|
+
items_per_page=len(page),
|
|
1304
|
+
resources=[self._scim_group_from_dict(g) for g in page],
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
def scim_create_group(self, group_data: dict[str, Any]) -> SCIMGroup:
|
|
1308
|
+
"""Provision a new SCIM group (RFC 7644 POST /scim/v2/Groups).
|
|
1309
|
+
|
|
1310
|
+
Args:
|
|
1311
|
+
group_data: SCIM Group schema dict with at minimum
|
|
1312
|
+
``displayName``. Members provided as
|
|
1313
|
+
``[{"value": user_id}, ...]``.
|
|
1314
|
+
|
|
1315
|
+
Returns:
|
|
1316
|
+
:class:`~spanforge.sdk._types.SCIMGroup`.
|
|
1317
|
+
"""
|
|
1318
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1319
|
+
resp = self._request("POST", "/scim/v2/Groups", group_data)
|
|
1320
|
+
return self._scim_group_from_dict(resp)
|
|
1321
|
+
|
|
1322
|
+
display_name = group_data.get("displayName", "").strip()
|
|
1323
|
+
if not display_name:
|
|
1324
|
+
raise SFAuthError("SCIM create group: displayName is required")
|
|
1325
|
+
|
|
1326
|
+
with self._lock:
|
|
1327
|
+
group_id = f"scim-group-{str(uuid.uuid4())[:8]}"
|
|
1328
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
1329
|
+
members = [m["value"] for m in group_data.get("members", []) if "value" in m]
|
|
1330
|
+
record: dict[str, Any] = {
|
|
1331
|
+
"id": group_id,
|
|
1332
|
+
"display_name": display_name,
|
|
1333
|
+
"members": members,
|
|
1334
|
+
"external_id": group_data.get("externalId"),
|
|
1335
|
+
"meta": {
|
|
1336
|
+
"resourceType": "Group",
|
|
1337
|
+
"created": now_iso,
|
|
1338
|
+
"lastModified": now_iso,
|
|
1339
|
+
"location": f"/scim/v2/Groups/{group_id}",
|
|
1340
|
+
},
|
|
1341
|
+
}
|
|
1342
|
+
self._scim_groups[group_id] = record
|
|
1343
|
+
|
|
1344
|
+
# Update member records
|
|
1345
|
+
for uid in members:
|
|
1346
|
+
if uid in self._scim_users:
|
|
1347
|
+
if group_id not in self._scim_users[uid]["groups"]:
|
|
1348
|
+
self._scim_users[uid]["groups"].append(group_id)
|
|
1349
|
+
|
|
1350
|
+
return self._scim_group_from_dict(record)
|
|
1351
|
+
|
|
1352
|
+
def scim_delete_group(self, group_id: str) -> None:
|
|
1353
|
+
"""Delete a SCIM group (RFC 7644 DELETE /scim/v2/Groups/{id}).
|
|
1354
|
+
|
|
1355
|
+
Args:
|
|
1356
|
+
group_id: SpanForge group id.
|
|
1357
|
+
|
|
1358
|
+
Raises:
|
|
1359
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If the group
|
|
1360
|
+
does not exist.
|
|
1361
|
+
"""
|
|
1362
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1363
|
+
self._request("DELETE", f"/scim/v2/Groups/{group_id}")
|
|
1364
|
+
return
|
|
1365
|
+
|
|
1366
|
+
with self._lock:
|
|
1367
|
+
record = self._scim_groups.pop(group_id, None)
|
|
1368
|
+
if record is None:
|
|
1369
|
+
raise SFAuthError(f"SCIM group not found: {group_id!r}")
|
|
1370
|
+
# Remove group from member user records
|
|
1371
|
+
for uid in record.get("members", []):
|
|
1372
|
+
if uid in self._scim_users:
|
|
1373
|
+
try:
|
|
1374
|
+
self._scim_users[uid]["groups"].remove(group_id)
|
|
1375
|
+
except ValueError:
|
|
1376
|
+
pass
|
|
1377
|
+
|
|
1378
|
+
# ------------------------------------------------------------------
|
|
1379
|
+
# ID-042: OIDC relying party (PKCE — RFC 7636)
|
|
1380
|
+
# ------------------------------------------------------------------
|
|
1381
|
+
|
|
1382
|
+
def oidc_authorize(
|
|
1383
|
+
self,
|
|
1384
|
+
*,
|
|
1385
|
+
provider_url: str = "https://idp.example.com",
|
|
1386
|
+
client_id: str = "spanforge-local",
|
|
1387
|
+
redirect_uri: str = "http://localhost:7464/v1/sso/oidc/callback",
|
|
1388
|
+
scope: str = "openid email profile",
|
|
1389
|
+
) -> OIDCAuthRequest:
|
|
1390
|
+
"""Generate an OIDC authorization request with PKCE (RFC 7636).
|
|
1391
|
+
|
|
1392
|
+
Produces a PKCE code verifier/challenge pair, a CSRF ``state`` token,
|
|
1393
|
+
and a replay-protection ``nonce``, then assembles the authorization
|
|
1394
|
+
URL to redirect the user to.
|
|
1395
|
+
|
|
1396
|
+
In remote mode, the service builds and signs the request; parameters
|
|
1397
|
+
are taken from server-side configuration and *provider_url* is
|
|
1398
|
+
ignored. In local mode, all parameters are used directly.
|
|
1399
|
+
|
|
1400
|
+
Args:
|
|
1401
|
+
provider_url: Base URL of the OIDC provider (local mode only).
|
|
1402
|
+
client_id: OAuth 2.0 client id (local mode only).
|
|
1403
|
+
redirect_uri: Where the IdP will POST the authorization code.
|
|
1404
|
+
scope: Space-separated scope string.
|
|
1405
|
+
|
|
1406
|
+
Returns:
|
|
1407
|
+
:class:`~spanforge.sdk._types.OIDCAuthRequest`.
|
|
1408
|
+
"""
|
|
1409
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1410
|
+
resp = self._request(
|
|
1411
|
+
"POST",
|
|
1412
|
+
"/v1/sso/oidc/authorize",
|
|
1413
|
+
{"redirect_uri": redirect_uri, "scope": scope},
|
|
1414
|
+
)
|
|
1415
|
+
return OIDCAuthRequest(
|
|
1416
|
+
authorization_url=resp["authorization_url"],
|
|
1417
|
+
state=resp["state"],
|
|
1418
|
+
code_verifier=resp["code_verifier"],
|
|
1419
|
+
code_challenge=resp["code_challenge"],
|
|
1420
|
+
nonce=resp["nonce"],
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
# PKCE: generate random code_verifier (RFC 7636 §4.1 — 43–128 unreserved chars)
|
|
1424
|
+
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode()
|
|
1425
|
+
code_challenge = base64.urlsafe_b64encode(
|
|
1426
|
+
hashlib.sha256(code_verifier.encode()).digest()
|
|
1427
|
+
).rstrip(b"=").decode()
|
|
1428
|
+
state = secrets.token_urlsafe(32)
|
|
1429
|
+
nonce = secrets.token_urlsafe(24)
|
|
1430
|
+
|
|
1431
|
+
import urllib.parse
|
|
1432
|
+
|
|
1433
|
+
params = urllib.parse.urlencode(
|
|
1434
|
+
{
|
|
1435
|
+
"response_type": "code",
|
|
1436
|
+
"client_id": client_id,
|
|
1437
|
+
"redirect_uri": redirect_uri,
|
|
1438
|
+
"scope": scope,
|
|
1439
|
+
"state": state,
|
|
1440
|
+
"nonce": nonce,
|
|
1441
|
+
"code_challenge": code_challenge,
|
|
1442
|
+
"code_challenge_method": "S256",
|
|
1443
|
+
}
|
|
1444
|
+
)
|
|
1445
|
+
authorization_url = f"{provider_url.rstrip('/')}/authorize?{params}"
|
|
1446
|
+
|
|
1447
|
+
with self._lock:
|
|
1448
|
+
self._oidc_states[state] = {
|
|
1449
|
+
"code_verifier": code_verifier,
|
|
1450
|
+
"nonce": nonce,
|
|
1451
|
+
"redirect_uri": redirect_uri,
|
|
1452
|
+
"created_at": time.time(),
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
return OIDCAuthRequest(
|
|
1456
|
+
authorization_url=authorization_url,
|
|
1457
|
+
state=state,
|
|
1458
|
+
code_verifier=code_verifier,
|
|
1459
|
+
code_challenge=code_challenge,
|
|
1460
|
+
nonce=nonce,
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
def oidc_callback(
|
|
1464
|
+
self,
|
|
1465
|
+
code: str,
|
|
1466
|
+
state: str,
|
|
1467
|
+
*,
|
|
1468
|
+
subject: str = "",
|
|
1469
|
+
email: str = "",
|
|
1470
|
+
) -> OIDCTokenResult:
|
|
1471
|
+
"""Exchange an OIDC authorization code for a SpanForge session JWT.
|
|
1472
|
+
|
|
1473
|
+
In remote mode, the service exchanges *code* at the IdP's token
|
|
1474
|
+
endpoint and returns a SpanForge-native session JWT.
|
|
1475
|
+
|
|
1476
|
+
In local mode (testing), *code* is treated as an opaque value; the
|
|
1477
|
+
method validates *state* (CSRF check), then issues a SpanForge session
|
|
1478
|
+
JWT using *subject* / *email* as the identity.
|
|
1479
|
+
|
|
1480
|
+
Args:
|
|
1481
|
+
code: Authorization code from the IdP redirect.
|
|
1482
|
+
state: CSRF state token from :meth:`oidc_authorize`.
|
|
1483
|
+
subject: Override subject (``sub``) for local mode.
|
|
1484
|
+
email: Override email for local mode.
|
|
1485
|
+
|
|
1486
|
+
Returns:
|
|
1487
|
+
:class:`~spanforge.sdk._types.OIDCTokenResult`.
|
|
1488
|
+
|
|
1489
|
+
Raises:
|
|
1490
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If ``state`` is
|
|
1491
|
+
invalid or expired.
|
|
1492
|
+
"""
|
|
1493
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1494
|
+
resp = self._request(
|
|
1495
|
+
"POST",
|
|
1496
|
+
"/v1/sso/oidc/callback",
|
|
1497
|
+
{"code": code, "state": state},
|
|
1498
|
+
)
|
|
1499
|
+
return OIDCTokenResult(
|
|
1500
|
+
session_jwt=resp["session_jwt"],
|
|
1501
|
+
id_token=resp.get("id_token", ""),
|
|
1502
|
+
access_token=resp.get("access_token", ""),
|
|
1503
|
+
expires_in=resp.get("expires_in", _SESSION_TTL_SECONDS),
|
|
1504
|
+
subject=resp.get("subject", ""),
|
|
1505
|
+
email=resp.get("email", ""),
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
with self._lock:
|
|
1509
|
+
state_record = self._oidc_states.pop(state, None)
|
|
1510
|
+
|
|
1511
|
+
if state_record is None:
|
|
1512
|
+
raise SFAuthError("OIDC callback: invalid or expired state token")
|
|
1513
|
+
|
|
1514
|
+
# State TTL: 10 minutes
|
|
1515
|
+
if time.time() - state_record["created_at"] > 600:
|
|
1516
|
+
raise SFAuthError("OIDC callback: state token has expired")
|
|
1517
|
+
|
|
1518
|
+
sub = subject or code # use code as surrogate sub in local mode
|
|
1519
|
+
em = email or f"{sub}@local.dev"
|
|
1520
|
+
|
|
1521
|
+
now = time.time()
|
|
1522
|
+
payload = {
|
|
1523
|
+
"sub": sub,
|
|
1524
|
+
"email": em,
|
|
1525
|
+
"iat": int(now),
|
|
1526
|
+
"exp": int(now + _SESSION_TTL_SECONDS),
|
|
1527
|
+
"jti": str(uuid.uuid4()),
|
|
1528
|
+
"sso": "oidc",
|
|
1529
|
+
"nonce": state_record["nonce"],
|
|
1530
|
+
}
|
|
1531
|
+
jwt = _issue_hs256_jwt(payload, self._signing_key.encode())
|
|
1532
|
+
return OIDCTokenResult(
|
|
1533
|
+
session_jwt=jwt,
|
|
1534
|
+
id_token="", # no live IdP in local mode # nosec B106
|
|
1535
|
+
access_token="",
|
|
1536
|
+
expires_in=_SESSION_TTL_SECONDS,
|
|
1537
|
+
subject=sub,
|
|
1538
|
+
email=em,
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
# ------------------------------------------------------------------
|
|
1542
|
+
# ID-043: SSO session delegation
|
|
1543
|
+
# ------------------------------------------------------------------
|
|
1544
|
+
|
|
1545
|
+
def sso_delegate_session(
|
|
1546
|
+
self,
|
|
1547
|
+
idp_session_id: str,
|
|
1548
|
+
subject: str,
|
|
1549
|
+
*,
|
|
1550
|
+
email: str = "",
|
|
1551
|
+
project_id: str = "default",
|
|
1552
|
+
) -> SSOSession:
|
|
1553
|
+
"""Create a SpanForge-native session mapped to an IdP session (ID-043).
|
|
1554
|
+
|
|
1555
|
+
When a project uses SSO (SAML or OIDC), call this method to issue
|
|
1556
|
+
a SpanForge session token that is logically bound to the IdP session.
|
|
1557
|
+
When the IdP session is revoked (e.g. via SCIM ``PATCH active=false``),
|
|
1558
|
+
call :meth:`sso_revoke_idp_session` to propagate the revocation.
|
|
1559
|
+
|
|
1560
|
+
Args:
|
|
1561
|
+
idp_session_id: Opaque IdP session identifier.
|
|
1562
|
+
subject: ``sub`` claim from the IdP.
|
|
1563
|
+
email: Email address for the session.
|
|
1564
|
+
project_id: SpanForge project to scope this session to.
|
|
1565
|
+
|
|
1566
|
+
Returns:
|
|
1567
|
+
:class:`~spanforge.sdk._types.SSOSession`.
|
|
1568
|
+
|
|
1569
|
+
Raises:
|
|
1570
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If a session
|
|
1571
|
+
for *idp_session_id* already exists and is still active.
|
|
1572
|
+
"""
|
|
1573
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1574
|
+
resp = self._request(
|
|
1575
|
+
"POST",
|
|
1576
|
+
"/v1/sso/session",
|
|
1577
|
+
{
|
|
1578
|
+
"idp_session_id": idp_session_id,
|
|
1579
|
+
"subject": subject,
|
|
1580
|
+
"email": email,
|
|
1581
|
+
"project_id": project_id,
|
|
1582
|
+
},
|
|
1583
|
+
)
|
|
1584
|
+
return SSOSession(
|
|
1585
|
+
session_id=resp["session_id"],
|
|
1586
|
+
idp_session_id=idp_session_id,
|
|
1587
|
+
subject=subject,
|
|
1588
|
+
email=email,
|
|
1589
|
+
jwt=resp["jwt"],
|
|
1590
|
+
project_id=project_id,
|
|
1591
|
+
created_at=resp["created_at"],
|
|
1592
|
+
expires_at=resp["expires_at"],
|
|
1593
|
+
active=resp.get("active", True),
|
|
1594
|
+
)
|
|
1595
|
+
|
|
1596
|
+
with self._lock:
|
|
1597
|
+
# Check for existing active session
|
|
1598
|
+
existing_id = self._sso_by_idp.get(idp_session_id)
|
|
1599
|
+
if existing_id:
|
|
1600
|
+
existing = self._sso_sessions.get(existing_id)
|
|
1601
|
+
if existing and existing.get("active"):
|
|
1602
|
+
raise SFAuthError(
|
|
1603
|
+
f"SSO delegate: active session already exists for idp_session_id={idp_session_id!r}"
|
|
1604
|
+
)
|
|
1605
|
+
|
|
1606
|
+
session_id = f"sso-{str(uuid.uuid4())[:12]}"
|
|
1607
|
+
now = time.time()
|
|
1608
|
+
now_iso = datetime.fromtimestamp(now, tz=timezone.utc).isoformat()
|
|
1609
|
+
exp_iso = datetime.fromtimestamp(now + _SESSION_TTL_SECONDS, tz=timezone.utc).isoformat()
|
|
1610
|
+
|
|
1611
|
+
jwt_payload = {
|
|
1612
|
+
"sub": subject,
|
|
1613
|
+
"email": email,
|
|
1614
|
+
"project_id": project_id,
|
|
1615
|
+
"sso_session_id": session_id,
|
|
1616
|
+
"idp_session_id": idp_session_id,
|
|
1617
|
+
"iat": int(now),
|
|
1618
|
+
"exp": int(now + _SESSION_TTL_SECONDS),
|
|
1619
|
+
"jti": str(uuid.uuid4()),
|
|
1620
|
+
}
|
|
1621
|
+
jwt = _issue_hs256_jwt(jwt_payload, self._signing_key.encode())
|
|
1622
|
+
|
|
1623
|
+
record: dict[str, Any] = {
|
|
1624
|
+
"session_id": session_id,
|
|
1625
|
+
"idp_session_id": idp_session_id,
|
|
1626
|
+
"subject": subject,
|
|
1627
|
+
"email": email,
|
|
1628
|
+
"jwt": jwt,
|
|
1629
|
+
"project_id": project_id,
|
|
1630
|
+
"created_at": now_iso,
|
|
1631
|
+
"expires_at": exp_iso,
|
|
1632
|
+
"active": True,
|
|
1633
|
+
}
|
|
1634
|
+
self._sso_sessions[session_id] = record
|
|
1635
|
+
self._sso_by_idp[idp_session_id] = session_id
|
|
1636
|
+
|
|
1637
|
+
_log.info(
|
|
1638
|
+
"SSO session delegated: session_id=%s subject=%s project_id=%s",
|
|
1639
|
+
session_id,
|
|
1640
|
+
subject,
|
|
1641
|
+
project_id,
|
|
1642
|
+
)
|
|
1643
|
+
return SSOSession(**record)
|
|
1644
|
+
|
|
1645
|
+
def sso_revoke_idp_session(self, idp_session_id: str) -> bool:
|
|
1646
|
+
"""Revoke all SpanForge sessions tied to an IdP session (ID-043).
|
|
1647
|
+
|
|
1648
|
+
Called when the IdP revokes the session (e.g. SCIM ``PATCH active=false``
|
|
1649
|
+
or a logout event). Marks the delegated session as inactive within
|
|
1650
|
+
5 minutes of the IdP event, per spec.
|
|
1651
|
+
|
|
1652
|
+
Args:
|
|
1653
|
+
idp_session_id: Opaque IdP session identifier.
|
|
1654
|
+
|
|
1655
|
+
Returns:
|
|
1656
|
+
``True`` if a session was found and revoked, ``False`` if no
|
|
1657
|
+
active session existed for *idp_session_id*.
|
|
1658
|
+
"""
|
|
1659
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1660
|
+
resp = self._request(
|
|
1661
|
+
"POST",
|
|
1662
|
+
"/v1/sso/session/revoke",
|
|
1663
|
+
{"idp_session_id": idp_session_id},
|
|
1664
|
+
)
|
|
1665
|
+
return bool(resp.get("revoked", False))
|
|
1666
|
+
|
|
1667
|
+
with self._lock:
|
|
1668
|
+
session_id = self._sso_by_idp.get(idp_session_id)
|
|
1669
|
+
if not session_id:
|
|
1670
|
+
return False
|
|
1671
|
+
record = self._sso_sessions.get(session_id)
|
|
1672
|
+
if not record or not record.get("active"):
|
|
1673
|
+
return False
|
|
1674
|
+
record["active"] = False
|
|
1675
|
+
|
|
1676
|
+
_log.info("SSO session revoked via IdP: idp_session_id=%s session_id=%s", idp_session_id, session_id)
|
|
1677
|
+
return True
|
|
1678
|
+
|
|
1679
|
+
def sso_get_session(self, session_id: str) -> SSOSession:
|
|
1680
|
+
"""Retrieve an SSO delegated session by SpanForge session id.
|
|
1681
|
+
|
|
1682
|
+
Args:
|
|
1683
|
+
session_id: SpanForge SSO session id as returned by
|
|
1684
|
+
:meth:`sso_delegate_session`.
|
|
1685
|
+
|
|
1686
|
+
Returns:
|
|
1687
|
+
:class:`~spanforge.sdk._types.SSOSession`.
|
|
1688
|
+
|
|
1689
|
+
Raises:
|
|
1690
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If the session
|
|
1691
|
+
does not exist.
|
|
1692
|
+
"""
|
|
1693
|
+
if not self._is_local_mode(): # pragma: no cover
|
|
1694
|
+
resp = self._request("GET", f"/v1/sso/session/{session_id}")
|
|
1695
|
+
return SSOSession(
|
|
1696
|
+
session_id=resp["session_id"],
|
|
1697
|
+
idp_session_id=resp["idp_session_id"],
|
|
1698
|
+
subject=resp["subject"],
|
|
1699
|
+
email=resp.get("email", ""),
|
|
1700
|
+
jwt=resp["jwt"],
|
|
1701
|
+
project_id=resp["project_id"],
|
|
1702
|
+
created_at=resp["created_at"],
|
|
1703
|
+
expires_at=resp["expires_at"],
|
|
1704
|
+
active=resp.get("active", True),
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
with self._lock:
|
|
1708
|
+
record = self._sso_sessions.get(session_id)
|
|
1709
|
+
if record is None:
|
|
1710
|
+
raise SFAuthError(f"SSO session not found: {session_id!r}")
|
|
1711
|
+
return SSOSession(**record)
|
|
1712
|
+
|
|
1713
|
+
# ------------------------------------------------------------------
|
|
1714
|
+
# 4.5 — SCIM / SSO internal helpers
|
|
1715
|
+
# ------------------------------------------------------------------
|
|
1716
|
+
|
|
1717
|
+
@staticmethod
|
|
1718
|
+
def _scim_user_from_dict(record: dict[str, Any]) -> SCIMUser:
|
|
1719
|
+
return SCIMUser(
|
|
1720
|
+
id=record["id"],
|
|
1721
|
+
user_name=record["user_name"],
|
|
1722
|
+
display_name=record.get("display_name", record.get("user_name", "")),
|
|
1723
|
+
active=record.get("active", True),
|
|
1724
|
+
email=record.get("email", ""),
|
|
1725
|
+
groups=list(record.get("groups", [])),
|
|
1726
|
+
external_id=record.get("external_id"),
|
|
1727
|
+
meta=dict(record.get("meta", {})),
|
|
1728
|
+
)
|
|
1729
|
+
|
|
1730
|
+
@staticmethod
|
|
1731
|
+
def _scim_group_from_dict(record: dict[str, Any]) -> SCIMGroup:
|
|
1732
|
+
return SCIMGroup(
|
|
1733
|
+
id=record["id"],
|
|
1734
|
+
display_name=record.get("display_name", ""),
|
|
1735
|
+
members=list(record.get("members", [])),
|
|
1736
|
+
external_id=record.get("external_id"),
|
|
1737
|
+
meta=dict(record.get("meta", {})),
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
@staticmethod
|
|
1741
|
+
def _scim_filter_users(
|
|
1742
|
+
users: list[dict[str, Any]], filter_str: str
|
|
1743
|
+
) -> list[dict[str, Any]]:
|
|
1744
|
+
"""Very lightweight SCIM filter: supports only ``attr eq 'value'``."""
|
|
1745
|
+
import re as _re
|
|
1746
|
+
|
|
1747
|
+
m = _re.match(r'(\w+)\s+eq\s+["\']([^"\']+)["\']', filter_str.strip(), _re.IGNORECASE)
|
|
1748
|
+
if not m:
|
|
1749
|
+
return users # unsupported filter — return all
|
|
1750
|
+
attr, value = m.group(1).lower(), m.group(2)
|
|
1751
|
+
field_map = {"username": "user_name", "email": "email", "active": "active"}
|
|
1752
|
+
field = field_map.get(attr, attr)
|
|
1753
|
+
result = []
|
|
1754
|
+
for u in users:
|
|
1755
|
+
v = u.get(field)
|
|
1756
|
+
if field == "active":
|
|
1757
|
+
if str(v).lower() == value.lower():
|
|
1758
|
+
result.append(u)
|
|
1759
|
+
elif v is not None and str(v).lower() == value.lower():
|
|
1760
|
+
result.append(u)
|
|
1761
|
+
return result
|
|
1762
|
+
|
|
1763
|
+
# ------------------------------------------------------------------
|
|
1764
|
+
# 4.6 Rate Limiting
|
|
1765
|
+
# ------------------------------------------------------------------
|
|
1766
|
+
|
|
1767
|
+
def check_rate_limit(self, key_id: str) -> RateLimitInfo:
|
|
1768
|
+
"""Return the current rate-limit state for *key_id*.
|
|
1769
|
+
|
|
1770
|
+
Does **not** count as a request. Use :meth:`record_request` to
|
|
1771
|
+
increment the counter.
|
|
1772
|
+
|
|
1773
|
+
Args:
|
|
1774
|
+
key_id: The ``key_id`` to inspect.
|
|
1775
|
+
|
|
1776
|
+
Returns:
|
|
1777
|
+
:class:`~spanforge.sdk._types.RateLimitInfo`.
|
|
1778
|
+
"""
|
|
1779
|
+
if not self._is_local_mode():
|
|
1780
|
+
resp = self._request("GET", f"/v1/keys/{key_id}/rate-limit")
|
|
1781
|
+
return RateLimitInfo(
|
|
1782
|
+
limit=resp["limit"],
|
|
1783
|
+
remaining=resp["remaining"],
|
|
1784
|
+
reset_at=datetime.fromisoformat(resp["reset_at"]),
|
|
1785
|
+
)
|
|
1786
|
+
|
|
1787
|
+
return self._rate_limiter.check(key_id)
|
|
1788
|
+
|
|
1789
|
+
def record_request(self, key_id: str) -> bool:
|
|
1790
|
+
"""Increment the request counter for *key_id*.
|
|
1791
|
+
|
|
1792
|
+
Args:
|
|
1793
|
+
key_id: The ``key_id`` that made the request.
|
|
1794
|
+
|
|
1795
|
+
Returns:
|
|
1796
|
+
``True`` if the request is within the rate limit.
|
|
1797
|
+
``False`` if the limit has been exceeded.
|
|
1798
|
+
"""
|
|
1799
|
+
if not self._is_local_mode():
|
|
1800
|
+
resp = self._request("POST", f"/v1/keys/{key_id}/rate-limit/record")
|
|
1801
|
+
return bool(resp.get("allowed", True))
|
|
1802
|
+
|
|
1803
|
+
return self._rate_limiter.record(key_id)
|
|
1804
|
+
|
|
1805
|
+
# ------------------------------------------------------------------
|
|
1806
|
+
# 4.7 Security — IP allowlist
|
|
1807
|
+
# ------------------------------------------------------------------
|
|
1808
|
+
|
|
1809
|
+
def check_ip_allowlist(self, key_id: str, ip: str) -> None:
|
|
1810
|
+
"""Check if *ip* is permitted by the key's IP allowlist.
|
|
1811
|
+
|
|
1812
|
+
Raises :exc:`~spanforge.sdk._exceptions.SFIPDeniedError` if *ip* is
|
|
1813
|
+
not in the key's ``ip_allowlist``.
|
|
1814
|
+
|
|
1815
|
+
If the key has no allowlist configured, all IPs are permitted.
|
|
1816
|
+
|
|
1817
|
+
Args:
|
|
1818
|
+
key_id: The ``key_id`` to look up.
|
|
1819
|
+
ip: Client IP address (IPv4 or IPv6).
|
|
1820
|
+
|
|
1821
|
+
Raises:
|
|
1822
|
+
:exc:`~spanforge.sdk._exceptions.SFIPDeniedError`: If the IP is
|
|
1823
|
+
not in any listed CIDR.
|
|
1824
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If *key_id* is
|
|
1825
|
+
unknown.
|
|
1826
|
+
"""
|
|
1827
|
+
if not self._is_local_mode():
|
|
1828
|
+
self._request("POST", "/v1/security/check-ip", {"key_id": key_id, "ip": ip})
|
|
1829
|
+
return
|
|
1830
|
+
|
|
1831
|
+
with self._lock:
|
|
1832
|
+
record = self._keys_by_id.get(key_id)
|
|
1833
|
+
|
|
1834
|
+
if record is None:
|
|
1835
|
+
raise SFAuthError(f"Key not found: key_id={key_id!r}")
|
|
1836
|
+
|
|
1837
|
+
allowlist = record.get("ip_allowlist") or []
|
|
1838
|
+
if not allowlist:
|
|
1839
|
+
return # no restriction
|
|
1840
|
+
|
|
1841
|
+
try:
|
|
1842
|
+
client_ip = ipaddress.ip_address(ip)
|
|
1843
|
+
except ValueError:
|
|
1844
|
+
raise SFIPDeniedError(ip) from None
|
|
1845
|
+
|
|
1846
|
+
for cidr in allowlist:
|
|
1847
|
+
try:
|
|
1848
|
+
network = ipaddress.ip_network(cidr, strict=False)
|
|
1849
|
+
except ValueError:
|
|
1850
|
+
_log.warning("Invalid CIDR in ip_allowlist: %r", cidr)
|
|
1851
|
+
continue
|
|
1852
|
+
if client_ip in network:
|
|
1853
|
+
return
|
|
1854
|
+
|
|
1855
|
+
raise SFIPDeniedError(ip)
|
|
1856
|
+
|
|
1857
|
+
# ------------------------------------------------------------------
|
|
1858
|
+
# JWKS endpoint
|
|
1859
|
+
# ------------------------------------------------------------------
|
|
1860
|
+
|
|
1861
|
+
def get_jwks(self) -> dict[str, Any]:
|
|
1862
|
+
"""Return the JSON Web Key Set.
|
|
1863
|
+
|
|
1864
|
+
In local mode (HS256), there is no asymmetric public key to publish;
|
|
1865
|
+
returns an empty ``keys`` array as per RFC 7517 §5.
|
|
1866
|
+
|
|
1867
|
+
In remote mode, fetches ``/.well-known/jwks.json`` from the service.
|
|
1868
|
+
"""
|
|
1869
|
+
if not self._is_local_mode():
|
|
1870
|
+
return self._request("GET", "/.well-known/jwks.json")
|
|
1871
|
+
return {"keys": []}
|
|
1872
|
+
|
|
1873
|
+
# ------------------------------------------------------------------
|
|
1874
|
+
# Scope enforcement helper
|
|
1875
|
+
# ------------------------------------------------------------------
|
|
1876
|
+
|
|
1877
|
+
def require_scope(self, claims: JWTClaims, scope: str) -> None:
|
|
1878
|
+
"""Assert that *scope* is present in *claims*, or raise an error.
|
|
1879
|
+
|
|
1880
|
+
Raises :exc:`~spanforge.sdk._exceptions.SFScopeError` if *scope* is not
|
|
1881
|
+
in *claims*.
|
|
1882
|
+
|
|
1883
|
+
Intended for resource servers validating incoming JWTs.
|
|
1884
|
+
|
|
1885
|
+
Args:
|
|
1886
|
+
claims: Decoded :class:`~spanforge.sdk._types.JWTClaims`.
|
|
1887
|
+
scope: Required scope string.
|
|
1888
|
+
|
|
1889
|
+
Raises:
|
|
1890
|
+
:exc:`~spanforge.sdk._exceptions.SFScopeError`: If the scope is
|
|
1891
|
+
missing.
|
|
1892
|
+
"""
|
|
1893
|
+
if scope not in claims.scopes:
|
|
1894
|
+
raise SFScopeError(required_scope=scope, key_scopes=claims.scopes)
|
|
1895
|
+
|
|
1896
|
+
# ------------------------------------------------------------------
|
|
1897
|
+
# ID-031: MFA enforcement policy
|
|
1898
|
+
# ------------------------------------------------------------------
|
|
1899
|
+
|
|
1900
|
+
def set_mfa_policy(self, project_id: str, mfa_required: bool) -> None:
|
|
1901
|
+
"""Set the MFA enforcement policy for *project_id*.
|
|
1902
|
+
|
|
1903
|
+
When ``mfa_required=True``, :meth:`exchange_magic_link` will raise
|
|
1904
|
+
:exc:`~spanforge.sdk._exceptions.SFMFARequiredError` if no OTP is
|
|
1905
|
+
supplied (in local mode) or if the key's project requires MFA.
|
|
1906
|
+
|
|
1907
|
+
Args:
|
|
1908
|
+
project_id: The project to configure.
|
|
1909
|
+
mfa_required: Whether MFA is required for this project.
|
|
1910
|
+
"""
|
|
1911
|
+
with self._lock:
|
|
1912
|
+
self._mfa_policies[project_id] = mfa_required
|
|
1913
|
+
|
|
1914
|
+
def get_mfa_policy(self, project_id: str) -> bool:
|
|
1915
|
+
"""Return whether MFA is required for *project_id*.
|
|
1916
|
+
|
|
1917
|
+
Args:
|
|
1918
|
+
project_id: Project to query.
|
|
1919
|
+
|
|
1920
|
+
Returns:
|
|
1921
|
+
``True`` if MFA is required, ``False`` (default) otherwise.
|
|
1922
|
+
"""
|
|
1923
|
+
with self._lock:
|
|
1924
|
+
return self._mfa_policies.get(project_id, False)
|
|
1925
|
+
|
|
1926
|
+
# ------------------------------------------------------------------
|
|
1927
|
+
# ID-051 / ID-052: Quota tier enforcement and telemetry
|
|
1928
|
+
# ------------------------------------------------------------------
|
|
1929
|
+
|
|
1930
|
+
def set_key_tier(self, key_id: str, tier: str) -> None:
|
|
1931
|
+
"""Assign a quota *tier* to *key_id*.
|
|
1932
|
+
|
|
1933
|
+
Args:
|
|
1934
|
+
key_id: Key to configure.
|
|
1935
|
+
tier: One of :class:`~spanforge.sdk._types.QuotaTier` constants
|
|
1936
|
+
(``"free"``, ``"api"``, ``"team"``, ``"enterprise"``).
|
|
1937
|
+
|
|
1938
|
+
Raises:
|
|
1939
|
+
ValueError: If *tier* is not a known tier name.
|
|
1940
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`: If *key_id* is unknown.
|
|
1941
|
+
"""
|
|
1942
|
+
if tier not in QuotaTier.DAILY_LIMITS:
|
|
1943
|
+
raise ValueError(
|
|
1944
|
+
f"Unknown quota tier: {tier!r}. Valid tiers: {list(QuotaTier.DAILY_LIMITS)}"
|
|
1945
|
+
)
|
|
1946
|
+
with self._lock:
|
|
1947
|
+
if key_id not in self._keys_by_id:
|
|
1948
|
+
raise SFAuthError(f"Key not found: key_id={key_id!r}")
|
|
1949
|
+
self._key_tiers[key_id] = tier
|
|
1950
|
+
|
|
1951
|
+
def consume_quota(self, key_id: str) -> bool:
|
|
1952
|
+
"""Consume one scored-record quota unit for *key_id*.
|
|
1953
|
+
|
|
1954
|
+
Resets daily at midnight UTC. Enterprise keys are always allowed.
|
|
1955
|
+
Free keys (daily limit = 0) are always blocked.
|
|
1956
|
+
|
|
1957
|
+
Args:
|
|
1958
|
+
key_id: Key that consumed a record.
|
|
1959
|
+
|
|
1960
|
+
Returns:
|
|
1961
|
+
``True`` if within quota.
|
|
1962
|
+
|
|
1963
|
+
Raises:
|
|
1964
|
+
:exc:`~spanforge.sdk._exceptions.SFQuotaExceededError`: If the
|
|
1965
|
+
daily quota has been exhausted.
|
|
1966
|
+
"""
|
|
1967
|
+
with self._lock:
|
|
1968
|
+
tier = self._key_tiers.get(key_id, QuotaTier.FREE)
|
|
1969
|
+
daily_limit = QuotaTier.daily_limit(tier)
|
|
1970
|
+
|
|
1971
|
+
today_midnight = _today_midnight_utc()
|
|
1972
|
+
counts = self._daily_counts.get(key_id, [])
|
|
1973
|
+
# Evict yesterday's timestamps
|
|
1974
|
+
counts = [ts for ts in counts if ts >= today_midnight]
|
|
1975
|
+
|
|
1976
|
+
if daily_limit != -1 and len(counts) >= daily_limit:
|
|
1977
|
+
now = time.time()
|
|
1978
|
+
next_midnight = today_midnight + 86_400.0
|
|
1979
|
+
retry_after = max(1, int(next_midnight - now))
|
|
1980
|
+
raise SFQuotaExceededError(
|
|
1981
|
+
tier=tier,
|
|
1982
|
+
daily_limit=daily_limit,
|
|
1983
|
+
retry_after=retry_after,
|
|
1984
|
+
)
|
|
1985
|
+
|
|
1986
|
+
counts.append(time.time())
|
|
1987
|
+
self._daily_counts[key_id] = counts
|
|
1988
|
+
return True
|
|
1989
|
+
|
|
1990
|
+
def get_quota_usage(self, key_id: str) -> dict[str, Any]:
|
|
1991
|
+
"""Return quota usage telemetry for *key_id* (ID-052).
|
|
1992
|
+
|
|
1993
|
+
Args:
|
|
1994
|
+
key_id: Key to query.
|
|
1995
|
+
|
|
1996
|
+
Returns:
|
|
1997
|
+
Dict with keys: ``key_id``, ``tier``, ``daily_limit``,
|
|
1998
|
+
``consumed_today``, ``remaining_today``.
|
|
1999
|
+
"""
|
|
2000
|
+
if not self._is_local_mode():
|
|
2001
|
+
return self._request("GET", f"/v1/auth/quota/{key_id}")
|
|
2002
|
+
|
|
2003
|
+
with self._lock:
|
|
2004
|
+
tier = self._key_tiers.get(key_id, QuotaTier.FREE)
|
|
2005
|
+
daily_limit = QuotaTier.daily_limit(tier)
|
|
2006
|
+
today_midnight = _today_midnight_utc()
|
|
2007
|
+
counts = self._daily_counts.get(key_id, [])
|
|
2008
|
+
today_count = sum(1 for ts in counts if ts >= today_midnight)
|
|
2009
|
+
|
|
2010
|
+
if daily_limit == -1:
|
|
2011
|
+
return {
|
|
2012
|
+
"key_id": key_id,
|
|
2013
|
+
"tier": tier,
|
|
2014
|
+
"daily_limit": "unlimited",
|
|
2015
|
+
"consumed_today": today_count,
|
|
2016
|
+
"remaining_today": "unlimited",
|
|
2017
|
+
}
|
|
2018
|
+
return {
|
|
2019
|
+
"key_id": key_id,
|
|
2020
|
+
"tier": tier,
|
|
2021
|
+
"daily_limit": daily_limit,
|
|
2022
|
+
"consumed_today": today_count,
|
|
2023
|
+
"remaining_today": max(0, daily_limit - today_count),
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
# ------------------------------------------------------------------
|
|
2027
|
+
# sso_delegate_session_async (F-10)
|
|
2028
|
+
# ------------------------------------------------------------------
|
|
2029
|
+
|
|
2030
|
+
async def sso_delegate_session_async(
|
|
2031
|
+
self,
|
|
2032
|
+
idp_session_id: str,
|
|
2033
|
+
subject: str,
|
|
2034
|
+
*,
|
|
2035
|
+
email: str = "",
|
|
2036
|
+
project_id: str = "default",
|
|
2037
|
+
):
|
|
2038
|
+
"""Async variant of :meth:`sso_delegate_session` (F-10).
|
|
2039
|
+
|
|
2040
|
+
Runs :meth:`sso_delegate_session` in a thread-pool executor via
|
|
2041
|
+
:func:`asyncio.run_in_executor`, making it safe to ``await``
|
|
2042
|
+
from async code without blocking the event loop.
|
|
2043
|
+
|
|
2044
|
+
Args:
|
|
2045
|
+
idp_session_id: Opaque IdP session identifier.
|
|
2046
|
+
subject: ``sub`` claim from the IdP.
|
|
2047
|
+
email: Email address for the session.
|
|
2048
|
+
project_id: SpanForge project to scope this session to.
|
|
2049
|
+
|
|
2050
|
+
Returns:
|
|
2051
|
+
:class:`~spanforge.sdk._types.SSOSession` — same as
|
|
2052
|
+
:meth:`sso_delegate_session`.
|
|
2053
|
+
"""
|
|
2054
|
+
import asyncio
|
|
2055
|
+
import functools
|
|
2056
|
+
|
|
2057
|
+
loop = asyncio.get_event_loop()
|
|
2058
|
+
return await loop.run_in_executor(
|
|
2059
|
+
None,
|
|
2060
|
+
functools.partial(
|
|
2061
|
+
self.sso_delegate_session,
|
|
2062
|
+
idp_session_id,
|
|
2063
|
+
subject,
|
|
2064
|
+
email=email,
|
|
2065
|
+
project_id=project_id,
|
|
2066
|
+
),
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
#: Alias for :meth:`verify_token` — preferred name in API-key workflows. # F-02
|
|
2070
|
+
validate_api_key = verify_token
|
|
2071
|
+
|
|
2072
|
+
def get_status(self) -> dict[str, Any]: # F-02
|
|
2073
|
+
"""Return a health/status snapshot for ``spanforge doctor``.
|
|
2074
|
+
|
|
2075
|
+
Returns:
|
|
2076
|
+
dict with at minimum ``{"status": "ok"}`` in healthy state.
|
|
2077
|
+
"""
|
|
2078
|
+
with self._lock:
|
|
2079
|
+
key_count = len(self._keys_by_id)
|
|
2080
|
+
session_count = len(self._sessions)
|
|
2081
|
+
return {
|
|
2082
|
+
"status": "ok",
|
|
2083
|
+
"mode": "local" if self._is_local_mode() else "remote",
|
|
2084
|
+
"keys_issued": key_count,
|
|
2085
|
+
"active_sessions": session_count,
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
# ------------------------------------------------------------------
|
|
2089
|
+
# Private helpers
|
|
2090
|
+
# ------------------------------------------------------------------
|
|
2091
|
+
|
|
2092
|
+
@staticmethod
|
|
2093
|
+
def _bundle_from_response(resp: dict[str, Any]) -> APIKeyBundle:
|
|
2094
|
+
"""Convert a remote service response dict to an :class:`APIKeyBundle`."""
|
|
2095
|
+
return APIKeyBundle(
|
|
2096
|
+
api_key=SecretStr(resp["api_key"]),
|
|
2097
|
+
key_id=resp["key_id"],
|
|
2098
|
+
jwt=resp["jwt"],
|
|
2099
|
+
expires_at=datetime.fromisoformat(resp["expires_at"]),
|
|
2100
|
+
scopes=resp.get("scopes", []),
|
|
2101
|
+
)
|
|
2102
|
+
|
|
2103
|
+
@staticmethod
|
|
2104
|
+
def _claims_from_response(resp: dict[str, Any]) -> JWTClaims:
|
|
2105
|
+
"""Convert a remote service response dict to :class:`JWTClaims`."""
|
|
2106
|
+
return JWTClaims(
|
|
2107
|
+
subject=resp["sub"],
|
|
2108
|
+
scopes=resp.get("scopes", []),
|
|
2109
|
+
project_id=resp.get("aud", ""),
|
|
2110
|
+
expires_at=datetime.fromisoformat(resp["exp"]),
|
|
2111
|
+
issued_at=datetime.fromisoformat(resp["iat"]),
|
|
2112
|
+
jti=resp.get("jti", ""),
|
|
2113
|
+
issuer=resp.get("iss", "spanforge"),
|
|
2114
|
+
)
|