intflow-auth-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ """Public import surface for the Intflow Auth relying application SDK."""
2
+
3
+ from intflow_auth_sdk.claims import (
4
+ extract_user_identity,
5
+ get_app_roles,
6
+ has_app_role,
7
+ require_app_role,
8
+ )
9
+ from intflow_auth_sdk.errors import (
10
+ IntflowAuthError,
11
+ IntflowDiscoveryError,
12
+ IntflowJwksError,
13
+ IntflowMissingAppRoleError,
14
+ IntflowMissingClaimError,
15
+ IntflowTokenVerificationError,
16
+ )
17
+ from intflow_auth_sdk.models import (
18
+ OidcDiscoveryMetadata,
19
+ PkcePair,
20
+ UserIdentity,
21
+ VerifiedIntflowToken,
22
+ )
23
+ from intflow_auth_sdk.pkce import create_authorization_url, generate_pkce_pair
24
+ from intflow_auth_sdk.verifier import (
25
+ DEFAULT_CACHE_TTL_SECONDS,
26
+ DEFAULT_CLOCK_TOLERANCE_SECONDS,
27
+ DEFAULT_ISSUER,
28
+ AsyncIntflowTokenVerifier,
29
+ IntflowTokenVerifier,
30
+ create_async_token_verifier,
31
+ create_token_verifier,
32
+ fetch_discovery,
33
+ fetch_discovery_async,
34
+ verify_access_token,
35
+ verify_access_token_async,
36
+ )
37
+
38
+ SDK_NAME = "intflow-auth-sdk"
39
+ __version__ = "0.1.0"
40
+
41
+ __all__ = [
42
+ "DEFAULT_CACHE_TTL_SECONDS",
43
+ "DEFAULT_CLOCK_TOLERANCE_SECONDS",
44
+ "DEFAULT_ISSUER",
45
+ "SDK_NAME",
46
+ "AsyncIntflowTokenVerifier",
47
+ "IntflowAuthError",
48
+ "IntflowDiscoveryError",
49
+ "IntflowJwksError",
50
+ "IntflowMissingAppRoleError",
51
+ "IntflowMissingClaimError",
52
+ "IntflowTokenVerificationError",
53
+ "IntflowTokenVerifier",
54
+ "OidcDiscoveryMetadata",
55
+ "PkcePair",
56
+ "UserIdentity",
57
+ "VerifiedIntflowToken",
58
+ "__version__",
59
+ "create_async_token_verifier",
60
+ "create_authorization_url",
61
+ "create_token_verifier",
62
+ "extract_user_identity",
63
+ "fetch_discovery",
64
+ "fetch_discovery_async",
65
+ "generate_pkce_pair",
66
+ "get_app_roles",
67
+ "has_app_role",
68
+ "require_app_role",
69
+ "verify_access_token",
70
+ "verify_access_token_async",
71
+ ]
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from intflow_auth_sdk.errors import IntflowMissingAppRoleError
7
+ from intflow_auth_sdk.models import UserIdentity
8
+
9
+
10
+ def get_app_roles(claims: Mapping[str, Any]) -> tuple[str, ...]:
11
+ value = claims.get("app_roles")
12
+ if not isinstance(value, list):
13
+ return ()
14
+ return tuple(role for role in value if isinstance(role, str))
15
+
16
+
17
+ def has_app_role(claims: Mapping[str, Any], role: str = "admin") -> bool:
18
+ return role in get_app_roles(claims)
19
+
20
+
21
+ def require_app_role(claims: Mapping[str, Any], role: str = "admin") -> None:
22
+ if not has_app_role(claims, role):
23
+ raise IntflowMissingAppRoleError(
24
+ "missing_app_role",
25
+ f"Required app role was not present: {role}",
26
+ )
27
+
28
+
29
+ def extract_user_identity(claims: Mapping[str, Any]) -> UserIdentity:
30
+ subject = claims.get("sub")
31
+ if not isinstance(subject, str) or subject == "":
32
+ subject = ""
33
+
34
+ return UserIdentity(
35
+ user_id=subject,
36
+ email=_optional_string(claims.get("email")),
37
+ display_name=_optional_string(claims.get("name")),
38
+ roles=_string_tuple(claims.get("roles")),
39
+ app_roles=get_app_roles(claims),
40
+ )
41
+
42
+
43
+ def _optional_string(value: Any) -> str | None:
44
+ return value if isinstance(value, str) else None
45
+
46
+
47
+ def _string_tuple(value: Any) -> tuple[str, ...]:
48
+ if not isinstance(value, list):
49
+ return ()
50
+ return tuple(item for item in value if isinstance(item, str))
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class IntflowAuthError(Exception):
5
+ def __init__(self, code: str, message: str) -> None:
6
+ super().__init__(message)
7
+ self.code = code
8
+
9
+
10
+ class IntflowDiscoveryError(IntflowAuthError):
11
+ pass
12
+
13
+
14
+ class IntflowJwksError(IntflowAuthError):
15
+ pass
16
+
17
+
18
+ class IntflowTokenVerificationError(IntflowAuthError):
19
+ pass
20
+
21
+
22
+ class IntflowMissingClaimError(IntflowTokenVerificationError):
23
+ pass
24
+
25
+
26
+ class IntflowMissingAppRoleError(IntflowAuthError):
27
+ pass
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ type JsonMapping = Mapping[str, Any]
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class OidcDiscoveryMetadata:
12
+ issuer: str
13
+ authorization_endpoint: str
14
+ token_endpoint: str
15
+ jwks_uri: str
16
+ raw: JsonMapping
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class PkcePair:
21
+ code_verifier: str
22
+ code_challenge: str
23
+ code_challenge_method: str = "S256"
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class VerifiedIntflowToken:
28
+ payload: JsonMapping
29
+ header: JsonMapping
30
+ token: str
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class UserIdentity:
35
+ user_id: str
36
+ email: str | None
37
+ display_name: str | None
38
+ roles: tuple[str, ...]
39
+ app_roles: tuple[str, ...]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import secrets
6
+ from collections.abc import Mapping, Sequence
7
+ from urllib.parse import urlencode, urlsplit, urlunsplit
8
+
9
+ from intflow_auth_sdk.models import PkcePair
10
+
11
+ DEFAULT_ISSUER = "https://id.intflow.dev"
12
+
13
+
14
+ def generate_pkce_pair() -> PkcePair:
15
+ code_verifier = secrets.token_urlsafe(32)
16
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
17
+ code_challenge = _base64_urlencode(digest)
18
+ return PkcePair(
19
+ code_verifier=code_verifier,
20
+ code_challenge=code_challenge,
21
+ code_challenge_method="S256",
22
+ )
23
+
24
+
25
+ def create_authorization_url(
26
+ *,
27
+ client_id: str,
28
+ redirect_uri: str,
29
+ state: str,
30
+ code_challenge: str,
31
+ scope: str | Sequence[str] | None = None,
32
+ issuer: str = DEFAULT_ISSUER,
33
+ authorization_endpoint: str | None = None,
34
+ code_challenge_method: str = "S256",
35
+ extra_params: Mapping[str, str | int | bool | None] | None = None,
36
+ ) -> str:
37
+ endpoint = authorization_endpoint or f"{_normalize_issuer(issuer)}/oauth/authorize"
38
+ params: dict[str, str] = {
39
+ "response_type": "code",
40
+ "client_id": client_id,
41
+ "redirect_uri": redirect_uri,
42
+ "scope": _normalize_scope(scope),
43
+ "state": state,
44
+ "code_challenge": code_challenge,
45
+ "code_challenge_method": code_challenge_method,
46
+ }
47
+ for key, value in (extra_params or {}).items():
48
+ if value is not None:
49
+ params[key] = str(value)
50
+
51
+ parts = urlsplit(endpoint)
52
+ query = urlencode(params)
53
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
54
+
55
+
56
+ def _normalize_scope(scope: str | Sequence[str] | None) -> str:
57
+ if scope is None:
58
+ return "openid"
59
+ if isinstance(scope, str):
60
+ return scope
61
+ return " ".join(scope)
62
+
63
+
64
+ def _normalize_issuer(issuer: str) -> str:
65
+ return issuer.rstrip("/")
66
+
67
+
68
+ def _base64_urlencode(value: bytes) -> str:
69
+ return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,479 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import httpx
9
+ import jwt
10
+ from jwt import PyJWK
11
+ from jwt.exceptions import (
12
+ ExpiredSignatureError,
13
+ ImmatureSignatureError,
14
+ InvalidAlgorithmError,
15
+ InvalidAudienceError,
16
+ InvalidIssuerError,
17
+ InvalidSignatureError,
18
+ InvalidTokenError,
19
+ MissingRequiredClaimError,
20
+ )
21
+
22
+ from intflow_auth_sdk.errors import (
23
+ IntflowAuthError,
24
+ IntflowDiscoveryError,
25
+ IntflowJwksError,
26
+ IntflowMissingClaimError,
27
+ IntflowTokenVerificationError,
28
+ )
29
+ from intflow_auth_sdk.models import OidcDiscoveryMetadata, VerifiedIntflowToken
30
+
31
+ DEFAULT_ISSUER = "https://id.intflow.dev"
32
+ DEFAULT_CLOCK_TOLERANCE_SECONDS = 60
33
+ DEFAULT_CACHE_TTL_SECONDS = 300
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class _CachedValue:
38
+ value: Mapping[str, Any] | OidcDiscoveryMetadata
39
+ expires_at: float
40
+
41
+
42
+ class IntflowTokenVerifier:
43
+ def __init__(
44
+ self,
45
+ *,
46
+ audience: str | Sequence[str],
47
+ issuer: str = DEFAULT_ISSUER,
48
+ algorithms: Sequence[str] | None = None,
49
+ cache_ttl_seconds: int = DEFAULT_CACHE_TTL_SECONDS,
50
+ clock_tolerance_seconds: int = DEFAULT_CLOCK_TOLERANCE_SECONDS,
51
+ timeout: float = 5.0,
52
+ client: httpx.Client | None = None,
53
+ transport: httpx.BaseTransport | None = None,
54
+ ) -> None:
55
+ self.audience = audience
56
+ self.issuer = _normalize_issuer(issuer)
57
+ self.algorithms = tuple(algorithms or ("RS256",))
58
+ self.cache_ttl_seconds = cache_ttl_seconds
59
+ self.clock_tolerance_seconds = clock_tolerance_seconds
60
+ self.timeout = timeout
61
+ self._client = client
62
+ self._transport = transport
63
+ self._discovery: _CachedValue | None = None
64
+ self._jwks: _CachedValue | None = None
65
+
66
+ def get_discovery(self, *, force_refresh: bool = False) -> OidcDiscoveryMetadata:
67
+ cached = self._get_cached_discovery(force_refresh=force_refresh)
68
+ if cached is not None:
69
+ return cached
70
+
71
+ url = f"{self.issuer}/.well-known/openid-configuration"
72
+ data = self._fetch_json(url, target="discovery")
73
+ discovery = _parse_discovery(data, expected_issuer=self.issuer)
74
+ self._discovery = _CachedValue(discovery, time.monotonic() + self.cache_ttl_seconds)
75
+ return discovery
76
+
77
+ def verify_access_token(self, token: str) -> VerifiedIntflowToken:
78
+ try:
79
+ return self._verify_with_jwks(token, self._get_jwks())
80
+ except IntflowTokenVerificationError as exc:
81
+ if exc.code != "unknown_key_id":
82
+ raise
83
+
84
+ return self._verify_with_jwks(token, self._get_jwks(force_refresh=True))
85
+
86
+ def _get_cached_discovery(self, *, force_refresh: bool) -> OidcDiscoveryMetadata | None:
87
+ if (
88
+ force_refresh
89
+ or self._discovery is None
90
+ or self._discovery.expires_at <= time.monotonic()
91
+ ):
92
+ return None
93
+ if not isinstance(self._discovery.value, OidcDiscoveryMetadata):
94
+ return None
95
+ return self._discovery.value
96
+
97
+ def _get_jwks(self, *, force_refresh: bool = False) -> Mapping[str, Any]:
98
+ if (
99
+ not force_refresh
100
+ and self._jwks is not None
101
+ and self._jwks.expires_at > time.monotonic()
102
+ and isinstance(self._jwks.value, Mapping)
103
+ ):
104
+ return self._jwks.value
105
+
106
+ discovery = self.get_discovery(force_refresh=force_refresh)
107
+ jwks = self._fetch_json(discovery.jwks_uri, target="jwks")
108
+ _assert_jwks(jwks)
109
+ self._jwks = _CachedValue(jwks, time.monotonic() + self.cache_ttl_seconds)
110
+ return jwks
111
+
112
+ def _fetch_json(self, url: str, *, target: str) -> Mapping[str, Any]:
113
+ try:
114
+ if self._client is not None:
115
+ response = self._client.get(url, headers={"accept": "application/json"})
116
+ else:
117
+ with httpx.Client(timeout=self.timeout, transport=self._transport) as client:
118
+ response = client.get(url, headers={"accept": "application/json"})
119
+ except httpx.HTTPError as exc:
120
+ raise _fetch_error(target) from exc
121
+
122
+ return _response_json(response, target=target)
123
+
124
+ def _verify_with_jwks(self, token: str, jwks: Mapping[str, Any]) -> VerifiedIntflowToken:
125
+ header = _unverified_header(token)
126
+ key = _find_key(jwks, header, allowed_algorithms=self.algorithms)
127
+ try:
128
+ payload = jwt.decode(
129
+ token,
130
+ key=key,
131
+ algorithms=list(self.algorithms),
132
+ audience=self.audience,
133
+ issuer=self.issuer,
134
+ leeway=self.clock_tolerance_seconds,
135
+ options={"require": ["iss", "sub", "aud", "exp", "nbf"]},
136
+ )
137
+ except MissingRequiredClaimError as exc:
138
+ raise _missing_claim_error(exc.claim) from exc
139
+ except ExpiredSignatureError as exc:
140
+ raise IntflowTokenVerificationError("token_expired", "Token is expired.") from exc
141
+ except ImmatureSignatureError as exc:
142
+ raise IntflowTokenVerificationError(
143
+ "token_not_yet_valid",
144
+ "Token is not valid yet.",
145
+ ) from exc
146
+ except InvalidIssuerError as exc:
147
+ raise IntflowTokenVerificationError(
148
+ "invalid_issuer",
149
+ "Token issuer was invalid.",
150
+ ) from exc
151
+ except InvalidAudienceError as exc:
152
+ raise IntflowTokenVerificationError(
153
+ "invalid_audience",
154
+ "Token audience was invalid.",
155
+ ) from exc
156
+ except InvalidSignatureError as exc:
157
+ raise IntflowTokenVerificationError(
158
+ "invalid_signature",
159
+ "Token signature was invalid.",
160
+ ) from exc
161
+ except InvalidAlgorithmError as exc:
162
+ raise IntflowTokenVerificationError(
163
+ "invalid_algorithm",
164
+ "Token algorithm was not allowed.",
165
+ ) from exc
166
+ except InvalidTokenError as exc:
167
+ raise IntflowTokenVerificationError(
168
+ "invalid_token",
169
+ "Token verification failed.",
170
+ ) from exc
171
+
172
+ _assert_intflow_claims(payload)
173
+ return VerifiedIntflowToken(payload=payload, header=header, token=token)
174
+
175
+
176
+ class AsyncIntflowTokenVerifier:
177
+ def __init__(
178
+ self,
179
+ *,
180
+ audience: str | Sequence[str],
181
+ issuer: str = DEFAULT_ISSUER,
182
+ algorithms: Sequence[str] | None = None,
183
+ cache_ttl_seconds: int = DEFAULT_CACHE_TTL_SECONDS,
184
+ clock_tolerance_seconds: int = DEFAULT_CLOCK_TOLERANCE_SECONDS,
185
+ timeout: float = 5.0,
186
+ client: httpx.AsyncClient | None = None,
187
+ transport: httpx.AsyncBaseTransport | None = None,
188
+ ) -> None:
189
+ self.audience = audience
190
+ self.issuer = _normalize_issuer(issuer)
191
+ self.algorithms = tuple(algorithms or ("RS256",))
192
+ self.cache_ttl_seconds = cache_ttl_seconds
193
+ self.clock_tolerance_seconds = clock_tolerance_seconds
194
+ self.timeout = timeout
195
+ self._client = client
196
+ self._transport = transport
197
+ self._discovery: _CachedValue | None = None
198
+ self._jwks: _CachedValue | None = None
199
+
200
+ async def get_discovery(self, *, force_refresh: bool = False) -> OidcDiscoveryMetadata:
201
+ cached = self._get_cached_discovery(force_refresh=force_refresh)
202
+ if cached is not None:
203
+ return cached
204
+
205
+ url = f"{self.issuer}/.well-known/openid-configuration"
206
+ data = await self._fetch_json(url, target="discovery")
207
+ discovery = _parse_discovery(data, expected_issuer=self.issuer)
208
+ self._discovery = _CachedValue(discovery, time.monotonic() + self.cache_ttl_seconds)
209
+ return discovery
210
+
211
+ async def verify_access_token(self, token: str) -> VerifiedIntflowToken:
212
+ try:
213
+ return self._verify_with_jwks(token, await self._get_jwks())
214
+ except IntflowTokenVerificationError as exc:
215
+ if exc.code != "unknown_key_id":
216
+ raise
217
+
218
+ return self._verify_with_jwks(token, await self._get_jwks(force_refresh=True))
219
+
220
+ def _get_cached_discovery(self, *, force_refresh: bool) -> OidcDiscoveryMetadata | None:
221
+ if (
222
+ force_refresh
223
+ or self._discovery is None
224
+ or self._discovery.expires_at <= time.monotonic()
225
+ ):
226
+ return None
227
+ if not isinstance(self._discovery.value, OidcDiscoveryMetadata):
228
+ return None
229
+ return self._discovery.value
230
+
231
+ async def _get_jwks(self, *, force_refresh: bool = False) -> Mapping[str, Any]:
232
+ if (
233
+ not force_refresh
234
+ and self._jwks is not None
235
+ and self._jwks.expires_at > time.monotonic()
236
+ and isinstance(self._jwks.value, Mapping)
237
+ ):
238
+ return self._jwks.value
239
+
240
+ discovery = await self.get_discovery(force_refresh=force_refresh)
241
+ jwks = await self._fetch_json(discovery.jwks_uri, target="jwks")
242
+ _assert_jwks(jwks)
243
+ self._jwks = _CachedValue(jwks, time.monotonic() + self.cache_ttl_seconds)
244
+ return jwks
245
+
246
+ async def _fetch_json(self, url: str, *, target: str) -> Mapping[str, Any]:
247
+ try:
248
+ if self._client is not None:
249
+ response = await self._client.get(url, headers={"accept": "application/json"})
250
+ else:
251
+ async with httpx.AsyncClient(
252
+ timeout=self.timeout,
253
+ transport=self._transport,
254
+ ) as client:
255
+ response = await client.get(url, headers={"accept": "application/json"})
256
+ except httpx.HTTPError as exc:
257
+ raise _fetch_error(target) from exc
258
+
259
+ return _response_json(response, target=target)
260
+
261
+ def _verify_with_jwks(self, token: str, jwks: Mapping[str, Any]) -> VerifiedIntflowToken:
262
+ verifier = IntflowTokenVerifier(
263
+ audience=self.audience,
264
+ issuer=self.issuer,
265
+ algorithms=self.algorithms,
266
+ cache_ttl_seconds=self.cache_ttl_seconds,
267
+ clock_tolerance_seconds=self.clock_tolerance_seconds,
268
+ )
269
+ return verifier._verify_with_jwks(token, jwks)
270
+
271
+
272
+ def create_token_verifier(**options: Any) -> IntflowTokenVerifier:
273
+ return IntflowTokenVerifier(**options)
274
+
275
+
276
+ def create_async_token_verifier(**options: Any) -> AsyncIntflowTokenVerifier:
277
+ return AsyncIntflowTokenVerifier(**options)
278
+
279
+
280
+ def verify_access_token(token: str, **options: Any) -> VerifiedIntflowToken:
281
+ return create_token_verifier(**options).verify_access_token(token)
282
+
283
+
284
+ async def verify_access_token_async(token: str, **options: Any) -> VerifiedIntflowToken:
285
+ return await create_async_token_verifier(**options).verify_access_token(token)
286
+
287
+
288
+ def fetch_discovery(
289
+ *,
290
+ issuer: str = DEFAULT_ISSUER,
291
+ client: httpx.Client | None = None,
292
+ transport: httpx.BaseTransport | None = None,
293
+ timeout: float = 5.0,
294
+ ) -> OidcDiscoveryMetadata:
295
+ verifier = IntflowTokenVerifier(
296
+ audience="discovery-only",
297
+ issuer=issuer,
298
+ client=client,
299
+ transport=transport,
300
+ timeout=timeout,
301
+ )
302
+ return verifier.get_discovery()
303
+
304
+
305
+ async def fetch_discovery_async(
306
+ *,
307
+ issuer: str = DEFAULT_ISSUER,
308
+ client: httpx.AsyncClient | None = None,
309
+ transport: httpx.AsyncBaseTransport | None = None,
310
+ timeout: float = 5.0,
311
+ ) -> OidcDiscoveryMetadata:
312
+ verifier = AsyncIntflowTokenVerifier(
313
+ audience="discovery-only",
314
+ issuer=issuer,
315
+ client=client,
316
+ transport=transport,
317
+ timeout=timeout,
318
+ )
319
+ return await verifier.get_discovery()
320
+
321
+
322
+ def _normalize_issuer(issuer: str) -> str:
323
+ return issuer.rstrip("/")
324
+
325
+
326
+ def _fetch_error(target: str) -> IntflowAuthError:
327
+ if target == "discovery":
328
+ return IntflowDiscoveryError("discovery_fetch_failed", "OIDC discovery failed.")
329
+ return IntflowJwksError("jwks_fetch_failed", "JWKS fetch failed.")
330
+
331
+
332
+ def _response_json(response: httpx.Response, *, target: str) -> Mapping[str, Any]:
333
+ if response.status_code < 200 or response.status_code >= 300:
334
+ if target == "discovery":
335
+ raise IntflowDiscoveryError(
336
+ "discovery_http_error",
337
+ f"OIDC discovery returned HTTP {response.status_code}.",
338
+ )
339
+ raise IntflowJwksError(
340
+ "jwks_http_error",
341
+ f"JWKS endpoint returned HTTP {response.status_code}.",
342
+ )
343
+
344
+ try:
345
+ data = response.json()
346
+ except ValueError as exc:
347
+ if target == "discovery":
348
+ raise IntflowDiscoveryError(
349
+ "discovery_invalid_json",
350
+ "OIDC discovery was not JSON.",
351
+ ) from exc
352
+ raise IntflowJwksError("jwks_invalid_json", "JWKS response was not JSON.") from exc
353
+
354
+ if not isinstance(data, Mapping):
355
+ if target == "discovery":
356
+ raise IntflowDiscoveryError(
357
+ "discovery_invalid_shape",
358
+ "OIDC discovery was invalid.",
359
+ )
360
+ raise IntflowJwksError("jwks_invalid_shape", "JWKS response was invalid.")
361
+ return data
362
+
363
+
364
+ def _parse_discovery(data: Mapping[str, Any], *, expected_issuer: str) -> OidcDiscoveryMetadata:
365
+ if data.get("issuer") != expected_issuer:
366
+ raise IntflowDiscoveryError(
367
+ "discovery_issuer_mismatch",
368
+ "OIDC discovery issuer did not match the configured issuer.",
369
+ )
370
+
371
+ issuer = _required_string(data, "issuer", "discovery_missing_issuer", IntflowDiscoveryError)
372
+ authorization_endpoint = _required_string(
373
+ data,
374
+ "authorization_endpoint",
375
+ "discovery_missing_authorization_endpoint",
376
+ IntflowDiscoveryError,
377
+ )
378
+ token_endpoint = _required_string(
379
+ data,
380
+ "token_endpoint",
381
+ "discovery_missing_token_endpoint",
382
+ IntflowDiscoveryError,
383
+ )
384
+ jwks_uri = _required_string(
385
+ data,
386
+ "jwks_uri",
387
+ "discovery_missing_jwks_uri",
388
+ IntflowDiscoveryError,
389
+ )
390
+ return OidcDiscoveryMetadata(
391
+ issuer=issuer,
392
+ authorization_endpoint=authorization_endpoint,
393
+ token_endpoint=token_endpoint,
394
+ jwks_uri=jwks_uri,
395
+ raw=data,
396
+ )
397
+
398
+
399
+ def _assert_jwks(data: Mapping[str, Any]) -> None:
400
+ if not isinstance(data.get("keys"), list):
401
+ raise IntflowJwksError("jwks_invalid_shape", "JWKS response was invalid.")
402
+
403
+
404
+ def _unverified_header(token: str) -> Mapping[str, Any]:
405
+ try:
406
+ header = jwt.get_unverified_header(token)
407
+ except InvalidTokenError as exc:
408
+ raise IntflowTokenVerificationError("invalid_token", "Token header was invalid.") from exc
409
+ if not isinstance(header, Mapping):
410
+ raise IntflowTokenVerificationError("invalid_token", "Token header was invalid.")
411
+ return header
412
+
413
+
414
+ def _find_key(
415
+ jwks: Mapping[str, Any],
416
+ header: Mapping[str, Any],
417
+ *,
418
+ allowed_algorithms: Sequence[str],
419
+ ) -> Any:
420
+ algorithm = header.get("alg")
421
+ if not isinstance(algorithm, str):
422
+ raise IntflowTokenVerificationError("missing_algorithm", "Token is missing alg.")
423
+ if algorithm not in allowed_algorithms:
424
+ raise IntflowTokenVerificationError("invalid_algorithm", "Token algorithm was not allowed.")
425
+
426
+ key_id = header.get("kid")
427
+ if not isinstance(key_id, str) or key_id == "":
428
+ raise IntflowTokenVerificationError("missing_key_id", "Token is missing kid.")
429
+
430
+ keys = jwks.get("keys")
431
+ if not isinstance(keys, list):
432
+ raise IntflowJwksError("jwks_invalid_shape", "JWKS response was invalid.")
433
+
434
+ for key_data in keys:
435
+ if isinstance(key_data, Mapping) and key_data.get("kid") == key_id:
436
+ return PyJWK.from_dict(dict(key_data), algorithm=algorithm).key
437
+
438
+ raise IntflowTokenVerificationError("unknown_key_id", "Token key id was not found in JWKS.")
439
+
440
+
441
+ def _assert_intflow_claims(payload: Mapping[str, Any]) -> None:
442
+ subject = payload.get("sub")
443
+ if not isinstance(subject, str) or subject == "":
444
+ raise IntflowMissingClaimError("missing_subject", "Token is missing sub.")
445
+ if not isinstance(payload.get("iss"), str):
446
+ raise IntflowMissingClaimError("missing_issuer", "Token is missing iss.")
447
+ audience = payload.get("aud")
448
+ if not isinstance(audience, str) and not isinstance(audience, list):
449
+ raise IntflowMissingClaimError("missing_audience", "Token is missing aud.")
450
+ if not isinstance(payload.get("exp"), int):
451
+ raise IntflowMissingClaimError("missing_expiration", "Token is missing exp.")
452
+ if not isinstance(payload.get("nbf"), int):
453
+ raise IntflowMissingClaimError("missing_not_before", "Token is missing nbf.")
454
+
455
+
456
+ def _missing_claim_error(claim: str) -> IntflowMissingClaimError:
457
+ code_by_claim = {
458
+ "iss": "missing_issuer",
459
+ "sub": "missing_subject",
460
+ "aud": "missing_audience",
461
+ "exp": "missing_expiration",
462
+ "nbf": "missing_not_before",
463
+ }
464
+ return IntflowMissingClaimError(
465
+ code_by_claim.get(claim, "missing_claim"),
466
+ f"Token is missing {claim}.",
467
+ )
468
+
469
+
470
+ def _required_string(
471
+ data: Mapping[str, Any],
472
+ key: str,
473
+ code: str,
474
+ error_type: type[IntflowDiscoveryError],
475
+ ) -> str:
476
+ value = data.get(key)
477
+ if not isinstance(value, str):
478
+ raise error_type(code, f"OIDC discovery did not include {key}.")
479
+ return value
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: intflow-auth-sdk
3
+ Version: 0.1.0
4
+ Summary: Intflow Auth relying application SDK.
5
+ Author: Intflow
6
+ License: Proprietary
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: httpx<1,>=0.28
9
+ Requires-Dist: pyjwt[crypto]<3,>=2.10
10
+ Provides-Extra: dev
11
+ Requires-Dist: build<2,>=1.2; extra == 'dev'
12
+ Requires-Dist: mypy<2,>=1.13; extra == 'dev'
13
+ Requires-Dist: pytest-asyncio<1,>=0.25; extra == 'dev'
14
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
15
+ Requires-Dist: twine<7,>=6; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Intflow Auth SDK
19
+
20
+ Python SDK for relying applications that integrate with Intflow Auth.
21
+
22
+ This package is for application backends that need to validate Intflow-issued
23
+ tokens and enforce application roles. It does not provide operator credential
24
+ storage, service runtime components, or Admin API automation.
25
+
26
+ ## Install
27
+
28
+ ```powershell
29
+ python -m pip install intflow-auth-sdk
30
+ ```
31
+
32
+ ## Current Status
33
+
34
+ This package provides PKCE helpers, authorization URL construction, OIDC
35
+ discovery/JWKS caching, Intflow access-token verification, and application-role
36
+ checks.
37
+
38
+ ```python
39
+ from intflow_auth_sdk import create_token_verifier, require_app_role
40
+
41
+ verifier = create_token_verifier(audience="your-oauth-client-id")
42
+ verified = verifier.verify_access_token(access_token)
43
+ require_app_role(verified.payload, "admin")
44
+
45
+ print(verified.payload["sub"])
46
+ ```
47
+
48
+ For local fake-token examples, see `examples/python-sdk-consumer`.
@@ -0,0 +1,10 @@
1
+ intflow_auth_sdk/__init__.py,sha256=JAO48ws56qig8elWz85WhLiFbWH-6n8HYuBMZST3ReY,1823
2
+ intflow_auth_sdk/claims.py,sha256=RjVkjxAUHQQ3i0kSMaDiM5Np2c-Q-ipP2IDCLzyWV1g,1523
3
+ intflow_auth_sdk/errors.py,sha256=4dCq6BjCbwMHrYhLOoW2-u3rnNcVqGjyAgjtfHGFCnA,502
4
+ intflow_auth_sdk/models.py,sha256=Vfa5qwLOswA5vEkJL6G9iJwp2xylorTmYDBxU107bUg,800
5
+ intflow_auth_sdk/pkce.py,sha256=-2qZWXnlMUzabHtrY6WFxvLlER7SDdNzqoqgqsciR8o,2030
6
+ intflow_auth_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
7
+ intflow_auth_sdk/verifier.py,sha256=9AyFB6GEYSkqUkAynDQs3vGP63BalMzZPRfIE0T1NLY,17520
8
+ intflow_auth_sdk-0.1.0.dist-info/METADATA,sha256=zow8KeObR96DV4Bit2LG7VHre6otrUPqwyPRGeQ9NIw,1438
9
+ intflow_auth_sdk-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ intflow_auth_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any