From 2ea3de3bc619c19843cd318b0bb3e24829af9df8 Mon Sep 17 00:00:00 2001 From: Tejas Rajopadhye <71188245+TejasRGitHub@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:16:04 -0500 Subject: [PATCH 1/6] configure: add OidcBrowserAuth and --dataall_url front-page discovery - Auth-type scoped prompts keep the Cognito and Custom flows unchanged - --dataall_url reads issuer, client id and API endpoint from the deployed frontend bundle and stores the page origin as frontend_url - Tutorial updated Co-Authored-By: Claude Fable 5.1 --- dataall_cli/cli.py | 160 ++++++++++++++++++--- dataall_cli/utils/__init__.py | 3 +- dataall_cli/utils/discovery.py | 40 ++++++ tests/functional/test_datall_cli_client.py | 134 +++++++++++++++++ tests/unit/test_config.py | 18 +++ tests/unit/test_discovery.py | 66 +++++++++ tutorials/002 - Getting Started.md | 44 +++++- 7 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 dataall_cli/utils/discovery.py create mode 100644 tests/unit/test_discovery.py diff --git a/dataall_cli/cli.py b/dataall_cli/cli.py index 5729235..964a8de 100644 --- a/dataall_cli/cli.py +++ b/dataall_cli/cli.py @@ -4,13 +4,15 @@ import logging import os from pathlib import Path +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse import click from dataall_core.dataall_client import DataallClient from dataall_core.profile import CONFIG_PATH from dataall_cli.bind_commands import bind -from dataall_cli.utils import save_config +from dataall_cli.utils import discover_from_frontend, save_config DA_CONFIG_PATH = os.getenv("dataall_config_path", CONFIG_PATH) CREDS_PATH = os.getenv("dataall_creds_path", None) @@ -50,51 +52,164 @@ def dataall_cli() -> None: ) +AUTH_TYPES = ["CognitoAuth", "CustomAuth", "OidcBrowserAuth"] +DEFAULT_REDIRECT_URI = "http://localhost:8765/callback" +DEFAULT_FALLBACK_REDIRECT_URI = "http://localhost:8766/callback" +DEFAULT_SCOPES = "openid offline_access" +DISCOVERED = "dataall_discovered" + +DOMAIN_PROMPT = "Enter data.all's domain URL (e.g. https://.com)" +IDP_PROMPT = "Enter data.all Identity Provider Domain (e.g. https://.com)" +ISSUER_PROMPT = ( + "Enter OIDC issuer URL (e.g. https://.okta.com/oauth2/)" +) +SECRET_PROMPT = "Enter IdP client secret (if applicable)" +AUTH_SERVER_PROMPT = "Enter IdP custom auth server (if applicable)" + + +class AuthScopedOption(click.Option): + """Option whose prompt depends on ``--auth_type``. + + ``scoped`` maps an auth type to ``{"prompt": text, "default": value}``. A spec + without ``prompt`` uses its default silently; auth types not listed get ``None``. + Values discovered from ``--dataall_url`` are used without prompting. + """ + + def __init__( + self, *args: Any, scoped: Dict[str, Dict[str, Any]], **kwargs: Any + ) -> None: + self.scoped = scoped + kwargs.setdefault("prompt", True) + super().__init__(*args, **kwargs) + + def prompt_for_value(self, ctx: click.Context) -> Any: + """Return the value for the active auth type, prompting only when needed.""" + discovered = ctx.meta.get(DISCOVERED, {}) + if self.name in discovered: + return discovered[self.name] + spec = self.scoped.get(str(ctx.params.get("auth_type"))) + if spec is None: + return None + if "prompt" not in spec: + return spec.get("default") + return click.prompt( + spec["prompt"], + default=spec.get("default"), + type=self.type, + value_proc=lambda x: self.process_value(ctx, x), + ) + + +def _discover( + ctx: click.Context, _param: click.Parameter, value: Optional[str] +) -> Optional[str]: + if not value: + return value + try: + found = discover_from_frontend(value) + except Exception as e: + click.echo(f"Could not read settings from {value}: {e}", err=True) + found = {} + parsed = urlparse(value) + found["frontend_url"] = f"{parsed.scheme}://{parsed.netloc}" + for key, item in found.items(): + click.echo(f"Discovered {key}: {item}", err=True) + ctx.meta[DISCOVERED] = found + if "auth_type" not in ctx.params: + ctx.default_map = {**(ctx.default_map or {}), "auth_type": "OidcBrowserAuth"} + return value + + +def _for(auth_types: List[str], **spec: Any) -> Dict[str, Dict[str, Any]]: + return {auth_type: dict(spec) for auth_type in auth_types} + + @dataall_cli.command() @click.option( "--auth_type", - type=click.Choice(["CognitoAuth", "CustomAuth"]), + type=click.Choice(AUTH_TYPES), default="CognitoAuth", prompt="Select authentication type", - help="Authentication type: Cognito or Custom", + help="Authentication type: Cognito, Custom (username/password) or OIDC browser login", +) +@click.option( + "--dataall_url", + default=None, + expose_value=False, + callback=_discover, + help="data.all front page URL; reads the IdP issuer, client id and API endpoint from it", ) @click.option( "--client_id", + cls=AuthScopedOption, required=True, - prompt="Enter data.all app client id", + scoped=_for(AUTH_TYPES, prompt="Enter data.all app client id"), help="data.all app client id", ) @click.option( "--api_endpoint_url", + cls=AuthScopedOption, required=True, - prompt="Enter data.all API endpoint url", + scoped=_for(AUTH_TYPES, prompt="Enter data.all API endpoint url"), help="data.all API endpoint url", ) @click.option( "--redirect_uri", + cls=AuthScopedOption, required=True, - prompt="Enter data.all's domain URL (e.g. https://.com)", - help="data.all domain URL", + scoped={ + **_for(["CognitoAuth", "CustomAuth"], prompt=DOMAIN_PROMPT), + "OidcBrowserAuth": {"default": DEFAULT_REDIRECT_URI}, + }, + help="OAuth redirect URI: the data.all domain URL, or the loopback URI registered for the CLI", ) @click.option( "--idp_domain_url", + cls=AuthScopedOption, required=True, - prompt="Enter data.all Identity Provider Domain (e.g. https://.com)", - help="data.all IdP domain URL", + scoped={ + **_for(["CognitoAuth", "CustomAuth"], prompt=IDP_PROMPT), + "OidcBrowserAuth": {"prompt": ISSUER_PROMPT}, + }, + help="Identity provider domain, or the OIDC issuer URL for browser login", ) @click.option( "--client_secret", + cls=AuthScopedOption, required=False, - prompt="Enter IdP client secret (if applicable)", - default="", - help="profile name for dataall_cli configured user", + scoped=_for(["CognitoAuth", "CustomAuth"], prompt=SECRET_PROMPT, default=""), + help="IdP client secret, if the app has one", ) @click.option( "--auth_server", - prompt="Enter IdP custom auth server (if applicable)", - default="default", + cls=AuthScopedOption, + required=False, + scoped=_for( + ["CognitoAuth", "CustomAuth"], prompt=AUTH_SERVER_PROMPT, default="default" + ), help="identity provider's custom authorization server used to get well-known openid config", ) +@click.option( + "--scopes", + cls=AuthScopedOption, + required=False, + scoped={"OidcBrowserAuth": {"default": DEFAULT_SCOPES}}, + help="OIDC scopes for browser login; use 'openid' if the IdP rejects offline_access", +) +@click.option( + "--frontend_url", + cls=AuthScopedOption, + required=False, + scoped={ + "OidcBrowserAuth": {"prompt": "Enter data.all front page URL", "default": ""} + }, + help="data.all UI URL sent as Origin header; some deployments only accept API calls carrying it", +) +@click.option( + "--fallback_redirect_uri", + default=DEFAULT_FALLBACK_REDIRECT_URI, + help="second loopback URI tried when the first port is busy (browser login)", +) @click.option( "--profile", prompt="Enter data.all profile name", @@ -102,20 +217,23 @@ def dataall_cli() -> None: help="profile name for dataall_cli configured user", ) def configure( + auth_type: str, client_id: str, api_endpoint_url: str, - auth_type: str, redirect_uri: str, idp_domain_url: str, - client_secret: str, - auth_server: str, + client_secret: Optional[str], + auth_server: Optional[str], + scopes: Optional[str], + frontend_url: Optional[str], + fallback_redirect_uri: str, profile: str, ) -> None: """Configure data.all client for a given user, use profile to setup multiple user profiles.""" click.echo("Configuring data.all CLI...", err=True) try: - profile_params_dict = { + profile_params_dict: Dict[str, Any] = { "client_id": client_id, "api_endpoint_url": api_endpoint_url, "auth_type": auth_type, @@ -131,6 +249,12 @@ def configure( "session_token_endpoint": session_token_endpoint, } ) + if auth_type == "OidcBrowserAuth": + profile_params_dict.update( + {"scopes": scopes, "fallback_redirect_uri": fallback_redirect_uri} + ) + if frontend_url: + profile_params_dict["frontend_url"] = frontend_url.rstrip("/") if CREDS_PATH: profile_params_dict.update( { diff --git a/dataall_cli/utils/__init__.py b/dataall_cli/utils/__init__.py index 0be749a..2786a28 100644 --- a/dataall_cli/utils/__init__.py +++ b/dataall_cli/utils/__init__.py @@ -1,5 +1,6 @@ """data.all cli utils.""" from .config import load_config, save_config +from .discovery import discover_from_frontend -__all__ = ["load_config", "save_config"] +__all__ = ["discover_from_frontend", "load_config", "save_config"] diff --git a/dataall_cli/utils/discovery.py b/dataall_cli/utils/discovery.py new file mode 100644 index 0000000..898b57b --- /dev/null +++ b/dataall_cli/utils/discovery.py @@ -0,0 +1,40 @@ +"""Discover CLI settings from a deployed data.all front page.""" + +import re +from typing import Dict +from urllib.parse import urljoin + +import httpx + +BUNDLE_PATTERN = re.compile(r'src="([^"]*static/js/main\.[^"]+\.js)"') +BUNDLE_VALUES = { + "idp_domain_url": "REACT_APP_CUSTOM_AUTH_URL", + "client_id": "REACT_APP_CUSTOM_AUTH_CLIENT_ID", + "api_endpoint_url": "REACT_APP_GRAPHQL_API", +} +API_SUFFIX = "/graphql/api" + + +def discover_from_frontend(dataall_url: str) -> Dict[str, str]: + """Read the OIDC issuer, client id and API endpoint embedded in the front page bundle. + + Returns only the values that were found. + """ + base = dataall_url.rstrip("/") + "/" + index = httpx.get(base, follow_redirects=True, timeout=30) + index.raise_for_status() + match = BUNDLE_PATTERN.search(index.text) + if not match: + return {} + bundle = httpx.get(urljoin(base, match.group(1)), follow_redirects=True, timeout=60) + bundle.raise_for_status() + + found: Dict[str, str] = {} + for key, name in BUNDLE_VALUES.items(): + value = re.search(rf'{name}:"([^"]+)"', bundle.text) + if value: + found[key] = value.group(1) + api = found.get("api_endpoint_url", "") + if api.endswith(API_SUFFIX): + found["api_endpoint_url"] = api[: -len(API_SUFFIX)] + return found diff --git a/tests/functional/test_datall_cli_client.py b/tests/functional/test_datall_cli_client.py index f840e77..61034b7 100644 --- a/tests/functional/test_datall_cli_client.py +++ b/tests/functional/test_datall_cli_client.py @@ -72,6 +72,10 @@ def test_cli_help_option(runner): "TestCustomProfile", "CustomAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\nauth_server\nTestCustomProfile\nTokenendpoint\nTestUser\nPassword1!\nPassword1!\n", ), + ( + "TestOidcProfile", + "OidcBrowserAuth\ntestclient\ntestAPIURL\ntestIdPURL\n\nTestOidcProfile\n", + ), ] ), ) @@ -85,6 +89,101 @@ def test_cli_configure_option(runner, profile_input, mocked_get_jwt_token): assert isinstance(profile, Profile) +def test_cli_configure_oidc_browser_defaults(runner, mocked_get_jwt_token): + result = runner.invoke( + configure, + input="OidcBrowserAuth\ntestclient\ntestAPIURL\nhttps://idp/oauth2/aus1\n\nTestOidcDefaults\n", + ) + assert result.exit_code == 0 + + profile = get_profile(profile="TestOidcDefaults", config_path=PROFILE_CONFIG) + assert profile.auth_type == "OidcBrowserAuth" + assert profile.idp_domain_url == "https://idp/oauth2/aus1" + assert profile.redirect_uri == "http://localhost:8765/callback" + assert profile.fallback_redirect_uri == "http://localhost:8766/callback" + assert profile.scopes == "openid offline_access" + assert profile.client_secret is None + assert profile.session_token_endpoint is None + assert profile.frontend_url is None + + +def test_cli_configure_from_dataall_url(runner, mocked_get_jwt_token, mocker): + mocker.patch( + "dataall_cli.cli.discover_from_frontend", + return_value={ + "idp_domain_url": "https://idp/oauth2/aus1", + "client_id": "0oaCLIENT", + "api_endpoint_url": "https://api/prod", + }, + ) + result = runner.invoke( + configure, + ["--dataall_url", "https://dataall.example.com", "--profile", "TestDiscovered"], + input="\n", + ) + assert result.exit_code == 0 + assert "Discovered client_id: 0oaCLIENT" in result.output + + profile = get_profile(profile="TestDiscovered", config_path=PROFILE_CONFIG) + assert profile.auth_type == "OidcBrowserAuth" + assert profile.client_id == "0oaCLIENT" + assert profile.api_endpoint_url == "https://api/prod" + assert profile.idp_domain_url == "https://idp/oauth2/aus1" + assert profile.redirect_uri == "http://localhost:8765/callback" + assert profile.frontend_url == "https://dataall.example.com" + + +def test_cli_configure_dataall_url_unreachable_prompts( + runner, mocked_get_jwt_token, mocker +): + mocker.patch("dataall_cli.cli.discover_from_frontend", side_effect=OSError("boom")) + result = runner.invoke( + configure, + [ + "--dataall_url", + "https://dataall.example.com", + "--profile", + "TestUnreachable", + ], + input="\ntestclient\ntestAPIURL\nhttps://idp/oauth2/aus1\n", + ) + assert result.exit_code == 0 + assert "Could not read settings" in result.output + profile = get_profile(profile="TestUnreachable", config_path=PROFILE_CONFIG) + assert profile.client_id == "testclient" + assert profile.frontend_url == "https://dataall.example.com" + + +def test_cli_configure_cognito_has_no_oidc_fields(runner, mocked_get_jwt_token): + result = runner.invoke( + configure, + input="CognitoAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\n\nTestCognitoPlain\n", + ) + assert result.exit_code == 0 + with open(PROFILE_CONFIG) as file: + saved = yaml.safe_load(file)["TestCognitoPlain"] + assert "scopes" not in saved + assert "fallback_redirect_uri" not in saved + assert "frontend_url" not in saved + assert saved["client_secret"] == "" + + +def test_cli_configure_frontend_url_flag_any_auth_type(runner, mocked_get_jwt_token): + result = runner.invoke( + configure, + [ + "--frontend_url", + "https://dataall.example.com/", + "--profile", + "TestCognitoOrigin", + ], + input="CognitoAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\n\n", + ) + assert result.exit_code == 0 + profile = get_profile(profile="TestCognitoOrigin", config_path=PROFILE_CONFIG) + assert profile.frontend_url == "https://dataall.example.com" + + def test_cli_commands(runner, mock_execute, mocked_get_jwt_token, N=10): result = runner.invoke( configure, @@ -157,3 +256,38 @@ def test_cli_commands_default_profile_dne(runner): op_name = list(commands.keys())[0] result = runner.invoke(dataall_cli, [op_name]) assert result.exit_code == 1 + + +def test_cli_configure_help_does_not_prompt(runner): + result = runner.invoke(configure, ["--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output + assert "Select authentication type" not in result.output + + +def test_cli_configure_dataall_url_after_other_options( + runner, mocked_get_jwt_token, mocker +): + mocker.patch( + "dataall_cli.cli.discover_from_frontend", + return_value={ + "idp_domain_url": "https://idp/oauth2/aus1", + "client_id": "0oaCLIENT", + "api_endpoint_url": "https://api/prod", + }, + ) + result = runner.invoke( + configure, + [ + "--profile", + "TestDiscoveredLate", + "--auth_type", + "OidcBrowserAuth", + "--dataall_url", + "https://dataall.example.com", + ], + ) + assert result.exit_code == 0 + profile = get_profile(profile="TestDiscoveredLate", config_path=PROFILE_CONFIG) + assert profile.client_id == "0oaCLIENT" + assert profile.idp_domain_url == "https://idp/oauth2/aus1" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 7e813b0..91a7c97 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -97,3 +97,21 @@ def test_load_config_dne(): def test_load_config_bad_path(): with pytest.raises(Exception): load_config(Path(__file__)) + + +def test_save_config_oidc_browser(base_profile_params): + auth_type = "OidcBrowserAuth" + profile = "default" + base_profile_params["auth_type"] = auth_type + base_profile_params["redirect_uri"] = "http://localhost:8765/callback" + base_profile_params["scopes"] = "openid offline_access" + + save_config( + profile=profile, + auth_type=auth_type, + params_dict=base_profile_params, + config_path=PROFILE_CONFIG, + ) + + config = load_config(PROFILE_CONFIG) + assert config[profile]["scopes"] == "openid offline_access" diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py new file mode 100644 index 0000000..9cb221b --- /dev/null +++ b/tests/unit/test_discovery.py @@ -0,0 +1,66 @@ +import httpx +import pytest + +from dataall_cli.utils import discover_from_frontend + +FRONT = "https://dataall.example.com" +INDEX = ( + "data.all" + '' + '
' +) +BUNDLE = ( + 'REACT_APP_GRAPHQL_API:"https://api.example.com/prod/graphql/api",' + 'REACT_APP_CUSTOM_AUTH:"okta",' + 'REACT_APP_CUSTOM_AUTH_URL:"https://idp.example.com/oauth2/aus1",' + 'REACT_APP_CUSTOM_AUTH_CLIENT_ID:"0oaCLIENT",' + 'REACT_APP_CUSTOM_AUTH_SCOPES:"openid"' +) + + +def serve(mocker, pages): + def fake_get(url, **kwargs): + status, text = pages.get(url, (404, "")) + return httpx.Response(status, text=text, request=httpx.Request("GET", url)) + + mocker.patch("dataall_cli.utils.discovery.httpx.get", side_effect=fake_get) + + +def test_discover_all_values(mocker): + serve( + mocker, + { + f"{FRONT}/": (200, INDEX), + f"{FRONT}/static/js/main.aaa1de7c.js": (200, BUNDLE), + }, + ) + assert discover_from_frontend(FRONT) == { + "idp_domain_url": "https://idp.example.com/oauth2/aus1", + "client_id": "0oaCLIENT", + "api_endpoint_url": "https://api.example.com/prod", + } + + +def test_discover_partial_bundle(mocker): + serve( + mocker, + { + f"{FRONT}/": (200, INDEX), + f"{FRONT}/static/js/main.aaa1de7c.js": ( + 200, + 'REACT_APP_CUSTOM_AUTH_CLIENT_ID:"0oaCLIENT"', + ), + }, + ) + assert discover_from_frontend(f"{FRONT}/") == {"client_id": "0oaCLIENT"} + + +def test_discover_without_bundle(mocker): + serve(mocker, {f"{FRONT}/": (200, "maintenance")}) + assert discover_from_frontend(FRONT) == {} + + +def test_discover_http_error(mocker): + serve(mocker, {}) + with pytest.raises(httpx.HTTPStatusError): + discover_from_frontend(FRONT) diff --git a/tutorials/002 - Getting Started.md b/tutorials/002 - Getting Started.md index 03acbfd..096a23d 100644 --- a/tutorials/002 - Getting Started.md +++ b/tutorials/002 - Getting Started.md @@ -5,21 +5,24 @@ Dataall's CLI requires user profile information to be stored either in a local file or in AWS Secrets Manager. The user information required includes: -- auth_type: Either `CognitoAuth` or `CustomAuth` +- auth_type: `CognitoAuth`, `CustomAuth` or `OidcBrowserAuth` (browser login, no password) - client_id: The App Client ID - api_endpoint_url: The URL data.all API Gateway Endpoint -- redirect_uri: The data.all domain URL -- idp_domain_url: The Identity Providers URL +- redirect_uri: The data.all domain URL (for `OidcBrowserAuth`: the loopback URI registered for the CLI, default `http://localhost:8765/callback`) +- idp_domain_url: The Identity Providers URL (for `OidcBrowserAuth`: the OIDC issuer URL) - client_secret (optional): The client secret used for the data.all App Client - auth_server (optional, used for CustomAuth): The Custom Authorization Server used if applicable - session_token_endpoint (optional, required for CustomAuth): The Identity Provider API endpoint to retrieve session tokens +- scopes (optional, OidcBrowserAuth): OIDC scopes, default `openid offline_access` +- fallback_redirect_uri (optional, OidcBrowserAuth): second loopback URI tried when the first port is busy +- frontend_url (optional): the data.all UI URL, sent as `Origin` and `Referer` headers; required where the API only accepts calls carrying the UI origin - profile: The Profile Name Data.all's SDK uses the profile information to fetch and save tokens from the data.all application. By default the user information is provided at `~/.dataall/config.yaml` and the token information is saved at `~/.dataall/credentials.yaml` -If a valid token or refresh token exists for the given user, that will be used to fetch a new token and authenticate the profile. Otherwise, the user will be prompted for username and password when running an API request and the fetched tokens will be saved. +If a valid token or refresh token exists for the given user, that will be used to fetch a new token and authenticate the profile. Otherwise, the user will be prompted for username and password when running an API request and the fetched tokens will be saved. With `OidcBrowserAuth` the CLI opens the identity provider's login page in the browser instead. ### Configuring your first data.all User profile @@ -49,6 +52,39 @@ TestCustomProfile: session_token_endpoint: testtokenendpoint ``` + +### Configuring a browser login (OIDC + PKCE, e.g. Okta) + +`OidcBrowserAuth` signs in the way the data.all web app does: the CLI opens your browser on the identity provider, receives the authorization code on a loopback redirect URI and exchanges it with PKCE. Tokens are refreshed silently while the refresh token is valid. + +Prerequisites on the identity provider app (public client with PKCE, Authorization Code + Refresh Token grants): + +- register `http://localhost:8765/callback` and `http://localhost:8766/callback` as sign-in redirect URIs (Okta matches them exactly, port included) +- enable the Device Authorization grant if the CLI is also used on hosts without a browser (SSH sessions) +- if the authorization server policy does not allow the `offline_access` scope, the CLI falls back to `openid` and you log in again when the access token expires (about hourly); set `scopes: openid` in the profile to skip the rejected first attempt + +The quickest way to configure it is to point the CLI at the data.all front page; the issuer, client id and API endpoint are read from the deployed app and the page URL is kept as `frontend_url`: + +```bash +dataall_cli configure --profile okta --auth_type OidcBrowserAuth --dataall_url https://DATAALL_DOMAIN_URL +``` + +The resulting profile in `~/.dataall/config.yaml`: + +``` +okta: + auth_type: OidcBrowserAuth + client_id: testclient + api_endpoint_url: https://API_GATEWAY_URL/prod + redirect_uri: http://localhost:8765/callback + fallback_redirect_uri: http://localhost:8766/callback + idp_domain_url: https://IDP_DOMAIN_URL/oauth2/AUTH_SERVER_ID + scopes: openid offline_access + frontend_url: https://DATAALL_DOMAIN_URL +``` + +On a host without a browser (or with `DATAALL_DEVICE_LOGIN=1`) the CLI prints an activation URL and a short code instead; open the URL in any browser, enter the code and sign in. + ### Specifying your user profile Once you have configured your user profile appropriately, you can begin running data.all API requests via the SDK using your configured profile(s) such as: From 750a18bb01cf0474fd978fc0fe2456736e639f58 Mon Sep 17 00:00:00 2001 From: Tejas Rajopadhye <71188245+TejasRGitHub@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:44:36 -0500 Subject: [PATCH 2/6] configure: prompt for the front page URL in the interactive flow With --auth_type OidcBrowserAuth and no flags, ask for the front page URL right after the auth type so the issuer, client id and API endpoint are discovered instead of typed. Co-Authored-By: Claude Fable 5.1 --- dataall_cli/cli.py | 6 +++++- tests/functional/test_datall_cli_client.py | 25 ++++++++++++++++++++-- tutorials/002 - Getting Started.md | 2 ++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/dataall_cli/cli.py b/dataall_cli/cli.py index 964a8de..11967a6 100644 --- a/dataall_cli/cli.py +++ b/dataall_cli/cli.py @@ -65,6 +65,9 @@ def dataall_cli() -> None: ) SECRET_PROMPT = "Enter IdP client secret (if applicable)" AUTH_SERVER_PROMPT = "Enter IdP custom auth server (if applicable)" +FRONT_PAGE_PROMPT = ( + "Enter data.all front page URL (leave empty to type the values yourself)" +) class AuthScopedOption(click.Option): @@ -134,9 +137,10 @@ def _for(auth_types: List[str], **spec: Any) -> Dict[str, Dict[str, Any]]: ) @click.option( "--dataall_url", - default=None, + cls=AuthScopedOption, expose_value=False, callback=_discover, + scoped={"OidcBrowserAuth": {"prompt": FRONT_PAGE_PROMPT, "default": ""}}, help="data.all front page URL; reads the IdP issuer, client id and API endpoint from it", ) @click.option( diff --git a/tests/functional/test_datall_cli_client.py b/tests/functional/test_datall_cli_client.py index 61034b7..dedf40c 100644 --- a/tests/functional/test_datall_cli_client.py +++ b/tests/functional/test_datall_cli_client.py @@ -74,7 +74,7 @@ def test_cli_help_option(runner): ), ( "TestOidcProfile", - "OidcBrowserAuth\ntestclient\ntestAPIURL\ntestIdPURL\n\nTestOidcProfile\n", + "OidcBrowserAuth\n\ntestclient\ntestAPIURL\ntestIdPURL\n\nTestOidcProfile\n", ), ] ), @@ -92,7 +92,7 @@ def test_cli_configure_option(runner, profile_input, mocked_get_jwt_token): def test_cli_configure_oidc_browser_defaults(runner, mocked_get_jwt_token): result = runner.invoke( configure, - input="OidcBrowserAuth\ntestclient\ntestAPIURL\nhttps://idp/oauth2/aus1\n\nTestOidcDefaults\n", + input="OidcBrowserAuth\n\ntestclient\ntestAPIURL\nhttps://idp/oauth2/aus1\n\nTestOidcDefaults\n", ) assert result.exit_code == 0 @@ -133,6 +133,27 @@ def test_cli_configure_from_dataall_url(runner, mocked_get_jwt_token, mocker): assert profile.frontend_url == "https://dataall.example.com" +def test_cli_configure_prompts_for_front_page(runner, mocked_get_jwt_token, mocker): + mocker.patch( + "dataall_cli.cli.discover_from_frontend", + return_value={ + "idp_domain_url": "https://idp/oauth2/aus1", + "client_id": "0oaCLIENT", + "api_endpoint_url": "https://api/prod", + }, + ) + result = runner.invoke( + configure, input="OidcBrowserAuth\nhttps://dataall.example.com\nTestPrompted\n" + ) + assert result.exit_code == 0 + assert "Enter data.all app client id" not in result.output + assert "Discovered client_id: 0oaCLIENT" in result.output + + profile = get_profile(profile="TestPrompted", config_path=PROFILE_CONFIG) + assert profile.client_id == "0oaCLIENT" + assert profile.frontend_url == "https://dataall.example.com" + + def test_cli_configure_dataall_url_unreachable_prompts( runner, mocked_get_jwt_token, mocker ): diff --git a/tutorials/002 - Getting Started.md b/tutorials/002 - Getting Started.md index 096a23d..0b7d8c6 100644 --- a/tutorials/002 - Getting Started.md +++ b/tutorials/002 - Getting Started.md @@ -69,6 +69,8 @@ The quickest way to configure it is to point the CLI at the data.all front page; dataall_cli configure --profile okta --auth_type OidcBrowserAuth --dataall_url https://DATAALL_DOMAIN_URL ``` +Without flags, `dataall_cli configure` asks for the authentication type and then for the front page URL; leave it empty to type the issuer, client id and API endpoint yourself. + The resulting profile in `~/.dataall/config.yaml`: ``` From 18f254c1c4778869045ed73f023b14211b3fe96f Mon Sep 17 00:00:00 2001 From: Tejas Rajopadhye <71188245+TejasRGitHub@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:29:23 -0500 Subject: [PATCH 3/6] configure: ask for the front page first and detect the auth type from it The front page bundle says whether the deployment uses Cognito or an OIDC provider, so the type and its values (client id, IdP, redirect URI, API endpoint) are filled in without prompts. An empty URL keeps the manual flow. Commands with an unknown --profile now fail with a one-line message. Co-Authored-By: Claude Fable 5.1 --- dataall_cli/bind_commands.py | 9 +- dataall_cli/cli.py | 52 ++++++---- dataall_cli/utils/__init__.py | 4 +- dataall_cli/utils/discovery.py | 48 ++++++--- tests/functional/test_datall_cli_client.py | 114 ++++++++++++++++----- tests/unit/test_discovery.py | 65 ++++++++---- tutorials/002 - Getting Started.md | 2 +- 7 files changed, 205 insertions(+), 89 deletions(-) diff --git a/dataall_cli/bind_commands.py b/dataall_cli/bind_commands.py index 73ff2c6..94a6613 100644 --- a/dataall_cli/bind_commands.py +++ b/dataall_cli/bind_commands.py @@ -2,10 +2,12 @@ import json import logging +from pathlib import Path from typing import Any, Callable, Dict, Optional import click from dataall_core.dataall_client import DataallClient +from dataall_core.profile import get_profile logger = logging.getLogger(__name__) @@ -45,10 +47,15 @@ def _bind_function( def func(**kwargs: Any) -> None: logger.debug("I am the '{}' command".format(name)) + profile = kwargs.get("profile", "default") + if get_profile(profile=profile, config_path=Path(config_path)) is None: + raise click.ClickException( + f"Profile '{profile}' is not configured; run: dataall_cli configure --profile {profile}" + ) da_client = DataallClient( schema_path=schema_path, schema_version=schema_version ).client( - profile=kwargs.get("profile", "default"), + profile=profile, config_path=config_path, custom_headers=custom_headers, ) diff --git a/dataall_cli/cli.py b/dataall_cli/cli.py index 11967a6..61394d8 100644 --- a/dataall_cli/cli.py +++ b/dataall_cli/cli.py @@ -5,14 +5,13 @@ import os from pathlib import Path from typing import Any, Dict, List, Optional -from urllib.parse import urlparse import click from dataall_core.dataall_client import DataallClient from dataall_core.profile import CONFIG_PATH from dataall_cli.bind_commands import bind -from dataall_cli.utils import discover_from_frontend, save_config +from dataall_cli.utils import discover_from_frontend, frontend_origin, save_config DA_CONFIG_PATH = os.getenv("dataall_config_path", CONFIG_PATH) CREDS_PATH = os.getenv("dataall_creds_path", None) @@ -65,21 +64,23 @@ def dataall_cli() -> None: ) SECRET_PROMPT = "Enter IdP client secret (if applicable)" AUTH_SERVER_PROMPT = "Enter IdP custom auth server (if applicable)" -FRONT_PAGE_PROMPT = ( - "Enter data.all front page URL (leave empty to type the values yourself)" -) +FRONT_PAGE_PROMPT = "Enter data.all front page URL (leave empty to configure manually)" class AuthScopedOption(click.Option): """Option whose prompt depends on ``--auth_type``. ``scoped`` maps an auth type to ``{"prompt": text, "default": value}``. A spec - without ``prompt`` uses its default silently; auth types not listed get ``None``. - Values discovered from ``--dataall_url`` are used without prompting. + without ``prompt`` uses its default silently; auth types not listed get ``None``; + without ``scoped`` the option prompts normally. Values discovered from + ``--dataall_url`` are used without prompting. """ def __init__( - self, *args: Any, scoped: Dict[str, Dict[str, Any]], **kwargs: Any + self, + *args: Any, + scoped: Optional[Dict[str, Dict[str, Any]]] = None, + **kwargs: Any, ) -> None: self.scoped = scoped kwargs.setdefault("prompt", True) @@ -90,6 +91,8 @@ def prompt_for_value(self, ctx: click.Context) -> Any: discovered = ctx.meta.get(DISCOVERED, {}) if self.name in discovered: return discovered[self.name] + if self.scoped is None: + return super().prompt_for_value(ctx) spec = self.scoped.get(str(ctx.params.get("auth_type"))) if spec is None: return None @@ -112,14 +115,19 @@ def _discover( found = discover_from_frontend(value) except Exception as e: click.echo(f"Could not read settings from {value}: {e}", err=True) - found = {} - parsed = urlparse(value) - found["frontend_url"] = f"{parsed.scheme}://{parsed.netloc}" + found = {"frontend_url": frontend_origin(value)} + chosen = ctx.params.get("auth_type") + detected = found.get("auth_type") + if chosen and detected and chosen != detected: + click.echo( + f"The front page uses {detected} but --auth_type {chosen} was given; " + "keeping only the page URL", + err=True, + ) + found = {"frontend_url": found["frontend_url"]} for key, item in found.items(): click.echo(f"Discovered {key}: {item}", err=True) ctx.meta[DISCOVERED] = found - if "auth_type" not in ctx.params: - ctx.default_map = {**(ctx.default_map or {}), "auth_type": "OidcBrowserAuth"} return value @@ -128,21 +136,23 @@ def _for(auth_types: List[str], **spec: Any) -> Dict[str, Dict[str, Any]]: @dataall_cli.command() +@click.option( + "--dataall_url", + prompt=FRONT_PAGE_PROMPT, + default="", + show_default=False, + expose_value=False, + callback=_discover, + help="data.all front page URL; the auth type, IdP, client id and API endpoint are read from it", +) @click.option( "--auth_type", + cls=AuthScopedOption, type=click.Choice(AUTH_TYPES), default="CognitoAuth", prompt="Select authentication type", help="Authentication type: Cognito, Custom (username/password) or OIDC browser login", ) -@click.option( - "--dataall_url", - cls=AuthScopedOption, - expose_value=False, - callback=_discover, - scoped={"OidcBrowserAuth": {"prompt": FRONT_PAGE_PROMPT, "default": ""}}, - help="data.all front page URL; reads the IdP issuer, client id and API endpoint from it", -) @click.option( "--client_id", cls=AuthScopedOption, diff --git a/dataall_cli/utils/__init__.py b/dataall_cli/utils/__init__.py index 2786a28..12324e7 100644 --- a/dataall_cli/utils/__init__.py +++ b/dataall_cli/utils/__init__.py @@ -1,6 +1,6 @@ """data.all cli utils.""" from .config import load_config, save_config -from .discovery import discover_from_frontend +from .discovery import discover_from_frontend, frontend_origin -__all__ = ["discover_from_frontend", "load_config", "save_config"] +__all__ = ["discover_from_frontend", "frontend_origin", "load_config", "save_config"] diff --git a/dataall_cli/utils/discovery.py b/dataall_cli/utils/discovery.py index 898b57b..b623a52 100644 --- a/dataall_cli/utils/discovery.py +++ b/dataall_cli/utils/discovery.py @@ -2,39 +2,53 @@ import re from typing import Dict -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse import httpx BUNDLE_PATTERN = re.compile(r'src="([^"]*static/js/main\.[^"]+\.js)"') -BUNDLE_VALUES = { - "idp_domain_url": "REACT_APP_CUSTOM_AUTH_URL", - "client_id": "REACT_APP_CUSTOM_AUTH_CLIENT_ID", - "api_endpoint_url": "REACT_APP_GRAPHQL_API", -} +VALUE_PATTERN = re.compile(r'(REACT_APP_[A-Z_]+):"([^"]*)"') API_SUFFIX = "/graphql/api" +def frontend_origin(dataall_url: str) -> str: + """Return ``scheme://host`` of a data.all URL.""" + parsed = urlparse(dataall_url) + return f"{parsed.scheme}://{parsed.netloc}" + + def discover_from_frontend(dataall_url: str) -> Dict[str, str]: - """Read the OIDC issuer, client id and API endpoint embedded in the front page bundle. + """Read the auth type, identity provider, client id and API endpoint from the front page bundle. - Returns only the values that were found. + Returns ``frontend_url`` plus whichever values were found. """ + found = {"frontend_url": frontend_origin(dataall_url)} base = dataall_url.rstrip("/") + "/" index = httpx.get(base, follow_redirects=True, timeout=30) index.raise_for_status() match = BUNDLE_PATTERN.search(index.text) if not match: - return {} + return found bundle = httpx.get(urljoin(base, match.group(1)), follow_redirects=True, timeout=60) bundle.raise_for_status() + values = dict(VALUE_PATTERN.findall(bundle.text)) - found: Dict[str, str] = {} - for key, name in BUNDLE_VALUES.items(): - value = re.search(rf'{name}:"([^"]+)"', bundle.text) - if value: - found[key] = value.group(1) - api = found.get("api_endpoint_url", "") - if api.endswith(API_SUFFIX): - found["api_endpoint_url"] = api[: -len(API_SUFFIX)] + if values.get("REACT_APP_CUSTOM_AUTH") and values.get("REACT_APP_CUSTOM_AUTH_URL"): + found["auth_type"] = "OidcBrowserAuth" + found["idp_domain_url"] = values["REACT_APP_CUSTOM_AUTH_URL"] + if values.get("REACT_APP_CUSTOM_AUTH_CLIENT_ID"): + found["client_id"] = values["REACT_APP_CUSTOM_AUTH_CLIENT_ID"] + elif values.get("REACT_APP_COGNITO_APP_CLIENT_ID"): + found["auth_type"] = "CognitoAuth" + found["client_id"] = values["REACT_APP_COGNITO_APP_CLIENT_ID"] + domain = values.get("REACT_APP_COGNITO_DOMAIN", "") + if domain: + found["idp_domain_url"] = domain if "://" in domain else f"https://{domain}" + if values.get("REACT_APP_COGNITO_REDIRECT_SIGNIN"): + found["redirect_uri"] = values["REACT_APP_COGNITO_REDIRECT_SIGNIN"] + api = values.get("REACT_APP_GRAPHQL_API", "") + if api: + found["api_endpoint_url"] = ( + api[: -len(API_SUFFIX)] if api.endswith(API_SUFFIX) else api + ) return found diff --git a/tests/functional/test_datall_cli_client.py b/tests/functional/test_datall_cli_client.py index dedf40c..1193073 100644 --- a/tests/functional/test_datall_cli_client.py +++ b/tests/functional/test_datall_cli_client.py @@ -14,6 +14,22 @@ from dataall_cli.cli import commands, configure, dataall_cli # noqa: E402 +OIDC_DISCOVERY = { + "frontend_url": "https://dataall.example.com", + "auth_type": "OidcBrowserAuth", + "idp_domain_url": "https://idp/oauth2/aus1", + "client_id": "0oaCLIENT", + "api_endpoint_url": "https://api/prod", +} +COGNITO_DISCOVERY = { + "frontend_url": "https://dataall.example.com", + "auth_type": "CognitoAuth", + "client_id": "cognitoclient", + "idp_domain_url": "https://dataall-dev.auth.us-east-1.amazoncognito.com", + "redirect_uri": "https://dataall.example.com", + "api_endpoint_url": "https://api/prod", +} + @pytest.fixture(scope="module", autouse=True) def clean_profile_configs(): @@ -66,15 +82,15 @@ def test_cli_help_option(runner): [ ( "TestCognitoProfile", - "CognitoAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\n\nTestCognitoProfile\nTestUser\nPassword1!\nPassword1!\n", + "\nCognitoAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\n\nTestCognitoProfile\nTestUser\nPassword1!\nPassword1!\n", ), ( "TestCustomProfile", - "CustomAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\nauth_server\nTestCustomProfile\nTokenendpoint\nTestUser\nPassword1!\nPassword1!\n", + "\nCustomAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\nauth_server\nTestCustomProfile\nTokenendpoint\nTestUser\nPassword1!\nPassword1!\n", ), ( "TestOidcProfile", - "OidcBrowserAuth\n\ntestclient\ntestAPIURL\ntestIdPURL\n\nTestOidcProfile\n", + "\nOidcBrowserAuth\ntestclient\ntestAPIURL\ntestIdPURL\n\nTestOidcProfile\n", ), ] ), @@ -92,7 +108,7 @@ def test_cli_configure_option(runner, profile_input, mocked_get_jwt_token): def test_cli_configure_oidc_browser_defaults(runner, mocked_get_jwt_token): result = runner.invoke( configure, - input="OidcBrowserAuth\n\ntestclient\ntestAPIURL\nhttps://idp/oauth2/aus1\n\nTestOidcDefaults\n", + input="\nOidcBrowserAuth\ntestclient\ntestAPIURL\nhttps://idp/oauth2/aus1\n\nTestOidcDefaults\n", ) assert result.exit_code == 0 @@ -110,19 +126,15 @@ def test_cli_configure_oidc_browser_defaults(runner, mocked_get_jwt_token): def test_cli_configure_from_dataall_url(runner, mocked_get_jwt_token, mocker): mocker.patch( "dataall_cli.cli.discover_from_frontend", - return_value={ - "idp_domain_url": "https://idp/oauth2/aus1", - "client_id": "0oaCLIENT", - "api_endpoint_url": "https://api/prod", - }, + return_value=OIDC_DISCOVERY, ) result = runner.invoke( configure, ["--dataall_url", "https://dataall.example.com", "--profile", "TestDiscovered"], - input="\n", ) assert result.exit_code == 0 assert "Discovered client_id: 0oaCLIENT" in result.output + assert "Select authentication type" not in result.output profile = get_profile(profile="TestDiscovered", config_path=PROFILE_CONFIG) assert profile.auth_type == "OidcBrowserAuth" @@ -136,16 +148,13 @@ def test_cli_configure_from_dataall_url(runner, mocked_get_jwt_token, mocker): def test_cli_configure_prompts_for_front_page(runner, mocked_get_jwt_token, mocker): mocker.patch( "dataall_cli.cli.discover_from_frontend", - return_value={ - "idp_domain_url": "https://idp/oauth2/aus1", - "client_id": "0oaCLIENT", - "api_endpoint_url": "https://api/prod", - }, + return_value=OIDC_DISCOVERY, ) result = runner.invoke( - configure, input="OidcBrowserAuth\nhttps://dataall.example.com\nTestPrompted\n" + configure, input="https://dataall.example.com\nTestPrompted\n" ) assert result.exit_code == 0 + assert "Select authentication type" not in result.output assert "Enter data.all app client id" not in result.output assert "Discovered client_id: 0oaCLIENT" in result.output @@ -166,10 +175,11 @@ def test_cli_configure_dataall_url_unreachable_prompts( "--profile", "TestUnreachable", ], - input="\ntestclient\ntestAPIURL\nhttps://idp/oauth2/aus1\n", + input="OidcBrowserAuth\ntestclient\ntestAPIURL\nhttps://idp/oauth2/aus1\n", ) assert result.exit_code == 0 assert "Could not read settings" in result.output + assert "Select authentication type" in result.output profile = get_profile(profile="TestUnreachable", config_path=PROFILE_CONFIG) assert profile.client_id == "testclient" assert profile.frontend_url == "https://dataall.example.com" @@ -178,7 +188,7 @@ def test_cli_configure_dataall_url_unreachable_prompts( def test_cli_configure_cognito_has_no_oidc_fields(runner, mocked_get_jwt_token): result = runner.invoke( configure, - input="CognitoAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\n\nTestCognitoPlain\n", + input="\nCognitoAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\n\nTestCognitoPlain\n", ) assert result.exit_code == 0 with open(PROFILE_CONFIG) as file: @@ -198,7 +208,7 @@ def test_cli_configure_frontend_url_flag_any_auth_type(runner, mocked_get_jwt_to "--profile", "TestCognitoOrigin", ], - input="CognitoAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\n\n", + input="\nCognitoAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\n\n", ) assert result.exit_code == 0 profile = get_profile(profile="TestCognitoOrigin", config_path=PROFILE_CONFIG) @@ -208,7 +218,7 @@ def test_cli_configure_frontend_url_flag_any_auth_type(runner, mocked_get_jwt_to def test_cli_commands(runner, mock_execute, mocked_get_jwt_token, N=10): result = runner.invoke( configure, - input="CustomAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\nauth_server\nTestCustomProfile\nTokenendpoint\nTestUser\nPassword1!\nPassword1!\n", + input="\nCustomAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\nauth_server\nTestCustomProfile\nTokenendpoint\nTestUser\nPassword1!\nPassword1!\n", ) for operation_name, op_details in dict( itertools.islice(commands.items(), N) @@ -224,7 +234,7 @@ def test_cli_commands(runner, mock_execute, mocked_get_jwt_token, N=10): def test_cli_commands_custom_header(runner, mock_execute, mocked_get_jwt_token, N=1): result = runner.invoke( configure, - input="CustomAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\nauth_server\nTestCustomProfile\nTokenendpoint\nTestUser\nPassword1!\nPassword1!\n", + input="\nCustomAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\nauth_server\nTestCustomProfile\nTokenendpoint\nTestUser\nPassword1!\nPassword1!\n", ) # Set env variable to use custom header @@ -251,7 +261,7 @@ def test_cli_commands_custom_header_invalid( ): result = runner.invoke( configure, - input="CustomAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\nauth_server\nTestCustomProfile\nTokenendpoint\nTestUser\nPassword1!\nPassword1!\n", + input="\nCustomAuth\ntestclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\nauth_server\nTestCustomProfile\nTokenendpoint\nTestUser\nPassword1!\nPassword1!\n", ) # Set env variable to use custom header @@ -275,8 +285,11 @@ def test_cli_commands_custom_header_invalid( def test_cli_commands_default_profile_dne(runner): op_name = list(commands.keys())[0] - result = runner.invoke(dataall_cli, [op_name]) + result = runner.invoke(dataall_cli, [op_name, "--profile", "okta_prod"]) assert result.exit_code == 1 + assert "Profile 'okta_prod' is not configured" in result.output + assert "configure --profile okta_prod" in result.output + assert "Traceback" not in result.output def test_cli_configure_help_does_not_prompt(runner): @@ -284,6 +297,7 @@ def test_cli_configure_help_does_not_prompt(runner): assert result.exit_code == 0 assert "Usage:" in result.output assert "Select authentication type" not in result.output + assert "front page URL" not in result.output.split("Options:")[0] def test_cli_configure_dataall_url_after_other_options( @@ -291,11 +305,7 @@ def test_cli_configure_dataall_url_after_other_options( ): mocker.patch( "dataall_cli.cli.discover_from_frontend", - return_value={ - "idp_domain_url": "https://idp/oauth2/aus1", - "client_id": "0oaCLIENT", - "api_endpoint_url": "https://api/prod", - }, + return_value=OIDC_DISCOVERY, ) result = runner.invoke( configure, @@ -312,3 +322,51 @@ def test_cli_configure_dataall_url_after_other_options( profile = get_profile(profile="TestDiscoveredLate", config_path=PROFILE_CONFIG) assert profile.client_id == "0oaCLIENT" assert profile.idp_domain_url == "https://idp/oauth2/aus1" + + +def test_cli_configure_detects_cognito(runner, mocked_get_jwt_token, mocker): + mocker.patch( + "dataall_cli.cli.discover_from_frontend", return_value=COGNITO_DISCOVERY + ) + result = runner.invoke( + configure, input="https://dataall.example.com\n\n\nTestCognitoDiscovered\n" + ) + assert result.exit_code == 0 + assert "Discovered auth_type: CognitoAuth" in result.output + assert "Select authentication type" not in result.output + + profile = get_profile(profile="TestCognitoDiscovered", config_path=PROFILE_CONFIG) + assert profile.auth_type == "CognitoAuth" + assert profile.client_id == "cognitoclient" + assert ( + profile.idp_domain_url == "https://dataall-dev.auth.us-east-1.amazoncognito.com" + ) + assert profile.redirect_uri == "https://dataall.example.com" + assert profile.api_endpoint_url == "https://api/prod" + assert profile.frontend_url == "https://dataall.example.com" + assert profile.client_secret == "" + + +def test_cli_configure_auth_type_mismatch_keeps_only_page_url( + runner, mocked_get_jwt_token, mocker +): + mocker.patch("dataall_cli.cli.discover_from_frontend", return_value=OIDC_DISCOVERY) + result = runner.invoke( + configure, + [ + "--auth_type", + "CognitoAuth", + "--dataall_url", + "https://dataall.example.com", + "--profile", + "TestMismatch", + ], + input="testclient\ntestAPIURL\ntestdataallURL\ntestIdPURL\n\n\n", + ) + assert result.exit_code == 0 + assert "uses OidcBrowserAuth but --auth_type CognitoAuth" in result.output + + profile = get_profile(profile="TestMismatch", config_path=PROFILE_CONFIG) + assert profile.auth_type == "CognitoAuth" + assert profile.client_id == "testclient" + assert profile.frontend_url == "https://dataall.example.com" diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py index 9cb221b..9021aad 100644 --- a/tests/unit/test_discovery.py +++ b/tests/unit/test_discovery.py @@ -1,7 +1,7 @@ import httpx import pytest -from dataall_cli.utils import discover_from_frontend +from dataall_cli.utils import discover_from_frontend, frontend_origin FRONT = "https://dataall.example.com" INDEX = ( @@ -9,13 +9,21 @@ '' '
' ) -BUNDLE = ( +OIDC_BUNDLE = ( 'REACT_APP_GRAPHQL_API:"https://api.example.com/prod/graphql/api",' 'REACT_APP_CUSTOM_AUTH:"okta",' 'REACT_APP_CUSTOM_AUTH_URL:"https://idp.example.com/oauth2/aus1",' 'REACT_APP_CUSTOM_AUTH_CLIENT_ID:"0oaCLIENT",' 'REACT_APP_CUSTOM_AUTH_SCOPES:"openid"' ) +COGNITO_BUNDLE = ( + 'REACT_APP_GRAPHQL_API:"https://api.example.com/prod/graphql/api",' + 'REACT_APP_COGNITO_USER_POOL_ID:"us-east-1_abc",' + 'REACT_APP_COGNITO_APP_CLIENT_ID:"cognitoclient",' + 'REACT_APP_COGNITO_DOMAIN:"dataall-dev.auth.us-east-1.amazoncognito.com",' + 'REACT_APP_COGNITO_REDIRECT_SIGNIN:"https://dataall.example.com",' + 'REACT_APP_COGNITO_REDIRECT_SIGNOUT:"https://dataall.example.com"' +) def serve(mocker, pages): @@ -26,41 +34,60 @@ def fake_get(url, **kwargs): mocker.patch("dataall_cli.utils.discovery.httpx.get", side_effect=fake_get) -def test_discover_all_values(mocker): - serve( - mocker, - { - f"{FRONT}/": (200, INDEX), - f"{FRONT}/static/js/main.aaa1de7c.js": (200, BUNDLE), - }, - ) +def pages(bundle): + return { + f"{FRONT}/": (200, INDEX), + f"{FRONT}/static/js/main.aaa1de7c.js": (200, bundle), + } + + +def test_discover_oidc_deployment(mocker): + serve(mocker, pages(OIDC_BUNDLE)) assert discover_from_frontend(FRONT) == { + "frontend_url": FRONT, + "auth_type": "OidcBrowserAuth", "idp_domain_url": "https://idp.example.com/oauth2/aus1", "client_id": "0oaCLIENT", "api_endpoint_url": "https://api.example.com/prod", } +def test_discover_cognito_deployment(mocker): + serve(mocker, pages(COGNITO_BUNDLE)) + assert discover_from_frontend(f"{FRONT}/") == { + "frontend_url": FRONT, + "auth_type": "CognitoAuth", + "client_id": "cognitoclient", + "idp_domain_url": "https://dataall-dev.auth.us-east-1.amazoncognito.com", + "redirect_uri": "https://dataall.example.com", + "api_endpoint_url": "https://api.example.com/prod", + } + + def test_discover_partial_bundle(mocker): serve( mocker, - { - f"{FRONT}/": (200, INDEX), - f"{FRONT}/static/js/main.aaa1de7c.js": ( - 200, - 'REACT_APP_CUSTOM_AUTH_CLIENT_ID:"0oaCLIENT"', - ), - }, + pages( + 'REACT_APP_CUSTOM_AUTH:"okta",REACT_APP_CUSTOM_AUTH_URL:"https://idp.example.com/oauth2/aus1"' + ), ) - assert discover_from_frontend(f"{FRONT}/") == {"client_id": "0oaCLIENT"} + assert discover_from_frontend(FRONT) == { + "frontend_url": FRONT, + "auth_type": "OidcBrowserAuth", + "idp_domain_url": "https://idp.example.com/oauth2/aus1", + } def test_discover_without_bundle(mocker): serve(mocker, {f"{FRONT}/": (200, "maintenance")}) - assert discover_from_frontend(FRONT) == {} + assert discover_from_frontend(FRONT) == {"frontend_url": FRONT} def test_discover_http_error(mocker): serve(mocker, {}) with pytest.raises(httpx.HTTPStatusError): discover_from_frontend(FRONT) + + +def test_frontend_origin(): + assert frontend_origin("https://dataall.example.com/console/") == FRONT diff --git a/tutorials/002 - Getting Started.md b/tutorials/002 - Getting Started.md index 0b7d8c6..fd1afc2 100644 --- a/tutorials/002 - Getting Started.md +++ b/tutorials/002 - Getting Started.md @@ -69,7 +69,7 @@ The quickest way to configure it is to point the CLI at the data.all front page; dataall_cli configure --profile okta --auth_type OidcBrowserAuth --dataall_url https://DATAALL_DOMAIN_URL ``` -Without flags, `dataall_cli configure` asks for the authentication type and then for the front page URL; leave it empty to type the issuer, client id and API endpoint yourself. +Without flags, `dataall_cli configure` first asks for the front page URL. From the deployed app it detects whether the deployment uses Cognito or an OIDC provider and fills in the matching values, so only the profile name (and, for Cognito, the optional client secret and auth server) is asked. Leave the URL empty to choose the type and enter the values yourself. The resulting profile in `~/.dataall/config.yaml`: From a236800bdb11c9c6f74634a71f5e0e64af93930a Mon Sep 17 00:00:00 2001 From: Tejas Rajopadhye <71188245+TejasRGitHub@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:30:49 -0500 Subject: [PATCH 4/6] configure: run front page discovery once per prompt click processes a prompted value twice (value_proc and handle_parse_result), which fetched the bundle and printed the results twice. Co-Authored-By: Claude Fable 5.1 --- dataall_cli/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dataall_cli/cli.py b/dataall_cli/cli.py index 61394d8..8a76cd6 100644 --- a/dataall_cli/cli.py +++ b/dataall_cli/cli.py @@ -56,6 +56,7 @@ def dataall_cli() -> None: DEFAULT_FALLBACK_REDIRECT_URI = "http://localhost:8766/callback" DEFAULT_SCOPES = "openid offline_access" DISCOVERED = "dataall_discovered" +DISCOVERED_FOR = "dataall_discovered_for" DOMAIN_PROMPT = "Enter data.all's domain URL (e.g. https://.com)" IDP_PROMPT = "Enter data.all Identity Provider Domain (e.g. https://.com)" @@ -109,8 +110,9 @@ def prompt_for_value(self, ctx: click.Context) -> Any: def _discover( ctx: click.Context, _param: click.Parameter, value: Optional[str] ) -> Optional[str]: - if not value: + if not value or ctx.meta.get(DISCOVERED_FOR) == value: return value + ctx.meta[DISCOVERED_FOR] = value try: found = discover_from_frontend(value) except Exception as e: From f7599168e692d6ea74517eebe69a193726fc6677 Mon Sep 17 00:00:00 2001 From: Tejas Rajopadhye <71188245+TejasRGitHub@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:31:40 -0500 Subject: [PATCH 5/6] tests: assert front page discovery runs once when prompted Co-Authored-By: Claude Fable 5.1 --- tests/functional/test_datall_cli_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/functional/test_datall_cli_client.py b/tests/functional/test_datall_cli_client.py index 1193073..a5dc234 100644 --- a/tests/functional/test_datall_cli_client.py +++ b/tests/functional/test_datall_cli_client.py @@ -146,7 +146,7 @@ def test_cli_configure_from_dataall_url(runner, mocked_get_jwt_token, mocker): def test_cli_configure_prompts_for_front_page(runner, mocked_get_jwt_token, mocker): - mocker.patch( + discover = mocker.patch( "dataall_cli.cli.discover_from_frontend", return_value=OIDC_DISCOVERY, ) @@ -154,6 +154,8 @@ def test_cli_configure_prompts_for_front_page(runner, mocked_get_jwt_token, mock configure, input="https://dataall.example.com\nTestPrompted\n" ) assert result.exit_code == 0 + discover.assert_called_once() + assert result.output.count("Discovered client_id") == 1 assert "Select authentication type" not in result.output assert "Enter data.all app client id" not in result.output assert "Discovered client_id: 0oaCLIENT" in result.output From fea868cd07de4d5a1537d33823c4bb5260f07682 Mon Sep 17 00:00:00 2001 From: Tejas Rajopadhye <71188245+TejasRGitHub@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:59:44 -0500 Subject: [PATCH 6/6] Require dataall-core 0.5.0 Co-Authored-By: Claude Fable 5.1 --- poetry.lock | 42 +++++++++++++++++++++--------------------- pyproject.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/poetry.lock b/poetry.lock index 201c1a2..50597e7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -668,7 +668,7 @@ jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" urllib3 = [ {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, - {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, + {version = ">=1.25.4,!=2.2.0,<3", markers = "python_version >= \"3.10\""}, ] [package.extras] @@ -711,7 +711,7 @@ pyproject_hooks = "*" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} [package.extras] -docs = ["furo (>=2021.08.31)", "sphinx (>=4.0,<5.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)"] +docs = ["furo (>=2021.8.31)", "sphinx (>=4.0,<5.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)"] test = ["filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "setuptools (>=42.0.0) ; python_version < \"3.10\"", "setuptools (>=56.0.0) ; python_version >= \"3.10\"", "toml (>=0.10.0)", "wheel (>=0.36.0)"] typing = ["importlib-metadata (>=5.1)", "mypy (==0.991)", "tomli", "typing-extensions (>=3.7.4.3)"] virtualenv = ["virtualenv (>=20.0.35)"] @@ -1408,14 +1408,14 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "dataall-core" -version = "0.4.3" +version = "0.5.0" description = "AWS data.all core" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "dataall_core-0.4.3-py3-none-any.whl", hash = "sha256:465c0d1c9371584d0df5ab32bf35ccc34191c28195a8f03d483becef9a583eb5"}, - {file = "dataall_core-0.4.3.tar.gz", hash = "sha256:b415d79a7cdcc1eabece70bb47c4f59d3ae4a32ed13ab671d50e5225759d2535"}, + {file = "dataall_core-0.5.0-py3-none-any.whl", hash = "sha256:5edf4c7d2a0f0d8f176e232a8cb7c4020aa1a736456d33a30858f3a20335c40e"}, + {file = "dataall_core-0.5.0.tar.gz", hash = "sha256:2b9ee04bb2c84547e1d87aa83947e259131ac558013751a2aad0473facfa3736"}, ] [package.dependencies] @@ -1938,7 +1938,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -1975,7 +1975,7 @@ files = [ [package.dependencies] importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.10\""} -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" python-dateutil = ">=2.8.2" pyzmq = ">=23.0" tornado = ">=6.2" @@ -2274,13 +2274,13 @@ files = [ [package.dependencies] boto3 = ">=1.9.201" -botocore = ">=1.20.88,<1.35.45 || >1.35.45,<1.35.46 || >1.35.46" +botocore = ">=1.20.88,!=1.35.45,!=1.35.46" cryptography = ">=35.0.0" Jinja2 = ">=2.10.1" python-dateutil = ">=2.1,<3.0.0" requests = ">=2.5" -responses = ">=0.15.0,<0.25.5 || >0.25.5" -werkzeug = ">=0.5,<2.2.0 || >2.2.0,<2.2.1 || >2.2.1" +responses = ">=0.15.0,!=0.25.5" +werkzeug = ">=0.5,!=2.2.0,!=2.2.1" xmltodict = "*" [package.extras] @@ -2882,7 +2882,7 @@ files = [ [package.dependencies] jupyter-client = ">=6.1.12" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" nbformat = ">=5.1" traitlets = ">=5.4" @@ -2944,7 +2944,7 @@ files = [ [package.dependencies] fastjsonschema = ">=2.15" jsonschema = ">=2.6" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" traitlets = ">=5.1" [package.extras] @@ -2966,7 +2966,7 @@ files = [ [package.dependencies] docutils = ">=0.18.1" jinja2 = "*" -nbconvert = ">=5.3,<5.4 || >5.4" +nbconvert = ">=5.3,!=5.4" nbformat = "*" sphinx = ">=1.8,<8.2" traitlets = ">=5" @@ -3252,10 +3252,10 @@ astroid = ">=3.3.8,<=3.4.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, {version = ">=0.3.6", markers = "python_version == \"3.11\""}, + {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, ] -isort = ">=4.2.5,<5.13 || >5.13,<7" +isort = ">=4.2.5,!=5.13,<7" mccabe = ">=0.6,<0.8" platformdirs = ">=2.2" tomli = {version = ">=1.1", markers = "python_version < \"3.11\""} @@ -3412,7 +3412,7 @@ files = [ [package.dependencies] packaging = ">=17.1" -pytest = ">=7.4,<8.2.2 || >8.2.2" +pytest = ">=7.4,!=8.2.2" [[package]] name = "pytest-rerunfailures" @@ -3429,7 +3429,7 @@ files = [ [package.dependencies] packaging = ">=17.1" -pytest = ">=7.4,<8.2.2 || >8.2.2" +pytest = ">=7.4,!=8.2.2" [[package]] name = "pytest-timeout" @@ -4049,10 +4049,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "setuptools" @@ -4991,4 +4991,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "b0988cf8e39311de3299bf2f124cc8e7ac5e1092ea98393880c1d99a12ea20d4" +content-hash = "02be3fe6cdd26745c036208f8973c9822282fb32d4a4c82c0c223ddecb8909d0" diff --git a/pyproject.toml b/pyproject.toml index 5f7e8f8..625817f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "atomicfile>=1.0.1", "PyYAML>=6.0.1", "setuptools; python_version >= '3.12'", - "dataall-core>=0.4.3", + "dataall-core>=0.5.0,<0.6.0", ] [project.scripts]