diff --git a/openeo/rest/auth/cli.py b/openeo/rest/auth/cli.py index 66761a765..ddd0eede4 100644 --- a/openeo/rest/auth/cli.py +++ b/openeo/rest/auth/cli.py @@ -10,6 +10,7 @@ from openeo import connect, Connection from openeo.rest.auth.config import AuthConfig, RefreshTokenStore +from openeo.rest.auth.oidc import OidcProviderInfo _log = logging.getLogger(__name__) @@ -75,6 +76,14 @@ def main(argv=None): add_oidc_parser.add_argument("backend", help="OpenEO Backend URL.") add_oidc_parser.add_argument("--provider-id", help="Provider ID to use.") add_oidc_parser.add_argument("--client-id", help="Client ID to use.") + add_oidc_parser.add_argument( + "--no-client-secret", dest="ask_client_secret", default=True, action="store_false", + help="Don't ask for secret (because client does not need one)." + ) + add_oidc_parser.add_argument( + "--use-default-client", action="store_true", + help="Use default client (as provided by backend)." + ) # Command: oidc-auth oidc_auth_parser = root_subparsers.add_parser( @@ -84,6 +93,7 @@ def main(argv=None): oidc_auth_parser.add_argument("backend", help="OpenEO Backend URL.") oidc_auth_parser.add_argument("--provider-id", help="Provider ID to use.") oidc_auth_parser.add_argument( + # TODO: use device flow by default? drop interactive choice? "--flow", choices=_OIDC_FLOW_CHOICES, default=None, help="OpenID Connect flow to use." ) @@ -208,6 +218,8 @@ def main_add_oidc(args): backend = args.backend provider_id = args.provider_id client_id = args.client_id + ask_client_secret = args.ask_client_secret + use_default_client = args.use_default_client config = AuthConfig() print("Will add OpenID Connect auth config for backend URL {b!r}".format(b=backend)) @@ -219,7 +231,8 @@ def main_add_oidc(args): raise CliToolException("Backend API version is too low: {v} < 1.0.0".format(v=api_version)) # Find provider ID oidc_info = con.get("/credentials/oidc", expected_status=200).json() - providers = OrderedDict([(p["id"], p) for p in oidc_info["providers"]]) + providers = OrderedDict((p["id"], OidcProviderInfo.from_dict(p)) for p in oidc_info["providers"]) + if not providers: raise CliToolException("No OpenID Connect providers listed by backend {b!r}.".format(b=backend)) if not provider_id: @@ -228,28 +241,39 @@ def main_add_oidc(args): else: provider_id = _interactive_choice( title="Backend {b!r} has multiple OpenID Connect providers.".format(b=backend), - options=[(p["id"], "{t} (issuer {s})".format(t=p["title"], s=p["issuer"])) for p in providers.values()] + options=[(p.id, "{t} (issuer {s})".format(t=p.title, s=p.issuer)) for p in providers.values()] ) if provider_id not in providers: raise CliToolException("Invalid provider ID {p!r}. Should be one of {o}.".format( p=provider_id, o=list(providers.keys()) )) - issuer = providers[provider_id]["issuer"] - print("Using provider ID {p!r} (issuer {i!r})".format(p=provider_id, i=issuer)) - - # Get client_id and client_secret - # Find username and password - if not client_id: - client_id = builtins.input("Enter client_id and press enter: ") - print("Using client ID {u!r}".format(u=client_id)) - if not client_id: - show_warning("Given client ID was empty.") - client_secret = getpass("Enter client_secret and press enter: ") - if not client_secret: - show_warning("Given client secret was empty.") + provider = providers[provider_id] + print("Using provider ID {p!r} (issuer {i!r})".format(p=provider_id, i=provider.issuer)) + + # Get client_id and client_secret (if necessary) + if use_default_client: + if not provider.default_client: + show_warning("No default client specified for provider {p!r}".format(p=provider_id)) + client_id, client_secret = None, None + else: + if not client_id: + if provider.default_client: + client_prompt = "Enter client_id or leave empty to use default client, and press enter: " + else: + client_prompt = "Enter client_id and press enter: " + client_id = builtins.input(client_prompt).strip() or None + print("Using client ID {u!r}".format(u=client_id)) + if not client_id and not provider.default_client: + show_warning("Given client ID was empty.") + + if client_id and ask_client_secret: + client_secret = getpass("Enter client_secret or leave empty to not use a secret, and press enter: ") or None + else: + client_secret = None config.set_oidc_client_config( - backend=backend, provider_id=provider_id, client_id=client_id, client_secret=client_secret, issuer=issuer + backend=backend, provider_id=provider_id, client_id=client_id, client_secret=client_secret, + issuer=provider.issuer ) print("Saved client information to {p!r}".format(p=str(config.path))) @@ -274,6 +298,7 @@ def main_oidc_auth(args): # Determine provider provider_configs = config.get_oidc_provider_configs(backend=backend) if not provider_configs: + # TODO: automatically do add config flow here? raise CliToolException("No OpenID Connect provider configs found for backend {b!r}".format(b=backend)) _log.debug("Provider configs: {c!r}".format(c=provider_configs)) if not provider_id: @@ -295,13 +320,10 @@ def main_oidc_auth(args): # Get client id and secret client_id, client_secret = config.get_oidc_client_configs(backend=backend, provider_id=provider_id) - if not client_id: - raise CliToolException("Client ID for provide {p} is empty (config {c!r})".format( - p=provider_id, c=str(config.path) - )) - print("Using client ID {c!r}.".format(c=client_id)) - if not client_secret: - show_warning("Empty client secret.") + if client_id: + print("Using client ID {c!r}.".format(c=client_id)) + else: + print("Will try to use default client.") if oidc_flow is None: oidc_flow = _interactive_choice( diff --git a/openeo/rest/auth/config.py b/openeo/rest/auth/config.py index ec3f15afe..11ca486cb 100644 --- a/openeo/rest/auth/config.py +++ b/openeo/rest/auth/config.py @@ -153,15 +153,15 @@ def get_oidc_client_configs(self, backend: str, provider_id: str) -> Tuple[str, return client_id, client_secret def set_oidc_client_config( - self, backend: str, provider_id: str, client_id: str, client_secret: str = None, issuer: str = None + self, backend: str, provider_id: str, + client_id: Union[str, None], client_secret: Union[str, None] = None, issuer: Union[str, None] = None ): data = self.load() keys = ("backends", _normalize_url(backend), "oidc", "providers", provider_id) # TODO: support multiple clients? (pick latest by default for example) deep_set(data, *keys, "date", value=utcnow_rfc3339()) deep_set(data, *keys, "client_id", value=client_id) - if client_secret: - deep_set(data, *keys, "client_secret", value=client_secret) + deep_set(data, *keys, "client_secret", value=client_secret) if issuer: deep_set(data, *keys, "issuer", value=issuer) self._write(data) diff --git a/openeo/rest/auth/oidc.py b/openeo/rest/auth/oidc.py index b75c33392..d2cf96f83 100644 --- a/openeo/rest/auth/oidc.py +++ b/openeo/rest/auth/oidc.py @@ -223,8 +223,12 @@ class OidcProviderInfo: def __init__( self, issuer: str = None, discovery_url: str = None, scopes: List[str] = None, - default_client: Union[dict, None] = None + provider_id: str = None, title: str = None, + default_client: Union[dict, None] = None, ): + # TODO: id and title are required in the openEO API spec. + self.id = provider_id + self.title = title if issuer is None and discovery_url is None: raise ValueError("At least `issuer` or `discovery_url` should be specified") self.discovery_url = discovery_url or (issuer.rstrip("/") + "/.well-known/openid-configuration") @@ -237,6 +241,15 @@ def __init__( self._scopes = {"openid"}.union(scopes or []).intersection(self._supported_scopes) self.default_client = default_client + @classmethod + def from_dict(cls, data: dict) -> "OidcProviderInfo": + return cls( + provider_id=data["id"], title=data["title"], + issuer=data["issuer"], + scopes=data.get("scopes"), + default_client=data.get("default_client"), + ) + def get_scopes_string(self, request_refresh_token: bool = False): """ Build "scope" string for authentication request. diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index d0d547fb6..5394b266b 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -331,11 +331,7 @@ def _get_oidc_provider(self, provider_id: Union[str, None] = None) -> Tuple[str, raise OpenEoClientException("No provider_id given. Available: {p!r}.".format( p=list(providers.keys())) ) - provider = OidcProviderInfo( - issuer=provider["issuer"], scopes=provider.get("scopes"), - # TODO: This "default_client" feature is still experimental in openEO API. See Open-EO/openeo-api#366 - default_client=provider.get("default_client") - ) + provider = OidcProviderInfo.from_dict(provider) else: # Per spec: '/credentials/oidc' will redirect to OpenID Connect discovery document provider = OidcProviderInfo(discovery_url=self.build_url('/credentials/oidc')) diff --git a/tests/rest/auth/test_cli.py b/tests/rest/auth/test_cli.py index 5cad83767..4fc4da9b4 100644 --- a/tests/rest/auth/test_cli.py +++ b/tests/rest/auth/test_cli.py @@ -81,6 +81,7 @@ def test_add_oidc_simple(auth_config, requests_mock): requests_mock.get("https://oeo.test/credentials/oidc", json={ "providers": [{"id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"]}] }) + requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"}) client_id, client_secret = "z3-cl13nt", "z3-z3cr3t-y6y6" with mock_secret_input(client_secret): cli.main(["add-oidc", "https://oeo.test", "--client-id", client_id]) @@ -89,6 +90,75 @@ def test_add_oidc_simple(auth_config, requests_mock): assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (client_id, client_secret) +def test_add_oidc_no_secret(auth_config, requests_mock): + requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"}) + requests_mock.get("https://oeo.test/credentials/oidc", json={ + "providers": [{"id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"]}] + }) + requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"}) + client_id = "z3-cl13nt" + cli.main(["add-oidc", "https://oeo.test", "--client-id", client_id, "--no-client-secret"]) + + assert "authit" in auth_config.get_oidc_provider_configs("https://oeo.test") + assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (client_id, None) + + +def test_add_oidc_use_default_client(auth_config, requests_mock): + requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"}) + requests_mock.get("https://oeo.test/credentials/oidc", json={ + "providers": [{ + "id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"], + "default_client": {"id": "d3f6ul7cl13n7"} + }] + }) + requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"}) + cli.main(["add-oidc", "https://oeo.test", "--use-default-client"]) + + assert "authit" in auth_config.get_oidc_provider_configs("https://oeo.test") + assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (None, None) + + +def test_add_oidc_default_client_interactive(auth_config, requests_mock, capsys): + requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"}) + requests_mock.get("https://oeo.test/credentials/oidc", json={ + "providers": [{ + "id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"], + "default_client": {"id": "d3f6ul7cl13n7"} + }] + }) + requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"}) + with mock_input("") as input: + cli.main(["add-oidc", "https://oeo.test"]) + + assert "authit" in auth_config.get_oidc_provider_configs("https://oeo.test") + assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (None, None) + + input.assert_called_with("Enter client_id or leave empty to use default client, and press enter: ") + stdout = capsys.readouterr().out + assert "Using client ID None" in stdout + + +def test_add_oidc_use_default_client_overwrite(auth_config, requests_mock): + requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"}) + requests_mock.get("https://oeo.test/credentials/oidc", json={ + "providers": [{ + "id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"], + "default_client": {"id": "d3f6ul7cl13n7"} + }] + }) + requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"}) + + client_id, client_secret = "z3-cl13nt", "z3-z3cr3t-y6y6" + with mock_secret_input(client_secret): + cli.main(["add-oidc", "https://oeo.test", "--client-id", client_id]) + assert "authit" in auth_config.get_oidc_provider_configs("https://oeo.test") + assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (client_id, client_secret) + + cli.main(["add-oidc", "https://oeo.test", "--use-default-client"]) + assert "authit" in auth_config.get_oidc_provider_configs("https://oeo.test") + assert auth_config.get_oidc_client_configs("https://oeo.test", "authit") == (None, None) + + def test_add_oidc_04(auth_config, requests_mock): requests_mock.get("https://oeo.test/", json={"api_version": "0.4.0"}) with pytest.raises(CliToolException, match="Backend API version is too low"): @@ -101,6 +171,8 @@ def test_add_oidc_multiple_providers(auth_config, requests_mock, capsys): {"id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"]}, {"id": "youauth", "issuer": "https://youauth.test", "title": "YouAuth", "scopes": ["openid"]} ]}) + requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"}) + requests_mock.get("https://youauth.test/.well-known/openid-configuration", json={"issuer": "https://youauth.test"}) client_id, client_secret = "z3-cl13nt", "z3-z3cr3t-y6y6" with mock_secret_input(client_secret): cli.main(["add-oidc", "https://oeo.test", "--provider-id", "youauth", "--client-id", client_id]) @@ -128,6 +200,8 @@ def test_add_oidc_interactive(auth_config, requests_mock, capsys): {"id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"]}, {"id": "youauth", "issuer": "https://youauth.test", "title": "YouAuth", "scopes": ["openid"]} ]}) + requests_mock.get("https://authit.test/.well-known/openid-configuration", json={"issuer": "https://authit.test"}) + requests_mock.get("https://youauth.test/.well-known/openid-configuration", json={"issuer": "https://youauth.test"}) client_id, client_secret = "z3-cl13nt", "z3-z3cr3t-y6y6" with mock_input("1", client_id), mock_secret_input(client_secret): cli.main(["add-oidc", "https://oeo.test"]) @@ -185,6 +259,51 @@ def test_oidc_auth_device_flow(auth_config, refresh_token_store, requests_mock, assert e in out +@pytest.mark.slow +def test_oidc_auth_device_flow_default_client(auth_config, refresh_token_store, requests_mock, capsys): + """Test device flow with default client (which uses PKCE instead of secret).""" + default_client_id = "d3f6u17cl13n7" + requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"}) + requests_mock.get("https://oeo.test/credentials/oidc", json={"providers": [ + { + "id": "authit", "issuer": "https://authit.test", "title": "Auth It", "scopes": ["openid"], + "default_client": {"id": default_client_id} + }, + {"id": "youauth", "issuer": "https://youauth.test", "title": "YouAuth", "scopes": ["openid"]} + ]}) + + auth_config.set_oidc_client_config("https://oeo.test", "authit", client_id=None, client_secret=None) + + oidc_mock = OidcMock( + requests_mock=requests_mock, + expected_grant_type="urn:ietf:params:oauth:grant-type:device_code", + expected_client_id=default_client_id, + provider_root_url="https://authit.test", + oidc_discovery_url="https://authit.test/.well-known/openid-configuration", + expected_fields={"scope": "openid", "code_verifier": True, "code_challenge": True}, + state={"device_code_callback_timeline": ["great success"]}, + scopes_supported=["openid"] + ) + + cli.main(["oidc-auth", "https://oeo.test", "--flow", "device"]) + + stored_refresh_token = refresh_token_store.get_refresh_token("https://authit.test", default_client_id) + assert stored_refresh_token == oidc_mock.state["refresh_token"] + + out = capsys.readouterr().out + expected = [ + "Using provider ID 'authit'", + "Will try to use default client.", + "To authenticate: visit https://authit.test/dc", + "enter the user code {c!r}".format(c=oidc_mock.state["user_code"]), + "Authorized successfully.", + "The OpenID Connect device flow was successful.", + "Stored refresh token in {p!r}".format(p=str(refresh_token_store.path)), + ] + for e in expected: + assert e in out + + @pytest.mark.slow def test_oidc_auth_auth_code_flow(auth_config, refresh_token_store, requests_mock, capsys): requests_mock.get("https://oeo.test/", json={"api_version": "1.0.0"})