iaptoolkit 0.3.4__tar.gz → 0.3.5__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 Robert A. Voigt
3
+ Copyright (c) 2024-2025 Robert A. Voigt
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,8 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: iaptoolkit
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Library of common utils for interacting with Identity-Aware Proxies
5
- Home-page: https://github.com/RAVoigt/iaptoolkit
6
5
  Author: Rob Voigt
7
6
  Author-email: code@ravoigt.com
8
7
  Requires-Python: >=3.11
@@ -11,9 +10,10 @@ Classifier: Programming Language :: Python :: 3.11
11
10
  Classifier: Programming Language :: Python :: 3.12
12
11
  Classifier: Programming Language :: Python :: 3.13
13
12
  Requires-Dist: google-auth (>=2.29.0,<3.0.0)
14
- Requires-Dist: kvcommon[k8s] (>=0.2.4,<0.3.0)
13
+ Requires-Dist: kvcommon[k8s] (>=0.2.8,<0.3.0)
15
14
  Requires-Dist: requests (>=2.31.0,<3.0.0)
16
15
  Requires-Dist: toml (>=0.10.2,<0.11.0)
16
+ Project-URL: Homepage, https://github.com/RAVoigt/iaptoolkit
17
17
  Project-URL: Repository, https://github.com/RAVoigt/iaptoolkit
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "iaptoolkit"
3
- version = "0.3.4"
3
+ version = "0.3.5"
4
4
  description = "Library of common utils for interacting with Identity-Aware Proxies"
