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 5729235..8a76cd6 100644 --- a/dataall_cli/cli.py +++ b/dataall_cli/cli.py @@ -4,13 +4,14 @@ import logging import os from pathlib import Path +from typing import Any, Dict, List, Optional 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, frontend_origin, save_config DA_CONFIG_PATH = os.getenv("dataall_config_path", CONFIG_PATH) CREDS_PATH = os.getenv("dataall_creds_path", None) @@ -50,51 +51,181 @@ 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" +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)" +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)" +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``; + without ``scoped`` the option prompts normally. Values discovered from + ``--dataall_url`` are used without prompting. + """ + + def __init__( + self, + *args: Any, + scoped: Optional[Dict[str, Dict[str, Any]]] = None, + **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] + 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 + 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 or ctx.meta.get(DISCOVERED_FOR) == value: + return value + ctx.meta[DISCOVERED_FOR] = value + try: + found = discover_from_frontend(value) + except Exception as e: + click.echo(f"Could not read settings from {value}: {e}", err=True) + 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 + 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( + "--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", - type=click.Choice(["CognitoAuth", "CustomAuth"]), + cls=AuthScopedOption, + 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( "--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 +233,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 +265,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..12324e7 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, frontend_origin -__all__ = ["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 new file mode 100644 index 0000000..b623a52 --- /dev/null +++ b/dataall_cli/utils/discovery.py @@ -0,0 +1,54 @@ +"""Discover CLI settings from a deployed data.all front page.""" + +import re +from typing import Dict +from urllib.parse import urljoin, urlparse + +import httpx + +BUNDLE_PATTERN = re.compile(r'src="([^"]*static/js/main\.[^"]+\.js)"') +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 auth type, identity provider, client id and API endpoint from the front page bundle. + + 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 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)) + + 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/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] diff --git a/tests/functional/test_datall_cli_client.py b/tests/functional/test_datall_cli_client.py index f840e77..a5dc234 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,11 +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", + "\nOidcBrowserAuth\ntestclient\ntestAPIURL\ntestIdPURL\n\nTestOidcProfile\n", ), ] ), @@ -85,10 +105,122 @@ 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="\nOidcBrowserAuth\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=OIDC_DISCOVERY, + ) + result = runner.invoke( + configure, + ["--dataall_url", "https://dataall.example.com", "--profile", "TestDiscovered"], + ) + 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" + 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_prompts_for_front_page(runner, mocked_get_jwt_token, mocker): + discover = mocker.patch( + "dataall_cli.cli.discover_from_frontend", + return_value=OIDC_DISCOVERY, + ) + result = runner.invoke( + 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 + + 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 +): + 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="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" + + +def test_cli_configure_cognito_has_no_oidc_fields(runner, mocked_get_jwt_token): + result = runner.invoke( + configure, + input="\nCognitoAuth\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="\nCognitoAuth\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, - 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) @@ -104,7 +236,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 @@ -131,7 +263,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 @@ -155,5 +287,88 @@ 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): + result = runner.invoke(configure, ["--help"]) + 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( + runner, mocked_get_jwt_token, mocker +): + mocker.patch( + "dataall_cli.cli.discover_from_frontend", + return_value=OIDC_DISCOVERY, + ) + 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" + + +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_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..9021aad --- /dev/null +++ b/tests/unit/test_discovery.py @@ -0,0 +1,93 @@ +import httpx +import pytest + +from dataall_cli.utils import discover_from_frontend, frontend_origin + +FRONT = "https://dataall.example.com" +INDEX = ( + "data.all" + '' + '
' +) +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): + 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 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, + pages( + 'REACT_APP_CUSTOM_AUTH:"okta",REACT_APP_CUSTOM_AUTH_URL:"https://idp.example.com/oauth2/aus1"' + ), + ) + 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) == {"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 03acbfd..fd1afc2 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,41 @@ 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 +``` + +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`: + +``` +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: