iaptoolkit 0.3.8__tar.gz → 0.4.0__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,7 +1,8 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: iaptoolkit
3
- Version: 0.3.8
3
+ Version: 0.4.0
4
4
  Summary: Library of common utils for interacting with Identity-Aware Proxies
5
+ License-File: LICENSE
5
6
  Author: Rob Voigt
6
7
  Author-email: code@ravoigt.com
7
8
  Requires-Python: >=3.11,<4.0
@@ -9,9 +10,11 @@ Classifier: Programming Language :: Python :: 3
9
10
  Classifier: Programming Language :: Python :: 3.11
10
11
  Classifier: Programming Language :: Python :: 3.12
11
12
  Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
12
14
  Requires-Dist: google-auth (>=2.29.0,<3.0.0)
13
- Requires-Dist: kvcommon[k8s] (>=0.4.0,<0.5.0)
14
- Requires-Dist: requests (>=2.31.0,<3.0.0)
15
+ Requires-Dist: google-cloud-iam (>=2.20.0,<3.0.0)
16
+ Requires-Dist: kvcommon[k8s] (>=0.4.5,<0.5.0)
17
+ Requires-Dist: requests (>=2.32.4)
15
18
  Requires-Dist: toml (>=0.10.2,<0.11.0)
16
19
  Project-URL: Homepage, https://github.com/RAVoigt/iaptoolkit
17
20
  Project-URL: Repository, https://github.com/RAVoigt/iaptoolkit
@@ -48,6 +51,7 @@ def example1(url: str):
48
51
  result = iaptk.check_url_and_add_token_header(
49
52
  url=url,
50
53
  request_headers=headers,
54
+ iap_audience="some_iap_client_id_string" # OAuth Client ID for the IAP-protected resource as 'audience'
51
55
  valid_domains=allowed_domains
52
56
  )
53
57
  # result.token_added (bool) indicates if the token was added, depending on whether or not URL was valid
@@ -58,20 +62,57 @@ def example1(url: str):
58
62
 
59
63
 
60
64
  # Example #2 - Separate Calls - Functionally the same as Example 1 but more flexibility in URL validation
61
- def example1(url: str):
65
+ def example2(url: str):
62
66
  is_url_safe: bool = iaptk.is_url_safe_for_token(url=url, valid_domains=valid_domains)
63
67
 
64
68
  if not is_url_safe:
65
69
  raise ExampleBadURLException("This URL isn't safe to send token headers to!")
66
70
 
67
71
  headers = dict()
68
- token_is_fresh: bool = iaptk.get_token_and_add_to_headers(request_headers=headers)
72
+ token_is_fresh: bool = iaptk.get_token_and_add_to_headers(
73
+ request_headers=headers,
74
+ iap_audience="some_iap_client_id_string" # OAuth Client ID for the IAP-protected resource as 'audience'
75
+ )
69
76
  # token_is_fresh indicates if token was newly retrieved (True), or if a cached token was reused (False)
70
77
  # headers dict now contains the appropriate Bearer Token header for Google IAP
71
78
 
72
79
  # Make HTTP GET request with requests lib, with our headers containing bearer token to auth with IAP
73
80
  response = requests.request("GET", url, headers=headers)
74
81
 
