Skip to content

Commit

Permalink
#191/#192: add device_code+PKCE and default_client support to `openeo…
Browse files Browse the repository at this point in the history
…-auth add-oidc` and `oidc-auth` cli tools

related: EP-3700/EP-3759
  • Loading branch information
soxofaan committed Mar 25, 2021
1 parent a806c8c commit 26c23de
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 32 deletions.
68 changes: 45 additions & 23 deletions openeo/rest/auth/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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(
Expand All @@ -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."
)
Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand All @@ -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)))

Expand All @@ -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:
Expand All @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions openeo/rest/auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion openeo/rest/auth/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.
Expand Down
6 changes: 1 addition & 5 deletions openeo/rest/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
119 changes: 119 additions & 0 deletions tests/rest/auth/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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"):
Expand All @@ -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])
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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"})
Expand Down

0 comments on commit 26c23de

Please sign in to comment.