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.
- intflow_auth_sdk/__init__.py +71 -0
- intflow_auth_sdk/claims.py +50 -0
- intflow_auth_sdk/errors.py +27 -0
- intflow_auth_sdk/models.py +39 -0
- intflow_auth_sdk/pkce.py +69 -0
- intflow_auth_sdk/py.typed +1 -0
- intflow_auth_sdk/verifier.py +479 -0
- intflow_auth_sdk-0.1.0.dist-info/METADATA +48 -0
- intflow_auth_sdk-0.1.0.dist-info/RECORD +10 -0
- intflow_auth_sdk-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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, ...]
|
intflow_auth_sdk/pkce.py
ADDED
|
@@ -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,,
|