Skip to content

Commit 6809b6e

Browse files
authored
feat: new credential provider methods for accepting global api keys (#512)
* feat: new credential provider methods for accepting global api keys * check key type to provide useful errors * use correct errors and test non-global api key path * rename global to v2, add disposable token method * from_env_var_v2 optional args * update docs and hints * add resolve docstring
1 parent 0169714 commit 6809b6e

File tree

3 files changed

+307
-0
lines changed

3 files changed

+307
-0
lines changed

src/momento/auth/credential_provider.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
import os
55
from dataclasses import dataclass
66
from typing import Dict, Optional
7+
from warnings import warn
8+
9+
from momento.errors.exceptions import InvalidArgumentException
10+
from momento.internal.services import Service
711

812
from . import momento_endpoint_resolver
913

@@ -27,6 +31,8 @@ def from_environment_variable(
2731
) -> CredentialProvider:
2832
"""Reads and parses a Momento auth token stored as an environment variable.
2933
34+
Deprecated as of v1.28.0. Use from_environment_variables_v2 instead.
35+
3036
Args:
3137
env_var_name (str): Name of the environment variable from which the API key will be read
3238
control_endpoint (Optional[str], optional): Optionally overrides the default control endpoint.
@@ -42,6 +48,11 @@ def from_environment_variable(
4248
Returns:
4349
CredentialProvider
4450
"""
51+
warn(
52+
"from_environment_variable is deprecated, use from_environment_variables_v2 instead",
53+
DeprecationWarning,
54+
stacklevel=2,
55+
)
4556
api_key = os.getenv(env_var_name)
4657
if not api_key:
4758
raise RuntimeError(f"Missing required environment variable {env_var_name}")
@@ -56,6 +67,8 @@ def from_string(
5667
) -> CredentialProvider:
5768
"""Reads and parses a Momento auth token.
5869
70+
Deprecated as of v1.28.0. Use from_api_key_v2 or from_disposable_token instead.
71+
5972
Args:
6073
auth_token (str): the Momento API key (previously: auth token)
6174
control_endpoint (Optional[str], optional): Optionally overrides the default control endpoint.
@@ -68,6 +81,11 @@ def from_string(
6881
Returns:
6982
CredentialProvider
7083
"""
84+
warn(
85+
"from_string is deprecated, use from_api_key_v2 or from_disposable_token instead",
86+
DeprecationWarning,
87+
stacklevel=2,
88+
)
7189
token_and_endpoints = momento_endpoint_resolver.resolve(auth_token)
7290
control_endpoint = control_endpoint or token_and_endpoints.control_endpoint
7391
cache_endpoint = cache_endpoint or token_and_endpoints.cache_endpoint
@@ -102,3 +120,86 @@ def _obscure(self, value: str) -> str:
102120

103121
def get_auth_token(self) -> str:
104122
return self.auth_token
123+
124+
@staticmethod
125+
def from_api_key_v2(api_key: str, endpoint: str) -> CredentialProvider:
126+
"""Creates a CredentialProvider from a v2 API key and endpoint.
127+
128+
Args:
129+
api_key (str): The v2 API key.
130+
endpoint (str): The Momento service endpoint.
131+
132+
Returns:
133+
CredentialProvider
134+
"""
135+
if len(api_key) == 0:
136+
raise InvalidArgumentException("API key cannot be empty.", Service.AUTH)
137+
if len(endpoint) == 0:
138+
raise InvalidArgumentException("Endpoint cannot be empty.", Service.AUTH)
139+
140+
if not momento_endpoint_resolver._is_v2_api_key(api_key):
141+
raise InvalidArgumentException(
142+
"Received an invalid v2 API key. Are you using the correct key and the correct CredentialProvider method?",
143+
Service.AUTH,
144+
)
145+
return CredentialProvider(
146+
auth_token=api_key,
147+
control_endpoint=momento_endpoint_resolver._MOMENTO_CONTROL_ENDPOINT_PREFIX + endpoint,
148+
cache_endpoint=momento_endpoint_resolver._MOMENTO_CACHE_ENDPOINT_PREFIX + endpoint,
149+
token_endpoint=momento_endpoint_resolver._MOMENTO_TOKEN_ENDPOINT_PREFIX + endpoint,
150+
port=443,
151+
)
152+
153+
@staticmethod
154+
def from_environment_variables_v2(
155+
api_key_env_var: str = "MOMENTO_API_KEY", endpoint_env_var: str = "MOMENTO_ENDPOINT"
156+
) -> CredentialProvider:
157+
"""Creates a CredentialProvider from an endpoint and v2 API key stored in the environment variables MOMENTO_API_KEY and MOMENTO_ENDPOINT.
158+
159+
Args:
160+
api_key_env_var (str): Optionally provide an alternate environment variable name from which the v2 API key will be read.
161+
endpoint_env_var (str): Optionally provide an alternate environment variable name from which the Momento service endpoint will be read.
162+
163+
Returns:
164+
CredentialProvider
165+
"""
166+
if len(api_key_env_var) == 0:
167+
raise InvalidArgumentException("API key environment variable name cannot be empty.", Service.AUTH)
168+
if len(endpoint_env_var) == 0:
169+
raise InvalidArgumentException("Endpoint environment variable name cannot be empty.", Service.AUTH)
170+
171+
api_key = os.getenv(api_key_env_var)
172+
if not api_key:
173+
raise RuntimeError(f"Missing required environment variable {api_key_env_var}")
174+
endpoint = os.getenv(endpoint_env_var)
175+
if not endpoint:
176+
raise RuntimeError(f"Missing required environment variable {endpoint_env_var}")
177+
178+
if not momento_endpoint_resolver._is_v2_api_key(api_key):
179+
raise InvalidArgumentException(
180+
"Received an invalid v2 API key. Are you using the correct key? Or did you mean to use `from_environment_variable()` with a legacy key instead?",
181+
Service.AUTH,
182+
)
183+
return CredentialProvider.from_api_key_v2(api_key, endpoint)
184+
185+
@staticmethod
186+
def from_disposable_token(auth_token: str) -> CredentialProvider:
187+
"""Reads and parses a Momento disposable auth token.
188+
189+
Args:
190+
auth_token (str): the Momento disposable auth token
191+
192+
Returns:
193+
CredentialProvider
194+
"""
195+
if len(auth_token) == 0:
196+
raise InvalidArgumentException("Disposable token cannot be empty.", Service.AUTH)
197+
token_and_endpoints = momento_endpoint_resolver.resolve(auth_token)
198+
auth_token = token_and_endpoints.auth_token
199+
return CredentialProvider(
200+
auth_token,
201+
token_and_endpoints.control_endpoint,
202+
token_and_endpoints.cache_endpoint,
203+
token_and_endpoints.token_endpoint,
204+
443,
205+
)

src/momento/auth/momento_endpoint_resolver.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
_MOMENTO_TOKEN_ENDPOINT_PREFIX = "token."
1515
_CONTROL_ENDPOINT_CLAIM_ID = "cp"
1616
_CACHE_ENDPOINT_CLAIM_ID = "c"
17+
_API_KEY_TYPE_CLAIM_ID = "t"
18+
_GLOBAL_API_KEY_TYPE = "g"
1719

1820

1921
@dataclass
@@ -31,6 +33,14 @@ class _Base64DecodedV1Token:
3133

3234

3335
def resolve(auth_token: str) -> _TokenAndEndpoints:
36+
"""Helper function used by from_string and from_disposable_token to parse legacy and v1 auth tokens.
37+
38+
Args:
39+
auth_token (str): The auth token to be resolved.
40+
41+
Returns:
42+
_TokenAndEndpoints
43+
"""
3444
if not auth_token:
3545
raise InvalidArgumentException("malformed auth token", Service.AUTH)
3646

@@ -44,6 +54,11 @@ def resolve(auth_token: str) -> _TokenAndEndpoints:
4454
auth_token=info["api_key"], # type: ignore[misc]
4555
)
4656
else:
57+
if _is_v2_api_key(auth_token):
58+
raise InvalidArgumentException(
59+
"Unexpectedly received a v2 API key. Are you using the correct key and the correct CredentialProvider method?",
60+
Service.AUTH,
61+
)
4762
return _get_endpoint_from_token(auth_token)
4863