82
+ # Example #3 - Service Account JWT (instead of OIDC Token)
83
+ def example3(url: str):
84
+ headers = dict()
85
+ result = iaptk.check_url_and_add_jwt_header(
86
+ url=url,
87
+ request_headers=headers,
88
+ service_account_email="service-account@PROJECT_ID.iam.gserviceaccount.com",
89
+ url_audience="https://some-iap-protected.resource/path",
90
+ valid_domains=allowed_domains
91
+ )
92
+ # result.token_added (bool) indicates if the token was added, depending on whether or not URL was valid
93
+ # headers dict now contains the appropriate Bearer JWT header for Google IAP
94
+
95
+ # Make HTTP GET request with requests lib, with our headers containing bearer token to auth with IAP
96
+ response = requests.request("GET", url, headers=headers)
97
+
98
+ # Example #4 - Separate Calls - Service Account JWT - Functionally the same as Example 3 but more flexibility in URL validation
99
+ def example4(url: str):
100
+ is_url_safe: bool = iaptk.is_url_safe_for_token(url=url, valid_domains=valid_domains)
101
+
102
+ if not is_url_safe:
103
+ raise ExampleBadURLException("This URL isn't safe to send token headers to!")
104
+
105
+ headers = dict()
106
+ token_is_fresh: bool = iaptk.get_jwt_and_add_to_headers(
107
+ request_headers=headers,
108
+ service_account_email="service-account@PROJECT_ID.iam.gserviceaccount.com",
109
+ url_audience="https://some-iap-protected.resource/path"
110
+ )
111
+ # token_is_fresh indicates if token was newly retrieved (True), or if a cached token was reused (False)
112
+ # headers dict now contains the appropriate Bearer Token header for Google IAP
113
+
114
+ # Make HTTP GET request with requests lib, with our headers containing bearer token to auth with IAP
115
+ response = requests.request("GET", url, headers=headers)
75
116
  ```
76
117
 
77
118
  ## Disclaimer
@@ -0,0 +1,98 @@
1
+ # IAP Toolkit
2
+
3
+ A library of utils to ease programmatic authentication with Google IAP (and ideally other IAPs in future).
4
+
5
+ # PyPi
6
+ https://pypi.org/project/iaptoolkit/
7
+
8
+ # Installation
9
+ ### With Poetry:
10
+ `poetry add iaptoolkit`
11
+
12
+ ### With pip:
13
+ `pip install iaptoolkit`
14
+
15
+ ## Quick Start / Example Usage
16
+
17
+ ```python
18
+ import requests
19
+
20
+ from iaptoolkit import IAPToolkit
21
+
22
+ iaptk = IAPToolkit(google_iap_client_id="EXAMPLE_ID_123456789ABCDEF")
23
+ allowed_domains = ["example.com", ]
24
+
25
+
26
+ # Example #1 - Combined Calls
27
+ def example1(url: str):
28
+ headers = dict()
29
+ result = iaptk.check_url_and_add_token_header(
30
+ url=url,
31
+ request_headers=headers,
32
+ iap_audience="some_iap_client_id_string" # OAuth Client ID for the IAP-protected resource as 'audience'
33
+ valid_domains=allowed_domains
34
+ )
35
+ # result.token_added (bool) indicates if the token was added, depending on whether or not URL was valid
36
+ # headers dict now contains the appropriate Bearer Token header for Google IAP
37
+
38
+ # Make HTTP GET request with requests lib, with our headers containing bearer token to auth with IAP
39
+ response = requests.request("GET", url, headers=headers)
40
+
41
+
42
+ # Example #2 - Separate Calls - Functionally the same as Example 1 but more flexibility in URL validation
43
+ def example2(url: str):
44
+ is_url_safe: bool = iaptk.is_url_safe_for_token(url=url, valid_domains=valid_domains)
45
+
46
+ if not is_url_safe:
47
+ raise ExampleBadURLException("This URL isn't safe to send token headers to!")
48
+
49
+ headers = dict()
50
+ token_is_fresh: bool = iaptk.get_token_and_add_to_headers(
51
+ request_headers=headers,
52
+ iap_audience="some_iap_client_id_string" # OAuth Client ID for the IAP-protected resource as 'audience'
53
+ )
54
+ # token_is_fresh indicates if token was newly retrieved (True), or if a cached token was reused (False)
55
+ # headers dict now contains the appropriate Bearer Token header for Google IAP
56
+
57
+ # Make HTTP GET request with requests lib, with our headers containing bearer token to auth with IAP
58
+ response = requests.request("GET", url, headers=headers)
59
+
60
+ # Example #3 - Service Account JWT (instead of OIDC Token)
61
+ def example3(url: str):
62
+ headers = dict()
63
+ result = iaptk.check_url_and_add_jwt_header(
64
+ url=url,
65
+ request_headers=headers,
66
+ service_account_email="service-account@PROJECT_ID.iam.gserviceaccount.com",
67
+ url_audience="https://some-iap-protected.resource/path",
68
+ valid_domains=allowed_domains
69
+ )
70
+ # result.token_added (bool) indicates if the token was added, depending on whether or not URL was valid
71
+ # headers dict now contains the appropriate Bearer JWT header for Google IAP
72
+
73
+ # Make HTTP GET request with requests lib, with our headers containing bearer token to auth with IAP
74
+ response = requests.request("GET", url, headers=headers)
75
+
76
+ # Example #4 - Separate Calls - Service Account JWT - Functionally the same as Example 3 but more flexibility in URL validation
77
+ def example4(url: str):
78
+ is_url_safe: bool = iaptk.is_url_safe_for_token(url=url, valid_domains=valid_domains)
79
+
80
+ if not is_url_safe:
81
+ raise ExampleBadURLException("This URL isn't safe to send token headers to!")
82
+
83
+ headers = dict()
84
+ token_is_fresh: bool = iaptk.get_jwt_and_add_to_headers(
85
+ request_headers=headers,
86
+ service_account_email="service-account@PROJECT_ID.iam.gserviceaccount.com",
87
+ url_audience="https://some-iap-protected.resource/path"
88
+ )
89
+ # token_is_fresh indicates if token was newly retrieved (True), or if a cached token was reused (False)
90
+ # headers dict now contains the appropriate Bearer Token header for Google IAP
91
+
92
+ # Make HTTP GET request with requests lib, with our headers containing bearer token to auth with IAP
93
+ response = requests.request("GET", url, headers=headers)
94
+ ```
95
+
96
+ ## Disclaimer
97
+
98
+ This project is not affiliated with Google. No trademark infringement intended.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "iaptoolkit"
3
- version = "0.3.8"
3
+ version = "0.4.0"
4
4
  description = "Library of common utils for interacting with Identity-Aware Proxies"
5
5
  authors = ["Rob Voigt <code@ravoigt.com>"]
6
6
  readme = "README.md"
@@ -23,9 +23,10 @@ include = '\.pyi?$'
23
23
  [tool.poetry.dependencies]
24
24
  python = "^3.11"
25
25
  google-auth = "^2.29.0"
26
- requests = "^2.31.0"
26
+ google-cloud-iam = "^2.20.0"
27
+ requests = ">=2.32.4"
27
28
  toml = "^0.10.2"
28
- kvcommon = {extras = ["k8s"], version = "^0.4.0"}
29
+ kvcommon = {extras = ["k8s"], version = "^0.4.5"}
29
30
 
30
31
  [tool.poetry.group.dev.dependencies]
31
32
  black = "*"
@@ -27,39 +27,54 @@ class IAPToolkit:
27
27
  Class to encapsulate client-specific vars and forward them to static functions
28
28
  """
29
29
 
30
- _GOOGLE_IAP_CLIENT_ID: str
31
-
32
- def __init__(self, google_iap_client_id: str) -> None:
33
- self._GOOGLE_IAP_CLIENT_ID = google_iap_client_id
34
-
35
30
  @staticmethod
36
31
  def sanitize_request_headers(request_headers: dict) -> dict:
37
32
  return headers.sanitize_request_headers(request_headers)
38
33
 
39
- def get_token_oidc(self, bypass_cached: bool = False) -> TokenStruct:
34
+ @staticmethod
35
+ def get_service_account_jwt(
36
+ service_account_email: str, url_audience: str, bypass_cached: bool = False
37
+ ) -> TokenStruct:
38
+ try:
39
+ return ServiceAccount.get_jwt(
40
+ service_account_email=service_account_email,
41
+ url_audience=url_audience,
42
+ bypass_cached=bypass_cached,
43
+ )
44
+ except ServiceAccountTokenException as ex:
45
+ LOG.warning(ex)
46
+ raise
47
+
48
+ @staticmethod
49
+ def get_token_oidc(iap_audience: str, bypass_cached: bool = False) -> TokenStruct:
40
50
  try:
41
51
  return ServiceAccount.get_token(
42
- iap_client_id=self._GOOGLE_IAP_CLIENT_ID, bypass_cached=bypass_cached,
52
+ iap_audience=iap_audience,
53
+ bypass_cached=bypass_cached,
43
54
  )
44
55
  except ServiceAccountTokenException as ex:
45
- LOG.debug(ex)
56
+ LOG.warning(ex)
46
57
  raise
47
58
 
48
- def get_token_oidc_str(self, bypass_cached: bool = False) -> str:
49
- struct = self.get_token_oidc(bypass_cached=bypass_cached)
59
+ @staticmethod
60
+ def get_token_oidc_str(iap_audience: str, bypass_cached: bool = False) -> str:
61
+ struct = IAPToolkit.get_token_oidc(iap_audience=iap_audience, bypass_cached=bypass_cached)
50
62
  return struct.id_token
51
63
 
52
- def get_token_oauth2(self, bypass_cached: bool = False) -> TokenRefreshStruct:
64
+ @staticmethod
65
+ def get_token_oauth2(bypass_cached: bool = False) -> TokenRefreshStruct:
53
66
  # TODO
54
67
  raise NotImplementedError()
55
68
 
56
- def get_token_oauth2_str(self, bypass_cached: bool = False) -> str:
57
- struct = self.get_token_oauth2(bypass_cached=bypass_cached)
69
+ @staticmethod
70
+ def get_token_oauth2_str(bypass_cached: bool = False) -> str:
71
+ struct = IAPToolkit.get_token_oauth2(bypass_cached=bypass_cached)
58
72
  return struct.id_token
59
73
 
74
+ @staticmethod
60
75
  def get_token_and_add_to_headers(
61
- self,
62
76
  request_headers: dict,
77
+ iap_audience: str,
63
78
  use_oauth2: bool = False,
64
79
  use_auth_header: bool = False,
65
80
  bypass_cached: bool = False,
@@ -82,23 +97,68 @@ class IAPToolkit:
82
97
  id_token = None
83
98
  from_cache = False
84
99
  if use_oauth2:
85
- token_refresh_struct: TokenRefreshStruct = self.get_token_oauth2(bypass_cached=bypass_cached)
100
+ token_refresh_struct: TokenRefreshStruct = IAPToolkit.get_token_oauth2(bypass_cached=bypass_cached)
86
101
  id_token = token_refresh_struct.id_token
87
102
  from_cache = token_refresh_struct.from_cache
88
103
  else:
89
- token_struct: TokenStruct = self.get_token_oidc(bypass_cached=bypass_cached)
104
+ token_struct: TokenStruct = IAPToolkit.get_token_oidc(
105
+ iap_audience=iap_audience, bypass_cached=bypass_cached
106
+ )
90
107
  id_token = token_struct.id_token
91
108
  from_cache = token_struct.from_cache
92
109
 
93
110
  headers.add_token_to_request_headers(
94
- request_headers=request_headers, id_token=id_token, use_auth_header=use_auth_header,
111
+ request_headers=request_headers,
112
+ id_token=id_token,
113
+ use_auth_header=use_auth_header,
114
+ )
115
+
116
+ return from_cache
117
+
118
+ @staticmethod
119
+ def get_jwt_and_add_to_headers(
120
+ request_headers: dict,
121
+ service_account_email: str,
122
+ url_audience: str,
123
+ use_auth_header: bool = False,
124
+ bypass_cached: bool = False,
125
+ ) -> bool:
126
+ """
127
+ Retrieves a Service Account JWT and inserts it into the supplied request_headers dict.
128
+
129
+ request_headers is modified in-place
130
+
131
+ Params:
132
+ request_headers: dict of headers to insert into
133
+ service_account_email: Email address of the Service Account requesting access to IAP resource
134
+ url_audience: Target URL/Audience of IAP resource
135
+ use_auth_header: If true, use the 'Authorization' header instead of 'Proxy-Authorization'
136
+
137
+ Returns:
138
+ True if token retrieved from cache, False if fresh from API
139
+
140
+
141
+ """
142
+ from_cache = False
143
+
144
+ token_struct: TokenStruct = IAPToolkit.get_service_account_jwt(
145
+ service_account_email=service_account_email, url_audience=url_audience, bypass_cached=bypass_cached
146
+ )
147
+ signed_jwt = token_struct.id_token
148
+ from_cache = token_struct.from_cache
149
+
150
+ headers.add_token_to_request_headers(
151
+ request_headers=request_headers,
152
+ id_token=signed_jwt,
153
+ use_auth_header=use_auth_header,
95
154
  )
96
155
 
97
156
  return from_cache
98
157
 
99
158
  @staticmethod
100
159
  def is_url_safe_for_token(
101
- url: str | ParseResult, valid_domains: t.Optional[t.List[str] | t.Set[str] | t.Tuple[str]] = None,
160
+ url: str | ParseResult,
161
+ valid_domains: t.Optional[t.List[str] | t.Set[str] | t.Tuple[str]] = None,
102
162
  ):
103
163
  if not isinstance(url, ParseResult):
104
164
  url = urlparse(url)
@@ -109,6 +169,7 @@ class IAPToolkit:
109
169
  self,
110
170
  url: str | ParseResult,
111
171
  request_headers: dict,
172
+ iap_audience: str,
112
173
  valid_domains: t.List[str] | None = None,
113
174
  use_oauth2: bool = False,
114
175
  use_auth_header: bool = False,
@@ -130,18 +191,60 @@ class IAPToolkit:
130
191
  if self.is_url_safe_for_token(url=url, valid_domains=valid_domains):
131
192
  token_is_fresh = self.get_token_and_add_to_headers(
132
193
  request_headers=request_headers,
194
+ iap_audience=iap_audience,
133
195
  use_oauth2=use_oauth2,
134
196
  use_auth_header=use_auth_header,
197
+ bypass_cached=bypass_cached
198
+ )
199
+ return ResultAddTokenHeader(token_added=True, token_is_fresh=token_is_fresh, token_is_jwt=False)
200
+ else:
201
+ LOG.warning(
202
+ "URL is not approved: %s - Token will not be added to headers. Valid domains are: %s",
203
+ url,
204
+ valid_domains,
205
+ )
206
+ return ResultAddTokenHeader(token_added=False, token_is_fresh=False, token_is_jwt=False)
207
+
208
+ def check_url_and_add_jwt_header(
209
+ self,
210
+ url: str | ParseResult,
211
+ request_headers: dict,
212
+ service_account_email: str,
213
+ url_audience: str,
214
+ valid_domains: t.List[str] | None = None,
215
+ use_auth_header: bool = False,
216
+ bypass_cached: bool = False,
217
+ ) -> ResultAddTokenHeader:
218
+ """
219
+ Checks that the supplied URL is valid (i.e.; in valid_domains) and if so, retrieves a
220
+ Service Account JWT and adds it to request_headers.
221
+
222
+ i.e.; A convenience wrapper with logging for is_url_safe_for_token() and get_jwt_and_add_to_headers()
223
+
224
+ Params:
225
+ url: URL string or urllib.ParseResult to check for validity
226
+ service_account_email: Email address of the Service Account requesting access to IAP resource
227
+ url_audience: Target URL/Audience of IAP resource - Should be the same as url or a substring thereof
228
+ request_headers: Dict of headers to insert into
229
+ valid_domains: List of domains to validate URL against
230
+ """
231
+
232
+ if self.is_url_safe_for_token(url=url, valid_domains=valid_domains):
233
+ token_is_fresh = self.get_jwt_and_add_to_headers(
234
+ request_headers=request_headers,
235
+ service_account_email=service_account_email,
236
+ url_audience=url_audience,
237
+ use_auth_header=use_auth_header,
135
238
  bypass_cached=bypass_cached,
136
239
  )
137
- return ResultAddTokenHeader(token_added=True, token_is_fresh=token_is_fresh)
240
+ return ResultAddTokenHeader(token_added=True, token_is_fresh=token_is_fresh, token_is_jwt=True)
138
241
  else:
139
242
  LOG.warning(
140
243
  "URL is not approved: %s - Token will not be added to headers. Valid domains are: %s",
141
244
  url,
142
245
  valid_domains,
143
246
  )
144
- return ResultAddTokenHeader(token_added=False, token_is_fresh=False)
247
+ return ResultAddTokenHeader(token_added=False, token_is_fresh=False, token_is_jwt=True)
145
248
 
146
249
 
147
250
  class IAPToolkit_OIDC(IAPToolkit):
@@ -158,13 +261,14 @@ class IAPToolkit_OIDC(IAPToolkit):
158
261
  def get_token_and_add_to_headers(
159
262
  self,
160
263
  request_headers: dict,
264
+ iap_audience: str,
161
265
  use_auth_header: bool = False,
162
- use_oauth2: bool = False,
163
266
  bypass_cached: bool = False,
164
267
  ) -> bool:
165
268
  return super().get_token_and_add_to_headers(
269
+ iap_audience=iap_audience,
166
270
  request_headers=request_headers,
167
- use_oauth2=use_oauth2,
271
+ use_oauth2=False,
168
272
  use_auth_header=use_auth_header,
169
273
  bypass_cached=bypass_cached,
170
274
  )
@@ -173,6 +277,7 @@ class IAPToolkit_OIDC(IAPToolkit):
173
277
  self,
174
278
  url: str | ParseResult,
175
279
  request_headers: dict,
280
+ iap_audience: str,
176
281
  valid_domains: t.List[str] | None = None,
177
282
  use_auth_header: bool = False,
178
283
  bypass_cached: bool = False,
@@ -180,6 +285,7 @@ class IAPToolkit_OIDC(IAPToolkit):
180
285
  return super().check_url_and_add_token_header(
181
286
  url,
182
287
  request_headers=request_headers,
288
+ iap_audience=iap_audience,
183
289
  valid_domains=valid_domains,
184
290
  use_oauth2=False,
185
291
  use_auth_header=use_auth_header,
@@ -195,8 +301,12 @@ class IAPToolkit_OAuth2(IAPToolkit):
195
301
  _GOOGLE_CLIENT_ID: str
196
302
  _GOOGLE_CLIENT_SECRET: str
197
303
 
198
- def __init__(self, google_iap_client_id: str, google_client_id: str, google_client_secret: str,) -> None:
199
- super().__init__(google_iap_client_id=google_iap_client_id)
304
+ def __init__(
305
+ self,
306
+ google_client_id: str,
307
+ google_client_secret: str,
308
+ ) -> None:
309
+ super().__init__()
200
310
  self._GOOGLE_CLIENT_ID = google_client_id
201
311
  self._GOOGLE_CLIENT_SECRET = google_client_secret
202
312
 
@@ -207,15 +317,12 @@ class IAPToolkit_OAuth2(IAPToolkit):
207
317
  raise NotImplementedError("Cannot call OIDC methods on OAuth2-only instance of IAPToolkit.")
208
318
 
209
319
  def get_token_and_add_to_headers(
210
- self,
211
- request_headers: dict,
212
- use_auth_header: bool = False,
213
- use_oauth2: bool = True,
214
- bypass_cached: bool = False,
320
+ self, request_headers: dict, iap_audience: str, use_auth_header: bool = False, bypass_cached: bool = False
215
321
  ) -> bool:
216
322
  return super().get_token_and_add_to_headers(
217
323
  request_headers=request_headers,
218
- use_oauth2=use_oauth2,
324
+ iap_audience=iap_audience,
325
+ use_oauth2=True,
219
326
  use_auth_header=use_auth_header,
220
327
  bypass_cached=bypass_cached,
221
328
  )
@@ -224,6 +331,7 @@ class IAPToolkit_OAuth2(IAPToolkit):
224
331
  self,
225
332
  url: str | ParseResult,
226
333
  request_headers: dict,
334
+ iap_audience: str,
227
335
  valid_domains: t.List[str] | None = None,
228
336
  use_auth_header: bool = False,
229
337
  bypass_cached: bool = False,
@@ -231,6 +339,7 @@ class IAPToolkit_OAuth2(IAPToolkit):
231
339
  return super().check_url_and_add_token_header(
232
340
  url=url,
233
341
  request_headers=request_headers,
342
+ iap_audience=iap_audience,
234
343
  valid_domains=valid_domains,
235
344
  use_oauth2=True,
236
345
  use_auth_header=use_auth_header,
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  import typing as t
3
3
 
4
+ from google.api_core.exceptions import PermissionDenied
4
5
  from google.auth.environment_vars import CREDENTIALS as GOOGLE_CREDENTIALS_FILE_PATH
5
6
  from google.auth.exceptions import DefaultCredentialsError
6
7
  from google.auth.exceptions import RefreshError
@@ -27,7 +28,9 @@ class IAPClientIDException(IAPToolkitBaseException):
27
28
 
28
29
 
29
30
  class ServiceAccountTokenException(TokenException):
30
- def __init__(self, message: str, google_exception: t.Union[DefaultCredentialsError, RefreshError] | None):
31
+ def __init__(
32
+ self, message: str, google_exception: t.Union[DefaultCredentialsError, RefreshError, PermissionDenied] | None
33
+ ):
31
34
  self.google_exception = google_exception
32
35
  credentials_env_var_value = os.environ.get(GOOGLE_CREDENTIALS_FILE_PATH)
33
36
  metadata_server_attempted = not credentials_env_var_value
@@ -42,7 +45,9 @@ class ServiceAccountTokenException(TokenException):
42
45
 
43
46
  @property
44
47
  def retryable(self):
45
- return self.google_exception and self.google_exception._retryable
48
+ if not self.google_exception:
49
+ return False
50
+ return getattr(self.google_exception, "_retryable", False)
46
51
 
47
52
 
48
53
  class ServiceAccountNoDefaultCredentials(ServiceAccountTokenException):
@@ -53,6 +58,10 @@ class ServiceAccountTokenFailedRefresh(ServiceAccountTokenException):
53
58
  pass
54
59
 
55
60
 
61
+ class JWTPermissionException(ServiceAccountTokenException):
62
+ pass
63
+
64
+
56
65
  class InvalidDomain(IAPToolkitBaseException):
57
66
  pass
58
67
 
@@ -0,0 +1,276 @@
1
+ import datetime
2
+ import json
3
+ import typing as t
4
+
5
+ import google.auth
6
+ import google.api_core.exceptions
7
+ from google.auth.compute_engine import IDTokenCredentials as GoogleIDTokenCredentials
8
+ from google.auth.exceptions import DefaultCredentialsError as GoogleDefaultCredentialsError
9
+ from google.auth.exceptions import RefreshError as GoogleRefreshError
10
+ from google.auth.transport.requests import Request as GoogleRequest
11
+ from google.cloud import iam_credentials_v1
12
+ from google.oauth2 import id_token as google_id_token_lib
13
+
14
+ from kvcommon import logger
15
+
16
+ from iaptoolkit import exceptions
17
+ from iaptoolkit.tokens.token_datastore import datastore
18
+
19
+ from .structs import TokenStruct
20
+
21
+
22
+ LOG = logger.get_logger("iaptk")
23
+ MAX_RECURSE = 3
24
+
25
+
26
+ def _utcnow() -> datetime.datetime:
27
+ return datetime.datetime.now(tz=datetime.UTC)
28
+
29
+
30
+ class ServiceAccount(object):
31
+ """Base class for interacting with service accounts and OIDC tokens for IAP"""
32
+
33
+ # TODO: This is a static namespace for SA functions. Turn it into a per-iap-client-id client
34
+ # TODO: Move Google-specific logic to GoogleServiceAccount
35
+
36
+ @staticmethod
37
+ def _store_token(iap_client_id: str, id_token: str, token_expiry: datetime.datetime):
38
+ try:
39
+ datastore.store_service_account_token(iap_client_id, id_token, token_expiry)
40
+ except Exception as ex: # Err on the side of not letting token-caching break requests.
41
+ raise exceptions.TokenStorageException(f"Exception when trying to store token. exception={ex}")
42
+
43
+ @staticmethod
44
+ def _store_jwt(service_account_email: str, url_audience: str, signed_jwt: str, expiry: datetime.datetime):
45
+ try:
46
+ datastore.store_service_account_jwt(
47
+ service_account_email=service_account_email,
48
+ url_audience=url_audience,
49
+ signed_jwt=signed_jwt,
50
+ expiry=expiry,
51
+ )
52
+ except Exception as ex: # Err on the side of not letting token-caching break requests.
53
+ raise exceptions.TokenStorageException(f"Exception when trying to store token. exception={ex}")
54
+
55
+ @staticmethod
56
+ def get_stored_token(iap_client_id: str) -> TokenStruct | None:
57
+ try:
58
+ return datastore.get_stored_service_account_token(iap_client_id)
59
+
60
+ except Exception as ex:
61
+ # Err on the side of not letting token-caching break requests, hence blanket except
62
+ raise exceptions.TokenStorageException(f"Exception when trying to retrieve stored token. exception={ex}")
63
+
64
+ @staticmethod
65
+ def get_stored_jwt(service_account_email: str, url_audience: str) -> TokenStruct | None:
66
+ try:
67
+ return datastore.get_stored_service_account_jwt(
68
+ service_account_email=service_account_email, url_audience=url_audience
69
+ )
70
+
71
+ except Exception as ex:
72
+ # Err on the side of not letting token-caching break requests, hence blanket except
73
+ raise exceptions.TokenStorageException(f"Exception when trying to retrieve stored token. exception={ex}")
74
+
75
+ @staticmethod
76
+ def _get_fresh_credentials(iap_client_id: str) -> GoogleIDTokenCredentials:
77
+
78
+ try:
79
+ request = GoogleRequest()
80
+ credentials: GoogleIDTokenCredentials = google_id_token_lib.fetch_id_token_credentials(
81
+ iap_client_id, request
82
+ ) # type: ignore
83
+ credentials.refresh(request)
84
+
85
+ except GoogleDefaultCredentialsError as ex:
86
+ # The exceptions that google's libs raise in this case are somewhat vague; wrap them.
87
+ raise exceptions.ServiceAccountNoDefaultCredentials(
88
+ message="Failed to get ServiceAccount token: Lacking default credentials.",
89
+ google_exception=ex,
90
+ )
91
+ except GoogleRefreshError as ex:
92
+ # Likely attempting to get a token for a service account in an environment that
93
+ # doesn't have one attached.
94
+ raise exceptions.ServiceAccountTokenFailedRefresh(
95
+ message="Failed to get ServiceAccount token: Refreshing token failed.",
96
+ google_exception=ex,
97
+ )
98
+ return credentials
99
+
100
+ @staticmethod
101
+ def _get_fresh_token(iap_client_id: str, use_jwt: bool = False) -> TokenStruct:
102
+ google_credentials = ServiceAccount._get_fresh_credentials(iap_client_id)
103
+ id_token: str = str(google_credentials.token)
104
+ if not id_token:
105
+ raise exceptions.TokenException("Invalid [empty] token retrieved for Service Account.")
106
+
107
+ # Google lib uses deprecated 'utcfromtimestamp' func as of v2.29.x
108
+ # e.g.: datetime.datetime.utcfromtimestamp(payload["exp"])
109
+ # This creates a TZ-naive datetime in UTC from a POSIX timestamp.
110
+ # Python datetimes assume local TZ, and we want to explicitly only work in UTC here.
111
+ token_expiry = google_credentials.expiry.replace(tzinfo=datetime.timezone.utc)
112
+
113
+ return TokenStruct(id_token=id_token, expiry=token_expiry, from_cache=False)
114
+
115
+ @staticmethod
116
+ def _get_jwt(service_account_email: str, url_audience: str) -> TokenStruct:
117
+ """
118
+ Returns a signed JWT for the specified service account
119
+ """
120
+ now = _utcnow()
121
+ expiration_delta = datetime.timedelta(seconds=3595)
122
+ expiry_dt = now + expiration_delta
123
+
124
+ issued_at = int(now.timestamp())
125
+ expiry = int(expiry_dt.timestamp())
126
+
127
+ jwt_payload = {
128
+ "iss": service_account_email,
129
+ "sub": service_account_email,
130
+ "aud": url_audience,
131
+ "iat": issued_at,
132
+ "exp": expiry,
133
+ }
134
+ jwt_payload_str = json.dumps(jwt_payload)
135
+
136
+ source_credentials, project_id = google.auth.default()
137
+ iam_client = iam_credentials_v1.IAMCredentialsClient(credentials=source_credentials) # type: ignore
138
+ name = iam_client.service_account_path("-", service_account_email)
139
+ response = iam_client.sign_jwt(name=name, payload=jwt_payload_str)
140
+ # return response.signed_jwt
141
+ return TokenStruct.for_jwt(signed_jwt=response.signed_jwt, expiry=expiry_dt, from_cache=False)
142
+
143
+ @staticmethod
144
+ def get_jwt(
145
+ service_account_email: str, url_audience: str, bypass_cached: bool = False, attempts: int = 0
146
+ ) -> TokenStruct:
147
+ use_cache = not bypass_cached
148
+
149
+ try:
150
+ token_struct: TokenStruct | None = None
151
+
152
+ if use_cache:
153
+ token_struct = ServiceAccount.get_stored_jwt(
154
+ service_account_email=service_account_email, url_audience=url_audience
155
+ )
156
+
157
+ if not token_struct:
158
+ token_struct = ServiceAccount._get_jwt(
159
+ service_account_email=service_account_email, url_audience=url_audience
160
+ )
161
+ if use_cache:
162
+ ServiceAccount._store_jwt(
163
+ service_account_email=service_account_email,
164
+ url_audience=url_audience,
165
+ signed_jwt=token_struct.id_token,
166
+ expiry=token_struct.expiry,
167
+ )
168
+
169
+ return token_struct
170
+
171
+ except google.api_core.exceptions.PermissionDenied as ex:
172
+ raise exceptions.JWTPermissionException(
173
+ "Permission denied while retrieving signed JWT for service account. "
174
+ "Service Account requires IAM Role: 'roles/iam.serviceAccountTokenCreator'",
175
+ google_exception=ex,
176
+ )
177
+
178
+ except exceptions.ServiceAccountTokenException as ex:
179
+ attempts += 1
180
+ if attempts > MAX_RECURSE or not ex.retryable:
181
+ raise
182
+ return ServiceAccount.get_jwt(
183
+ service_account_email=service_account_email,
184
+ url_audience=url_audience,
185
+ bypass_cached=False,
186
+ attempts=attempts,
187
+ )
188
+
189
+ except exceptions.TokenStorageException as ex:
190
+ if attempts > 1:
191
+ raise
192
+ attempts += 1
193
+ # Try again without involving the cache
194
+ return ServiceAccount.get_jwt(
195
+ service_account_email=service_account_email,
196
+ url_audience=url_audience,
197
+ bypass_cached=True,
198
+ attempts=attempts,
199
+ )
200
+
201
+ @staticmethod
202
+ def get_token(iap_audience: str, bypass_cached: bool = False, attempts: int = 0) -> TokenStruct:
203
+ """Retrieves an OIDC token for the current environment either from environment variable or from
204
+ metadata service.
205
+
206
+ 1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
207
+ to the path of a valid service account JSON file, then ID token is
208
+ acquired using this service account credentials.
209
+ 2. If the application is running in Compute Engine, App Engine or Cloud Run,
210
+ then the ID token is obtained from the metadata server.
211
+
212
+ Args:
213
+ iap_client_id: The client ID used by IAP. Can be thought of as JWT audience.
214
+
215
+ Returns:
216
+ An OIDC token for use in connecting through IAP.
217
+
218
+ Raises:
219
+ :class:`ServiceAccountTokenException` if a token could not be retrieved due to either
220
+ missing credentials from env-var/JSON or inability to talk to metadata server.
221
+ """
222
+
223
+ use_cache = not bypass_cached
224
+
225
+ try:
226
+ token_struct: TokenStruct | None = None
227
+
228
+ if use_cache:
229
+ token_struct = ServiceAccount.get_stored_token(iap_audience)
230
+
231
+ if not token_struct:
232
+ token_struct = ServiceAccount._get_fresh_token(iap_audience)
233
+ if use_cache:
234
+ ServiceAccount._store_token(iap_audience, token_struct.id_token, token_struct.expiry)
235
+
236
+ return token_struct
237
+
238
+ except exceptions.ServiceAccountTokenException as ex:
239
+ attempts += 1
240
+ if attempts > MAX_RECURSE or not ex.retryable:
241
+ raise
242
+ return ServiceAccount.get_token(iap_audience, bypass_cached=False, attempts=attempts)
243
+
244
+ except exceptions.TokenStorageException as ex:
245
+ if attempts > 1:
246
+ raise
247
+ attempts += 1
248
+ # Try again without involving the cache
249
+ return ServiceAccount.get_token(iap_audience, bypass_cached=True, attempts=attempts)
250
+
251
+
252
+ class GoogleServiceAccount(ServiceAccount):
253
+ """
254
+ For interacting with Google service accounts, Service Account JWTs and OIDC tokens for Google IAP
255
+
256
+ Service Account requires IAM Role: 'roles/iam.serviceAccountTokenCreator'
257
+ """
258
+
259
+ def __init__(self, service_account_email: str) -> None:
260
+ if not service_account_email or not isinstance(service_account_email, str):
261
+ raise exceptions.ServiceAccountTokenException(
262
+ "Invalid iap_client_id for GoogleServiceAccount", google_exception=None
263
+ )
264
+ self._service_account_email = service_account_email
265
+ super().__init__()
266
+
267
+ def get_stored_jwt(self, url_audience: str) -> t.Optional[TokenStruct]:
268
+ return ServiceAccount.get_stored_jwt(self._service_account_email, url_audience=url_audience)
269
+
270
+ def get_jwt(self, url_audience: str, bypass_cached: bool = False, attempts: int = 0) -> TokenStruct:
271
+ return ServiceAccount.get_jwt(
272
+ service_account_email=self._service_account_email,
273
+ url_audience=url_audience,
274
+ bypass_cached=bypass_cached,
275
+ attempts=attempts,
276
+ )
@@ -20,6 +20,11 @@ class TokenStruct:
20
20
  id_token: str
21
21
  expiry: datetime.datetime
22
22
  from_cache: bool = False
23
+ is_jwt: bool = False
24
+
25
+ @classmethod
26
+ def for_jwt(cls, signed_jwt: str, expiry: datetime.datetime, from_cache: bool = False):
27
+ return TokenStruct(id_token=signed_jwt, is_jwt=True, from_cache=from_cache, expiry=expiry)
23
28
 
24
29
  @property
25
30
  def expired(self):
@@ -67,3 +72,4 @@ class TokenStructOAuth2(TokenStruct):
67
72
  class ResultAddTokenHeader:
68
73
  token_added: bool
69
74
  token_is_fresh: bool
75
+ token_is_jwt: bool
@@ -0,0 +1,133 @@
1
+ import datetime
2
+ import typing as t
3
+
4
+ from kvcommon import logger
5
+ from kvcommon.datastore.backend import DatastoreBackend
6
+ from kvcommon.datastore.backend import DictBackend
7
+
8
+ from kvcommon.datastore import VersionedDatastore
9
+
10
+ from iaptoolkit.exceptions import TokenStorageException
11
+ from iaptoolkit.constants import IAPTOOLKIT_CONFIG_VERSION
12
+
13
+ from .structs import TokenStruct
14
+
15
+
16
+ LOG = logger.get_logger("iaptk-ds")
17
+
18
+
19
+ class TokenDatastore(VersionedDatastore):
20
+ _service_account_jwts_email_limit = 1000
21
+
22
+ def __init__(self, backend: DatastoreBackend | type[DatastoreBackend]) -> None:
23
+ super().__init__(backend=backend, config_version=IAPTOOLKIT_CONFIG_VERSION)
24
+ self._ensure_tokens_dict()
25
+
26
+ def _ensure_tokens_dict(self):
27
+ tokens_dict = self.get_or_create_nested_dict("tokens")
28
+ if "refresh" not in tokens_dict.keys():
29
+ tokens_dict["refresh"] = None
30
+ self.set_value("tokens", tokens_dict)
31
+
32
+ @property
33
+ def service_account_tokens(self) -> dict:
34
+ return self.get_or_create_nested_dict("service_account_tokens")
35
+
36
+ @property
37
+ def service_account_jwts(self) -> dict:
38
+ return self.get_or_create_nested_dict("service_account_jwts")
39
+
40
+ def discard_existing_tokens(self):
41
+ LOG.debug("Discarding existing tokens.")
42
+ self.update_data(tokens={})
43
+
44
+ def get_stored_service_account_token(self, iap_client_id: str) -> TokenStruct | None:
45
+ token_data = self.service_account_tokens.get(iap_client_id, None)
46
+ if not token_data or not token_data.id_token or not token_data.expiry:
47
+ LOG.debug("No stored service account token for current iap_client_id")
48
+ return
49
+ return self._dict_to_tokenstruct(token_data)
50
+
51
+ def store_service_account_token(self, iap_client_id: str, id_token: str, token_expiry: datetime.datetime):
52
+ if not id_token:
53
+ raise TokenStorageException("TokenDatastore: Attempting to store invalid [empty] token")
54
+
55
+ tokens_dict = self.service_account_tokens
56
+ self.service_account_tokens[iap_client_id] = dict(id_token=id_token, token_expiry=token_expiry.isoformat())
57
+
58
+ try:
59
+ self.update_data(service_account_tokens=tokens_dict)
60
+ except Exception as ex:
61
+ LOG.error("Failed to store service account token for re-use. exception=%s", ex)
62
+
63
+ def _get_or_create_dict_for_service_account_and_url(self, service_account_email: str, url_audience: str):
64
+ jwts_dict = self.service_account_jwts
65
+ jwts_dict_for_email = jwts_dict.get(service_account_email, dict())
66
+ jwts_dict[service_account_email] = jwts_dict_for_email
67
+
68
+ token_dict = jwts_dict_for_email.get(url_audience, dict())
69
+ return token_dict
70
+
71
+ def get_stored_service_account_jwt(self, service_account_email: str, url_audience: str) -> TokenStruct | None:
72
+ jwts_dict_for_email = self._get_or_create_dict_for_service_account_and_url(service_account_email, url_audience)
73
+ token_data = jwts_dict_for_email.get(url_audience, None)
74
+ if not token_data:
75
+ LOG.debug("No stored service account JWT for service account '%s'", service_account_email)
76
+ return
77
+ return self._dict_to_tokenstruct(token_data, is_jwt=True)
78
+
79
+ def store_service_account_jwt(
80
+ self, service_account_email: str, url_audience: str, signed_jwt: str, expiry: datetime.datetime
81
+ ):
82
+ if not signed_jwt:
83
+ raise TokenStorageException("TokenDatastore: Attempting to store invalid [empty] jwt")
84
+
85
+ try:
86
+ token_dict = self._get_or_create_dict_for_service_account_and_url(service_account_email, url_audience)
87
+ token_dict["signed_jwt"] = signed_jwt
88
+ token_dict["token_expiry"] = expiry.isoformat()
89
+ except Exception as ex:
90
+ LOG.error("Failed to store service account JWT for re-use. exception=%s", ex)
91
+
92
+ @staticmethod
93
+ def _dict_to_tokenstruct(token_data: dict, is_jwt: bool = False) -> TokenStruct | None:
94
+ if not token_data:
95
+ return
96
+
97
+ id_token_from_dict: str = token_data.get("id_token", "")
98
+ token_expiry_from_dict: str = token_data.get("token_expiry", "")
99
+ if not id_token_from_dict or not token_expiry_from_dict:
100
+ return
101
+
102
+ token_expiry = ""
103
+ try:
104
+ token_expiry = datetime.datetime.fromisoformat(token_expiry_from_dict)
105
+ except (ValueError, TypeError) as ex:
106
+ LOG.warning("Invalid token expiry for stored token - Could not parse from ISO format to datetime.")
107
+ return
108
+
109
+ token_struct = TokenStruct(id_token=id_token_from_dict, expiry=token_expiry, from_cache=True, is_jwt=is_jwt)
110
+ if not token_struct.valid:
111
+ LOG.warning("Stored service account token is INVALID")
112
+ return
113
+ if token_struct.expired:
114
+ LOG.debug("Stored service account token has EXPIRED")
115
+ return
116
+
117
+ return token_struct
118
+
119
+ def _migrate_version(self):
120
+ # Override
121
+ self.discard_existing_tokens()
122
+ return super()._migrate_version()
123
+
124
+ # def get_stored_oauth2_token(self, iap_client_id: str):
125
+ # # TODO: OAuth2
126
+ # raise NotImplementedError()
127
+
128
+ # def store_oauth2_token(self, iap_client_id: str):
129
+ # # TODO: OAuth2
130
+ # raise NotImplementedError()
131
+
132
+
133
+ datastore = TokenDatastore(DictBackend)
@@ -1,60 +0,0 @@
1
- # IAP Toolkit
2
-
3
- A library of utils to ease programmatic authentication with Google IAP (and ideally other IAPs in future).
4
-
5
- # PyPi
6
- https://pypi.org/project/iaptoolkit/
7
-
8
- # Installation
9
- ### With Poetry:
10
- `poetry add iaptoolkit`
11
-
12
- ### With pip:
13
- `pip install iaptoolkit`
14
-
15
- ## Quick Start / Example Usage
16
-
17
- ```python
18
- import requests
19
-
20
- from iaptoolkit import IAPToolkit
21
-
22
- iaptk = IAPToolkit(google_iap_client_id="EXAMPLE_ID_123456789ABCDEF")
23
- allowed_domains = ["example.com", ]
24
-
25
-
26
- # Example #1 - Combined Calls
27
- def example1(url: str):
28
- headers = dict()
29
- result = iaptk.check_url_and_add_token_header(
30
- url=url,
31
- request_headers=headers,
32
- valid_domains=allowed_domains
33
- )
34
- # result.token_added (bool) indicates if the token was added, depending on whether or not URL was valid
35
- # headers dict now contains the appropriate Bearer Token header for Google IAP
36
-
37
- # Make HTTP GET request with requests lib, with our headers containing bearer token to auth with IAP
38
- response = requests.request("GET", url, headers=headers)
39
-
40
-
41
- # Example #2 - Separate Calls - Functionally the same as Example 1 but more flexibility in URL validation
42
- def example1(url: str):
43
- is_url_safe: bool = iaptk.is_url_safe_for_token(url=url, valid_domains=valid_domains)
44
-
45
- if not is_url_safe:
46
- raise ExampleBadURLException("This URL isn't safe to send token headers to!")
47
-
48
- headers = dict()
49
- token_is_fresh: bool = iaptk.get_token_and_add_to_headers(request_headers=headers)
50
- # token_is_fresh indicates if token was newly retrieved (True), or if a cached token was reused (False)
51
- # headers dict now contains the appropriate Bearer Token header for Google IAP
52
-
53
- # Make HTTP GET request with requests lib, with our headers containing bearer token to auth with IAP
54
- response = requests.request("GET", url, headers=headers)
55
-
56
- ```
57
-
58
- ## Disclaimer
59
-
60
- This project is not affiliated with Google. No trademark infringement intended.
@@ -1,186 +0,0 @@
1
- import datetime
2
- import typing as t
3
-
4
- from google.auth.compute_engine import IDTokenCredentials as GoogleIDTokenCredentials
5
- from google.auth.exceptions import DefaultCredentialsError as GoogleDefaultCredentialsError
6
- from google.auth.exceptions import RefreshError as GoogleRefreshError
7
- from google.auth.transport.requests import Request as GoogleRequest
8
- from google.oauth2 import id_token as google_id_token_lib
9
-
10
- from kvcommon import logger
11
-
12
- # TODO: Don't hardcode the association between OIDC/SA and dict-datastore
13
- from iaptoolkit.tokens.token_datastore import datastore
14
- from iaptoolkit.exceptions import ServiceAccountTokenException
15
- from iaptoolkit.exceptions import ServiceAccountTokenFailedRefresh
16
- from iaptoolkit.exceptions import ServiceAccountNoDefaultCredentials
17
- from iaptoolkit.exceptions import TokenException
18
- from iaptoolkit.exceptions import TokenStorageException
19
-
20
- from .structs import TokenStruct
21
- from .structs import TokenRefreshStruct
22
-
23
-
24
- LOG = logger.get_logger("iaptk")
25
- MAX_RECURSE = 3
26
-
27
-
28
- class ServiceAccount(object):
29
- """Base class for interacting with service accounts and OIDC tokens for IAP"""
30
-
31
- # TODO: This is a static namespace for SA functions. Turn it into a per-iap-client-id client
32
- # TODO: Move Google-specific logic to GoogleServiceAccount
33
-
34
- @staticmethod
35
- def _store_token(iap_client_id: str, id_token: str, token_expiry: datetime.datetime):
36
- try:
37
- datastore.store_service_account_token(iap_client_id, id_token, token_expiry)
38
- except Exception as ex: # Err on the side of not letting token-caching break requests.
39
- raise TokenStorageException(f"Exception when trying to store token. exception={ex}")
40
-
41
- @staticmethod
42
- def get_stored_token(iap_client_id: str) -> t.Optional[TokenStruct]:
43
- try:
44
- token_dict = datastore.get_stored_service_account_token(iap_client_id)
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
- ):
50
- LOG.debug("No stored service account token for current iap_client_id")
51
- return
52
-
53
- id_token_from_dict: str = token_dict.get("id_token", "")
54
- token_expiry_from_dict: str = token_dict.get("token_expiry", "")
55
-
56
- token_expiry = ""
57
- try:
58
- token_expiry = datetime.datetime.fromisoformat(token_expiry_from_dict)
59
- except (ValueError, TypeError) as ex:
60
- LOG.debug(
61
- "Invalid token expiry for stored token - Could not parse from ISO format to datetime."
62
- )
63
- return
64
-
65
- token_struct = TokenStruct(id_token=id_token_from_dict, expiry=token_expiry, from_cache=True)
66
- if not token_struct.valid:
67
- LOG.debug("Stored service account token for current iap_client_id is INVALID")
68
- return
69
- if token_struct.expired:
70
- LOG.debug("Stored service account token for current iap_client_id has EXPIRED")
71
- return
72
-
73
- return token_struct
74
-
75
- except Exception as ex:
76
- # Err on the side of not letting token-caching break requests, hence blanket except
77
- raise TokenStorageException(f"Exception when trying to retrieve stored token. exception={ex}")
78
-
79
- @staticmethod
80
- def _get_fresh_credentials(iap_client_id: str) -> GoogleIDTokenCredentials:
81
-
82
- try:
83
- request = GoogleRequest()
84
- credentials: GoogleIDTokenCredentials = google_id_token_lib.fetch_id_token_credentials(
85
- iap_client_id, request
86
- ) # type: ignore
87
- credentials.refresh(request)
88
-
89
- except GoogleDefaultCredentialsError as ex:
90
- # The exceptions that google's libs raise in this case are somewhat vague; wrap them.
91
- raise ServiceAccountNoDefaultCredentials(
92
- message="Failed to get ServiceAccount token: Lacking default credentials.",
93
- google_exception=ex,
94
- )
95
- except GoogleRefreshError as ex:
96
- # Likely attempting to get a token for a service account in an environment that
97
- # doesn't have one attached.
98
- raise ServiceAccountTokenFailedRefresh(
99
- message="Failed to get ServiceAccount token: Refreshing token failed.", google_exception=ex,
100
- )
101
- return credentials
102
-
103
- @staticmethod
104
- def _get_fresh_token(iap_client_id: str) -> TokenStruct:
105
- google_credentials = ServiceAccount._get_fresh_credentials(iap_client_id)
106
- id_token: str = str(google_credentials.token)
107
- if not id_token:
108
- raise TokenException("Invalid [empty] token retrieved for Service Account.")
109
-
110
- # Google lib uses deprecated 'utcfromtimestamp' func as of v2.29.x
111
- # e.g.: datetime.datetime.utcfromtimestamp(payload["exp"])
112
- # This creates a TZ-naive datetime in UTC from a POSIX timestamp.
113
- # Python datetimes assume local TZ, and we want to explicitly only work in UTC here.
114
- token_expiry = google_credentials.expiry.replace(tzinfo=datetime.timezone.utc)
115
-
116
- return TokenStruct(id_token=id_token, expiry=token_expiry, from_cache=False)
117
-
118
- @staticmethod
119
- def get_token(iap_client_id: str, bypass_cached: bool = False, attempts: int = 0) -> TokenStruct:
120
- """Retrieves an OIDC token for the current environment either from environment variable or from
121
- metadata service.
122
-
123
- 1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
124
- to the path of a valid service account JSON file, then ID token is
125
- acquired using this service account credentials.
126
- 2. If the application is running in Compute Engine, App Engine or Cloud Run,
127
- then the ID token is obtained from the metadata server.
128
-
129
- Args:
130
- iap_client_id: The client ID used by IAP. Can be thought of as JWT audience.
131
-
132
- Returns:
133
- An OIDC token for use in connecting through IAP.
134
-
135
- Raises:
136
- :class:`ServiceAccountTokenException` if a token could not be retrieved due to either
137
- missing credentials from env-var/JSON or inability to talk to metadata server.
138
- """
139
-
140
- use_cache = not bypass_cached
141
-
142
- try:
143
- token_struct: TokenStruct | None = None
144
-
145
- if use_cache:
146
- token_struct = ServiceAccount.get_stored_token(iap_client_id)
147
-
148
- if not token_struct:
149
- token_struct = ServiceAccount._get_fresh_token(iap_client_id)
150
- if use_cache:
151
- ServiceAccount._store_token(iap_client_id, token_struct.id_token, token_struct.expiry)
152
-
153
- return token_struct
154
-
155
- except ServiceAccountTokenException as ex:
156
- attempts += 1
157
- if attempts > MAX_RECURSE or not ex.retryable:
158
- raise
159
- return ServiceAccount.get_token(iap_client_id, bypass_cached=False, attempts=attempts)
160
-
161
- except TokenStorageException as ex:
162
- if attempts > 1:
163
- raise
164
- attempts += 1
165
- # Try again without involving the cache
166
- return ServiceAccount.get_token(iap_client_id, bypass_cached=True, attempts=attempts)
167
-
168
-
169
- class GoogleServiceAccount(ServiceAccount):
170
- """For interacting with Google service accounts and OIDC tokens for Google IAP"""
171
-
172
- def __init__(self, iap_client_id: str) -> None:
173
- if not iap_client_id or not isinstance(iap_client_id, str):
174
- raise ServiceAccountTokenException(
175
- "Invalid iap_client_id for GoogleServiceAccount", google_exception=None
176
- )
177
- self._iap_client_id = iap_client_id
178
- super().__init__()
179
-
180
- def get_stored_token(self) -> t.Optional[TokenStruct]:
181
- return ServiceAccount.get_stored_token(self._iap_client_id)
182
-
183
- def get_token(self, bypass_cached: bool = False, attempts: int = 0) -> TokenStruct:
184
- return ServiceAccount.get_token(
185
- iap_client_id=self._iap_client_id, bypass_cached=bypass_cached, attempts=attempts
186
- )
@@ -1,72 +0,0 @@
1
- import datetime
2
- import typing as t
3
-
4
- from kvcommon import logger
5
- from kvcommon.datastore.backend import DatastoreBackend
6
- from kvcommon.datastore.backend import DictBackend
7
-
8
- # from kvcommon.datastore.backend import TOMLBackend
9
- from kvcommon.datastore import VersionedDatastore
10
-
11
- from iaptoolkit.exceptions import TokenException
12
- from iaptoolkit.constants import IAPTOOLKIT_CONFIG_VERSION
13
-
14
-
15
- LOG = logger.get_logger("iaptk-ds")
16
-
17
-
18
- class TokenDatastore(VersionedDatastore):
19
- _service_account_tokens_key = "service_account_tokens"
20
-
21
- def __init__(self, backend: DatastoreBackend | type[DatastoreBackend]) -> None:
22
- super().__init__(backend=backend, config_version=IAPTOOLKIT_CONFIG_VERSION)
23
- self._ensure_tokens_dict()
24
-
25
- def _ensure_tokens_dict(self):
26
- tokens_dict = self.get_or_create_nested_dict("tokens")
27
- if "refresh" not in tokens_dict.keys():
28
- tokens_dict["refresh"] = None
29
- self.set_value("tokens", tokens_dict)
30
-
31
- def discard_existing_tokens(self):
32
- LOG.debug("Discarding existing tokens.")
33
- self.update_data(tokens={})
34
-
35
- def get_stored_service_account_token(self, iap_client_id: str) -> t.Optional[dict]:
36
- tokens_dict = self.get_or_create_nested_dict(self._service_account_tokens_key)
37
- token_struct_dict = tokens_dict.get(iap_client_id, None)
38
- if not token_struct_dict:
39
- LOG.debug("No stored service account token for current iap_client_id")
40
- return
41
- return token_struct_dict
42
-
43
- def store_service_account_token(self, iap_client_id: str, id_token: str, token_expiry: datetime.datetime):
44
- if not id_token:
45
- raise TokenException("TokenDatastore: Attempting to store invalid [empty] token")
46
-
47
- tokens_dict = self.get_or_create_nested_dict(self._service_account_tokens_key)
48
- tokens_dict[iap_client_id] = dict(id_token=id_token, token_expiry=token_expiry.isoformat())
49
-
50
- try:
51
- self.update_data(service_account_tokens=tokens_dict)
52
- except OSError as ex:
53
- LOG.error("Failed to store service account token for re-use. exception=%s", ex)
54
-
55
- def _migrate_version(self):
56
- # Override
57
- self.discard_existing_tokens()
58
- return super()._migrate_version()
59
-
60
- # def get_stored_oauth2_token(self, iap_client_id: str):
61
- # # TODO: OAuth2
62
- # raise NotImplementedError()
63
-
64
- # def store_oauth2_token(self, iap_client_id: str):
65
- # # TODO: OAuth2
66
- # raise NotImplementedError()
67
-
68
-
69
- datastore = TokenDatastore(DictBackend)
70
-
71
- # if PERSISTENT_DATASTORE_ENABLED:
72
- # datastore_toml = TokenDatastore(TOMLBackend(PERSISTENT_DATASTORE_PATH, PERSISTENT_DATASTORE_USERNAME))
File without changes