Skip to content

Commit e532513

Browse files
committed
Merge branch 'EP3493-oidc-cli-tool'
2 parents 1a6f6b4 + 1a24727 commit e532513

File tree

15 files changed

+1156
-267
lines changed

15 files changed

+1156
-267
lines changed

openeo/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.4.3a1'
1+
__version__ = '0.4.4a1'

openeo/rest/auth/cli.py

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import argparse
2+
import builtins
3+
import json
4+
import logging
5+
import sys
6+
from getpass import getpass
7+
from pathlib import Path
8+
from typing import List, Tuple
9+
10+
from openeo import connect, Connection
11+
from openeo.rest.auth.config import AuthConfig, RefreshTokenStore
12+
13+
_log = logging.getLogger(__name__)
14+
15+
16+
class CliToolException(RuntimeError):
17+
pass
18+
19+
20+
_OIDC_FLOW_CHOICES = [
21+
"auth-code",
22+
"device",
23+
# TODO: add client credentials flow?
24+
]
25+
26+
27+
def main(argv=None):
28+
root_parser = argparse.ArgumentParser(
29+
description="Tool to manage openEO related authentication and configuration."
30+
)
31+
root_parser.add_argument(
32+
"--verbose", "-v", action="count", default=0,
33+
help="Increase logging verbosity. Can be given multiple times."
34+
)
35+
root_subparsers = root_parser.add_subparsers(title="Subcommands", dest="subparser_name")
36+
37+
# Command: paths
38+
paths_parser = root_subparsers.add_parser(
39+
"paths", help="Show paths to config/token files."
40+
)
41+
paths_parser.set_defaults(func=main_paths)
42+
43+
# Command: config-dump
44+
config_dump_parser = root_subparsers.add_parser(
45+
"config-dump", help="Dump config file.", aliases=["config"]
46+
)
47+
config_dump_parser.set_defaults(func=main_config_dump)
48+
config_dump_parser.add_argument("--show-secrets", action="store_true", help="Don't redact secrets in the dump.")
49+
50+
# Command: token-dump
51+
token_dump_parser = root_subparsers.add_parser(
52+
"token-dump", help="Dump OpenID Connect refresh tokens file.", aliases=["tokens"]
53+
)
54+
token_dump_parser.set_defaults(func=main_token_dump)
55+
token_dump_parser.add_argument("--show-secrets", action="store_true", help="Don't redact secrets in the dump.")
56+
57+
# Command: add-basic
58+
add_basic_parser = root_subparsers.add_parser(
59+
"add-basic", help="Add or update config entry for basic auth."
60+
)
61+
add_basic_parser.set_defaults(func=main_add_basic)
62+
add_basic_parser.add_argument("backend", help="OpenEO Backend URL.")
63+
add_basic_parser.add_argument("--username", help="Basic auth username.")
64+
add_basic_parser.add_argument(
65+
"--no-try", dest="try_auth", action="store_false",
66+
help="Don't try out the credentials against the backend, just store them."
67+
)
68+
69+
# Command: add-oidc
70+
add_oidc_parser = root_subparsers.add_parser(
71+
"add-oidc", help="Add or update config entry for OpenID Connect."
72+
)
73+
add_oidc_parser.set_defaults(func=main_add_oidc)
74+
add_oidc_parser.add_argument("backend", help="OpenEO Backend URL.")
75+
add_oidc_parser.add_argument("--provider-id", help="Provider ID to use.")
76+
add_oidc_parser.add_argument("--client-id", help="Client ID to use.")
77+
78+
# Command: oidc-auth
79+
oidc_auth_parser = root_subparsers.add_parser(
80+
"oidc-auth", help="Do OpenID Connect authentication flow and store refresh tokens."
81+
)
82+
oidc_auth_parser.set_defaults(func=main_oidc_auth)
83+
oidc_auth_parser.add_argument("backend", help="OpenEO Backend URL.")
84+
oidc_auth_parser.add_argument("--provider-id", help="Provider ID to use.")
85+
oidc_auth_parser.add_argument(
86+
"--flow", choices=_OIDC_FLOW_CHOICES, default=None,
87+
help="OpenID Connect flow to use."
88+
)
89+
oidc_auth_parser.add_argument(
90+
"--timeout", type=int, default=60, help="Timeout in seconds to wait for (user) response."
91+
)
92+
93+
# Parse arguments and execute sub-command
94+
args = root_parser.parse_args(argv)
95+
logging.basicConfig(level={0: logging.WARN, 1: logging.INFO}.get(args.verbose, logging.DEBUG))
96+
_log.debug(repr(args))
97+
if args.subparser_name:
98+
args.func(args)
99+
else:
100+
root_parser.print_help()
101+
102+
103+
def main_paths(args):
104+
"""
105+
Print paths of auth config file and refresh token cache file.
106+
"""
107+
108+
def describe(p: Path):
109+
if p.exists():
110+
return "perms: 0o{p:o}, size: {s}B".format(p=p.stat().st_mode & 0o777, s=p.stat().st_size)
111+
else:
112+
return "does not exist"
113+
114+
config_path = AuthConfig().path
115+
print("openEO auth config: {p} ({d})".format(p=str(config_path), d=describe(config_path)))
116+
tokens_path = RefreshTokenStore().path
117+
print("openEO OpenID Connect refresh token store: {p} ({d})".format(p=str(tokens_path), d=describe(tokens_path)))
118+
119+
120+
def _redact(d: dict, keys_to_redact: List[str]):
121+
"""Redact secrets in given dict in-place."""
122+
for k, v in d.items():
123+
if k in keys_to_redact:
124+
d[k] = "<redacted>"
125+
elif isinstance(v, dict):
126+
_redact(v, keys_to_redact=keys_to_redact)
127+
128+
129+
def main_config_dump(args):
130+
"""
131+
Dump auth config file
132+
"""
133+
config = AuthConfig()
134+
print("### {p} ".format(p=str(config.path)).ljust(80, "#"))
135+
data = config.load(empty_on_file_not_found=False)
136+
if not args.show_secrets:
137+
_redact(data, keys_to_redact=["client_secret", "password", "refresh_token"])
138+
json.dump(data, fp=sys.stdout, indent=2)
139+
print()
140+
141+
142+
def main_token_dump(args):
143+
"""
144+
Dump refresh token file
145+
"""
146+
tokens = RefreshTokenStore()
147+
print("### {p} ".format(p=str(tokens.path)).ljust(80, "#"))
148+
data = tokens.load(empty_on_file_not_found=False)
149+
if not args.show_secrets:
150+
_redact(data, keys_to_redact=["client_secret", "password", "refresh_token"])
151+
json.dump(data, fp=sys.stdout, indent=2)
152+
print()
153+
154+
155+
def main_add_basic(args):
156+
"""
157+
Add a config entry for basic auth
158+
"""
159+
backend = args.backend
160+
username = args.username
161+
try_auth = args.try_auth
162+
config = AuthConfig()
163+
164+
print("Will add basic auth config for backend URL {b!r}".format(b=backend))
165+
print("to config file: {c!r}".format(c=str(config.path)))
166+
167+
# Find username and password
168+
if not username:
169+
username = builtins.input("Enter username and press enter: ")
170+
print("Using username {u!r}".format(u=username))
171+
password = getpass("Enter password and press enter: ") or None
172+
173+
if try_auth:
174+
print("Trying to authenticate with {b!r}".format(b=backend))
175+
con = connect(backend)
176+
con.authenticate_basic(username, password)
177+
print("Successfully authenticated {u!r}".format(u=username))
178+
179+
config.set_basic_auth(backend=backend, username=username, password=password)
180+
print("Saved credentials to {p!r}".format(p=str(config.path)))
181+
182+
183+
def _interactive_choice(title: str, options: List[Tuple[str, str]], attempts=10) -> str:
184+
"""
185+
Let user choose between options (given as dict) and return chosen key
186+
"""
187+
print(title)
188+
for c, (k, v) in enumerate(options):
189+
print("[{c:d}] {v}".format(c=c + 1, v=v))
190+
for _ in range(attempts):
191+
try:
192+
entered = builtins.input("Choose one (enter index): ")
193+
return options[int(entered) - 1][0]
194+
except Exception:
195+
pass
196+
else:
197+
raise CliToolException("Failed to pick valid option.")
198+
199+
200+
def show_warning(message: str):
201+
_log.warning(message)
202+
203+
204+
def main_add_oidc(args):
205+
"""
206+
Add a config entry for OIDC auth
207+
"""
208+
backend = args.backend
209+
provider_id = args.provider_id
210+
client_id = args.client_id
211+
config = AuthConfig()
212+
213+
print("Will add OpenID Connect auth config for backend URL {b!r}".format(b=backend))
214+
print("to config file: {c!r}".format(c=str(config.path)))
215+
216+
con = connect(backend)
217+
api_version = con.capabilities().api_version_check
218+
if api_version < "1.0.0":
219+
raise CliToolException("Backend API version is too low: {v} < 1.0.0".format(v=api_version))
220+
# Find provider ID
221+
oidc_info = con.get("/credentials/oidc", expected_status=200).json()
222+
providers = {p["id"]: p for p in oidc_info["providers"]}
223+
if not providers:
224+
raise CliToolException("No OpenID Connect providers listed by backend {b!r}.".format(b=backend))
225+
if not provider_id:
226+
if len(providers) == 1:
227+
provider_id = list(providers.keys())[0]
228+
else:
229+
provider_id = _interactive_choice(
230+
title="Backend {b!r} has multiple OpenID Connect providers.".format(b=backend),
231+
options=[(p["id"], "{t} (issuer {s})".format(t=p["title"], s=p["issuer"])) for p in providers.values()]
232+
)
233+
if provider_id not in providers:
234+
raise CliToolException("Invalid provider ID {p!r}. Should be one of {o}.".format(
235+
p=provider_id, o=list(providers.keys())
236+
))
237+
issuer = providers[provider_id]["issuer"]
238+
print("Using provider ID {p!r} (issuer {i!r})".format(p=provider_id, i=issuer))
239+
240+
# Get client_id and client_secret
241+
# Find username and password
242+
if not client_id:
243+
client_id = builtins.input("Enter client_id and press enter: ")
244+
print("Using client ID {u!r}".format(u=client_id))
245+
if not client_id:
246+
show_warning("Given client ID was empty.")
247+
client_secret = getpass("Enter client_secret and press enter: ")
248+
if not client_secret:
249+
show_warning("Given client secret was empty.")
250+
251+
config.set_oidc_client_config(
252+
backend=backend, provider_id=provider_id, client_id=client_id, client_secret=client_secret, issuer=issuer
253+
)
254+
print("Saved client information to {p!r}".format(p=str(config.path)))
255+
256+
257+
_webbrowser_open = None
258+
259+
260+
def main_oidc_auth(args):
261+
"""
262+
Do OIDC auth flow and store refresh tokens.
263+
"""
264+
backend = args.backend
265+
oidc_flow = args.flow
266+
provider_id = args.provider_id
267+
timeout = args.timeout
268+
269+
config = AuthConfig()
270+
271+
print("Will do OpenID Connect flow to authenticate with backend {b!r}.".format(b=backend))
272+
print("Using config {c!r}.".format(c=str(config.path)))
273+
274+
# Determine provider
275+
provider_configs = config.get_oidc_provider_configs(backend=backend)
276+
if not provider_configs:
277+
raise CliToolException("No OpenID Connect provider configs found for backend {b!r}".format(b=backend))
278+
_log.debug("Provider configs: {c!r}".format(c=provider_configs))
279+
if not provider_id:
280+
if len(provider_configs) == 1:
281+
provider_id = list(provider_configs.keys())[0]
282+
else:
283+
provider_id = _interactive_choice(
284+
title="Multiple OpenID Connect providers available for backend {b!r}".format(b=backend),
285+
options=[
286+
(k, "{k}: issuer {s}".format(k=k, s=v.get("issuer", "n/a")))
287+
for k, v in provider_configs.items()
288+
]
289+
)
290+
if provider_id not in provider_configs:
291+
raise CliToolException("Invalid provider ID {p!r}. Should be one of {o}.".format(
292+
p=provider_id, o=list(provider_configs.keys())
293+
))
294+
print("Using provider ID {p!r}.".format(p=provider_id))
295+
296+
# Get client id and secret
297+
client_id, client_secret = config.get_oidc_client_configs(backend=backend, provider_id=provider_id)
298+
if not client_id:
299+
raise CliToolException("Client ID for provide {p} is empty (config {c!r})".format(
300+
p=provider_id, c=str(config.path)
301+
))
302+
print("Using client ID {c!r}.".format(c=client_id))
303+
if not client_secret:
304+
show_warning("Empty client secret.")
305+
306+
if oidc_flow is None:
307+
oidc_flow = _interactive_choice(
308+
"Which OpenID Connect flow should be used? (Note: some options might not be supported by the provider.)",
309+
options=[("auth-code", "Authorization code flow"), ("device", "Device flow")]
310+
)
311+
refresh_token_store = RefreshTokenStore()
312+
con = Connection(backend, refresh_token_store=refresh_token_store)
313+
if oidc_flow == "auth-code":
314+
print("Starting OpenID Connect authorization code flow:")
315+
print("a browser window should open allowing you to log in with the identity provider\n"
316+
"and grant access to the client {c!r} (timeout: {t}s).".format(c=client_id, t=timeout))
317+
con.authenticate_oidc_authorization_code(
318+
client_id=client_id, client_secret=client_secret,
319+
provider_id=provider_id,
320+
timeout=timeout,
321+
store_refresh_token=True,
322+
webbrowser_open=_webbrowser_open
323+
)
324+
print("The OpenID Connect authorization code flow was successful.")
325+
elif oidc_flow == "device":
326+
print("Starting OpenID Connect device flow.")
327+
con.authenticate_oidc_device(
328+
client_id=client_id, client_secret=client_secret,
329+
provider_id=provider_id,
330+
store_refresh_token=True
331+
)
332+
print("The OpenID Connect device flow was successful.")
333+
else:
334+
raise CliToolException("Invalid flow {f!r}".format(f=oidc_flow))
335+
336+
print("Stored refresh token in {p!r}".format(p=str(refresh_token_store.path)))
337+
338+
339+
if __name__ == '__main__':
340+
main()

0 commit comments

Comments
 (0)