4964

@@ -67,3 +82,13 @@ def _is_base64(value: Union[bytes, str]) -> bool:
6782
return base64.b64encode(base64.b64decode(value)) == value
6883
except Exception:
6984
return False
85+
86+
87+
def _is_v2_api_key(key: str) -> bool:
88+
if _is_base64(key):
89+
return False
90+
try:
91+
claims = jwt.decode(key, options={"verify_signature": False}) # type: ignore[misc]
92+
return _API_KEY_TYPE_CLAIM_ID in claims and claims[_API_KEY_TYPE_CLAIM_ID] == _GLOBAL_API_KEY_TYPE # type: ignore[misc]
93+
except DecodeError:
94+
return False

tests/momento/auth/test_credential_provider.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import base64
22
import json
33
import os
4+
import re
45

56
import jwt
67
import pytest
78
from momento.auth.credential_provider import CredentialProvider
89
from momento.auth.momento_endpoint_resolver import _Base64DecodedV1Token
10+
from momento.errors.exceptions import InvalidArgumentException
911

1012
from tests.utils import uuid_str
1113

@@ -23,6 +25,15 @@
2325
os.environ[test_env_var_name] = test_token
2426
os.environ[test_v1_env_var_name] = test_encoded_v1_token.decode("utf-8")
2527

