iaptoolkit 0.3.3__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.
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/LICENSE +1 -1
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/PKG-INFO +3 -3
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/pyproject.toml +4 -4
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/src/iaptoolkit/__init__.py +7 -16
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/src/iaptoolkit/constants.py +2 -0
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/src/iaptoolkit/exceptions.py +12 -0
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/src/iaptoolkit/tokens/service_account.py +12 -5
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/src/iaptoolkit/tokens/structs.py +1 -0
- iaptoolkit-0.3.5/src/iaptoolkit/utils/urls.py +92 -0
- iaptoolkit-0.3.5/src/iaptoolkit/utils/verify.py +82 -0
- iaptoolkit-0.3.3/src/iaptoolkit/utils/urls.py +0 -44
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/README.md +0 -0
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/src/iaptoolkit/headers.py +0 -0
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/src/iaptoolkit/tokens/__init__.py +0 -0
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/src/iaptoolkit/tokens/token_datastore.py +0 -0
- {iaptoolkit-0.3.3 → iaptoolkit-0.3.5}/src/iaptoolkit/utils/__init__.py +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: iaptoolkit
|
|
3
|
-
Version: 0.3.
|
|
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.
|
|
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.
|
|
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 =
|
|
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.
|
|
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(
|
|
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"
|
|
@@ -22,6 +22,10 @@ class TokenStorageException(TokenException):
|
|
|
22
22
|
pass
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
class IAPClientIDException(IAPToolkitBaseException):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
25
29
|
class ServiceAccountTokenException(TokenException):
|
|
26
30
|
def __init__(self, message: str, google_exception: t.Union[DefaultCredentialsError, RefreshError] | None):
|
|
27
31
|
self.google_exception = google_exception
|
|
@@ -51,3 +55,11 @@ class ServiceAccountTokenFailedRefresh(ServiceAccountTokenException):
|
|
|
51
55
|
|
|
52
56
|
class InvalidDomain(IAPToolkitBaseException):
|
|
53
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
|
|
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(
|
|
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(
|
|
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
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import requests
|
|
3
|
+
import typing as t
|
|
4
|
+
from urllib.parse import parse_qs
|
|
5
|
+
from urllib.parse import ParseResult
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from kvcommon import logger
|
|
9
|
+
from kvcommon.urls import get_netloc_without_port_from_url_parts
|
|
10
|
+
|
|
11
|
+
from iaptoolkit.exceptions import IAPClientIDException
|
|
12
|
+
from iaptoolkit.exceptions import InvalidDomain
|
|
13
|
+
|
|
14
|
+
LOG = logger.get_logger("iaptk")
|
|
15
|
+
|
|
16
|
+
|
|
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,
|
|
19
|
+
) -> bool:
|
|
20
|
+
"""Determines if the given url is considered a safe to receive a token in request headers.
|
|
21
|
+
|
|
22
|
+
If URL validation is enabled, check that the URL's netloc is in the list of valid domains.
|
|
23
|
+
"""
|
|
24
|
+
if not isinstance(url_parts, ParseResult):
|
|
25
|
+
raise TypeError(
|
|
26
|
+
f"Invalid url_parts - Expected a ParseResult - Got: "
|
|
27
|
+
f"'{str(url_parts)}' (type#: {type(url_parts).__name__})"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if allowed_domains is not None and not isinstance(allowed_domains, (list, set, tuple)):
|
|
31
|
+
raise TypeError("allowed_domains must be a list, set or tuple if not None")
|
|
32
|
+
|
|
33
|
+
netloc = get_netloc_without_port_from_url_parts(url_parts)
|
|
34
|
+
if not netloc:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
if not allowed_domains:
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
for domain in allowed_domains:
|
|
41
|
+
if domain == "" or not isinstance(domain, str):
|
|
42
|
+
raise InvalidDomain(
|
|
43
|
+
f"Empty or non-string domain in allowed_domains: "
|
|
44
|
+
f"'{str(domain)}' (type#: {type(domain).__name__})"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if netloc.endswith(domain):
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(kw_only=True)
|
|
54
|
+
class IAPURLState:
|
|
55
|
+
protected: bool = False
|
|
56
|
+
iap_client_id: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_url_iap_state(url: str) -> IAPURLState:
|
|
60
|
+
# This approach may not be reliable - Undocumented?
|
|
61
|
+
|
|
62
|
+
iap_client_id = None
|
|
63
|
+
requires_iap = False
|
|
64
|
+
|
|
65
|
+
response = requests.get(url, allow_redirects=False)
|
|
66
|
+
if response.status_code == 302:
|
|
67
|
+
location = response.headers.get("location")
|
|
68
|
+
qs = str(urlparse(location).query)
|
|
69
|
+
query = parse_qs(qs) or {}
|
|
70
|
+
if "client_id" in query:
|
|
71
|
+
iap_client_id = str(query["client_id"][0])
|
|
72
|
+
requires_iap = True
|
|
73
|
+
|
|
74
|
+
return IAPURLState(protected=requires_iap, iap_client_id=iap_client_id)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_url_iap_protected(url: str) -> bool:
|
|
78
|
+
url_state: IAPURLState = get_url_iap_state(url)
|
|
79
|
+
return url_state.protected
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_iap_client_id_for_url(url: str) -> str | None:
|
|
83
|
+
url_state: IAPURLState = get_url_iap_state(url)
|
|
84
|
+
if not url_state.protected:
|
|
85
|
+
raise IAPClientIDException(f"URL does not appear to be IAP-protected: '{url}'")
|
|
86
|
+
|
|
87
|
+
iap_client_id = url_state.iap_client_id
|
|
88
|
+
if not iap_client_id:
|
|
89
|
+
raise IAPClientIDException(
|
|
90
|
+
f"No client_id returned in redirect for query when trying to retrieve IAP Client ID for url: '{url}'"
|
|
91
|
+
)
|
|
92
|
+
return iap_client_id
|
|
@@ -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
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import typing as t
|
|
2
|
-
from urllib.parse import ParseResult
|
|
3
|
-
|
|
4
|
-
from kvcommon import logger
|
|
5
|
-
from kvcommon.urls import get_netloc_without_port_from_url_parts
|
|
6
|
-
|
|
7
|
-
from iaptoolkit.exceptions import InvalidDomain
|
|
8
|
-
|
|
9
|
-
LOG = logger.get_logger("iaptk")
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def is_url_safe_for_token(
|
|
13
|
-
url_parts: ParseResult, allowed_domains: t.Optional[t.List[str] | t.Set[str] | t.Tuple[str]] = None
|
|
14
|
-
) -> bool:
|
|
15
|
-
"""Determines if the given url is considered a safe to receive a token in request headers.
|
|
16
|
-
|
|
17
|
-
If URL validation is enabled, check that the URL's netloc is in the list of valid domains.
|
|
18
|
-
"""
|
|
19
|
-
if not isinstance(url_parts, ParseResult):
|
|
20
|
-
raise TypeError(
|
|
21
|
-
f"Invalid url_parts - Expected a ParseResult - Got: "
|
|
22
|
-
f"'{str(url_parts)}' (type#: {type(url_parts).__name__})"
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
if allowed_domains is not None and not isinstance(allowed_domains, (list, set, tuple)):
|
|
26
|
-
raise TypeError("allowed_domains must be a list, set or tuple if not None")
|
|
27
|
-
|
|
28
|
-
netloc = get_netloc_without_port_from_url_parts(url_parts)
|
|
29
|
-
if not netloc:
|
|
30
|
-
return False
|
|
31
|
-
|
|
32
|
-
if not allowed_domains:
|
|
33
|
-
return True
|
|
34
|
-
|
|
35
|
-
for domain in allowed_domains:
|
|
36
|
-
if domain == "" or not isinstance(domain, str):
|
|
37
|
-
raise InvalidDomain(
|
|
38
|
-
f"Empty or non-string domain in allowed_domains: " f"'{str(domain)}' (type#: {type(domain).__name__})"
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
if netloc.endswith(domain):
|
|
42
|
-
return True
|
|
43
|
-
|
|
44
|
-
return False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|