-
Notifications
You must be signed in to change notification settings - Fork 5
Wip device flow #348
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
damienbfs
wants to merge
11
commits into
develop
Choose a base branch
from
wip-device-flow
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Wip device flow #348
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
0b50917
building blocks for device flow
damienbfs 75e262c
authlib + device flow, tests needed
damienbfs 0a7154b
linting
damienbfs 2f5f5a3
tests for device flow and token refresh
damienbfs 2f641eb
added token backup to temp file + tests
damienbfs a2b2213
fix uv.lock
damienbfs 2bb097c
tiny fix
damienbfs ead5c25
fix http client
damienbfs 61f6e58
use more specific file name for lomas token
damienbfs 1f474c5
add missing scope to client oauth session
damienbfs c2b4846
cleanup
damienbfs 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,18 @@ | ||
| import json | ||
| import os | ||
| from time import sleep | ||
| import tempfile | ||
| import time | ||
|
|
||
| import requests | ||
| from oauthlib.oauth2 import LegacyApplicationClient, TokenExpiredError | ||
| from authlib.integrations.base_client.errors import OAuthError | ||
| from authlib.integrations.requests_client import OAuth2Session | ||
| from authlib.oauth2.rfc6749.errors import OAuth2Error | ||
| from opentelemetry.instrumentation.requests import RequestsInstrumentor | ||
| from requests_oauthlib import OAuth2Session | ||
|
|
||
| from lomas_client.constants import CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT | ||
| from lomas_client.constants import CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, OIDC_REQUIRED_SCOPES | ||
| from lomas_client.models.config import ClientConfig | ||
| from lomas_core.constants import OIDC_LOMAS_CLIENT__CLIENT_ID | ||
| from lomas_core.models.config import OIDCDeviceCodeResponse | ||
| from lomas_core.models.constants import init_logging | ||
| from lomas_core.models.requests import LomasRequestModel | ||
| from lomas_core.models.responses import Job | ||
|
|
@@ -28,29 +32,108 @@ def __init__(self, config: ClientConfig) -> None: | |
| self.config = config | ||
|
|
||
| if not self.config.oidc_use_tls or not self.config.lomas_service_use_tls: | ||
| logger.warning( | ||
| "OIDC IdP or Lomas service configured without TLS -> using oauthlib insecure transport" | ||
| ) | ||
| os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" | ||
| else: | ||
| # Reset in case it was changed before | ||
| os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "0" | ||
| logger.warning("OIDC IdP or Lomas service configured without TLS -> using insecure transport") | ||
|
|
||
| self._oauth2_session = OAuth2Session( | ||
| client_id="lomas_client", | ||
| token_endpoint=self.config.oidc_config.token_endpoint, | ||
| scope=OIDC_REQUIRED_SCOPES, | ||
| update_token=self._save_token, | ||
| token=self._load_token(), | ||
| token_endpoint_auth_method="none", | ||
| leeway=30, # refresh token 30 seconds before expiry | ||
| ) | ||
|
|
||
| oauth_client = LegacyApplicationClient(OIDC_LOMAS_CLIENT__CLIENT_ID) | ||
| self._oauth2_session = OAuth2Session(client=oauth_client) | ||
| try: | ||
| self._oauth2_session.refresh_token() | ||
| except (OAuth2Error, AttributeError, requests.HTTPError): | ||
| # Fallback to authorize | ||
| # We catch http errors because dex fails when it cannot link a token to existing user. | ||
| # We catch attribute error in case the token is none | ||
| self._authorize() | ||
|
|
||
| def _get_token_file(self) -> str: | ||
| """Returns a temp filename for saving/loading the token.""" | ||
| return os.path.join( | ||
| tempfile.gettempdir(), f"lomas_{self.config.user_name}_{self.config.dataset_name}_token.json" | ||
| ) | ||
|
|
||
| # Fetch first token: | ||
| self._fetch_token() | ||
| def _save_token(self, token: dict, refresh_token: str | None = None) -> None: | ||
| """Saves the token to disk.""" | ||
| with open(self._get_token_file(), "w") as f: | ||
| json.dump(token, f) | ||
|
|
||
| def _load_token(self) -> dict | None: | ||
| """Tries to load the saved token from disk.""" | ||
| if os.path.exists(self._get_token_file()): | ||
| with open(self._get_token_file()) as f: | ||
| return json.load(f) | ||
| return None | ||
|
|
||
| def _authorize(self) -> None: | ||
| """Chooses the right grant and gets access token.""" | ||
| if self.config.use_password_flow: | ||
| self._password_flow() | ||
| else: | ||
| self._device_flow() | ||
|
|
||
| def _fetch_token(self) -> None: | ||
| """Fetches an authorization token and stores it.""" | ||
| def _password_flow(self) -> None: | ||
| """Performs a legacy password flow to fetch an access token.""" | ||
| self._oauth2_session.fetch_token( | ||
| str(self.config.oidc_config.token_endpoint), | ||
| self.config.oidc_config.token_endpoint, | ||
| username=self.config.user_name, | ||
| password=self.config.user_password, | ||
| scope=["openid", "profile", "email"], | ||
| grant_type="password", | ||
| ) | ||
|
|
||
| def _device_flow(self) -> None: | ||
| """Fetches an access token using the device auth flow. | ||
|
|
||
| Waits until the user has authorized the python client. | ||
|
|
||
| Raises: | ||
| TimeoutError: In case the user did not authorize the Lomas Python client in time. | ||
| """ | ||
| print("Authorizing Lomas Python client") | ||
|
|
||
| device_data_resp = requests.post( | ||
| str(self.config.oidc_config.device_authorization_endpoint), | ||
| data={"client_id": OIDC_LOMAS_CLIENT__CLIENT_ID, "scope": OIDC_REQUIRED_SCOPES}, | ||
| ) | ||
| device_data_resp.raise_for_status() | ||
| device_data = OIDCDeviceCodeResponse.model_validate(device_data_resp.json()) | ||
|
|
||
| if not device_data.verification_uri_complete: | ||
| print(f"Go to: {device_data.verification_uri}") | ||
| print(f"Log in and authorize the Lomas Python client with this code {device_data.user_code}") | ||
| else: | ||
| print(f"Go to: {device_data.verification_uri_complete}") | ||
| print("Log in and authorize the Lomas Python client.") | ||
|
|
||
|
Comment on lines
+106
to
+112
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. rich.pretty for flex ? |
||
| print("This will hang until the authorization is complete...") | ||
|
|
||
| interval = 5 | ||
| while True: | ||
| try: | ||
| self._oauth2_session.fetch_token( | ||
| self.config.oidc_config.token_endpoint, | ||
| grant_type="urn:ietf:params:oauth:grant-type:device_code", | ||
| device_code=device_data.device_code, | ||
| ) | ||
| break | ||
| except (OAuth2Error, OAuthError) as e: | ||
| if e.error == "authorization_pending": | ||
| time.sleep(interval) | ||
| elif e.error == "slow_down": | ||
| interval += 5 | ||
| time.sleep(interval) | ||
| elif e.error == "expired_token": | ||
| raise TimeoutError("Lomas Python client was not authorized soon enough.") from e | ||
| else: | ||
| raise e | ||
|
|
||
| print("Authorization process complete.") | ||
|
|
||
| def post( | ||
| self, | ||
| endpoint: str, | ||
|
|
@@ -88,17 +171,16 @@ def post( | |
| headers=self.headers, | ||
| timeout=(CONNECT_TIMEOUT, read_timeout), | ||
| ) | ||
| except TokenExpiredError: | ||
| # This also catches if there is no token at first try. | ||
| # Retry with new token | ||
| self._fetch_token() | ||
| except OAuth2Error: | ||
| # Handle expired refresh token | ||
| self._authorize() | ||
|
|
||
| r = self._oauth2_session.post( | ||
| f"{self.config.app_url}/{endpoint}", | ||
| json=body.model_dump(), | ||
| headers=self.headers, | ||
| timeout=(CONNECT_TIMEOUT, read_timeout), | ||
| ) | ||
|
|
||
| return r | ||
|
|
||
| def wait_for_job(self, job_uid: str, n_retry: int = 1800, sleep_sec: float = 1) -> Job: | ||
|
|
@@ -108,9 +190,10 @@ def wait_for_job(self, job_uid: str, n_retry: int = 1800, sleep_sec: float = 1) | |
| job_query = self._oauth2_session.get( | ||
| f"{self.config.app_url}/status/{job_uid}", headers=self.headers, timeout=(CONNECT_TIMEOUT) | ||
| ).json() | ||
| except TokenExpiredError: | ||
| # This also catches if there is no token at first try. | ||
| self._fetch_token() | ||
| except OAuth2Error: | ||
| # Handle expired refresh token | ||
| self._authorize() | ||
|
|
||
| job_query = self._oauth2_session.get( | ||
| f"{self.config.app_url}/status/{job_uid}", headers=self.headers, timeout=(CONNECT_TIMEOUT) | ||
| ).json() | ||
|
|
@@ -119,11 +202,6 @@ def wait_for_job(self, job_uid: str, n_retry: int = 1800, sleep_sec: float = 1) | |
| if "status" in job_query and job_query["status"] in {"complete", "failed"}: | ||
| return Job.model_validate(job_query) | ||
|
|
||
| if "type" in job_query and job_query["type"] == "UnauthorizedAccessException": | ||
| # Handle unauthorized specifically | ||
| self._fetch_token() # refresh token | ||
| continue # retry the request | ||
|
|
||
| sleep(sleep_sec) | ||
| time.sleep(sleep_sec) | ||
|
|
||
| raise TimeoutError(f"Job {job_uid} didn't complete in time ({sleep_sec * n_retry})") | ||
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
Oops, something went wrong.
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.
no pathlib ? :sadface: