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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. 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
+ )