5
5
  authors = [
6
6
  {name = "Rob Voigt", email = "code@ravoigt.com"}
@@ -22,8 +22,8 @@ Repository = "https://github.com/RAVoigt/iaptoolkit"
22
22
  # ================================
23
23
  # Tools etc.
24
24
  [tool.black]
25
- line-length = 120
26
- target-version = ['py311']
25
+ line-length = 110
26
+ # target-version = ['py311'] # Weirdly unsupported currently
27
27
  include = '\.pyi?$'
28
28
 
29
29
  # ================================
@@ -33,7 +33,7 @@ python = "^3.11"
33
33
  google-auth = "^2.29.0"
34
34
  requests = "^2.31.0"
35
35
  toml = "^0.10.2"
36
- kvcommon = {extras = ["k8s"], version = "^0.2.4"}
36
+ kvcommon = {extras = ["k8s"], version = "^0.2.8"}
37
37
 
38
38
  [tool.poetry.group.dev.dependencies]
39
39
  black = "*"
@@ -29,10 +29,7 @@ class IAPToolkit:
29
29
 
30
30
  _GOOGLE_IAP_CLIENT_ID: str
31
31
 
32
- def __init__(
33
- self,
34
- google_iap_client_id: str,
35
- ) -> None:
32
+ def __init__(self, google_iap_client_id: str) -> None:
36
33
  self._GOOGLE_IAP_CLIENT_ID = google_iap_client_id
37
34
 
38
35
  @staticmethod
@@ -41,7 +38,9 @@ class IAPToolkit:
41
38
 
42
39
  def get_token_oidc(self, bypass_cached: bool = False) -> TokenStruct:
43
40
  try:
44
- return ServiceAccount.get_token(iap_client_id=self._GOOGLE_IAP_CLIENT_ID, bypass_cached=bypass_cached)
41
+ return ServiceAccount.get_token(
42
+ iap_client_id=self._GOOGLE_IAP_CLIENT_ID, bypass_cached=bypass_cached,
43
+ )
45
44
  except ServiceAccountTokenException as ex:
46
45
  LOG.debug(ex)
47
46
  raise
@@ -92,17 +91,14 @@ class IAPToolkit:
92
91
  from_cache = token_struct.from_cache
93
92
 
94
93
  headers.add_token_to_request_headers(
95
- request_headers=request_headers,
96
- id_token=id_token,
97
- use_auth_header=use_auth_header,
94
+ request_headers=request_headers, id_token=id_token, use_auth_header=use_auth_header,
98
95
  )
99
96
 
100
97
  return from_cache
101
98
 
102
99
  @staticmethod
103
100
  def is_url_safe_for_token(
104
- url: str | ParseResult,
105
- valid_domains: t.Optional[t.List[str] | t.Set[str] | t.Tuple[str]] = None,
101
+ url: str | ParseResult, valid_domains: t.Optional[t.List[str] | t.Set[str] | t.Tuple[str]] = None,
106
102
  ):
107
103
  if not isinstance(url, ParseResult):
108
104
  url = urlparse(url)
@@ -199,12 +195,7 @@ class IAPToolkit_OAuth2(IAPToolkit):
199
195
  _GOOGLE_CLIENT_ID: str
200
196
  _GOOGLE_CLIENT_SECRET: str
201
197
 
202
- def __init__(
203
- self,
204
- google_iap_client_id: str,
205
- google_client_id: str,
206
- google_client_secret: str,
207
- ) -> None:
198
+ def __init__(self, google_iap_client_id: str, google_client_id: str, google_client_secret: str,) -> None:
208
199
  super().__init__(google_iap_client_id=google_iap_client_id)
209
200
  self._GOOGLE_CLIENT_ID = google_client_id
210
201
  self._GOOGLE_CLIENT_SECRET = google_client_secret
@@ -8,3 +8,5 @@ IAPTOOLKIT_CONFIG_VERSION = 1
8
8
  GOOGLE_IAP_AUTH_HEADER = "Authorization"
9
9
  # Alternative auth header used for IAP-aware requests when 'Authorization' clashes. Stripped by IAP if consumed.
10
10
  GOOGLE_IAP_AUTH_HEADER_PROXY = "Proxy-Authorization"
11
+
12
+ GOOGLE_IAP_PUBLIC_KEY_URL = "https://www.gstatic.com/iap/verify/public_key-jwk"
@@ -55,3 +55,11 @@ class ServiceAccountTokenFailedRefresh(ServiceAccountTokenException):
55
55
 
56
56
  class InvalidDomain(IAPToolkitBaseException):
57
57
  pass
58
+
59
+
60
+ class PublicKeyException(IAPToolkitBaseException):
61
+ pass
62
+
63
+
64
+ class JWTVerificationFailure(IAPToolkitBaseException):
65
+ pass
@@ -42,7 +42,11 @@ class ServiceAccount(object):
42
42
  def get_stored_token(iap_client_id: str) -> t.Optional[TokenStruct]:
43
43
  try:
44
44
  token_dict = datastore.get_stored_service_account_token(iap_client_id)
45
- if not token_dict or not token_dict.get("id_token", None) or not token_dict.get("token_expiry", None):
45
+ if (
46
+ not token_dict
47
+ or not token_dict.get("id_token", None)
48
+ or not token_dict.get("token_expiry", None)
49
+ ):
46
50
  LOG.debug("No stored service account token for current iap_client_id")
47
51
  return
48
52
 
@@ -53,7 +57,9 @@ class ServiceAccount(object):
53
57
  try:
54
58
  token_expiry = datetime.datetime.fromisoformat(token_expiry_from_dict)
55
59
  except (ValueError, TypeError) as ex:
56
- LOG.debug("Invalid token expiry for stored token - Could not parse from ISO format to datetime.")
60
+ LOG.debug(
61
+ "Invalid token expiry for stored token - Could not parse from ISO format to datetime."
62
+ )
57
63
  return
58
64
 
59
65
  token_struct = TokenStruct(id_token=id_token_from_dict, expiry=token_expiry, from_cache=True)
@@ -90,8 +96,7 @@ class ServiceAccount(object):
90
96
  # Likely attempting to get a token for a service account in an environment that
91
97
  # doesn't have one attached.
92
98
  raise ServiceAccountTokenFailedRefresh(
93
- message="Failed to get ServiceAccount token: Refreshing token failed.",
94
- google_exception=ex,
99
+ message="Failed to get ServiceAccount token: Refreshing token failed.", google_exception=ex,
95
100
  )
96
101
  return credentials
97
102
 
@@ -166,7 +171,9 @@ class GoogleServiceAccount(ServiceAccount):
166
171
 
167
172
  def __init__(self, iap_client_id: str) -> None:
168
173
  if not iap_client_id or not isinstance(iap_client_id, str):
169
- raise ServiceAccountTokenException("Invalid iap_client_id for GoogleServiceAccount", google_exception=None)
174
+ raise ServiceAccountTokenException(
175
+ "Invalid iap_client_id for GoogleServiceAccount", google_exception=None
176
+ )
170
177
  self._iap_client_id = iap_client_id
171
178
  super().__init__()
172
179
 
@@ -15,7 +15,7 @@ LOG = logger.get_logger("iaptk")
15
15
 
16
16
 
17
17
  def is_url_safe_for_token(
18
- url_parts: ParseResult, allowed_domains: t.Optional[t.List[str] | t.Set[str] | t.Tuple[str]] = None
18
+ url_parts: ParseResult, allowed_domains: t.Optional[t.List[str] | t.Set[str] | t.Tuple[str]] = None,
19
19
  ) -> bool:
20
20
  """Determines if the given url is considered a safe to receive a token in request headers.
21
21
 
@@ -40,7 +40,8 @@ def is_url_safe_for_token(
40
40
  for domain in allowed_domains:
41
41
  if domain == "" or not isinstance(domain, str):
42
42
  raise InvalidDomain(
43
- f"Empty or non-string domain in allowed_domains: " f"'{str(domain)}' (type#: {type(domain).__name__})"
43
+ f"Empty or non-string domain in allowed_domains: "
44
+ f"'{str(domain)}' (type#: {type(domain).__name__})"
44
45
  )
45
46
 
46
47
  if netloc.endswith(domain):
@@ -0,0 +1,82 @@
1
+ import datetime
2
+ import json
3
+ import requests
4
+
5
+ from google.auth import jwt
6
+
7
+ from iaptoolkit.constants import GOOGLE_IAP_PUBLIC_KEY_URL
8
+ from iaptoolkit.exceptions import PublicKeyException
9
+ from iaptoolkit.exceptions import JWTVerificationFailure
10
+
11
+
12
+ class GooglePublicKeyException(PublicKeyException):
13
+ pass
14
+
15
+
16
+ class GoogleIAPKeys:
17
+ """
18
+ Retrieve Google's public keys for JWT verification and record the timestamp at retrieval.
19
+ If the retrieval was >5m ago (default), refresh the keys in case of rotation or expiry
20
+ """
21
+
22
+ _retrieved_timestamp: datetime.datetime
23
+ _key_ttl_seconds: int = 300 # 5 mins
24
+ _certs: dict
25
+
26
+ def __init__(self, key_ttl_seconds: int = 300) -> None:
27
+ self._key_ttl_seconds = key_ttl_seconds
28
+ self.refresh()
29
+
30
+ def refresh(self):
31
+
32
+ response = requests.get(GOOGLE_IAP_PUBLIC_KEY_URL)
33
+ response.raise_for_status()
34
+
35
+ try:
36
+ certs = json.loads(response.text)["keys"]
37
+ except json.JSONDecodeError as ex:
38
+ raise GooglePublicKeyException(f"Decode error in JSON retrieved for Google Public Keys: {ex}")
39
+ except KeyError as ex:
40
+ raise GooglePublicKeyException(f"KeyError with JSON retrieved for Google Public Keys: {ex}")
41
+ if not certs:
42
+ raise GooglePublicKeyException(
43
+ f"Failed to retrieve JSON public keys from Google at: '{GOOGLE_IAP_PUBLIC_KEY_URL}'"
44
+ )
45
+
46
+ self._retrieved_timestamp = datetime.datetime.now(tz=datetime.UTC)
47
+ self._certs = certs
48
+
49
+ @property
50
+ def should_refresh(self) -> bool:
51
+ if self._retrieved_timestamp > datetime.datetime.now() - datetime.timedelta(
52
+ seconds=self._key_ttl_seconds
53
+ ):
54
+ return False
55
+ return True
56
+
57
+ @property
58
+ def certs(self) -> dict:
59
+ if self.should_refresh:
60
+ self.refresh()
61
+ return self._certs.copy()
62
+
63
+
64
+ google_public_keys: GoogleIAPKeys | None = None
65
+
66
+
67
+ def verify_iap_jwt(iap_jwt: str, expected_audience: str) -> str:
68
+ global google_public_keys # Use as singleton
69
+ if not google_public_keys:
70
+ google_public_keys = GoogleIAPKeys()
71
+
72
+ decoded_jwt = jwt.decode(iap_jwt, certs=google_public_keys.certs, verify=True)
73
+
74
+ # Extract claims
75
+ email = decoded_jwt.get("email")
76
+ audience = decoded_jwt.get("aud")
77
+
78
+ if audience != expected_audience:
79
+ print("Invalid audience:", audience)
80
+ raise JWTVerificationFailure("Audience mismatch when verifying IAP JWT")
81
+
82
+ return email
File without changes