28+
# For v2 API key tests
29+
test_v2_key_message = {"t": "g", "jti": "some-id"}
30+
test_v2_api_key = jwt.encode(test_v2_key_message, "secret", algorithm="HS512")
31+
test_v2_key_env_var_name = "MOMENTO_API_KEY"
32+
test_v2_endpoint = "testEndpoint"
33+
test_v2_endpoint_env_var_name = "MOMENTO_ENDPOINT"
34+
os.environ[test_v2_key_env_var_name] = test_v2_api_key
35+
os.environ[test_v2_endpoint_env_var_name] = test_v2_endpoint
36+
2637

2738
@pytest.mark.parametrize(
2839
"provider, auth_token, control_endpoint, cache_endpoint",
@@ -97,3 +108,173 @@ def test_endpoints(provider: CredentialProvider, auth_token: str, control_endpoi
97108
def test_env_token_raises_if_not_exists() -> None:
98109
with pytest.raises(RuntimeError, match=r"Missing required environment variable"):
99110
CredentialProvider.from_environment_variable(env_var_name=uuid_str())
111+
112+
113+
@pytest.mark.parametrize(
114+
"provider, expected_api_key, expected_control_endpoint, expected_cache_endpoint, expected_token_endpoint",
115+
[
116+
(
117+
CredentialProvider.from_api_key_v2(
118+
api_key=test_v2_api_key,
119+
endpoint=test_v2_endpoint,
120+
),
121+
test_v2_api_key,
122+
f"control.{test_v2_endpoint}",
123+
f"cache.{test_v2_endpoint}",
124+
f"token.{test_v2_endpoint}",
125+
),
126+
(
127+
CredentialProvider.from_environment_variables_v2(
128+
api_key_env_var=test_v2_key_env_var_name,
129+
endpoint_env_var=test_v2_endpoint_env_var_name,
130+
),
131+
test_v2_api_key,
132+
f"control.{test_v2_endpoint}",
133+
f"cache.{test_v2_endpoint}",
134+
f"token.{test_v2_endpoint}",
135+
),
136+
(
137+
CredentialProvider.from_environment_variables_v2(),
138+
test_v2_api_key,
139+
f"control.{test_v2_endpoint}",
140+
f"cache.{test_v2_endpoint}",
141+
f"token.{test_v2_endpoint}",
142+
),
143+
],
144+
)
145+
def test_v2_api_key_endpoints(
146+
provider: CredentialProvider,
147+
expected_api_key: str,
148+
expected_control_endpoint: str,
149+
expected_cache_endpoint: str,
150+
expected_token_endpoint: str,
151+
) -> None:
152+
assert provider.auth_token == expected_api_key
153+
assert provider.control_endpoint == expected_control_endpoint
154+
assert provider.cache_endpoint == expected_cache_endpoint
155+
assert provider.token_endpoint == expected_token_endpoint
156+
157+
158+
def test_v2_key_from_string_raises_if_api_key_empty() -> None:
159+
with pytest.raises(InvalidArgumentException, match="API key cannot be empty"):
160+
CredentialProvider.from_api_key_v2(api_key="", endpoint=test_v2_endpoint)
161+
162+
163+
def test_v2_key_from_string_raises_if_endpoint_empty() -> None:
164+
with pytest.raises(InvalidArgumentException, match="Endpoint cannot be empty"):
165+
CredentialProvider.from_api_key_v2(api_key=test_v2_api_key, endpoint="")
166+
167+
168+
def test_v2_key_from_env_raises_if_env_var_name_empty() -> None:
169+
with pytest.raises(InvalidArgumentException, match="API key environment variable name cannot be empty"):
170+
CredentialProvider.from_environment_variables_v2(
171+
api_key_env_var="", endpoint_env_var=test_v2_endpoint_env_var_name
172+
)
173+
174+
175+
def test_v2_key_from_env_raises_if_env_var_missing() -> None:
176+
with pytest.raises(RuntimeError, match="Missing required environment variable"):
177+
CredentialProvider.from_environment_variables_v2(
178+
api_key_env_var=uuid_str(), endpoint_env_var=test_v2_endpoint_env_var_name
179+
)
180+
181+
182+
def test_v2_key_from_env_raises_if_endpoint_empty() -> None:
183+
with pytest.raises(InvalidArgumentException, match="Endpoint environment variable name cannot be empty"):
184+
CredentialProvider.from_environment_variables_v2(api_key_env_var=test_v2_key_env_var_name, endpoint_env_var="")
185+
186+
187+
def test_v2_key_from_env_raises_if_api_key_empty_string() -> None:
188+
empty_api_key_env_var = uuid_str()
189+
os.environ[empty_api_key_env_var] = ""
190+
with pytest.raises(RuntimeError, match="Missing required environment variable"):
191+
CredentialProvider.from_environment_variables_v2(
192+
api_key_env_var=empty_api_key_env_var, endpoint_env_var=test_v2_endpoint_env_var_name
193+
)
194+
195+
196+
def test_v2_key_from_string_raises_if_base64_api_key() -> None:
197+
with pytest.raises(
198+
InvalidArgumentException,
199+
match=re.escape(
200+
"Received an invalid v2 API key. Are you using the correct key and the correct CredentialProvider method?"
201+
),
202+
):
203+
CredentialProvider.from_api_key_v2(
204+
api_key=test_encoded_v1_token.decode("utf-8"), endpoint=test_v2_endpoint_env_var_name
205+
)
206+
207+
208+
def test_v2_key_from_env_raises_if_base64_api_key() -> None:
209+
with pytest.raises(
210+
InvalidArgumentException,
211+
match=re.escape(
212+
"Received an invalid v2 API key. Are you using the correct key? Or did you mean to use `from_environment_variable()` with a legacy key instead?"
213+
),
214+
):
215+
CredentialProvider.from_environment_variables_v2(
216+
api_key_env_var=test_v1_env_var_name, endpoint_env_var=test_v2_endpoint_env_var_name
217+
)
218+
219+
220+
def test_v2_key_from_string_raises_if_pre_v1_token() -> None:
221+
with pytest.raises(
222+
InvalidArgumentException,
223+
match=re.escape(
224+
"Received an invalid v2 API key. Are you using the correct key and the correct CredentialProvider method?"
225+
),
226+
):
227+
CredentialProvider.from_api_key_v2(api_key=test_token, endpoint=test_v2_endpoint_env_var_name)
228+
229+
230+
def test_v2_key_from_env_raises_if_pre_v1_token() -> None:
231+
with pytest.raises(
232+
InvalidArgumentException,
233+
match=re.escape(
234+
"Received an invalid v2 API key. Are you using the correct key? Or did you mean to use `from_environment_variable()` with a legacy key instead?"
235+
),
236+
):
237+
CredentialProvider.from_environment_variables_v2(
238+
api_key_env_var=test_env_var_name, endpoint_env_var=test_v2_endpoint_env_var_name
239+
)
240+
241+
242+
def test_v2_key_provided_to_from_string() -> None:
243+
with pytest.raises(
244+
InvalidArgumentException,
245+
match=re.escape(
246+
"Unexpectedly received a v2 API key. Are you using the correct key and the correct CredentialProvider method?"
247+
),
248+
):
249+
CredentialProvider.from_string(auth_token=test_v2_api_key)
250+
251+
252+
def test_v2_key_provided_to_from_disposable_token() -> None:
253+
with pytest.raises(
254+
InvalidArgumentException,
255+
match=re.escape(
256+
"Unexpectedly received a v2 API key. Are you using the correct key and the correct CredentialProvider method?"
257+
),
258+
):
259+
CredentialProvider.from_disposable_token(auth_token=test_v2_api_key)
260+
261+
262+
def test_from_disposable_token_raises_if_token_empty() -> None:
263+
with pytest.raises(InvalidArgumentException, match="Disposable token cannot be empty."):
264+
CredentialProvider.from_disposable_token(auth_token="")
265+
266+
267+
def test_from_disposable_token_accepts_v1_api_key() -> None:
268+
provider = CredentialProvider.from_disposable_token(auth_token=test_encoded_v1_token.decode("utf-8"))
269+
assert provider.auth_token == test_v1_api_key
270+
assert provider.control_endpoint == "control.test.momentohq.com"
271+
assert provider.cache_endpoint == "cache.test.momentohq.com"
272+
assert provider.token_endpoint == "token.test.momentohq.com"
273+
274+
275+
def test_from_disposable_token_accepts_pre_v1_token() -> None:
276+
provider = CredentialProvider.from_disposable_token(auth_token=test_token)
277+
assert provider.auth_token == test_token
278+
assert provider.control_endpoint == test_control_endpoint
279+
assert provider.cache_endpoint == test_cache_endpoint
280+
assert provider.token_endpoint == f"token.{test_cache_endpoint}"

0 commit comments

Comments
 (0)