Repository navigation
feat: auth bearer token management (issue #110) #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
8bbb159
implement token provider + base outbound request client
jennifer-bowser 8206378
adds tests
jennifer-bowser da98aeb
fix failing test?
jennifer-bowser 8d14b81
make 'token_provider' an arg for 'VlmClient' instead of having the cl…
jennifer-bowser ca95d73
add margin to token expiry time
jennifer-bowser adf4c4b
add documentation for 'VLM_CLIENT_ID' and 'VLM_CLIENT_SECRET' env vars
jennifer-bowser 07f861f
adds '__init__' documentation
jennifer-bowser 872047a
make 'VlmClient.request' synchronous
jennifer-bowser b68fe57
adds test for 'VlmClient'
jennifer-bowser 97b58f5
don't accidentally extend a token's lifetime beyond its expiration
jennifer-bowser f9cbc5a
use better placeholders for vlm network cred documentation
jennifer-bowser 39829d0
adds HTTP status code on failed requests to obtain tokens from Auth0
jennifer-bowser 47383d7
check for 'VLM_CLIENT_ID' and 'VLM_CLIENT_SECRET' env vars during 'To…
jennifer-bowser 7dd88e7
mock vlm network cred env vars so tests don't fail
jennifer-bowser File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Handles auth for requests on the VLM network""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| """Handles retrieving/caching valid bearer tokens""" | ||
|
|
||
| import os | ||
| from datetime import UTC, datetime, timedelta | ||
| from http import HTTPStatus | ||
|
|
||
| import requests | ||
| from requests.models import Response | ||
|
|
||
| from anyvlm.utils.exceptions import TokenRetrievalError | ||
|
|
||
|
|
||
| class TokenProvider: | ||
| """Handles retrieving/caching valid bearer tokens""" | ||
|
|
||
| BASE_VLM_AUTH_URL: str = "https://vlm-auth.us.auth0.com" | ||
| AUDIENCE_URL: str = BASE_VLM_AUTH_URL + "/api/v2/" | ||
| TOKEN_REQUEST_URL: str = BASE_VLM_AUTH_URL + "/oauth/token" | ||
|
|
||
| # used to add margin to the token expiry time, to account for the time | ||
| # it takes for the token provider to send its response back to AnyVLM | ||
| TOKEN_EXPIRY_MARGIN_SECONDS = 30 | ||
|
|
||
| _token: str = "" | ||
| _token_expiry: datetime | ||
|
|
||
| def __init__(self) -> None: | ||
| """Initializes a TokenProvider to obtain JWT tokens for outbound HTTP requests to other nodes on the VLM Network""" | ||
| client_id: str | None = os.getenv("VLM_CLIENT_ID") | ||
| client_secret: str | None = os.getenv("VLM_CLIENT_SECRET") | ||
|
|
||
| if not client_id or not client_secret: | ||
| error_message: str = "Unable to retrieve token - missing 'VLM_CLIENT_ID' and/or 'VLM_CLIENT_SECRET' environment variables" | ||
| raise TokenRetrievalError(error_message) | ||
|
|
||
| self.client_id: str = client_id | ||
| self.client_secret: str = client_secret | ||
|
|
||
| self.get_token() | ||
|
|
||
| def get_token(self) -> str: | ||
| """Retrieves a valid JWT token. Will obtain a new token if | ||
| one is not currently set or if the current one is expired. | ||
|
|
||
| :return: a JWT token | ||
| :raises: a `TokenRetrievalError`, if unable to obtain a token | ||
| """ | ||
| now: datetime = datetime.now(tz=UTC) | ||
| if (not self._token) or (now >= self._token_expiry): | ||
| self._refresh_token() | ||
| return self._token | ||
|
|
||
| def _refresh_token(self) -> None: | ||
| """Retrieves and sets a new JWT token and updates the token expiry time""" | ||
| now: datetime = datetime.now(tz=UTC) | ||
| response: Response = requests.post( | ||
| url=self.TOKEN_REQUEST_URL, | ||
| json={ | ||
| "client_id": self.client_id, | ||
| "client_secret": self.client_secret, | ||
| "audience": self.AUDIENCE_URL, | ||
|
jennifer-bowser marked this conversation as resolved.
Resolved
|
||
| "grant_type": "client_credentials", | ||
| }, | ||
| timeout=10, | ||
| ) | ||
|
|
||
| if response.status_code != HTTPStatus.OK: | ||
| error_message: str = f"Unable to fetch bearer token from Auth0 - {response.status_code} response received" | ||
| raise TokenRetrievalError(error_message) | ||
|
|
||
| data = response.json() | ||
|
|
||
| self._token = data["access_token"] | ||
| self._token_expiry = now + timedelta( | ||
| seconds=(data["expires_in"] - self.TOKEN_EXPIRY_MARGIN_SECONDS) | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Handles sending outbound requests to other VLM Network nodes""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| """Handles sending authenticated outbound requests to the VLM Network""" | ||
|
|
||
| from http import HTTPMethod | ||
| from typing import Any | ||
|
|
||
| import requests | ||
| from requests import Response | ||
|
|
||
| from anyvlm.auth.token_provider import TokenProvider | ||
|
|
||
|
|
||
| class VlmClient: | ||
| """Handles sending authenticated outbound requests to the VLM Network""" | ||
|
|
||
| def __init__(self, token_provider: TokenProvider) -> None: | ||
| """Initializes a VLM client to send outbound requests to the VLM Network | ||
|
|
||
| :param token_provider: A TokenProvider instance; used to obtain JWT tokens for outbound HTTP requests to other nodes on the VLM network | ||
| """ | ||
| self.token_provider: TokenProvider = token_provider | ||
|
|
||
| def request( | ||
| self, | ||
| method_type: HTTPMethod, | ||
| url: str, | ||
| request_body: dict[Any, Any] | None = None, | ||
| ) -> Response: | ||
| """Send an authenticated outbound request to the VLM Network""" | ||
|
jennifer-bowser marked this conversation as resolved.
Resolved
|
||
| return requests.request( | ||
| method=method_type, | ||
| url=url, | ||
| headers={"Authorization": f"Bearer {self.token_provider.get_token()}"}, | ||
|
jennifer-bowser marked this conversation as resolved.
Resolved
jennifer-bowser marked this conversation as resolved.
Resolved
|
||
| json=request_body, | ||
| timeout=10, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import time | ||
| from datetime import UTC, datetime, timedelta | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| from anyvlm.auth.token_provider import TokenProvider | ||
|
|
||
|
|
||
| def _token_response( | ||
| token: str = "test-token", expires_in: int = 3600, delay: bool = False | ||
| ) -> MagicMock: | ||
| if delay: | ||
| time.sleep(3) | ||
|
|
||
| response: MagicMock = MagicMock() | ||
| response.status_code = 200 | ||
| response.json.return_value = { | ||
| "access_token": token, | ||
| "expires_in": expires_in, | ||
| } | ||
| return response | ||
|
|
||
|
|
||
| def test_fetches_token_on_initialization() -> None: | ||
| with patch("anyvlm.auth.token_provider.requests.post") as mock_post: | ||
| mock_post.return_value = _token_response() | ||
|
|
||
| provider: TokenProvider = TokenProvider() | ||
|
|
||
| assert provider.get_token() == "test-token" | ||
| mock_post.assert_called_once() | ||
|
|
||
|
|
||
| def test_fetches_new_token_when_current_token_is_expired() -> None: | ||
| with patch("anyvlm.auth.token_provider.requests.post") as mock_post: | ||
| mock_post.side_effect = [ | ||
| _token_response(token="initial-token"), | ||
| _token_response(token="refreshed-token"), | ||
| ] | ||
| provider: TokenProvider = TokenProvider() | ||
| provider._token_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage] | ||
|
|
||
| assert provider.get_token() == "refreshed-token" | ||
| assert mock_post.call_count == 2 | ||
|
|
||
|
|
||
| def test_does_not_fetch_new_token_when_current_token_is_not_expired() -> None: | ||
| with patch("anyvlm.auth.token_provider.requests.post") as mock_post: | ||
| mock_post.side_effect = [ | ||
| _token_response(token="initial-token"), | ||
| _token_response( | ||
| token="refreshed-token" | ||
| ), # this token should NOT be returned, since the call should only be made once | ||
| ] | ||
| provider: TokenProvider = TokenProvider() | ||
|
|
||
| assert provider.get_token() == "initial-token" | ||
| assert ( | ||
| provider.get_token() == "initial-token" | ||
| ) # a second call should still return the same initial token | ||
| mock_post.assert_called_once() | ||
|
|
||
|
|
||
| def test_token_provider_does_not_extend_token_validity_after_delayed_response() -> None: | ||
| with patch("anyvlm.auth.token_provider.requests.post") as mock_post: | ||
| mock_post.side_effect = [ | ||
| # Initial token will arrive already expired due to response delay | ||
| _token_response(token="initial-token", expires_in=1, delay=True), | ||
| _token_response(token="refreshed-token"), | ||
| ] | ||
|
|
||
| # TokenProvider is unknowingly initialized w/ expired token | ||
| provider: TokenProvider = TokenProvider() | ||
|
|
||
| # Call to 'get_token' should trigger token refresh | ||
| assert provider.get_token() == "refreshed-token" | ||
| assert mock_post.call_count == 2 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| from datetime import UTC, datetime, timedelta | ||
| from http import HTTPMethod | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
|
|
||
| from anyvlm.auth.token_provider import TokenProvider | ||
| from anyvlm.outbound.vlm_client import VlmClient | ||
| from anyvlm.utils.exceptions import TokenRetrievalError | ||
|
|
||
|
|
||
| def _token_response(token: str) -> MagicMock: | ||
| response: MagicMock = MagicMock() | ||
| response.status_code = 200 | ||
| response.json.return_value = { | ||
| "access_token": token, | ||
| "expires_in": 3600, | ||
| } | ||
| return response | ||
|
|
||
|
|
||
| def test_request_includes_cached_token_in_authorization_header() -> None: | ||
| with patch("anyvlm.auth.token_provider.requests.post") as mock_post: | ||
| mock_post.return_value = _token_response(token="cached-token") | ||
| token_provider: TokenProvider = TokenProvider() | ||
|
|
||
| with patch("anyvlm.outbound.vlm_client.requests.request") as mock_request: | ||
| VlmClient(token_provider).request( | ||
| method_type=HTTPMethod.GET, | ||
| url="https://example.com/variants", | ||
| request_body={"variant": "test"}, | ||
| ) | ||
|
|
||
| mock_request.assert_called_once_with( | ||
| method=HTTPMethod.GET, | ||
| url="https://example.com/variants", | ||
| headers={"Authorization": "Bearer cached-token"}, | ||
| json={"variant": "test"}, | ||
| timeout=10, | ||
| ) | ||
| mock_post.assert_called_once() | ||
|
|
||
|
|
||
| def test_request_includes_refreshed_token_in_authorization_header() -> None: | ||
| with patch("anyvlm.auth.token_provider.requests.post") as mock_post: | ||
| mock_post.side_effect = [ | ||
| _token_response(token="cached-token"), | ||
| _token_response(token="refreshed-token"), | ||
| ] | ||
| token_provider: TokenProvider = TokenProvider() | ||
| token_provider._token_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage] | ||
|
|
||
| with patch("anyvlm.outbound.vlm_client.requests.request") as mock_request: | ||
| VlmClient(token_provider).request( | ||
| method_type=HTTPMethod.POST, url="https://example.com/variants" | ||
| ) | ||
|
|
||
| mock_request.assert_called_once_with( | ||
| method=HTTPMethod.POST, | ||
| url="https://example.com/variants", | ||
| headers={"Authorization": "Bearer refreshed-token"}, | ||
| json=None, | ||
| timeout=10, | ||
| ) | ||
| assert mock_post.call_count == 2 | ||
|
|
||
|
|
||
| def test_request_does_not_send_request_when_token_retrieval_fails() -> None: | ||
| token_provider: MagicMock = MagicMock(spec=TokenProvider) | ||
| token_provider.get_token.side_effect = TokenRetrievalError( | ||
| "Unable to fetch bearer token from Auth0" | ||
| ) | ||
| client: VlmClient = VlmClient(token_provider) | ||
|
|
||
| with ( | ||
| patch("anyvlm.outbound.vlm_client.requests.request") as mock_request, | ||
| pytest.raises(TokenRetrievalError), | ||
| ): | ||
| client.request(method_type=HTTPMethod.GET, url="https://example.com/variants") | ||
|
|
||
| mock_request.assert_not_called() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Codex finding: Minor: Add outbound authentication documentation
These variables appear in the generated example environment page, but users have no setup or client usage instructions. Please document credential setup, the fixed Auth0 endpoint and audience, and a minimal client example under
docs/, and include the new modules in the API reference. Clarify that credentials are required for outbound authentication; the existing inbound endpoints do not instantiate this provider.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Codex finding: Severity - Moderate (incorrect behavior under specific, realistic conditions); Blocking: Correct the credential setup instructions
Credential setup instructions are now present, but the configuration table names the secret
VLM_SECRET_ID; the implementation readsVLM_CLIENT_SECRET. Following the table therefore sends no configured client secret to Auth0 and prevents token retrieval. Please change the documented name toVLM_CLIENT_SECRET.Please also distinguish these outbound credentials from the settings required to launch the existing inbound service. The table’s introductory note currently says every listed value is required for startup, although the inbound service does not instantiate
TokenProvider. These are corrections needed to make the setup instructions accurate, separate from the optional documentation additions in the original comment.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Codex finding: Severity - Low (limited impact, such as documentation or maintainability issues); Non-blocking: Add client usage guidance
The previously suggested fixed Auth0 endpoint and audience, minimal client example, and API-reference entries are still absent. Adding them would help developers construct and use the outbound client without reading its implementation. These additions are optional and can be left out; they do not need a separate issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
whoops, fixed the incorrect variable name in the env var documentation. But instructions re: how to obtain a token are not public information, that's something the VLM Network admins explain once you've been accepted in. So I've documented that, but won't add further info.
Also these aren't technically required for startup now, but they will be in #112 so no point in changing docs now just to change 'em back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Codex finding: Severity - Low (limited impact, such as documentation or maintainability issues); Non-blocking: Clarify when credentials are required
The variable-name correction fixes the blocking issue. The remaining documentation point is smaller: the configuration page says AnyVLM cannot start without
VLM_CLIENT_IDandVLM_CLIENT_SECRET, but this PR only requires them when constructing the outbound token provider.Consider wording this as: “These credentials are required for authenticated outbound requests; they are not currently required to start the inbound service.” I understand #112 may change that requirement. This clarification is optional and can be left out.